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
package/src/App.jsx CHANGED
@@ -48,18 +48,35 @@ import { createLiveControl, loadLiveWorkspaceId, mintLiveWorkspaceId } from "./l
48
48
  import { createFirstEditTracker, semanticEditKind } from "./semantic-edit.js";
49
49
 
50
50
  import AgentPanel from "./workflow/AgentPanel.jsx";
51
+ import SculptPanel from "./panels/SculptPanel.jsx";
52
+ import { createAgentPort, createSculptRun } from "./sculpt-run.js";
53
+ import { SculptTurntable } from "./sculpt-stage.jsx";
54
+ import { measureReferenceOutline } from "./sculpt-compare.js";
55
+ import { BLUEPRINT_LAYOUT, buildBlueprintPrompt } from "./sculpt-blueprint.js";
56
+ import { browserSculptImages } from "./commands/sculpt-images.js";
57
+ import { sculptAgentClient, sculptModelLabel } from "./sculpt-mode.js";
58
+ import { useSculptMode } from "./use-sculpt-mode.js";
59
+ import { useFirstSuccessPrompt } from "./use-first-success-prompt.js";
60
+ import { useCameraTutorial } from "./use-camera-tutorial.js";
61
+ import { useEmbedBridge } from "./use-embed-bridge.js";
62
+ import { useAssetShelf } from "./use-asset-shelf.js";
63
+ import { useI2vCaptureModal } from "./use-i2v-capture-modal.js";
51
64
  import { StudioProtocolError } from "./studio-agent-protocol.js";
52
65
  import { elementByPath } from "./studio-elements.js";
53
66
  import {
54
67
  resolveStudioToast,
55
68
  } from "./studio-actions.js";
56
69
  import { createStudioAppActions } from "./commands/index.js";
70
+ import { STORYBOARD_MOTION_REFUSAL } from "./commands/view.js";
57
71
 
58
72
  import { createStudioAppBinding } from "./studio-app-binding.js";
59
73
  import { AppContext, createAppContext } from "./app-context.js";
60
74
  import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
61
75
 
62
76
  import { PlanBoard } from "./planview.jsx";
77
+ import { drawPlanDiagram } from "./plan-diagram.js";
78
+ import { placementAt } from "./root-path.js";
79
+ import { proxyYaw } from "./proxy-figure.jsx";
63
80
  import { autoColorHex, loadAutoColor } from "./auto-color.js";
64
81
  import { DualRender, fitAspect, GIZMO_LAYER } from "./dualview.jsx";
65
82
  import { GridFloor } from "./grid-floor.jsx";
@@ -114,10 +131,9 @@ import {
114
131
  RAIL_SCHEDULE_RANGE,
115
132
  resolveRailSchedule,
116
133
  } from "./camera-rail-schedule.js";
117
- import { SetProps } from "./props.jsx";
134
+ import { ObjectCarrier, SetProps } from "./props.jsx";
118
135
  import {
119
136
  CUTOUT_KIND,
120
- MESH_KIND,
121
137
  isEffectivelyHidden,
122
138
  objectSize,
123
139
  sceneObjectIdFromHierarchy,
@@ -147,10 +163,10 @@ import {
147
163
  } from "./matte.js";
148
164
  import { createMatteEditor } from "./matte-editor.js";
149
165
  import {
150
- SCENES_STORAGE_KEY,
151
166
  SCENES_VERSION,
152
167
  activeSceneIndex,
153
168
  createSceneStage,
169
+ isProxyFigure,
154
170
  normalizeReferenceImage,
155
171
  } from "./scenes.js";
156
172
  import {
@@ -184,16 +200,19 @@ import { demoSeedGate, hasLineEditCapability, motionReadiness } from "./motion-r
184
200
  import { PWA_UPDATE_EVENT } from "./pwa.js";
185
201
  import {
186
202
  createObjectPath,
203
+ insertPathMark,
187
204
  objectTransformAt,
188
205
  pathMetrics,
189
206
  strokeToPathPoints,
190
- MAX_PATH_POINTS,
191
207
  } from "./object-path.js";
208
+ import { carriedPointAt, sceneObjectsAt } from "./object-travel.js";
209
+ import { routeOwnerFor } from "./route-owner.js";
192
210
  import {
193
211
  analyticsActive,
194
212
  exportFailureCode,
195
213
  recordSemanticEdit,
196
214
  recordUseCase,
215
+ sculptRunTelemetry,
197
216
  shouldAskUseCase,
198
217
  startExportAttempt,
199
218
  track,
@@ -201,13 +220,13 @@ import {
201
220
  trackFeature,
202
221
  } from "./analytics.js";
203
222
  import { ko, isKo } from "./locale.js";
204
- import {
205
- isPlaygroundEmbed,
206
- } from "./playground.js";
223
+ import { characterCapabilities, kindRefusal, proxyFigureBounds } from "./character-kind.js";
207
224
  import { STARTER_SCENES } from "./starter-scenes.js";
208
225
  import { PART_COLOURS } from "./part-colours.js";
209
226
  import {
210
227
  DEFAULT_POSE,
228
+ applyHipsOffset,
229
+ applyPose,
211
230
  } from "./poses.js";
212
231
  import {
213
232
  IkHandles,
@@ -264,7 +283,7 @@ import {
264
283
  } from "./shot-guides.js";
265
284
  import { shotCaptureMeta } from "./shot-meta.js";
266
285
  import { buildShotPrompt } from "./shot-prompt.js";
267
- import { keyframePackEntries, keyframePackName } from "./keyframe-pack.js";
286
+ import { buildKeyframePack, keyframePackEntries, keyframePackName, slugifyTitle } from "./keyframe-pack.js";
268
287
  import { buildZip } from "./zip-store.js";
269
288
  import { composeStoryboard } from "./storyboard.js";
270
289
  import { DEPTH_RANGE_M, depthRangeFromFrames, passFileName, renderPass } from "./render-passes.js";
@@ -272,12 +291,12 @@ import { DEPTH_RANGE_M, depthRangeFromFrames, passFileName, renderPass } from ".
272
291
  import { motionApiOrigin, I2V_MOTION_SHOT_ASPECT } from "./i2v-motion-client.js";
273
292
  import { serializeOtio } from "./otio.js";
274
293
  import "./shell/mode.css";
294
+ import { readPoseFadeOpacity, withPoseFadeLifted, writePoseFadeOpacity } from "./pose-fade.js";
275
295
  import {
276
296
  shotAtFrame,
277
297
  shotIndexAtFrame,
278
298
  } from "./cuts.js";
279
299
  import {
280
- ASSET_DELETE_UNDO_MS,
281
300
  BRIDGE_RECHECK_MS,
282
301
  CameraGlide,
283
302
  CameraRailScenePreview,
@@ -437,11 +456,14 @@ function koSubjectParticle(word) {
437
456
  * A missing rig, a member with no clip and a layer with no keys are all
438
457
  * no-ops rather than errors: characters without a take keep their pose.
439
458
  */
440
- function poseMemberAtFrame(rig, clip, ikState, frame, blendFrames = 0) {
459
+ function poseMemberAtFrame(rig, clip, ikState, frame, blendFrames = 0, pose = null) {
441
460
  if (!rig) return;
442
461
  if (clip) {
443
462
  const sampled = sampleAt({ frameCount: clip.frames, motion: clip }, null, frame);
444
463
  applyMotionFrame(rig, clip, sampled.motionFrame);
464
+ } else if (pose) {
465
+ applyPose(rig, pose);
466
+ applyHipsOffset(rig, pose.rootY ?? 0);
445
467
  }
446
468
  if (ikState && ikState.keys.size > 0 && ikState.chains && ikState.rig === rig) {
447
469
  ikEvaluate(ikState.chains, ikState, frame, ikState.fkJoints, clip ? blendFrames : 0);
@@ -483,56 +505,12 @@ export default function App() {
483
505
  getBus: () => studioBindingRef.current.bus,
484
506
  notify: (...args) => setToast(...args),
485
507
  }));
486
- const embedMode = ["scene", "playview"].includes(new URLSearchParams(globalThis.location?.search || "").get("embed"));
487
- // The landing page's try-it iframe: full studio interaction on a preset
488
- // scene with the project chrome hidden and saving off (see playground.js).
489
- const playgroundMode = isPlaygroundEmbed(globalThis.location?.search);
490
- const [playgroundHint, setPlaygroundHint] = useState(null);
491
- const playgroundExportRef = useRef(null);
492
- // The camera tutorial (#206): the landing page's seven steps, run against
493
- // the real studio instead of the playground iframe. It opens from
494
- // /app/?tutorial=camera or from Settings ▾, and it is not offered inside an
495
- // embed.
496
-
497
- // Both doors go through startCameraTutorial (#209), which puts the studio in
498
- // the state the landing page teaches these steps in — the city-block starter
499
- // scene with the walk take on its character — so Shot / Rail / Play always
500
- // have something to frame. That function is declared with the project
501
- // actions further down; the listeners here reach it through a ref so they
502
- // always call the current render's closure (the confirm reads projectDirty).
503
- const [cameraTutorialEntry] = useState(() => {
504
- const params = new URLSearchParams(globalThis.location?.search || "");
505
- const query = params.get("tutorial") === "camera";
506
- let firstRun = false;
507
- try {
508
- firstRun = !query && !params.get("scene") && !cameraTutorialSuppressed()
509
- && !loadProjectSession()?.name && !globalThis.localStorage?.getItem(SCENES_STORAGE_KEY);
510
- } catch { firstRun = false; }
511
- return !embedMode && !playgroundMode && !cameraTutorialSuppressed() && (query ? "query" : firstRun ? "first-run" : null);
512
- });
513
- const cameraTutorialQuery = Boolean(cameraTutorialEntry);
514
- const [cameraTutorial, setCameraTutorial] = useState(false);
515
- const cameraTutorialAnalytics = useRef(null);
516
- const [cameraTutorialAttempt, setCameraTutorialAttempt] = useState(0);
517
- const [cameraTutorialHandoff, setCameraTutorialHandoff] = useState(null);
518
- const cameraTutorialCompletedRef = useRef(false);
519
- const exportMenuTriggerRef = useRef(null);
520
- const exportShotIdRef = useRef(null);
521
- // The step the tutorial is on, published on the .app root so styles.css can
522
- // spotlight the one control that step needs (#211).
523
- const [cameraTutorialStep, setCameraTutorialStep] = useState(null);
524
- const startCameraTutorialRef = useRef(null);
525
- const cameraTutorialStarted = useRef(false);
526
- // A tutorial restart never reloads the starter over the user's edits.
527
- const tutorialStarterRef = useRef(false);
528
- const tutorialLoadingRef = useRef(false);
529
- const tutorialProjectEpochRef = useRef(0);
530
- const tutorialSeedEpochRef = useRef(null);
531
- // Armed once the starter is applied, consumed by the seed effect next to the
532
- // hosted-demo seed as soon as the new character's rig exists.
533
- const [tutorialSeedPending, setTutorialSeedPending] = useState(false);
508
+ const { embedMode, playgroundMode, playgroundHint, setPlaygroundHint, playgroundExportRef } = useEmbedBridge();
509
+ const { cameraTutorialEntry, cameraTutorialQuery, cameraTutorial, setCameraTutorial, cameraTutorialAnalytics, cameraTutorialAttempt, setCameraTutorialAttempt, cameraTutorialHandoff, setCameraTutorialHandoff, cameraTutorialCompletedRef, exportMenuTriggerRef, exportShotIdRef, cameraTutorialStep, setCameraTutorialStep, startCameraTutorialRef, cameraTutorialStarted, tutorialStarterRef, tutorialLoadingRef, tutorialProjectEpochRef, tutorialSeedEpochRef, tutorialSeedPending, setTutorialSeedPending } = useCameraTutorial({ embedMode, playgroundMode });
534
510
  useEffect(() => {
535
511
  if (!cameraTutorialQuery || cameraTutorialStarted.current) return;
512
+ // Storyboard projects never auto-open the seven-step camera tutorial.
513
+ if (scenesDomain.previsMode === "storyboard") return;
536
514
  cameraTutorialStarted.current = true;
537
515
  void startCameraTutorialRef.current?.({ source: cameraTutorialEntry });
538
516
  }, [cameraTutorialQuery, cameraTutorialEntry]);
@@ -547,6 +525,7 @@ export default function App() {
547
525
  setCameraTutorialHandoff(null);
548
526
  return;
549
527
  }
528
+ if (scenesDomain.previsMode === "storyboard") return;
550
529
  void startCameraTutorialRef.current?.({ source: event.detail?.source ?? "settings" });
551
530
  };
552
531
  window.addEventListener("cozyclay:camera-tutorial", onTutorial);
@@ -795,6 +774,23 @@ export default function App() {
795
774
  // bridge read this global, and the render path is still PlayView's.
796
775
  globalThis.playMode = preview;
797
776
  const playMode = preview;
777
+ // #441 moved shot authoring into commands and dropped the Shot and Rail
778
+ // signals the landing checklist waits for. Read them from the authored state,
779
+ // as the studio tutorial does (#586): a shot added here, and a rail drawn.
780
+ // Declared before the play signal: a drawn rail starts playback in the same
781
+ // commit, and the checklist expects Rail before Play.
782
+ const playgroundShotCount = useRef(null);
783
+ useEffect(() => {
784
+ if (!playgroundMode) return;
785
+ if (playgroundShotCount.current !== null && shots.length > playgroundShotCount.current) {
786
+ window.parent?.postMessage({ type: "cozyclay:playground-nav", kind: "shot" }, "*");
787
+ }
788
+ playgroundShotCount.current = shots.length;
789
+ }, [playgroundMode, shots.length]);
790
+ const playgroundRailReady = playgroundMode && shots.some((shot) => Array.isArray(shot.camera?.cameraRail) && shot.camera.cameraRail.length >= 2);
791
+ useEffect(() => {
792
+ if (playgroundRailReady) window.parent?.postMessage({ type: "cozyclay:playground-nav", kind: "rail" }, "*");
793
+ }, [playgroundRailReady]);
798
794
  // Preview is the player for the finished motion: entering starts playback,
799
795
  // leaving pauses it. The editor view stays the manipulation surface.
800
796
  const [tlPlaying, setTlPlaying] = useState(false);
@@ -1177,7 +1173,7 @@ export default function App() {
1177
1173
  setPromptBlocksReveal, revealPromptBlocks, waypointMode, setWaypointMode, waypoints, setWaypoints,
1178
1174
  activeWaypointId, setActiveWaypointId, pendingWaypointFrame, setPendingWaypointFrame, promptClips,
1179
1175
  setPromptClips, editPromptClips, selectedPromptId, setSelectedPromptId, photoPoseState, photoPoseError,
1180
- setPhotoPoseError, allPoses, selectablePoses, posingIndex, posingChar, posedRig, setPosed, rootStart,
1176
+ setPhotoPoseError, allPoses, selectablePoses, posingIndex, posingChar, posedRig, setPosed, rootStart, commitPosedPose,
1181
1177
  queueRootWaypointFrame, castMemberOf, addCharacterWaypoint, moveCharacterWaypoint,
1182
1178
  removeCharacterWaypoint, clearCharacterWaypoints, addFloorWaypoint, moveWaypoint, removeWaypoint,
1183
1179
  toggleWaypointMode, openStudio, closeStudio, saveCurrentPose, savePose, posePhotoFile, removePose,
@@ -1190,9 +1186,14 @@ export default function App() {
1190
1186
  // not the one its closure captured.
1191
1187
  appContext.publishCharacters(characters);
1192
1188
 
1193
- const [i2vMotionEnabled, setI2vMotionEnabled] = useState(false);
1194
- const [i2vMotionMode, setI2vMotionMode] = useState("interpolate");
1195
- const [i2vMotionCameraUnlocked, setI2vMotionCameraUnlocked] = useState(false);
1189
+ const {
1190
+ i2vMotionEnabled,
1191
+ setI2vMotionEnabled,
1192
+ i2vMotionMode,
1193
+ setI2vMotionMode,
1194
+ i2vMotionCameraUnlocked,
1195
+ setI2vMotionCameraUnlocked,
1196
+ } = useI2vCaptureModal();
1196
1197
 
1197
1198
  useEffect(() => {
1198
1199
  let cancelled = false;
@@ -1369,7 +1370,8 @@ export default function App() {
1369
1370
  bodyContact, setBodyContact, IK_CORRECTION_BLEND_FRAMES, autoPhysicsRunning, setAutoPhysicsRunning,
1370
1371
  physicsPreview, setPhysicsPreview, physicsShow, physicsProgress, physicsOptions, setPhysicsOptions,
1371
1372
  ikTick, setIkTick, committedIkEdits, setCommittedIkEdits, trailFalloffS, setTrailFalloffS, showTrails,
1372
- setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
1373
+ setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailTrackFocus, selectTrailTrack, clearTrailTrackFocus, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
1374
+ pendingIkEdit, applyPendingIkEdit, cancelPendingIkEdit,
1373
1375
  rangePins, rangePinResiduals, rangePinSelection, setRangePinSelection, rangePinPartPick, setRangePinPartPick, rangePinPreview, previewRangePinDraft, applyRangePinDraft, deleteRangePin,
1374
1376
  setCharacterIkKey, removeCharacterIkKey, clearCharacterIkKeys, bridge, setBridge, bridgeChecking,
1375
1377
  motionSetupReveal, motionSetupKind, setArdyPrompt, setArdyDuration, ardySeed, preserveStrength,
@@ -1399,14 +1401,24 @@ export default function App() {
1399
1401
  // Keep the underlying selection model intact, but use this small workflow
1400
1402
  // state to surface only the tools that belong to the current job.
1401
1403
  const [workflowMode, setWorkflowMode] = useState("scene");
1404
+ const [poseObjectOpacity, setPoseObjectOpacityState] = useState(readPoseFadeOpacity);
1405
+ const setPoseObjectOpacity = (value) => { setPoseObjectOpacityState(value); writePoseFadeOpacity(value); };
1402
1406
  // Pose mode IS IK editing (#521): with no rig to solve there is nothing to
1403
1407
  // pose, so the mode refuses with the reason instead of opening empty.
1404
- const poseRefusal = ikChains ? null : ko("Load a character to pose it — Pose mode edits its rig with IK", "캐릭터를 불러와야 포즈를 잡을 수 있어요 — 포즈 모드는 IK로 리그를 편집합니다");
1408
+ // A capsule figure has no rig at all, so it gets its own reason rather than
1409
+ // the "load a character" one, which would read as a loading problem.
1410
+ const activeCapabilities = characterCapabilities(activeChar);
1411
+ const poseRefusal = !activeCapabilities.pose ? kindRefusal("pose", isKo) : ikChains ? null : ko("Load a character to pose it — Pose mode edits its rig with IK", "캐릭터를 불러와야 포즈를 잡을 수 있어요 — 포즈 모드는 IK로 리그를 편집합니다");
1405
1412
  function selectWorkflowMode(next) {
1406
1413
  if (next === "pose" && poseRefusal) {
1407
1414
  setToast(poseRefusal);
1408
1415
  return;
1409
1416
  }
1417
+ // A storyboard is stills: there is no Motion department to open.
1418
+ if (next === "motion" && storyboardProject) {
1419
+ setToast(ko(STORYBOARD_MOTION_REFUSAL.en, STORYBOARD_MOTION_REFUSAL.ko));
1420
+ return;
1421
+ }
1410
1422
  setWorkflowMode(next);
1411
1423
  // Picking a department leaves the chrome-free player. Shot-look is
1412
1424
  // camera work, so it only yields when the operator leaves Camera.
@@ -1425,7 +1437,8 @@ export default function App() {
1425
1437
  // Pose lands on the active character's rig: its Rig Control panel holds
1426
1438
  // the IK tools that W/E/R switch between.
1427
1439
  else if (next === "pose") setSelectedHierarchyId(`${rowIdForCharIndex(activeCharIndex)}.rig`);
1428
- else setSelectedHierarchyId("shot");
1440
+ // Sculpt keeps the selection: a selected sculpt is the one it opens on.
1441
+ else if (next !== "sculpt") setSelectedHierarchyId("shot");
1429
1442
  }
1430
1443
  // Every door into the mode (tab, 1-4 keys, view.setMode on the bus) moves
1431
1444
  // IK with it through the motion domain's own entry/exit path...
@@ -1435,7 +1448,7 @@ export default function App() {
1435
1448
  // ...and whatever else ends IK (a take starting playback, the rig going
1436
1449
  // away) hands the operator back to Motion instead of a dead Pose mode.
1437
1450
  useEffect(() => {
1438
- if (!ikMode && workflowMode === "pose") setWorkflowMode("motion");
1451
+ if (!ikMode && workflowMode === "pose") setWorkflowMode(storyboardProject ? "scene" : "motion");
1439
1452
  }, [ikMode]);
1440
1453
 
1441
1454
  const toggleHierarchyHidden = (hierarchyId) => {
@@ -1496,10 +1509,75 @@ export default function App() {
1496
1509
  matteShrink, setMatteShrink, matteFeather, setMatteFeather, matteMode, setMatteMode, matteStats,
1497
1510
  setMatteStats, matteBusy, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled, addSceneObject,
1498
1511
  importCutout, importCutouts, spawnCutoutAt, persistMeshAsset, importMesh, importMeshes, spawnMeshAt,
1499
- applyMatte, duplicateSelectedSceneObject, frameSelection, renameSceneObject, sceneObjectWorldMatrix,
1512
+ applyMatte, duplicateSelectedSceneObject, frameSelection, groupObjectUnderNewEmpty, renameRequest, renameSceneObject, sceneObjectWorldMatrix,
1500
1513
  attachTargetForRow, attachTargetLabel, attachSceneObject,
1501
1514
  } = objectsDomain;
1502
1515
 
1516
+ const { sculptMode, sculptCamRef, sculptView, sculptReference, setSculptReference, sculptRun, setSculptRun, sculptRunRef, sculptAgentStatus, sculptModels, sculptModelPick, chooseSculptModel, sculptTransport, sculptTap, sculptTarget, setSculptTargetId, requestSculptView, sculptBlueprint, setSculptBlueprint, generateSculptBlueprint, cancelSculptBlueprint } = useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectId });
1517
+ async function chooseSculptReference(file) {
1518
+ try {
1519
+ // A new reference makes the old sheet a drawing of something else.
1520
+ cancelSculptBlueprint();
1521
+ setSculptBlueprint((current) => ({ ...current, state: "idle", sheet: null, error: null }));
1522
+ setSculptReference(await objectsDomain.storeSculptReference(file));
1523
+ } catch (error) {
1524
+ setToast(ko(`Could not read that image: ${error.message}`, `이미지를 읽지 못했어요: ${error.message}`));
1525
+ }
1526
+ }
1527
+ function startSculptRun() {
1528
+ if (!sculptReference) return;
1529
+ sculptRunRef.current?.cancel();
1530
+ const run = createSculptRun({
1531
+ bus: { subscribe: (listener) => sculptTap.subscribe(listener, studioBindingRef.current?.bus) },
1532
+ agent: createAgentPort(sculptAgentClient(sculptTransport, () => buildStudioAgentContext())),
1533
+ reference: sculptReference,
1534
+ sculptId: sculptTarget?.id ?? null,
1535
+ score: (args) => appContext.bus.run("sculpt.compare", args, { origin: "ui" }),
1536
+ measure: async (reference) => measureReferenceOutline(await browserSculptImages.decode(reference)),
1537
+ model: sculptModelPick.model || undefined,
1538
+ effort: sculptModelPick.effort || undefined,
1539
+ modelLabel: sculptModelLabel(sculptModels, sculptModelPick.model) || undefined,
1540
+ });
1541
+ sculptRunRef.current = run;
1542
+ let shownId = sculptTarget?.id ?? null;
1543
+ const hadTarget = Boolean(sculptTarget);
1544
+ let reported = false;
1545
+ run.subscribe((state) => {
1546
+ // Before the current-run gate: a run replaced by a new Start still ends (cancelled) once.
1547
+ const telemetry = reported ? null : sculptRunTelemetry(state, { hadTarget });
1548
+ if (telemetry) { reported = true; track("sculpt_run", telemetry); }
1549
+ // A finished run's bus listener outlives it; only the current run speaks.
1550
+ if (sculptRunRef.current !== run) return;
1551
+ setSculptRun(state);
1552
+ if (state.sculptId && state.sculptId !== shownId) {
1553
+ shownId = state.sculptId;
1554
+ setSculptTargetId(state.sculptId);
1555
+ setSelectedHierarchyId(`object:${state.sculptId}`);
1556
+ }
1557
+ });
1558
+ setSculptRun(run.getState());
1559
+ void run.start();
1560
+ }
1561
+ /** Place in scene: on the ground in front of the scene camera, selected,
1562
+ * back on Stage. The move is one object.update, so one undo. */
1563
+ function placeSculptInScene() {
1564
+ if (!sculptTarget) return;
1565
+ try {
1566
+ selectWorkflowMode("scene");
1567
+ objectsDomain.placeSculptInScene(sculptTarget.id);
1568
+ } catch (error) {
1569
+ if (!(error instanceof StudioProtocolError)) throw error;
1570
+ setToast(error.uiMessage ?? error.message);
1571
+ }
1572
+ }
1573
+
1574
+ // The record whose route the studio shows for the selection: the selected
1575
+ // record itself, or the routed record inside the group it stands for (a car
1576
+ // Empty over its Chassis). Selection stays on the group; only the route's
1577
+ // source moves. With no route anywhere a new stroke lands on the selection.
1578
+ const routeOwner = useMemo(() => routeOwnerFor(sceneObjects, selectedSceneObjectId), [sceneObjects, selectedSceneObjectId]);
1579
+ const pathTarget = routeOwner ?? selectedSceneObject;
1580
+
1503
1581
  // An undo offer is an offer, not a banner: without a window it sits on the
1504
1582
  // screen for the rest of the session. Long enough to notice and reach, then
1505
1583
  // gone — the deletion is still reversible through Undo history afterwards.
@@ -1566,7 +1644,7 @@ export default function App() {
1566
1644
  get objectsDomain() { return objectsDomain; },
1567
1645
  }));
1568
1646
  const {
1569
- scenes, activeSceneId, sceneSaveError, snapshotActiveScene, persistScenes, projectName, projectDirty,
1647
+ scenes, activeSceneId, sceneSaveError, snapshotActiveScene, persistScenes, projectName, previsMode, projectDirty,
1570
1648
  setProjectDirty, projectSaveState, setProjectSaveState, projectMenuOpen, setProjectMenuOpen,
1571
1649
  projectBrowserOpen, setProjectBrowserOpen, projectNameDialog, setProjectNameDialog, projectStartupOpen,
1572
1650
  setProjectStartupOpen, projectManifest, setProjectManifest, saveBlockedReasons, setSaveBlockedReasons,
@@ -1577,6 +1655,18 @@ export default function App() {
1577
1655
  renameSceneDocumentFromUi, deleteSceneDocumentFromUi, switchSceneDocument, addSceneDocument,
1578
1656
  duplicateSceneDocument, renameSceneDocument, deleteSceneDocument,
1579
1657
  } = scenesDomain;
1658
+ // A sculpt run belongs to Sculpt mode and the scene it started in: leaving
1659
+ // either cancels it, so no paid turn runs unseen and nothing reselects.
1660
+ useEffect(() => {
1661
+ if (!sculptMode) sculptRunRef.current?.cancel();
1662
+ }, [sculptMode]);
1663
+ useEffect(() => () => sculptRunRef.current?.cancel(), [activeSceneId]);
1664
+ // A storyboard project has no Motion department: a project opened while
1665
+ // Motion was up lands on Stage instead.
1666
+ const storyboardProject = scenesDomain.previsMode === "storyboard";
1667
+ useEffect(() => {
1668
+ if (storyboardProject && workflowMode === "motion") setWorkflowMode("scene");
1669
+ }, [storyboardProject, workflowMode]);
1580
1670
 
1581
1671
  const saveBlockedRef = useRef(startup.saveBlocked);
1582
1672
  const dirtyRef = useRef(false);
@@ -1638,6 +1728,8 @@ export default function App() {
1638
1728
  // the gesture is "paste into the studio", not "paste into this box".
1639
1729
  useEffect(() => {
1640
1730
  const onPaste = (event) => {
1731
+ // The Agent composer takes a picture pasted anywhere on it first.
1732
+ if (event.defaultPrevented) return;
1641
1733
  const target = event.target;
1642
1734
  // Never steal a paste aimed at somewhere text goes.
1643
1735
  if (target instanceof HTMLElement) {
@@ -1819,12 +1911,20 @@ export default function App() {
1819
1911
  }
1820
1912
  // 1-4 pick the workflow mode; pose is refused (with its reason) when
1821
1913
  // there is no rig to solve. The embedded player has no modes to pick.
1822
- const modeKey = { Digit1: "scene", Digit2: "pose", Digit3: "camera", Digit4: "motion" }[event.code];
1914
+ const modeKey = { Digit1: "scene", Digit2: "pose", Digit3: "camera", Digit4: "motion", Digit5: "sculpt" }[event.code];
1823
1915
  if (modeKey && !embedMode && !event.ctrlKey && !event.metaKey && !event.altKey) {
1916
+ // A storyboard has no key 4: it does nothing, not even a refusal.
1917
+ if (modeKey === "motion" && storyboardProject) return;
1824
1918
  event.preventDefault();
1825
1919
  if (modeKey !== workflowMode) selectWorkflowMode(modeKey);
1826
1920
  return;
1827
1921
  }
1922
+ // Sculpt mode's W/E/R swing the turntable to front, side and back.
1923
+ if (workflowMode === "sculpt" && GIZMO_HOTKEYS[event.code]) {
1924
+ event.preventDefault();
1925
+ requestSculptView({ KeyW: "front", KeyE: "side", KeyR: "back" }[event.code]);
1926
+ return;
1927
+ }
1828
1928
  // Pose mode's W/E/R are the IK tools, gated like their Rig Control
1829
1929
  // buttons: the trail tool needs visible trails, the range pin a take.
1830
1930
  if (workflowMode === "pose" && GIZMO_HOTKEYS[event.code]) {
@@ -1912,7 +2012,7 @@ export default function App() {
1912
2012
  // selection would point the gizmo at a stale dot.
1913
2013
  useEffect(() => {
1914
2014
  setPathPointIndex(null);
1915
- }, [selectedSceneObjectId]);
2015
+ }, [selectedSceneObjectId, routeOwner?.id]);
1916
2016
 
1917
2017
  useEffect(() => {
1918
2018
  if (!inspectorActionsOpen) return undefined;
@@ -1942,6 +2042,13 @@ export default function App() {
1942
2042
  // Object travel-path drawing: the same Top-View stroke gesture as the rail,
1943
2043
  // aimed at the selected object instead of the shot camera.
1944
2044
  const [pathDraw, setPathDraw] = useState(false);
2045
+ // Root-path (waypoint) authoring and Top-View path drawing are motion
2046
+ // tooling; a storyboard never leaves either switched on.
2047
+ useEffect(() => {
2048
+ if (!storyboardProject) return;
2049
+ if (waypointMode) setWaypointMode(false);
2050
+ if (pathDraw) setPathDraw(false);
2051
+ }, [storyboardProject, waypointMode, pathDraw]);
1945
2052
  const [pathPointIndex, setPathPointIndex] = useState(null);
1946
2053
  const pathDragTokenRef = useRef(null);
1947
2054
  const planPathTokenRef = useRef(null);
@@ -1975,6 +2082,16 @@ export default function App() {
1975
2082
  if (typeof toast.uiMessage === "string" && toast.uiMessage) for (const sink of toastSinkRef.current) sink(toast);
1976
2083
  showToast(toast.uiMessage);
1977
2084
  }, []);
2085
+ // The dots on a route are found by double-clicking the line, a gesture
2086
+ // nothing hints at: say it once, the first time a route is selected.
2087
+ const routeHintShownRef = useRef(false);
2088
+ // The route shown may belong to a routed record inside the selected group.
2089
+ const selectedRouteId = routeOwner?.path ? routeOwner.id : null;
2090
+ useEffect(() => {
2091
+ if (!selectedRouteId || routeHintShownRef.current) return;
2092
+ routeHintShownRef.current = true;
2093
+ setToast(ko("Double-click the line to add a dot", "선을 더블클릭하면 점이 추가돼요"));
2094
+ }, [selectedRouteId, setToast]);
1978
2095
  // The PWA's "a newer studio is waiting" registration, once one arrives.
1979
2096
  const [pwaUpdate, setPwaUpdate] = useState(null);
1980
2097
  useEffect(() => {
@@ -1993,58 +2110,7 @@ export default function App() {
1993
2110
  const takeRecipeRef = useRef(null);
1994
2111
 
1995
2112
  const [bottomTab, setBottomTab] = useState("timeline");
1996
- // The imported-pictures region of the Assets shelf. null = the scan has
1997
- // never resolved (the pane shows skeletons, NEVER the empty message); an
1998
- // array = the SOURCE ids to show, mattes and cut renders already filtered
1999
- // out (asset-shelf.js). Scans run only while the shelf is visible, and
2000
- // re-run when a cutout's lineage changes — not on every transform tick, so
2001
- // a gizmo drag never hammers IndexedDB.
2002
- const [shelfImageIds, setShelfImageIds] = useState(null);
2003
- const [shelfMeshIds, setShelfMeshIds] = useState(null);
2004
- const [manageAssetStorage, setManageAssetStorage] = useState(false);
2005
- // A separate scan preserves the source-only placement shelf while the
2006
- // manager exposes every unreachable stored record, including matte and cut
2007
- // derivatives orphaned with a deleted card.
2008
- const [unusedAssetIds, setUnusedAssetIds] = useState(null);
2009
- const [usedAssetIds, setUsedAssetIds] = useState(null);
2010
- const [usageCounts, setUsageCounts] = useState(new Map());
2011
- const assetShelfScanTokenRef = useRef(0);
2012
- const legacyDerivedIdsRef = useRef(new Set());
2013
- // This is deliberately session-only. Each entry is a complete IndexedDB
2014
- // record, so Undo can put it back byte-for-byte until the page is reloaded.
2015
- const [assetTrash, setAssetTrash] = useState([]);
2016
- // Same rule as the object deletion offer: the toast is a window, not a
2017
- // banner. Only the OFFER expires — the trashed record itself is the restore
2018
- // data, so it is deliberately kept for the session and never timed out.
2019
- const [assetUndoOffered, setAssetUndoOffered] = useState(false);
2020
- useEffect(() => {
2021
- if (!assetUndoOffered) return undefined;
2022
- const timer = setTimeout(() => setAssetUndoOffered(false), ASSET_DELETE_UNDO_MS);
2023
- return () => clearTimeout(timer);
2024
- }, [assetUndoOffered]);
2025
- const [deletingAssetId, setDeletingAssetId] = useState(null);
2026
- const cutoutLineage = useMemo(
2027
- () => JSON.stringify(sceneObjects.flatMap((object) => (object.renderer === CUTOUT_KIND ? [[object.assetId, object.sourceAssetId, object.matteAssetId]] : []))),
2028
- [sceneObjects],
2029
- );
2030
- const meshLineage = useMemo(
2031
- () => JSON.stringify(sceneObjects.flatMap((object) => (object.renderer === MESH_KIND ? [object.assetId] : []))),
2032
- [sceneObjects],
2033
- );
2034
- const projectCutoutLineage = useMemo(() => {
2035
- const allScenes = scenes.map((scene) => (scene.id === activeSceneId ? { ...scene, objects: sceneObjects } : scene));
2036
- return JSON.stringify(
2037
- allScenes.flatMap((scene) =>
2038
- (Array.isArray(scene.objects) ? scene.objects : []).flatMap((object) =>
2039
- object.renderer === CUTOUT_KIND ? [[object.assetId, object.sourceAssetId, object.matteAssetId]] : [],
2040
- ),
2041
- ),
2042
- );
2043
- }, [scenes, activeSceneId, sceneObjects]);
2044
- const projectAssetGraphSignature = useMemo(() => {
2045
- const allScenes = scenes.map((scene) => (scene.id === activeSceneId ? { ...scene, objects: sceneObjects } : scene));
2046
- return assetGraphSignature(allScenes);
2047
- }, [scenes, activeSceneId, sceneObjects]);
2113
+ const { shelfImageIds, setShelfImageIds, shelfMeshIds, setShelfMeshIds, manageAssetStorage, setManageAssetStorage, unusedAssetIds, setUnusedAssetIds, usedAssetIds, setUsedAssetIds, usageCounts, setUsageCounts, assetShelfScanTokenRef, legacyDerivedIdsRef, assetTrash, setAssetTrash, assetUndoOffered, setAssetUndoOffered, deletingAssetId, setDeletingAssetId, cutoutLineage, meshLineage, projectCutoutLineage, projectAssetGraphSignature } = useAssetShelf({ scenes, activeSceneId, sceneObjects });
2048
2114
  useEffect(() => {
2049
2115
  const { scenes: latestScenes, activeSceneId: latestActiveSceneId, sceneObjects: latestObjects } = projectStateRef.current;
2050
2116
  const allScenes = latestScenes.map((scene) => (scene.id === latestActiveSceneId ? { ...scene, objects: latestObjects } : scene));
@@ -2217,15 +2283,21 @@ export default function App() {
2217
2283
  // the export overwrites it per captured frame and restores nothing, which
2218
2284
  // is correct — the next render puts it back.
2219
2285
  propFrameRef.current = tlFrame;
2220
- const animatedSceneObjects = useMemo(() => {
2221
- if (!sceneObjects.some((object) => object.path)) return sceneObjects;
2222
- const take = { frameCount: tlFrameCount, fps: tlFps };
2223
- return sceneObjects.map((object) => {
2224
- const at = objectTransformAt(object, tlFrame, take);
2225
- if (!at) return object;
2226
- return { ...object, x: at.x, y: at.y, z: at.z, rot: at.rot ?? object.rot };
2227
- });
2228
- }, [sceneObjects, tlFrame, tlFrameCount, tlFps]);
2286
+ // Routed records and everything grouped under them (object-travel.js).
2287
+ const animatedSceneObjects = useMemo(
2288
+ () => sceneObjectsAt(sceneObjects, tlFrame, { frameCount: tlFrameCount, fps: tlFps }),
2289
+ [sceneObjects, tlFrame, tlFrameCount, tlFps],
2290
+ );
2291
+
2292
+ // The selection as it is DRAWN at this frame, for the transform gizmo to sit
2293
+ // on: a routed record, the group under one, and the Empty that rides its
2294
+ // routed descendant all stand away from their authored numbers while the
2295
+ // playhead is off frame 0. Drags still read the authored records (the gizmo
2296
+ // is handed those separately), so they stay delta-based on authored numbers.
2297
+ const gizmoSceneObject = useMemo(
2298
+ () => (selectedSceneObject ? animatedSceneObjects.find((object) => object.id === selectedSceneObject.id) ?? selectedSceneObject : null),
2299
+ [selectedSceneObject, animatedSceneObjects],
2300
+ );
2229
2301
 
2230
2302
  // Auto color: Blender's viewport "Random" mode. A DISPLAY-ONLY marker rides
2231
2303
  // each non-cutout object into the renderers; the authored `color`, the scene
@@ -2257,6 +2329,14 @@ export default function App() {
2257
2329
  // r3f frame loop — so it asks the set for one placement pass itself, right
2258
2330
  // after it has written that frame's bones.
2259
2331
  const propSyncRef = useRef(null);
2332
+ // Characters grouped under a scene object ride it inside an ObjectCarrier.
2333
+ // The carriers read the AUTHORED records by id, and register a placement
2334
+ // pass the recorder runs before the props' (a prop carried on a riding
2335
+ // character's hand needs the carrier placed first).
2336
+ const carrierRegistryRef = useRef(new Set());
2337
+ const sceneObjectLookupRef = useRef(null);
2338
+ sceneObjectLookupRef.current = useMemo(() => new Map(sceneObjects.map((object) => [object.id, object])), [sceneObjects]);
2339
+ const syncCarriers = () => { for (const place of carrierRegistryRef.current) place(); };
2260
2340
  // Where a prop actually IS, read off its live group: the one authority on
2261
2341
  // the transform currently on screen, and so the only honest starting point
2262
2342
  // for a no-jump conversion.
@@ -2265,9 +2345,30 @@ export default function App() {
2265
2345
  /**
2266
2346
  * Hierarchy row drag policy (the panel holds none). An object row dropped on
2267
2347
  * another object GROUPS; on a character or one of its bone rows it ATTACHES;
2268
- * on Props it comes back to the world. Anything else is not a drop.
2348
+ * on Props it comes back to the world. A character row dropped on an object
2349
+ * row is GROUPED under it and rides its travel; dropped on the scene root it
2350
+ * stands in the world again. Anything else is not a drop.
2269
2351
  */
2270
- const hierarchyReparent = { canDrop: objectsDomain.canReparentSceneObject, onDrop: objectsDomain.reparentSceneObject };
2352
+ function characterGroupMove(sourceRowId, targetRowId) {
2353
+ const characterId = charIdFromHierarchyId(sourceRowId);
2354
+ const entry = characterId ? characters.find((item) => item.id === characterId) : null;
2355
+ if (!entry) return null;
2356
+ if (targetRowId === "shot") return (entry.parent ?? null) !== null ? { characterId, parent: null } : null;
2357
+ const objectId = sceneObjectIdFromHierarchy(String(targetRowId ?? ""));
2358
+ const object = objectId ? sceneObjects.find((item) => item.id === objectId) : null;
2359
+ if (!object || object.attach || entry.parent === objectId) return null;
2360
+ return { characterId, parent: objectId };
2361
+ }
2362
+ const hierarchyReparent = {
2363
+ canDrop: (source, target) => (charIdFromHierarchyId(source)
2364
+ ? characterGroupMove(source, target) !== null
2365
+ : objectsDomain.canReparentSceneObject(source, target)),
2366
+ onDrop: (source, target) => {
2367
+ if (!charIdFromHierarchyId(source)) return objectsDomain.reparentSceneObject(source, target);
2368
+ const move = characterGroupMove(source, target);
2369
+ if (move) runStudioAction("character.update", { characterId: move.characterId, patch: { parent: move.parent } });
2370
+ },
2371
+ };
2271
2372
 
2272
2373
  useEffect(() => {
2273
2374
  setCraneSelectedIndex(null);
@@ -2453,10 +2554,20 @@ export default function App() {
2453
2554
  appContext.publishScenes(scenes);
2454
2555
  activeSceneIdRef.current = activeSceneId;
2455
2556
  shotDocumentRef.current = createShotAuthoringDocument({ shots, waypoints, frameCount: tlFrameCount });
2557
+ // The motion layers: their IK edits (Pose mode, IK keys, range pins) ride
2558
+ // into the saved stage below with each character, and a change to them is
2559
+ // a reason to autosave.
2560
+ const motionLayers = appContext.storeDomain("motion")?.read() ?? null;
2561
+ const ikEditsOf = (id) => {
2562
+ const row = motionLayers?.find((layer) => layer.id === id);
2563
+ if (!row || (!row.ikKeys?.length && !row.ikPins?.length)) return null;
2564
+ return { keys: row.ikKeys, pins: row.ikPins ?? [], pinResiduals: row.ikPinResiduals ?? [] };
2565
+ };
2566
+ // sessionMotion is stripped: generated clips are session-only and far too
2567
+ // heavy for the stage envelope; paths, prompt blocks and IK edits persist.
2568
+ const stageCharacters = characters.map(({ sessionMotion, ...entry }) => ({ ...entry, ikEdits: ikEditsOf(entry.id) }));
2456
2569
  actorStageRef.current = {
2457
- // sessionMotion is stripped: generated clips are session-only and far
2458
- // too heavy for the stage envelope; paths and prompt blocks persist.
2459
- characters: characters.map(({ sessionMotion, ...entry }) => entry),
2570
+ characters: stageCharacters,
2460
2571
  hasCharSheet,
2461
2572
  environmentImage,
2462
2573
  shotAspect: shotAspectKey,
@@ -2470,8 +2581,7 @@ export default function App() {
2470
2581
  hasEnvSheet,
2471
2582
  };
2472
2583
 
2473
- const [firstSuccessGuideOpen, setFirstSuccessGuideOpen] = useState(false);
2474
- const [useCaseAskOpen, setUseCaseAskOpen] = useState(false);
2584
+ const { firstSuccessGuideOpen, setFirstSuccessGuideOpen, useCaseAskOpen, setUseCaseAskOpen } = useFirstSuccessPrompt();
2475
2585
 
2476
2586
  // Dismissal mirrors the inspector-actions menu: only listen while open,
2477
2587
  // ignore presses inside the wrap (the trigger's own click keeps toggling),
@@ -2804,6 +2914,8 @@ export default function App() {
2804
2914
  activeShotId: activeShot?.id ?? null,
2805
2915
  captureCurrentFraming,
2806
2916
  captureFramingPng,
2917
+ // The Top View pull (verify_result visual "plan", capture_plan_png).
2918
+ capturePlanPng,
2807
2919
  captureShotMeta,
2808
2920
  // The identity / environment reference pictures a capture carries (#167).
2809
2921
  captureShotReferences,
@@ -2839,6 +2951,7 @@ export default function App() {
2839
2951
  aspectRatio: live.filmback.aspectRatio,
2840
2952
  },
2841
2953
  stage: live.stage,
2954
+ previsMode: scenesDomain.metadata().previsMode,
2842
2955
  timeline: live.timeline,
2843
2956
  activeCharacterId: live.activeCharacterId,
2844
2957
  characters: castDomain.read(),
@@ -2874,10 +2987,7 @@ export default function App() {
2874
2987
  // not capture_frame's 640x360 preview, which stays exactly as it is.
2875
2988
  capture_framing_png: (args = {}) => {
2876
2989
  const live = appContext.live.state;
2877
- const requested = args?.output;
2878
- const output = Number.isFinite(requested?.width) && Number.isFinite(requested?.height)
2879
- ? { width: Math.round(requested.width), height: Math.round(requested.height) }
2880
- : SHOT_ASPECT_PRESETS[live.stage.shotAspect] ?? SHOT_ASPECT_PRESETS["16:9"];
2990
+ const output = requestedCaptureOutput(args) ?? SHOT_ASPECT_PRESETS[live.stage.shotAspect] ?? SHOT_ASPECT_PRESETS["16:9"];
2881
2991
  const dataUrl = live.captureFramingPng(live.captureCurrentFraming(), output);
2882
2992
  if (!dataUrl) throw new Error("The shot renderer is not ready");
2883
2993
  return {
@@ -2894,6 +3004,9 @@ export default function App() {
2894
3004
  references: live.captureShotReferences(),
2895
3005
  };
2896
3006
  },
3007
+ // The Top View as the agent's plan picture: a 2D diagram of the cast,
3008
+ // footprints and every shot camera drawn from state (src/plan-diagram.js).
3009
+ capture_plan_png: (args = {}) => appContext.live.state.capturePlanPng(requestedCaptureOutput(args) ?? undefined),
2897
3010
  };
2898
3011
 
2899
3012
  useEffect(() => {
@@ -2904,7 +3017,8 @@ export default function App() {
2904
3017
  const timer = setTimeout(() => {
2905
3018
  if (!liveControlRef.current) {
2906
3019
  liveControlRef.current = createLiveControl({
2907
- handlers: { ...liveQueries, ...studioBindingRef.current.handlers },
3020
+ // Tapped so a sculpt run hears the agent's object.sculpt / sculpt.compare.
3021
+ handlers: sculptTap.wrap({ ...liveQueries, ...studioBindingRef.current.handlers }),
2908
3022
  workspaceId: liveWorkspaceIdRef.current,
2909
3023
  meta: {
2910
3024
  project: projectName ?? "Untitled",
@@ -2965,7 +3079,7 @@ export default function App() {
2965
3079
  dirtyRef.current = true;
2966
3080
  const timer = setTimeout(flushScenes, 400);
2967
3081
  return () => clearTimeout(timer);
2968
- }, [sceneObjects, shots, waypoints, tlFrameCount, charA, charB, showB, poseA, poseB, hasCharSheet, environmentImage, environment, style, hasEnvSheet, subject, subject2, shotAspectKey, sensorId, keyLight, scenes, activeSceneId]);
3082
+ }, [sceneObjects, shots, waypoints, tlFrameCount, charA, charB, showB, poseA, poseB, hasCharSheet, environmentImage, environment, style, hasEnvSheet, subject, subject2, shotAspectKey, sensorId, keyLight, scenes, activeSceneId, motionLayers]);
2969
3083
  useEffect(() => {
2970
3084
  const onPageHide = () => flushScenes();
2971
3085
  const onVisibility = () => {
@@ -3028,25 +3142,33 @@ export default function App() {
3028
3142
  // props did not change must not re-render its subtree.
3029
3143
  const characterViews = useMemo(() => characters.flatMap((entry, index) => {
3030
3144
  if (entry.hidden) return [];
3145
+ const pickId = index === 0 ? "A" : index === 1 ? "B" : entry.id;
3031
3146
  // Each cast member is driven by ITS OWN clip: the active one reads
3032
3147
  // the editing buffer, the others their stored session motion.
3033
3148
  const clip = entry.id === activeChar.id ? motion : entry.sessionMotion;
3149
+ const placement = placementAt(entry, tlFrame, {
3150
+ shotAt: frame => shotAtFrame(shots, frame),
3151
+ takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, tlFrame).subject : null,
3152
+ });
3034
3153
  return [{
3035
3154
  id: entry.id,
3036
- url: characterModelUrl(entry.model),
3037
- position: [entry.x, entry.y ?? 0, entry.z],
3038
- rot: entry.rot,
3155
+ model: entry.model,
3156
+ ...(isProxyFigure(entry) ? {} : { url: characterModelUrl(entry.model) }),
3157
+ position: [placement.x, entry.y ?? 0, placement.z],
3158
+ rot: placement.rot,
3039
3159
  tint: entry.tint ?? defaultCharacterTint(entry, index),
3040
3160
  partColoursEnabled,
3041
3161
  partColoursMode,
3042
- pose: clip ? null : (entry.pose ?? DEFAULT_POSE),
3162
+ pose: clip ? null : (placement.pose ?? DEFAULT_POSE),
3163
+ posture: placement.posture,
3043
3164
  // The stature the entry's take was extracted at. It rides with the
3044
3165
  // clip, never separately — see Character for why.
3045
3166
  scale: entry.scale ?? 1,
3046
- onRig: reportRig(entry.id),
3047
- pickId: index === 0 ? "A" : index === 1 ? "B" : entry.id,
3167
+ ...(isProxyFigure(entry) ? {} : { onRig: reportRig(entry.id) }),
3168
+ pickId,
3169
+ parent: entry.parent ?? null,
3048
3170
  }];
3049
- }), [characters, activeChar.id, motion, partColoursEnabled, partColoursMode]);
3171
+ }), [characters, activeChar.id, motion, shots, tlFrame, partColoursEnabled, partColoursMode]);
3050
3172
  // Where the selection gizmo stands: same driving rules as the render,
3051
3173
  // for the active (selected) cast member only. Gated on the HIERARCHY
3052
3174
  // selection, not the sticky active layer — the layer stays on the last
@@ -3057,8 +3179,10 @@ export default function App() {
3057
3179
  if (!charIdFromHierarchyId(selectedHierarchyId)) return null;
3058
3180
  const entry = characters.find((item) => item.id === activeChar.id);
3059
3181
  if (!entry || entry.hidden) return null;
3060
- return { position: [entry.x, entry.y ?? 0, entry.z] };
3061
- }, [characters, activeChar.id, motion, selectedHierarchyId]);
3182
+ const hasTake = activeChar.id === activeCharacterId ? Boolean(motion) : Boolean(activeChar.sessionMotion);
3183
+ const placement = hasTake ? entry : placementAt(entry, tlFrame, { shotAt: frame => shotAtFrame(shots, frame) });
3184
+ return { position: [placement.x, entry.y ?? 0, placement.z], rot: placement.rot };
3185
+ }, [characters, activeChar.id, motion, shots, tlFrame, selectedHierarchyId]);
3062
3186
  // The cast rides the SAME gizmo as scene objects — one movement grammar
3063
3187
  // for everything on stage. The proxy hands ObjectGizmo the object shape
3064
3188
  // it expects; `height` puts the pivot at the hips like a prop's centre.
@@ -3072,7 +3196,7 @@ export default function App() {
3072
3196
  z: gizmoView.position[2],
3073
3197
  height: 1.15,
3074
3198
  footprint: { width: 0.6, depth: 0.6 },
3075
- rotY: entry?.rot ?? 0,
3199
+ rotY: gizmoView.rot ?? entry?.rot ?? 0,
3076
3200
  scaleX: entry?.scale ?? 1,
3077
3201
  scaleY: entry?.scale ?? 1,
3078
3202
  scaleZ: entry?.scale ?? 1,
@@ -3235,7 +3359,7 @@ export default function App() {
3235
3359
  ikStates: new Map([...ikStatesRef.current].map(([id, state]) => [id, copyIk(state)])),
3236
3360
  rigStates: Object.values(rigs).filter(Boolean).map(snapshotExportRig),
3237
3361
  };
3238
- return Object.freeze({ kind, format: kind === "frame" ? "png" : kind === "keyframe_pack" ? "zip" : "mp4",
3362
+ return Object.freeze({ kind, format: kind === "frame" || kind === "contact_sheet" ? "png" : kind === "keyframe_pack" || kind === "panel_pack" ? "zip" : "mp4",
3239
3363
  context, run, external, download, handedOff: new Set() });
3240
3364
  }
3241
3365
 
@@ -3335,16 +3459,23 @@ export default function App() {
3335
3459
  propFrameRef.current = frame;
3336
3460
  const context = recRef.current?.request.context;
3337
3461
  for (const entry of context?.characters ?? characters) {
3462
+ if (isProxyFigure(entry)) continue;
3338
3463
  const activeId = context?.activeId ?? activeChar.id;
3339
3464
  const clip = entry.id === activeId ? (context ? context.motion : motion) : entry.sessionMotion;
3340
3465
  const state = context
3341
3466
  ? (entry.id === activeId ? context.ikState : context.ikStates.get(entry.id))
3342
3467
  : (entry.id === activeChar.id ? ikStateRef.current : ikStatesRef.current.get(entry.id));
3343
- poseMemberAtFrame(rigs[entry.id], clip, state, frame, IK_CORRECTION_BLEND_FRAMES);
3468
+ const placement = placementAt(entry, frame, {
3469
+ shotAt: at => shotAtFrame(context?.shots ?? shots, at),
3470
+ takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, frame).subject : null,
3471
+ });
3472
+ poseMemberAtFrame(rigs[entry.id], clip, state, frame, IK_CORRECTION_BLEND_FRAMES, placement.pose ?? null);
3344
3473
  }
3345
3474
  // The bones for this frame are now written, so a carried prop can take
3346
3475
  // its place on them. gl.render() never runs the r3f frame loop, so this
3347
3476
  // pass is the recorder's stand-in for the useFrame the preview gets.
3477
+ // Riding characters move with their object first, then the props.
3478
+ syncCarriers();
3348
3479
  propSyncRef.current?.();
3349
3480
  const sampled = sampleAt(context?.playbackScene ?? playbackScene, shotAtFrame(context?.shots ?? shots, frame), frame);
3350
3481
  const framing = sampled.camera ?? context?.framing;
@@ -3384,18 +3515,43 @@ export default function App() {
3384
3515
  const cameraSnapshot = { position: cam.position.clone(), quaternion: cam.quaternion.clone(),
3385
3516
  rotationOrder: cam.rotation.order, fov: cam.fov, yaw: look.current.yaw, pitch: look.current.pitch };
3386
3517
  const rigSnapshots = Object.values(rigs).filter(Boolean).map(snapshotExportRig);
3518
+ const proxySnapshots = [];
3519
+ const context = recRef.current?.request.context;
3520
+ const cast = context?.characters ?? characters;
3521
+ const scene = (recRef.current?.capture ?? captureRef.current)?.scene;
3522
+ const pickIdByCharacter = new Map(characterViews.map(view => [view.id, view.pickId]));
3523
+ if (typeof scene?.traverse === "function") scene.traverse(node => {
3524
+ const index = cast.findIndex(entry => isProxyFigure(entry) && node.userData.characterPick === pickIdByCharacter.get(entry.id));
3525
+ if (index < 0) return;
3526
+ proxySnapshots.push({ node, entry: cast[index], position: node.position.clone(), quaternion: node.quaternion.clone() });
3527
+ });
3387
3528
  const propFrame = propFrameRef.current;
3388
3529
  try {
3389
3530
  // Character stature lives on the rig and placement/yaw on its parent,
3390
3531
  // not in playback bones. Retry temporarily restores both, plus the
3391
3532
  // original held pose, then puts the CURRENT editor transforms back.
3392
3533
  for (const snapshot of recRef.current?.request.context?.rigStates ?? []) restoreExportRig(snapshot);
3534
+ for (const { node, entry } of proxySnapshots) {
3535
+ const clip = entry.id === (context?.activeId ?? activeChar.id) ? (context ? context.motion : motion) : entry.sessionMotion;
3536
+ const placement = placementAt(entry, frame, {
3537
+ shotAt: at => shotAtFrame(context?.shots ?? shots, at),
3538
+ takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, frame).subject : null,
3539
+ });
3540
+ node.position.set(placement.x, entry.y ?? 0, placement.z);
3541
+ node.rotation.y = proxyYaw(placement.rot, placement.posture);
3542
+ node.updateMatrixWorld(true);
3543
+ }
3393
3544
  const plate = applyExportFrame(frame);
3394
3545
  return render ? render() : plate;
3395
3546
  } catch (error) {
3396
3547
  throw Object.assign(new Error(error?.message || String(error), { cause: error }), { exportFailureCode: exportFailureCode(error, "render_failed") });
3397
3548
  } finally {
3398
3549
  for (const snapshot of rigSnapshots) restoreExportRig(snapshot);
3550
+ for (const { node, position, quaternion } of proxySnapshots) {
3551
+ node.position.copy(position);
3552
+ node.quaternion.copy(quaternion);
3553
+ node.updateMatrixWorld(true);
3554
+ }
3399
3555
  cam.position.copy(cameraSnapshot.position);
3400
3556
  cam.rotation.order = cameraSnapshot.rotationOrder;
3401
3557
  cam.quaternion.copy(cameraSnapshot.quaternion);
@@ -3404,6 +3560,7 @@ export default function App() {
3404
3560
  look.current.yaw = cameraSnapshot.yaw;
3405
3561
  look.current.pitch = cameraSnapshot.pitch;
3406
3562
  propFrameRef.current = propFrame;
3563
+ syncCarriers();
3407
3564
  propSyncRef.current?.();
3408
3565
  }
3409
3566
  }
@@ -3762,6 +3919,13 @@ export default function App() {
3762
3919
  return output;
3763
3920
  }
3764
3921
 
3922
+ // One storyboard panel as a picture: the shot's first frame through the
3923
+ // offscreen capture rig, as a PNG data URL (null while the renderer has no
3924
+ // frame). The contact sheet and the Board dock tab share it.
3925
+ function capturePanelThumbnail(entry) {
3926
+ return captureShotFramePng(entry.startFrame);
3927
+ }
3928
+
3765
3929
  /** Contact sheet of the whole cut: one thumbnail and prompt per shot. */
3766
3930
  async function exportStoryboard() {
3767
3931
  try {
@@ -3769,7 +3933,7 @@ export default function App() {
3769
3933
  setToast(ko("Composing the storyboard…", "스토리보드 구성 중…"));
3770
3934
  const cells = [];
3771
3935
  for (const [index, entry] of shots.entries()) {
3772
- const dataUrl = captureShotFramePng(entry.startFrame);
3936
+ const dataUrl = capturePanelThumbnail(entry);
3773
3937
  const meta = packMetaForShot(entry, index);
3774
3938
  cells.push({
3775
3939
  title: storyboardLine(`${index + 1}. ${entry.name}`),
@@ -3789,18 +3953,7 @@ export default function App() {
3789
3953
  // the fit, so the frame is scaled to height) and a caption block wide
3790
3954
  // enough for the 90 characters the composer draws at this size.
3791
3955
  cell: { width: 480, height: 300 },
3792
- createCanvas: (width, height) => {
3793
- const element = document.createElement("canvas");
3794
- element.width = width;
3795
- element.height = height;
3796
- const ctx = element.getContext("2d");
3797
- // The sheet is a deliverable, so it uses the studio's own type
3798
- // stack rather than the canvas default (10px sans-serif).
3799
- ctx.font = STORYBOARD_FONT;
3800
- ctx.textAlign = "left";
3801
- ctx.textBaseline = "top";
3802
- return element;
3803
- },
3956
+ createCanvas: storyboardCanvas,
3804
3957
  });
3805
3958
  saveDownload(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
3806
3959
  setToast(isKo ? `스토리보드 저장됨 · ${cells.length}샷` : `Storyboard saved · ${cells.length} shots`);
@@ -3810,6 +3963,162 @@ export default function App() {
3810
3963
  }
3811
3964
  }
3812
3965
 
3966
+ function storyboardCanvas(width, height) {
3967
+ const element = document.createElement("canvas");
3968
+ element.width = width;
3969
+ element.height = height;
3970
+ const ctx = element.getContext("2d");
3971
+ // The sheet is a deliverable, so it uses the studio's own type
3972
+ // stack rather than the canvas default (10px sans-serif).
3973
+ ctx.font = STORYBOARD_FONT;
3974
+ ctx.textAlign = "left";
3975
+ ctx.textBaseline = "top";
3976
+ return element;
3977
+ }
3978
+
3979
+ /* ===================== storyboard exports (#644) =======================
3980
+ * A storyboard project's panels are its stills, in still order. Each
3981
+ * export runs as one export job (status panel, cancel, retry, analytics
3982
+ * export_kind contact_sheet | panel_pack | animatic) and draws its frames
3983
+ * through withExportFrame, so per-panel cast overrides and proxy paths
3984
+ * land exactly as the preview shows them.
3985
+ */
3986
+
3987
+ function storyboardPanels(list = shots) {
3988
+ return list.filter((entry) => entry.kind === "still").sort((a, b) => a.startFrame - b.startFrame);
3989
+ }
3990
+
3991
+ const noPanelsMessage = () => ko("Add a panel first — storyboard exports describe panels", "패널을 먼저 추가하세요 — 스토리보드 내보내기는 패널을 담습니다");
3992
+
3993
+ // The panel's stylized picture as PNG bytes, or null when it has none (or
3994
+ // its asset is gone from this browser).
3995
+ async function panelStylizedPng(entry) {
3996
+ if (!entry.stylizedAssetId) return null;
3997
+ const record = await assetRecord(entry.stylizedAssetId);
3998
+ if (!record) {
3999
+ console.warn(`[cozyclay] stylized panel ${entry.stylizedAssetId} is missing; exporting the greybox only`);
4000
+ return null;
4001
+ }
4002
+ const bytes = new Uint8Array(record.bytes);
4003
+ if (record.type === "image/png") return bytes;
4004
+ const bitmap = await createImageBitmap(new Blob([bytes], { type: record.type }));
4005
+ try {
4006
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
4007
+ canvas.getContext("2d").drawImage(bitmap, 0, 0);
4008
+ return new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer());
4009
+ } finally {
4010
+ bitmap.close();
4011
+ }
4012
+ }
4013
+
4014
+ async function loadPngBytes(bytes) {
4015
+ const url = URL.createObjectURL(new Blob([bytes], { type: "image/png" }));
4016
+ try { return await loadImage(url); }
4017
+ finally { URL.revokeObjectURL(url); }
4018
+ }
4019
+
4020
+ const panelFolder = (order) => `panel-${String(order + 1).padStart(2, "0")}`;
4021
+
4022
+ /** Contact sheet of the storyboard: the stylized picture with the greybox
4023
+ * inset when a panel has one, and the panel caption under it. */
4024
+ async function exportContactSheet() {
4025
+ if (recRef.current) return null;
4026
+ if (!storyboardPanels().length) { setToast(noPanelsMessage()); return null; }
4027
+ return executeExportRequest(exportRequest("contact_sheet", async (job) => {
4028
+ const all = job.request.context.shots;
4029
+ const panels = storyboardPanels(all);
4030
+ updateExportStatus(job, "preparing", { stage: "frames", cancellable: true });
4031
+ const cells = [];
4032
+ for (const [order, entry] of panels.entries()) {
4033
+ const dataUrl = captureShotFramePng(entry.startFrame);
4034
+ if (!dataUrl) throw Object.assign(new Error(ko("The shot renderer is not ready", "샷 렌더러가 아직 준비되지 않았어요")), { exportFailureCode: "render_failed" });
4035
+ const stylized = await panelStylizedPng(entry);
4036
+ const meta = packMetaForShot(entry, all.indexOf(entry));
4037
+ cells.push({
4038
+ title: storyboardLine(`${order + 1}. ${entry.name}`),
4039
+ durationSeconds: Number(((entry.endFrame - entry.startFrame + 1) / tlFps).toFixed(2)),
4040
+ prompt: storyboardCaption(buildShotPrompt(meta, { target: "image" })),
4041
+ caption: storyboardLine((entry.caption ?? "").trim()),
4042
+ image: await loadImage(dataUrl),
4043
+ stylized: stylized ? await loadPngBytes(stylized) : null,
4044
+ });
4045
+ await exportBoundary(job);
4046
+ }
4047
+ const canvas = composeStoryboard({ shots: cells, columns: Math.min(3, cells.length), cell: { width: 480, height: 300 }, createCanvas: storyboardCanvas });
4048
+ job.controller.signal.throwIfAborted();
4049
+ saveDownload(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
4050
+ setToast(isKo ? `콘택트 시트 다운로드 요청 · 패널 ${cells.length}개` : `Download requested: contact sheet · ${cells.length} panels`);
4051
+ return { fileName: "cozyclay-storyboard.png", panelCount: cells.length };
4052
+ }));
4053
+ }
4054
+
4055
+ // One panel's pack: the greybox as first.png, the stylized picture when
4056
+ // there is one, the still's camera and hold, and the image prompt plus caption.
4057
+ async function panelPackArgs(entry, order, all) {
4058
+ const firstUrl = captureShotFramePng(entry.startFrame);
4059
+ if (!firstUrl) throw Object.assign(new Error(ko("The shot renderer is not ready", "샷 렌더러가 아직 준비되지 않았어요")), { exportFailureCode: "render_failed" });
4060
+ const meta = packMetaForShot(entry, all.indexOf(entry));
4061
+ const caption = (entry.caption ?? "").trim();
4062
+ return {
4063
+ shot: { title: entry.name, index: order + 1, startFrame: entry.startFrame, endFrame: entry.endFrame, kind: "still" },
4064
+ fps: tlFps,
4065
+ firstFramePng: dataUrlToBytes(firstUrl),
4066
+ lastFramePng: null,
4067
+ clip: null,
4068
+ stylizedPng: await panelStylizedPng(entry),
4069
+ camera: { ...meta, framing: { start: shotFramingAtFrame(entry, entry.startFrame) } },
4070
+ prompt: buildShotPrompt(meta, { target: "image" }) + (caption ? `\nCAPTION: ${caption}` : ""),
4071
+ folder: panelFolder(order),
4072
+ };
4073
+ }
4074
+
4075
+ /** Panel pack (zip) for one panel — the given one, else the selected one,
4076
+ * else the one under the playhead, else the first — or, with `every`, one
4077
+ * zip holding a panel-NN/ folder per panel. */
4078
+ async function exportPanelPacks(every = false, shotId = null) {
4079
+ if (recRef.current) return null;
4080
+ const panels = storyboardPanels();
4081
+ if (!panels.length) { setToast(noPanelsMessage()); return null; }
4082
+ const target = shotId ? panels.find((entry) => entry.id === shotId)
4083
+ : panels.find((entry) => entry.id === activeShot?.id)
4084
+ ?? panels.find((entry) => tlFrame >= entry.startFrame && tlFrame <= entry.endFrame) ?? panels[0];
4085
+ if (!target) return null;
4086
+ return executeExportRequest(exportRequest("panel_pack", async (job) => {
4087
+ const all = job.request.context.shots;
4088
+ const contextPanels = storyboardPanels(all);
4089
+ const targets = every ? contextPanels : contextPanels.filter((entry) => entry.id === target.id);
4090
+ updateExportStatus(job, "preparing", { stage: "frames", cancellable: true });
4091
+ const packs = [];
4092
+ for (const [step, entry] of targets.entries()) {
4093
+ job.label = ko(`Panel ${step + 1} of ${targets.length}`, `패널 ${step + 1} / ${targets.length}`);
4094
+ packs.push(await panelPackArgs(entry, contextPanels.indexOf(entry), all));
4095
+ await exportBoundary(job);
4096
+ }
4097
+ updateExportStatus(job, "finalizing", { stage: "archive", cancellable: true });
4098
+ await exportBoundary(job);
4099
+ const entries = packs.flatMap((args) => keyframePackEntries(args));
4100
+ const bytes = every ? buildZip(entries) : buildKeyframePack(packs[0]);
4101
+ const name = every ? "cozyclay-panels.zip" : `cozyclay-${packs[0].folder}-${slugifyTitle(target.name)}.zip`;
4102
+ await exportBoundary(job);
4103
+ const url = URL.createObjectURL(new Blob([bytes], { type: "application/zip" }));
4104
+ try { saveDownload(url, name); }
4105
+ finally { setTimeout(() => URL.revokeObjectURL(url), 10_000); }
4106
+ setToast(isKo ? `${name} 다운로드 요청 · 파일 ${entries.length}개` : `Download requested: ${name} · ${entries.length} files`);
4107
+ return { fileName: name, entries: entries.map((entry) => entry.name) };
4108
+ }));
4109
+ }
4110
+
4111
+ /** Animatic (mp4): every panel held for its hold, from frame 0 to the last
4112
+ * still's end, at the production fps. */
4113
+ async function exportAnimatic() {
4114
+ if (recRef.current) return null;
4115
+ const panels = storyboardPanels();
4116
+ if (!panels.length) { setToast(noPanelsMessage()); return null; }
4117
+ const endFrame = panels.at(-1).endFrame;
4118
+ return executeExportRequest(exportRequest("animatic",
4119
+ (job) => runShotExport({ startFrame: 0, endFrame, fileName: "cozyclay-animatic.mp4" }, job)));
4120
+ }
4121
+
3813
4122
  function captureCurrentFraming() {
3814
4123
  const cam = shotCamRef.current;
3815
4124
  const pos = cam ? cam.position : cameraPos;
@@ -3914,7 +4223,7 @@ export default function App() {
3914
4223
  const rigSelection = parseRigNodeId(selectedHierarchyId);
3915
4224
  const isRigSelection = rigSelection !== null;
3916
4225
  const inspectorHasContent = isSceneSelection || isCameraSelection || isCharacterSelection || isRigSelection
3917
- || selectedHierarchyId === "environment" || selectedHierarchyId === "props" || selectedHierarchyId === "light" || Boolean(selectedSceneObject);
4226
+ || selectedHierarchyId === "environment" || selectedHierarchyId === "props" || selectedHierarchyId === "light" || Boolean(selectedSceneObject) || sculptMode;
3918
4227
 
3919
4228
  useEffect(() => () => {
3920
4229
  if (multiModelObjectUrlRef.current) URL.revokeObjectURL(multiModelObjectUrlRef.current);
@@ -4005,7 +4314,7 @@ export default function App() {
4005
4314
  useEffect(() => {
4006
4315
  // The ACTIVE member's clip only: its own corrections are applied by the
4007
4316
  // evaluate effect below, after its editing state settles.
4008
- poseMemberAtFrame(rigs[activeChar.id], motion, null, tlFrame);
4317
+ if (activeCapabilities.rig) poseMemberAtFrame(rigs[activeChar.id], motion, null, tlFrame);
4009
4318
  poseOtherCastMembers(tlFrame);
4010
4319
  // eslint-disable-next-line react-hooks/exhaustive-deps
4011
4320
  }, [characters, activeChar.id, motion, rigs, tlFrame, ikTick]);
@@ -4022,7 +4331,7 @@ export default function App() {
4022
4331
  // character's rig (re)loads. A rig missing any bone resolves to null and
4023
4332
  // IK mode stays unavailable.
4024
4333
  useEffect(() => {
4025
- const resolved = resolveIkRig(activeRig);
4334
+ const resolved = activeCapabilities.ik ? resolveIkRig(activeRig) : null;
4026
4335
  const chains = resolved ? resolved.chains : null;
4027
4336
  setIkChains(chains);
4028
4337
  setIkFkJoints(resolved ? resolved.fkJoints : null);
@@ -4032,7 +4341,7 @@ export default function App() {
4032
4341
  ikStateRef.current.fkJoints = resolved ? resolved.fkJoints : null;
4033
4342
  ikStateRef.current.rig = chains ? activeRig : null;
4034
4343
  if (!chains) leaveIkMode();
4035
- }, [activeRig]);
4344
+ }, [activeRig, activeCapabilities.ik]);
4036
4345
 
4037
4346
  // Whether the collision capsules can be built for this rig at all. Bone
4038
4347
  // lookups only — no mesh measurement — so it is cheap enough to hang off
@@ -4041,7 +4350,7 @@ export default function App() {
4041
4350
  // a SEPARATE question from `ikChains`: the two collision buttons disable
4042
4351
  // on it rather than offering a click whose only possible answer is "not
4043
4352
  // supported".
4044
- const collisionCleanupSupported = useMemo(() => supportsCollisionCleanup(activeRig), [activeRig]);
4353
+ const collisionCleanupSupported = useMemo(() => activeCapabilities.physics && supportsCollisionCleanup(activeRig), [activeRig, activeCapabilities.physics]);
4045
4354
 
4046
4355
  //
4047
4356
 
@@ -4078,11 +4387,12 @@ export default function App() {
4078
4387
  // Preview changes may re-run this effect without a playhead change.
4079
4388
  // Always re-establish the motion base before adding a correction.
4080
4389
  if (motion) poseMemberAtFrame(activeRig, motion, null, tlFrame);
4081
- const layer = physicsPreview && physicsShow ? physicsPreview.candidate : ikStateRef.current;
4390
+ const layer = pendingIkEdit?.characterId === activeChar.id ? pendingIkEdit.candidate
4391
+ : physicsPreview && physicsShow ? physicsPreview.candidate : ikStateRef.current;
4082
4392
  if (ikMode || layer.keys.size > 0) {
4083
4393
  ikEvaluate(ikChains, layer, tlFrame, ikFkJoints, motion ? IK_CORRECTION_BLEND_FRAMES : 0);
4084
4394
  }
4085
- }, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints, physicsPreview, physicsShow]);
4395
+ }, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints, physicsPreview, physicsShow, pendingIkEdit, activeChar.id]);
4086
4396
 
4087
4397
  // Re-seat the handles on the keyed pose when the FRAME changes with IK
4088
4398
  // on — scrubbing to frame 39 shows that frame's interpolated pose AND
@@ -4103,6 +4413,7 @@ export default function App() {
4103
4413
  // (tools/ardy/visual-qa.mjs). Harmless in normal use.
4104
4414
  useEffect(() => {
4105
4415
  window.__cozyclay = {
4416
+ runStudioAction,
4106
4417
  runArdy: (options) => appContext.live.state.runArdy(options),
4107
4418
  rigA: activeRig, motion, tlFrame, frameCount: tlFrameCount, playing: tlPlaying, ikMode, ikChains, ikFocus, contactRadii: ikChains?.values().next().value?.contactRadii ?? null, ik: ikStateRef.current,
4108
4419
  rangePins, rangePinResiduals,
@@ -4209,22 +4520,33 @@ export default function App() {
4209
4520
  insetPane: insetPaneRef.current,
4210
4521
  mainPane: mainPaneRef.current,
4211
4522
  // the selected prop's route, so QA can aim a gesture at the line
4212
- objectPath: selectedSceneObject?.path ?? null,
4523
+ objectPath: routeOwner?.path ?? null,
4524
+ objectPathOwnerId: routeOwner?.id ?? null,
4213
4525
  pathPointIndex,
4214
- pathHandlesEnabled: !preview && !lookThroughShot && !ikMode && !posing && !!selectedSceneObject?.path,
4526
+ pathHandlesEnabled: !preview && !lookThroughShot && !ikMode && !posing && !!routeOwner?.path,
4215
4527
  addSceneObject: (kind, placement = {}) => addSceneObject(kind, placement),
4216
4528
  scrub: (frame) => setTlFrame(Math.max(0, Math.min(tlFrameCount - 1, Math.round(frame)))),
4217
4529
  pause: () => setTlPlaying(false),
4218
4530
  // Motion-trail QA surface: read the current trail policy and drive the
4219
4531
  // same drag -> preview -> pending-edit path headless checks cannot reach
4220
4532
  // through synthetic pointers reliably.
4221
- trail: { falloffFrames: trailFalloffFrames, falloffS: trailFalloffS, edit: trailEdit, tool: ikEditTool, visible: showTrails },
4533
+ trail: { falloffFrames: trailFalloffFrames, falloffS: trailFalloffS, edit: trailEdit, tool: ikEditTool, visible: showTrails, activeTrackId: trailTrackFocus, visibleTracks: trailTrackFocus ? [trailTrackFocus] : null },
4222
4534
  trailPoints: (jointName = "Hips") => jointTrailPoints(motion, jointName, { baseY: activeChar.y ?? 0, scale: activeChar.scale ?? 1 }),
4223
4535
  trailEditApply: (grabFrame, delta) => {
4224
- onTrailDragStart({ grabFrame });
4536
+ onTrailDragStart({ track: "hips", grabFrame });
4225
4537
  onTrailDragPreview({ track: "hips", grabFrame, delta });
4226
4538
  onTrailDragEnd({ track: "hips", grabFrame, delta });
4227
4539
  },
4540
+ // QA-only IK drag seam: resolves the same production solve/end path as
4541
+ // the viewport manipulator, with a deterministic world-space offset.
4542
+ ikPreviewApply: (track = "leftHand", offset = { x: 0.02, y: 0, z: 0 }) => {
4543
+ const chain = ikChains?.get(track), bone = chain?.bones?.[2];
4544
+ if (!bone) return false;
4545
+ const target = bone.getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(offset.x ?? 0, offset.y ?? 0, offset.z ?? 0));
4546
+ ikSolve("chain", track, target);
4547
+ ikDragEnd();
4548
+ return true;
4549
+ },
4228
4550
  trailRegenerate: runTrailRegeneration,
4229
4551
  // Fix-collisions QA surface: live penetration readout for headless
4230
4552
  // checks; the fix itself runs through the buttons / runFixCollisions.
@@ -4271,7 +4593,7 @@ export default function App() {
4271
4593
  // close over them: a stale closure would report the set as it was two
4272
4594
  // edits ago — and, after an undo that removes a subject, would keep
4273
4595
  // reporting the ghost's capsules.
4274
- }, [activeRig, motion, tlFrame, tlFrameCount, ikMode, ikChains, ikFocus, ikTick, charA, rangePins, rangePinResiduals, committedIkEdits, waypoints, lookThroughShot, selectedSceneObject, sceneObjects, rigs, characters, pathPointIndex, preview, posing, playMode, pathDraw, trailEdit, trailFalloffFrames, trailFalloffS, ikEditTool, showTrails, physicsPreview, physicsShow, physicsOptions, autoPhysicsRunning, platformFitRunning, platformFitLast, platformFitApplied]);
4596
+ }, [activeRig, motion, tlFrame, tlFrameCount, ikMode, ikChains, ikFocus, ikTick, charA, rangePins, rangePinResiduals, committedIkEdits, waypoints, lookThroughShot, selectedSceneObject, sceneObjects, rigs, characters, pathPointIndex, preview, posing, playMode, pathDraw, trailEdit, trailTrackFocus, pendingIkEdit, trailFalloffFrames, trailFalloffS, ikEditTool, showTrails, physicsPreview, physicsShow, physicsOptions, autoPhysicsRunning, platformFitRunning, platformFitLast, platformFitApplied]);
4275
4597
  // QA hook (plan §6.5): exposes history depth and the present === objects
4276
4598
  // invariant so the browser suite can assert undo entry counts directly.
4277
4599
  // Reads live store state at call time; re-registered after every render.
@@ -4320,19 +4642,37 @@ export default function App() {
4320
4642
  fovDeg,
4321
4643
  filmback,
4322
4644
  }), [tlFrameCount, tlFps, charA, motion, fovDeg, filmback]);
4323
- const motionPos = useMemo(() => (
4324
- motion ? sampleAt(playbackSceneBase, null, tlFrame).subject : null
4325
- ), [motion, playbackSceneBase, tlFrame]);
4645
+ // The camera's subject grouped under a routed object (#655) stands where
4646
+ // that object carries it: its played root through the object's carry,
4647
+ // frame by frame — the same carry the renderer gives the body. Null when it
4648
+ // rides nothing, so every reader below keeps the plain played root.
4649
+ const subjectCarry = useMemo(() => {
4650
+ if (!charA.parent) return null;
4651
+ const objects = new Map(sceneObjects.map((object) => [object.id, object]));
4652
+ const take = { frameCount: tlFrameCount, fps: tlFps };
4653
+ return (point, frame) => {
4654
+ if (!point) return point;
4655
+ const at = carriedPointAt(objects, charA.parent, { x: point.x, y: charA.y ?? 0, z: point.z }, frame, take);
4656
+ return { x: at.x, z: at.z };
4657
+ };
4658
+ }, [charA.parent, charA.y, sceneObjects, tlFrameCount, tlFps]);
4659
+ const motionPos = useMemo(() => {
4660
+ if (!motion && !subjectCarry) return null;
4661
+ const subject = sampleAt(playbackSceneBase, null, tlFrame).subject;
4662
+ return subjectCarry ? subjectCarry(subject, tlFrame) : subject;
4663
+ }, [motion, subjectCarry, playbackSceneBase, tlFrame]);
4326
4664
 
4327
4665
  // The subject's full per-frame scene trajectory — what the follow camera
4328
- // is derived from. Without a loaded motion the subject stands still and
4329
- // the follow camera simply composes a static frame.
4666
+ // is derived from. Without a loaded motion, and riding nothing, the subject
4667
+ // stands still and the follow camera simply composes a static frame.
4330
4668
  const subjectTrack = useMemo(() => {
4331
4669
  if (!shots.some((shot) => shot.camera?.mode === "follow" || shot.camera?.mode === "rail")) return null;
4332
4670
  const frames = Math.max(tlFrameCount, 1);
4333
- return Array.from({ length: frames }, (_, frame) =>
4334
- sampleAt(playbackSceneBase, null, frame).subject);
4335
- }, [shots, playbackSceneBase, tlFrameCount]);
4671
+ return Array.from({ length: frames }, (_, frame) => {
4672
+ const subject = sampleAt(playbackSceneBase, null, frame).subject;
4673
+ return subjectCarry ? subjectCarry(subject, frame) : subject;
4674
+ });
4675
+ }, [shots, playbackSceneBase, tlFrameCount, subjectCarry]);
4336
4676
 
4337
4677
  // The dense rail (spline through the drawn control points) — shared by
4338
4678
  // the follow controller and the Top-View display.
@@ -4443,7 +4783,7 @@ export default function App() {
4443
4783
  ? { label: ko("ROOT PATH", "루트 경로"), kind: "root" }
4444
4784
  : null;
4445
4785
 
4446
- function bufferToPng(buffer, output = shotOutput) {
4786
+ function bufferToPng(buffer, output = shotOutput, decorate = null) {
4447
4787
  const canvas = document.createElement("canvas");
4448
4788
  if (output === shotOutput) {
4449
4789
  canvas.width = shotOutput.width;
@@ -4463,6 +4803,7 @@ export default function App() {
4463
4803
  );
4464
4804
  }
4465
4805
  ctx.putImageData(image, 0, 0);
4806
+ decorate?.(ctx);
4466
4807
  return canvas.toDataURL("image/png");
4467
4808
  }
4468
4809
 
@@ -4476,6 +4817,12 @@ export default function App() {
4476
4817
  cam.rotation.order = "YXZ";
4477
4818
  cam.rotation.set(framing.pitch, framing.yaw, 0);
4478
4819
  cam.fov = framing.fovDeg;
4820
+ const prevAspect = cam.aspect;
4821
+ const offset = framing.viewOffset;
4822
+ if (offset) {
4823
+ cam.aspect = offset.fullWidth / offset.fullHeight;
4824
+ cam.setViewOffset(offset.fullWidth, offset.fullHeight, offset.x, offset.y, offset.width, offset.height);
4825
+ }
4479
4826
  cam.updateProjectionMatrix();
4480
4827
  const needsOwnTarget = output.width !== shotOutput.width || output.height !== shotOutput.height;
4481
4828
  const capture = needsOwnTarget && typeof captureRef.current.createExportCapture === "function"
@@ -4491,10 +4838,85 @@ export default function App() {
4491
4838
  look.current.yaw = prev.yaw;
4492
4839
  look.current.pitch = prev.pitch;
4493
4840
  cam.fov = prev.fov;
4841
+ if (offset) {
4842
+ cam.clearViewOffset();
4843
+ cam.aspect = prevAspect;
4844
+ }
4494
4845
  cam.updateProjectionMatrix();
4495
4846
  }
4496
4847
  }
4497
4848
 
4849
+ /** A live capture command's requested `output` size, or null to use its default. */
4850
+ function requestedCaptureOutput(args) {
4851
+ const requested = args?.output;
4852
+ return Number.isFinite(requested?.width) && Number.isFinite(requested?.height)
4853
+ ? { width: Math.round(requested.width), height: Math.round(requested.height) }
4854
+ : null;
4855
+ }
4856
+
4857
+ /** The Top View as a PNG: a 2D data diagram drawn from editor state by
4858
+ src/plan-diagram.js (grid, footprints, cast, every shot camera, the current
4859
+ shot's action line and blocked sight lines). No render is involved. */
4860
+ function capturePlanPng(output = { width: 1280, height: 720 }) {
4861
+ const live = appContext.live.state, s = readStudioState();
4862
+ const canvas = document.createElement("canvas");
4863
+ canvas.width = output.width;
4864
+ canvas.height = output.height;
4865
+ drawPlanDiagram(canvas.getContext("2d"), { frame: s.view.frame, frameCount: s.frameCount, objects: s.objects, characters: s.characters, shotDocument: { shots: s.shots }, camera: s.camera },
4866
+ { width: output.width, height: output.height, ports: { bounds: studioBounds }, currentShotId: live.activeShotId, aspect: shotOutput.width / shotOutput.height });
4867
+ return { dataUrl: canvas.toDataURL("image/png"), width: output.width, height: output.height, frame: live.timeline.currentFrame, shotId: live.activeShotId };
4868
+ }
4869
+
4870
+ /** The canvas runs under the floating panels, so "what I see" is only the uncovered middle.
4871
+ Returns the largest centred `aspect` rectangle inside that region as a camera view offset. */
4872
+ function visibleViewOffset(aspect) {
4873
+ const canvas = document.querySelector(".viewport canvas");
4874
+ if (!canvas) return null;
4875
+ const box = canvas.getBoundingClientRect();
4876
+ const edge = (selector, side) => {
4877
+ const rect = document.querySelector(selector)?.getBoundingClientRect();
4878
+ return rect && rect.width > 0 && rect.height > 0 ? rect[side] : null;
4879
+ };
4880
+ const left = Math.max(box.left, edge(".studio-left-column", "right") ?? box.left);
4881
+ const right = Math.min(box.right, edge(".studio-right-column", "left") ?? box.right);
4882
+ const top = Math.max(box.top, edge(".topbar", "bottom") ?? box.top);
4883
+ const bottom = Math.min(box.bottom, edge(".studio-dock-slot", "top") ?? box.bottom);
4884
+ if (right - left < 50 || bottom - top < 50) return null;
4885
+ const width = Math.min(right - left, (bottom - top) * aspect);
4886
+ const height = width / aspect;
4887
+ return {
4888
+ fullWidth: box.width, fullHeight: box.height,
4889
+ x: (left + right) / 2 - width / 2 - box.left, y: (top + bottom) / 2 - height / 2 - box.top,
4890
+ width, height,
4891
+ };
4892
+ }
4893
+
4894
+ /** Pose mode's image generation: the shot camera's view of the pose as it stands, at the
4895
+ delivery size, plus the scene's identity/environment references. The pose-mode object
4896
+ fade is lifted for this draw only (props are re-synced right after, so a demand frame loop keeps it), and editor
4897
+ overlays such as motion trails are hidden around it. */
4898
+ function capturePoseImage(source = "view") {
4899
+ const viewCam = source === "view" ? poserCamRef.current : null;
4900
+ let framing = captureCurrentFraming();
4901
+ if (viewCam) {
4902
+ const dir = viewCam.getWorldDirection(new THREE.Vector3());
4903
+ const pos = viewCam.getWorldPosition(new THREE.Vector3());
4904
+ framing = captureFraming({ pos, yaw: Math.atan2(-dir.x, -dir.z), pitch: Math.asin(THREE.MathUtils.clamp(dir.y, -1, 1)), fovDeg: viewCam.fov });
4905
+ framing.viewOffset = visibleViewOffset(shotOutput.width / shotOutput.height);
4906
+ }
4907
+ const scene = captureRef.current?.scene;
4908
+ const overlays = [];
4909
+ scene?.traverse((node) => {
4910
+ if (node.userData.editorOverlay && node.visible) { node.visible = false; overlays.push(node); }
4911
+ });
4912
+ try {
4913
+ const dataUrl = withPoseFadeLifted(propSyncRef.current, () => captureFramingPng(framing, shotOutput));
4914
+ return dataUrl ? { dataUrl, references: captureShotReferences() } : null;
4915
+ } finally {
4916
+ for (const node of overlays) node.visible = true;
4917
+ }
4918
+ }
4919
+
4498
4920
  function copyPrompt(prompt) {
4499
4921
  const write = navigator.clipboard?.writeText(prompt);
4500
4922
  if (!write) return;
@@ -6428,7 +6850,7 @@ export default function App() {
6428
6850
  preserveAuthoredMotion: Boolean(c.layer?.waypoints?.length) }]));
6429
6851
  return { host: { workspaceId: liveWorkspaceIdRef.current, documentEpoch: studioDocumentEpochRef.current,
6430
6852
  sceneId: activeSceneIdRef.current, sceneEpoch: studioSceneEpochRef.current }, workspaceHandle: liveWorkspaceHandleRef.current,
6431
- sceneName: live.scenes.find(s => s.id === activeSceneIdRef.current)?.name ?? "Untitled Scene", aspect: live.stage.shotAspect, stage: live.stage,
6853
+ sceneName: live.scenes.find(s => s.id === activeSceneIdRef.current)?.name ?? "Untitled Scene", previsMode: scenesDomain.previsMode, aspect: live.stage.shotAspect, stage: live.stage,
6432
6854
  objects: storeRef.current.objects, characters: list, targets, shots: live.shots, frameCount: live.timeline.frameCount,
6433
6855
  selection: live.studioSelection, activeCharacterId: live.activeCharacterId, selectedShotId: live.studioShotId,
6434
6856
  view: live.studioView, camera: live.studioCamera ?? readStudioCamera(), filmback: live.filmback, manual: manualCameraOverrideRef.current,
@@ -6502,6 +6924,14 @@ export default function App() {
6502
6924
  const raw = readStudioState();
6503
6925
  const original = raw.characters.find(c => c.id === entity.id) ?? raw.characters.find(c => c.model === entity.model);
6504
6926
  const target = original && raw.targets.get(original.id);
6927
+ if (isProxyFigure(original)) {
6928
+ const clip = target?.motion;
6929
+ const placement = placementAt(original, frame, {
6930
+ shotAt: at => shotAtFrame(raw.shots, at),
6931
+ takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, frame).subject : null,
6932
+ });
6933
+ return proxyFigureBounds(original, placement);
6934
+ }
6505
6935
  if (!target?.rig) throw new StudioProtocolError("TARGET_NOT_READY", "Character bounds require its loaded rig.");
6506
6936
  const rig = cloneSkeleton(target.rig), parent = new THREE.Group();
6507
6937
  const originals = [], copies = [];
@@ -6530,13 +6960,15 @@ export default function App() {
6530
6960
  for (const skeleton of skeletons) skeleton.dispose(); parent.remove(rig);
6531
6961
  }
6532
6962
  }
6533
- function operateStudio({ selection, view, shotId }) {
6963
+ function operateStudio({ selection, view, shotId, modeSet = true }) {
6534
6964
  const { frame } = view;
6535
6965
  appContext.patchLive({ studioSelection: selection, studioView: view, studioShotId: shotId });
6536
6966
  appContext.patchTimeline({ currentFrame: frame }); tlFrameRef.current = frame;
6537
6967
  if (selection && ["character", "rig"].includes(selection.kind)) { appContext.patchLive({ activeCharacterId: selection.id }); setActiveCharacterId(selection.id); }
6538
6968
  setSelectedHierarchyId(selection ? selection.kind === "object" ? `object:${selection.id}` : ["character", "rig"].includes(selection.kind) ? `character:${selection.id}` : selection.kind === "camera" ? "camera" : "shot" : "");
6539
- setTlFrame(frame); setWorkflowMode(view.mode); setLookThroughShot(view.lookThrough); setGridView(view.grid); setAutoColor(view.autoColor); setTlPlaying(view.playing);
6969
+ // Sculpt is UI-only and publishes as Stage (below): a view write that
6970
+ // names no mode keeps the editor in it.
6971
+ setTlFrame(frame); setWorkflowMode((current) => (current === "sculpt" && !modeSet && view.mode === "scene" ? current : view.mode)); setLookThroughShot(view.lookThrough); setGridView(view.grid); setAutoColor(view.autoColor); setTlPlaying(view.playing);
6540
6972
  }
6541
6973
  const studioGestureRef = useRef(false);
6542
6974
  // The Studio turn authors the scene through its own families and never
@@ -6555,13 +6987,15 @@ export default function App() {
6555
6987
  selectedStudioChar ? { kind: "character", id: selectedStudioChar, hierarchyId: selectedHierarchyId } : selectedHierarchyId === "camera" ? { kind: "camera", id: "camera" } : { kind: "scene", id: activeSceneId },
6556
6988
  studioShotId: activeShot?.id ?? null,
6557
6989
  studioPhysicsRunning: autoPhysicsRunning,
6558
- studioView: { mode: workflowMode, frame: tlFrame, playing: tlPlaying, lookThrough: lookThroughShot, grid: gridView, autoColor },
6990
+ // The agent's mode vocabulary is four (studio-agent-protocol.js): the
6991
+ // sculpt turntable is the Stage's scene seen in isolation.
6992
+ studioView: { mode: sculptMode ? "scene" : workflowMode, frame: tlFrame, playing: tlPlaying, lookThrough: lookThroughShot, grid: gridView, autoColor },
6559
6993
  });
6560
6994
  appContext.updatePorts({
6561
6995
  read: readStudioState, revision: sceneRevisionRef, bounds: studioBounds, commit: commitStudioDraft,
6562
6996
  operate: operateStudio, undo: () => stepStudioHistory(false), redo: () => stepStudioHistory(true),
6563
6997
  history: redo => appContext.historyEntry(redo), finishHistoryGesture: finishStudioHistoryGesture,
6564
- stepHistory: stepStudioHistory, capture: () => liveQueries.capture_framing_png({}),
6998
+ stepHistory: stepStudioHistory, capture: () => liveQueries.capture_framing_png({}), capturePlan: () => liveQueries.capture_plan_png({}),
6565
6999
  // One shot frame as raw read-back pixels (rows bottom-up), from the export
6566
7000
  // path captureShotFramePng uses; an export in flight renders at its output.
6567
7001
  renderFrameBuffer: frame => {
@@ -6605,7 +7039,7 @@ export default function App() {
6605
7039
  scenes: appContext.live.scenes.map(({ id, name }) => ({ id, name })), activeSceneId: activeSceneIdRef.current,
6606
7040
  // What a save needs: a name, a file this session, and (to pick or
6607
7041
  // re-grant a file) the user's click still active.
6608
- project: { name: projectName, hasFile: Boolean(projectHandleRef.current), fileAccess: hasFileSystemAccess(),
7042
+ project: { name: projectName, previsMode, hasFile: Boolean(projectHandleRef.current), fileAccess: hasFileSystemAccess(),
6609
7043
  gesture: globalThis.navigator?.userActivation?.isActive === true },
6610
7044
  // What generate() reads for the Send-to-AI package, as this render has it.
6611
7045
  aiShot: { mode, imageModel },
@@ -6670,21 +7104,63 @@ export default function App() {
6670
7104
  ? ko("Unsaved changes", "저장되지 않은 변경사항")
6671
7105
  : ko("Saved", "저장됨");
6672
7106
 
7107
+ // Sculpt mode's panel heads the Details column (DetailsSlot mounts it).
7108
+ const sculptPanel = sculptMode && !embedMode ? (
7109
+ <SculptPanel
7110
+ run={sculptRun}
7111
+ reference={sculptReference}
7112
+ onReference={(file) => void chooseSculptReference(file)}
7113
+ onStart={startSculptRun}
7114
+ onCancel={() => sculptRunRef.current?.cancel()}
7115
+ onPlaceInScene={placeSculptInScene}
7116
+ canStart={Boolean(sculptReference) && sculptAgentStatus === "ready" && Boolean(liveWorkspaceHandle) && Boolean(sculptModelPick.model)}
7117
+ blueprint={{
7118
+ ...sculptBlueprint,
7119
+ prompt: buildBlueprintPrompt({ pose: sculptBlueprint.pose, notes: sculptBlueprint.notes }),
7120
+ layout: BLUEPRINT_LAYOUT,
7121
+ // The image model is ChatGPT-only (the sidecar refuses others with a reason).
7122
+ refusal: sculptAgentStatus === "signed-out" ? ko("Sign in with ChatGPT in the Agent panel to generate a blueprint.", "도면을 생성하려면 에이전트 패널에서 ChatGPT로 로그인하세요.") : null,
7123
+ onPose: (pose) => setSculptBlueprint((current) => ({ ...current, pose })),
7124
+ onNotes: (notes) => setSculptBlueprint((current) => ({ ...current, notes })),
7125
+ onGenerate: () => void generateSculptBlueprint(),
7126
+ onCancel: cancelSculptBlueprint,
7127
+ }}
7128
+ modelPicker={{
7129
+ ...sculptModels,
7130
+ model: sculptModelPick.model,
7131
+ effort: sculptModelPick.effort,
7132
+ // A new model starts at its own default effort.
7133
+ onModel: (model) => chooseSculptModel({ model, effort: null }),
7134
+ onEffort: (effort) => chooseSculptModel({ effort }),
7135
+ }}
7136
+ startRefusal={sculptAgentStatus === "signed-out"
7137
+ ? ko("Sign in to the Agent (⌘B) to sculpt from an image.", "이미지로 조형하려면 에이전트(⌘B)에 로그인하세요.")
7138
+ : sculptAgentStatus === "ready" && !liveWorkspaceHandle
7139
+ ? ko("The live editor is disconnected — reconnect to sculpt.", "라이브 편집기가 연결되지 않았어요 — 연결 후 조형하세요.")
7140
+ : sculptAgentStatus === "ready" && sculptModels.state === "failed"
7141
+ ? ko("No model is available — add a provider key in the Agent panel.", "사용할 모델이 없어요 — 에이전트 패널에서 제공자 키를 추가하세요.")
7142
+ : null}
7143
+ sculptName={sculptTarget?.name ?? null}
7144
+ />
7145
+ ) : null;
6673
7146
  // The single shell snapshot keeps subsequent region work out of App.
6674
7147
  const shellContext = {
6675
- projectMenuOpen, setProjectMenuOpen, projectDirty, projectName, projectStartupOpen,
7148
+ sculptPanel,
7149
+ projectMenuOpen, setProjectMenuOpen, projectDirty, projectName, previsMode, projectStartupOpen,
6676
7150
  requestNewProject, setProjectStartupOpen, setProjectBrowserOpen, runStudioAction, saveProject,
6677
7151
  projectManifest, projectSaveState, recState, exportMenuTriggerRef, exportMenuOpen,
6678
7152
  exportShotIdRef, setExportMenuAnchor, setExportMenuOpen, exportPhaseLabel,
6679
7153
  exportMenuAnchor, resultOpen, exportFeedback, shots, exportKeyframePacks,
6680
7154
  hasCameraKeys, motion, exportRenderPasses, exportDepthVideo, exportStoryboard,
7155
+ exportContactSheet, exportPanelPacks, exportAnimatic,
7156
+ capturePanelThumbnail, packMetaForShot, previsMode: scenesDomain.previsMode, sceneRevision: sceneRevisionRef.current,
6681
7157
  downloadOtioCutList, projectStatus, liveWorkspaceHandle, selectedHierarchyId, selectHierarchy,
6682
7158
  aimEditorAtKeyLight, characters, showB, ikFrames, ikMode,
6683
7159
  rowIdForCharIndex, activeCharIndex, waypoints, sceneObjects, scenes,
6684
7160
  activeSceneId, selectSceneDocument, createSceneDocumentFromUi, duplicateSceneDocumentFromUi, renameSceneDocumentFromUi,
6685
- deleteSceneDocumentFromUi, addSceneObject, renameSceneObject, deleteSceneObject, frameSelection,
7161
+ deleteSceneDocumentFromUi, addSceneObject, renameSceneObject, deleteSceneObject, frameSelection, groupObjectUnderNewEmpty, renameRequest,
6686
7162
  toggleHierarchyHidden, propsDrop, hierarchyReparent, agentTouchedRows, workflowMode,
6687
- selectWorkflowMode, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled,
7163
+ selectWorkflowMode, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled, sculptView, requestSculptView,
6688
7164
  preset, applyPreset, cameraPresetId, i2vMotionCameraLocked, shotAspectKey,
6689
7165
  fovDeg, shotsDomain, shot, setNonce, workspaceLayout,
6690
7166
  viewMenuTriggerRef, viewMenuOpen, setViewMenuAnchor, setViewMenuOpen, viewLooksActive,
@@ -6712,12 +7188,13 @@ export default function App() {
6712
7188
  linePreviewError, generationBusy, bridgeChecking, lineReadinessState, runLineEdit,
6713
7189
  openMotionSetup, recheckMotionHealth, resetLineCurve, exitLineEditMode, readinessState,
6714
7190
  ardyRunning, cancelArdy, ardyStatus, ardyOutcome, addPromptClip,
6715
- tlFrame, isRigSelection, ikChains, ikFocus, footSnap,
7191
+ tlFrame, isRigSelection, ikChains, ikFocus, footSnap, poseObjectOpacity, setPoseObjectOpacity, capturePoseImage,
6716
7192
  collisionCleanupSupported, runFixCollisions, runFixCollisionsRange, autoPhysicsRunning, physicsProgress,
6717
7193
  physicsPreview, physicsShow, physicsOptions, platformFitRunning, platformFitProgress,
6718
7194
  platformFitLast, platformFitApplied, changePhysicsOptions, runAutoPhysics, showPhysicsPreview,
6719
7195
  applyPhysicsPreview, cancelPhysicsPreview, ikEditTool, setIkEditTool, showTrails,
6720
- setShowTrails, trailFalloffS, setTrailFalloffS, trailEdit, runTrailRegeneration,
7196
+ setShowTrails, trailFalloffS, setTrailFalloffS, trailEdit, trailTrackFocus, selectTrailTrack, clearTrailTrackFocus, runTrailRegeneration,
7197
+ pendingIkEdit, applyPendingIkEdit, cancelPendingIkEdit,
6721
7198
  trailReadinessState, rangePins, rangePinResiduals, rangePinSelection, rangePinPartPick,
6722
7199
  rangePinPreview, setRangePinSelection, setRangePinPartPick, previewRangePinDraft, applyRangePinDraft,
6723
7200
  deleteRangePin, hasEnvSheet, environment, style, environmentImage,
@@ -6739,10 +7216,11 @@ export default function App() {
6739
7216
  addActiveCranePoint, deleteSelectedCranePoint, setCraneSelectedIndex, tlFps, ghostLayers,
6740
7217
  pathSpeed, tlPlaying, pendingWaypointFrame, stateBadge, applyMotionTrim,
6741
7218
  resetMotionTrim, cutMotionAtPlayhead, changeMotionSegmentSpeed, removeMotionSegmentById, bodyContact,
6742
- activeShotIdx, railDraw, pathDraw, setPathDraw, setRailDraw,
6743
- setWorkspaceLayout, timingTokenRef, railCurve, ikAddKeyframe, ikDeleteKeyframe,
7219
+ activeShotIdx, railDraw, pathDraw, pathTarget, setPathDraw, setRailDraw,
7220
+ setWorkspaceLayout, timingTokenRef, railCurve, ikAddKeyframe, ikDeleteKeyframe, pathPointIndex, setPathPointIndex,
6744
7221
  setBodyContact, setFootSnap, advanceFrame, stepFrame, cameraPreviewEndRef,
6745
- manualCameraOverrideRef, setTlPlaying, toggleWaypointMode, setWaypointMode, selectActiveCharacterInHierarchy,
7222
+ manualCameraOverrideRef, setTlPlaying, setWaypointMode, selectActiveCharacterInHierarchy,
7223
+ toggleWaypointMode: storyboardProject ? undefined : toggleWaypointMode,
6746
7224
  setActiveWaypointId, setPendingWaypointFrame, removeWaypoint, queueRootWaypointFrame, revealPromptBlocks,
6747
7225
  resizePromptClip, movePromptClip, removePromptClip, setSelectedHierarchyId, addCameraKeyframe,
6748
7226
  moveCameraKeyframe, removeCameraKeyframe, syncActiveCameraFraming, activeCamera, activeShotDuration,
@@ -6764,7 +7242,7 @@ export default function App() {
6764
7242
  return (
6765
7243
  <AppContext.Provider value={appContext}>
6766
7244
  <StudioShellContext.Provider value={shellContext}>
6767
- <StudioShell className={"app" + (renderActive ? "" : " render-idle")} style={workspaceStyle} data-workflow-mode={workflowMode} data-embed-mode={embedMode ? "playview" : playgroundMode ? "playground" : undefined} data-playground-hint={playgroundMode ? playgroundHint ?? undefined : undefined} data-tutorial-step={cameraTutorial ? cameraTutorialStep ?? undefined : undefined} data-rail-draw={railDraw ? 1 : undefined} data-shot-look={lookThroughShot && !embedMode ? 1 : undefined} data-plan-draw={railDraw || pathDraw || waypointMode ? 1 : undefined}
7245
+ <StudioShell className={"app" + (renderActive ? "" : " render-idle")} style={workspaceStyle} data-workflow-mode={workflowMode} data-previs-mode={scenesDomain.previsMode} data-embed-mode={embedMode ? "playview" : playgroundMode ? "playground" : undefined} data-playground-hint={playgroundMode ? playgroundHint ?? undefined : undefined} data-tutorial-step={cameraTutorial ? cameraTutorialStep ?? undefined : undefined} data-rail-draw={railDraw ? 1 : undefined} data-shot-look={lookThroughShot && !embedMode ? 1 : undefined} data-plan-draw={railDraw || pathDraw || waypointMode ? 1 : undefined}
6768
7246
  viewport={
6769
7247
  <div className="viewport" data-drop={viewportDrop.over ? "over" : undefined} {...viewportDrop.handlers}
6770
7248
  onWheel={(event) => {
@@ -6775,6 +7253,12 @@ export default function App() {
6775
7253
  }
6776
7254
  }}>
6777
7255
  <ViewportToolbar />
7256
+ {sculptMode && !sculptTarget && (
7257
+ <div className="vp-sculpt-hint" role="status" data-testid="sculpt-empty">
7258
+ <strong>{ko("New sculpt", "새 조형물")}</strong>
7259
+ <span>{ko("Add a reference image in the Sculpt panel and press Start — or select a sculpt and press 5.", "조형 패널에 참조 이미지를 넣고 시작을 누르세요 — 또는 조형물을 선택하고 5를 누르세요.")}</span>
7260
+ </div>
7261
+ )}
6778
7262
 
6779
7263
  {/* Sits under the mode tabs and left of the Top-View inset, over the
6780
7264
  stage it is teaching. The overlay itself never takes the pointer
@@ -6784,6 +7268,7 @@ export default function App() {
6784
7268
  key={cameraTutorialAttempt}
6785
7269
  analytics={cameraTutorialAnalytics.current}
6786
7270
  previewing={lookThroughShot}
7271
+ previsMode={scenesDomain.previsMode}
6787
7272
  shotCount={shots.length}
6788
7273
  railReady={shots.some((shot) => Array.isArray(shot.camera?.cameraRail) && shot.camera.cameraRail.length >= 2)}
6789
7274
  onStepChange={setCameraTutorialStep}
@@ -6850,7 +7335,7 @@ export default function App() {
6850
7335
  <KeyLightPuck
6851
7336
  keyLight={keyLight}
6852
7337
  selected={keyLightSelected}
6853
- visible={!preview && !lookThroughShot}
7338
+ visible={!preview && !lookThroughShot && !sculptMode}
6854
7339
  paneRef={mainPaneRef}
6855
7340
  camRef={editorCamRef}
6856
7341
  onSelect={() => selectHierarchy("light")}
@@ -6862,12 +7347,14 @@ export default function App() {
6862
7347
  {gridView ? <GridFloor layer={GIZMO_LAYER} /> : <Room light={uiTheme === "light"} />}
6863
7348
  <SetProps
6864
7349
  objects={stageSceneObjects}
7350
+ authoredObjects={sceneObjects}
6865
7351
  selectedId={selectedSceneObjectId}
6866
7352
  frameRef={propFrameRef}
6867
7353
  take={{ frameCount: tlFrameCount, fps: tlFps }}
6868
7354
  attachFrameRef={attachFrameRef}
6869
7355
  syncRef={propSyncRef}
6870
7356
  worldRef={propWorldRef}
7357
+ fadeOpacity={workflowMode === "pose" ? poseObjectOpacity : null}
6871
7358
  />
6872
7359
 
6873
7360
  <PerspectiveCamera
@@ -6883,7 +7370,7 @@ export default function App() {
6883
7370
  operator's own view of the set. */}
6884
7371
  <PerspectiveCamera
6885
7372
  ref={editorCamRef}
6886
- makeDefault={!ikMode && !lookThroughShot}
7373
+ makeDefault={!ikMode && !lookThroughShot && !sculptMode}
6887
7374
  fov={55}
6888
7375
  near={0.1}
6889
7376
  far={100}
@@ -6908,21 +7395,47 @@ export default function App() {
6908
7395
  position={[0, 24, 0]}
6909
7396
  rotation={[-Math.PI / 2, 0, 0]}
6910
7397
  />
7398
+ {/* Sculpt mode's own orbit camera and turntable; it owns the main
7399
+ pane (DualRender's editor slot) while the shot preview keeps
7400
+ drawing the whole scene. Unmounting restores normal rendering. */}
7401
+ {sculptMode && (
7402
+ <SculptTurntable
7403
+ camRef={sculptCamRef}
7404
+ objects={sceneObjects}
7405
+ target={sculptTarget}
7406
+ size={sculptTarget ? objectSize(sculptTarget) : null}
7407
+ view={sculptView}
7408
+ light={uiTheme === "light"}
7409
+ busy={["measure", "draft", "score", "refine"].includes(sculptRun?.stage)}
7410
+ changedParts={sculptRun?.changedParts ?? null}
7411
+ />
7412
+ )}
6911
7413
 
6912
7414
  {characterViews.map((view) => (
6913
- <Character
7415
+ // Always wrapped, grouped or not: giving or taking a parent
7416
+ // must not remount the rig the playback effects hold.
7417
+ <ObjectCarrier
6914
7418
  key={`${view.id}:${rigMountEpoch}`}
6915
- url={view.url}
6916
- position={view.position}
6917
- rot={view.rot}
6918
- tint={view.tint}
6919
- partColoursEnabled={view.partColoursEnabled}
6920
- partColoursMode={view.partColoursMode}
6921
- pose={view.pose}
6922
- scale={view.scale}
6923
- onRig={view.onRig}
6924
- pickId={view.pickId}
6925
- />
7419
+ objectId={view.parent}
7420
+ objectsRef={sceneObjectLookupRef}
7421
+ frameRef={propFrameRef}
7422
+ take={{ frameCount: tlFrameCount, fps: tlFps }}
7423
+ registryRef={carrierRegistryRef}
7424
+ >
7425
+ <Character
7426
+ url={view.url}
7427
+ {...{ model: view.model, posture: view.posture }}
7428
+ position={view.position}
7429
+ rot={view.rot}
7430
+ tint={view.tint}
7431
+ partColoursEnabled={view.partColoursEnabled}
7432
+ partColoursMode={view.partColoursMode}
7433
+ pose={view.pose}
7434
+ scale={view.scale}
7435
+ onRig={view.onRig}
7436
+ pickId={view.pickId}
7437
+ />
7438
+ </ObjectCarrier>
6926
7439
  ))}
6927
7440
 
6928
7441
  {/* Selection marker: XYZ tripod + ring on the picked cast
@@ -6935,7 +7448,7 @@ export default function App() {
6935
7448
  objects={characterGizmoObject ? [characterGizmoObject] : []}
6936
7449
  mode={gizmoMode}
6937
7450
  snap={snapEnabled}
6938
- enabled={!planIsMain && !posing && !ikMode && !playMode && !!characterGizmoObject}
7451
+ enabled={!planIsMain && !posing && !ikMode && !playMode && !sculptMode && !!characterGizmoObject}
6939
7452
  paneRef={mainPaneRef}
6940
7453
  camRef={lookThroughShot ? shotCamRef : editorCamRef}
6941
7454
  shotAspect={lookThroughShot ? shotOutput.aspect : null}
@@ -6994,6 +7507,7 @@ export default function App() {
6994
7507
  // editorial camera keys resume when the block returns to Keys mode.
6995
7508
  following={!followCamActive && hasCameraKeys && (preview || (moveFollow && !ikMode && !waypointMode && !posing))}
6996
7509
  followFrame={tlFrame}
7510
+ frameRef={propFrameRef}
6997
7511
  fps={tlFps}
6998
7512
  keys={cameraKeys}
6999
7513
  shots={shots}
@@ -7006,6 +7520,7 @@ export default function App() {
7006
7520
  <FollowCamRig
7007
7521
  enabled={followCamActive && !movePlaying}
7008
7522
  frame={tlFrame}
7523
+ frameRef={propFrameRef}
7009
7524
  scene={playbackScene}
7010
7525
  shot={activeShot}
7011
7526
  camRef={shotCamRef}
@@ -7017,7 +7532,7 @@ export default function App() {
7017
7532
  pointerdowns that hit a handle, so empty-space drags orbit
7018
7533
  and the wheel dollies without wrecking the framing. */}
7019
7534
  <FlyControls
7020
- enabled={!posing && !playMode}
7535
+ enabled={!posing && !playMode && !sculptMode}
7021
7536
  cameraLocked={lookThroughShot && i2vMotionCameraLocked}
7022
7537
  camRef={ikMode ? poserCamRef : lookThroughShot ? shotCamRef : editorCamRef}
7023
7538
  look={ikMode ? poserLook : lookThroughShot ? look : editorLook}
@@ -7039,6 +7554,7 @@ export default function App() {
7039
7554
  markSemanticEdit("pose", before, after);
7040
7555
  setPoseTick((n) => n + 1);
7041
7556
  }}
7557
+ onCommit={commitPosedPose}
7042
7558
  />
7043
7559
  <IkHandles
7044
7560
  chains={ikChains}
@@ -7069,6 +7585,7 @@ export default function App() {
7069
7585
  look={look}
7070
7586
  fovDeg={fovDeg}
7071
7587
  characters={characters}
7588
+ characterPlacements={characterViews}
7072
7589
  onMoveCharacter={moveCharacter}
7073
7590
  onCameraGestureStart={beginCameraFramingGesture}
7074
7591
  pathStart={activeChar}
@@ -7092,33 +7609,30 @@ export default function App() {
7092
7609
  cameraRailPoints={railCurve ? railCurve.points : null}
7093
7610
  railDraw={railDraw}
7094
7611
  pathDraw={pathDraw}
7095
- objectPathPoints={selectedSceneObject?.path?.points ?? null}
7612
+ objectPath={routeOwner?.path ?? null}
7096
7613
  objectPathSelectedIndex={pathPointIndex}
7097
7614
  onObjectPathPointSelect={setPathPointIndex}
7098
- onObjectPathPointMove={(index, floor) => {
7099
- const path = selectedSceneObject?.path;
7615
+ onObjectPathChange={(patch) => {
7616
+ const path = routeOwner?.path;
7100
7617
  if (!path) return;
7101
- // The board edits the floor route only; a point's height is
7102
- // the scene's business, so y rides through untouched.
7103
- const points = path.points.map((point, i) => (i === index ? { ...point, x: floor.x, z: floor.z } : point));
7104
- changeSceneObject(selectedSceneObject.id, { path: { ...path, points } }, planPathTokenRef.current);
7618
+ // Inside the gesture's own transaction (planPathTokenRef), like
7619
+ // the 3D view. The board bends the floor route only; a point's
7620
+ // height is the scene's business, so y rides through untouched.
7621
+ changeSceneObject(routeOwner.id, { path: { ...path, ...patch } }, planPathTokenRef.current);
7105
7622
  }}
7106
- onObjectPathPointInsert={(index, t) => {
7107
- const path = selectedSceneObject?.path;
7108
- if (!path || path.points.length >= MAX_PATH_POINTS) return;
7109
- const a = path.points[index];
7110
- const b = path.points[index + 1];
7111
- const inserted = {
7112
- x: a.x + (b.x - a.x) * t,
7113
- y: (a.y ?? 0) + ((b.y ?? 0) - (a.y ?? 0)) * t,
7114
- z: a.z + (b.z - a.z) * t,
7115
- };
7116
- const points = [...path.points.slice(0, index + 1), inserted, ...path.points.slice(index + 1)];
7623
+ onObjectPathMarkInsert={(t) => {
7624
+ const path = routeOwner?.path;
7625
+ if (!path) return;
7626
+ // The new dot is born with the lean the route already has there,
7627
+ // and it goes to the record that owns the route (the selected
7628
+ // group may only contain it).
7629
+ const added = insertPathMark(path, t);
7630
+ if (!added) return;
7117
7631
  const token = beginSceneTransaction({ owner: "object-path", cancel: () => {} });
7118
- changeSceneObject(selectedSceneObject.id, { path: { ...path, points } }, token);
7632
+ changeSceneObject(routeOwner.id, { path: { ...path, marks: added.marks } }, token);
7119
7633
  endSceneTransaction(token, { commit: true });
7120
- setPathPointIndex(index + 1);
7121
- setToast(ko("Point added — drag it here, or lift it in the scene", "점을 추가했어요 — 여기서 끌거나 씬에서 높이를 올리세요"));
7634
+ setPathPointIndex(added.index);
7635
+ setToast(ko("Dot added — drag it to bend the route", "점을 추가했어요 — 끌어서 경로를 휘세요"));
7122
7636
  }}
7123
7637
  onObjectPathGestureStart={() => {
7124
7638
  planPathTokenRef.current = beginSceneTransaction({ owner: "object-path", cancel: () => { planPathTokenRef.current = null; } });
@@ -7131,16 +7645,22 @@ export default function App() {
7131
7645
  keyLight={keyLight}
7132
7646
  onRailStroke={shotsDomain.drawCameraRail}
7133
7647
  onPathStroke={(stroke) => {
7134
- if (!selectedSceneObject) return;
7135
- // Few points on purpose: the stroke sets the shape, the
7136
- // operator adds the handles they actually want by
7137
- // double-clicking the line. Height comes later, from
7648
+ if (!pathTarget) return;
7649
+ // The stroke keeps its shape, as the camera rail's does; the
7650
+ // operator adds the dots they want to hold by double-clicking
7651
+ // the line. The route starts at the height
7652
+ // the object stands at, so a raised body (a chassis on its
7653
+ // wheels, a prop on a table) travels where it is instead of
7654
+ // dropping to the floor; changing height comes later, from
7138
7655
  // dragging a point in the scene.
7139
- const points = strokeToPathPoints(stroke, simplifyStroke);
7656
+ const height = pathTarget.attach ? 0 : pathTarget.y ?? 0;
7657
+ const points = strokeToPathPoints(stroke, simplifyStroke).map((point) => ({ ...point, y: height }));
7140
7658
  if (points.length < 2) return;
7141
7659
  const token = beginSceneTransaction({ owner: "object-path", cancel: () => {} });
7142
- changeSceneObject(selectedSceneObject.id, { path: { ...(selectedSceneObject.path ?? {}), points } }, token);
7660
+ // A new stroke is a new shape: marks belong to the old one.
7661
+ changeSceneObject(pathTarget.id, { path: { ...(pathTarget.path ?? {}), points, marks: [] } }, token);
7143
7662
  endSceneTransaction(token, { commit: true });
7663
+ setPathPointIndex(null);
7144
7664
  setPathDraw(false);
7145
7665
  const metrics = pathMetrics(createObjectPath({ points }));
7146
7666
  setToast(isKo
@@ -7153,11 +7673,11 @@ export default function App() {
7153
7673
  the plan owns the big pane (the pucks are the handles there)
7154
7674
  and while posing/IK owns the pointer. */}
7155
7675
  <ObjectGizmo
7156
- object={cameraGizmoObject ?? lightGizmoObject ?? (selectedSceneObject && !isEffectivelyHidden(selectedSceneObject, sceneObjects, characters) ? selectedSceneObject : null)}
7676
+ object={cameraGizmoObject ?? lightGizmoObject ?? (selectedSceneObject && !isEffectivelyHidden(selectedSceneObject, sceneObjects, characters) ? gizmoSceneObject : null)}
7157
7677
  objects={sceneObjects}
7158
7678
  mode={lightGizmoObject ? "move" : cameraGizmoObject ? (gizmoMode === "scale" ? "move" : gizmoMode) : gizmoMode}
7159
7679
  snap={snapEnabled}
7160
- enabled={!planIsMain && !posing && !ikMode && !playMode}
7680
+ enabled={!planIsMain && !posing && !ikMode && !playMode && !sculptMode}
7161
7681
  paneRef={mainPaneRef}
7162
7682
  camRef={lookThroughShot ? shotCamRef : editorCamRef}
7163
7683
  shotAspect={lookThroughShot ? shotOutput.aspect : null}
@@ -7187,15 +7707,19 @@ export default function App() {
7187
7707
  />
7188
7708
  )}
7189
7709
  <ObjectPathHandles
7190
- path={selectedSceneObject?.path ?? null}
7710
+ path={routeOwner?.path ?? null}
7191
7711
  selectedIndex={pathPointIndex}
7192
- enabled={!preview && !lookThroughShot && !ikMode && !posing && !!selectedSceneObject?.path}
7712
+ mode={gizmoMode}
7713
+ enabled={!preview && !lookThroughShot && !ikMode && !posing && !!routeOwner?.path}
7193
7714
  paneRef={mainPaneRef}
7194
7715
  camRef={editorCamRef}
7195
7716
  onSelect={setPathPointIndex}
7196
- onChangePoints={(points) => {
7197
- if (!selectedSceneObject) return;
7198
- changeSceneObject(selectedSceneObject.id, { path: points === null ? null : { ...selectedSceneObject.path, points } });
7717
+ onChangePath={(patch) => {
7718
+ if (!routeOwner) return;
7719
+ // Inside the drag's own transaction, like the Top-View mark
7720
+ // move: a plain update while the transaction is open is
7721
+ // refused, so the dot never moved in the 3D view.
7722
+ changeSceneObject(routeOwner.id, { path: patch === null ? null : { ...routeOwner.path, ...patch } }, pathDragTokenRef.current);
7199
7723
  }}
7200
7724
  onDragStart={() => {
7201
7725
  pathDragTokenRef.current = beginSceneTransaction({ owner: "object-path", cancel: () => {} });
@@ -7246,6 +7770,7 @@ export default function App() {
7246
7770
  baseY={activeChar.y ?? 0}
7247
7771
  charScale={activeChar.scale ?? 1}
7248
7772
  ikFocus={ikFocus}
7773
+ activeTrackId={trailTrackFocus}
7249
7774
  falloffFrames={trailFalloffFrames}
7250
7775
  playheadFrame={tlFrame}
7251
7776
  pendingEdit={trailEdit}
@@ -7264,10 +7789,11 @@ export default function App() {
7264
7789
  shotCamRef={shotCamRef}
7265
7790
  planCamRef={planCamRef}
7266
7791
  poserCamRef={poserCamRef}
7267
- editorCamRef={editorCamRef}
7268
- // The studio hides the facing marks on screen; embeds show the
7269
- // scene as exported.
7270
- editorLook={!embedMode && !playgroundMode}
7792
+ editorCamRef={sculptMode ? sculptCamRef : editorCamRef}
7793
+ // The studio and the landing playground hide the facing marks on
7794
+ // screen; the Workflow embed shows the scene as exported. Captures
7795
+ // render outside this loop and keep them everywhere.
7796
+ editorLook={!embedMode}
7271
7797
  ikMode={ikMode}
7272
7798
  planIsMain={planIsMain}
7273
7799
  // Preview IS PlayView's render path: DualRender tests this branch
@@ -7276,7 +7802,7 @@ export default function App() {
7276
7802
  // shot camera in the editing draw instead.
7277
7803
  playMode={preview}
7278
7804
  lookThrough={lookThroughShot}
7279
- insetCollapsed={workspaceLayout.insetCollapsed || workflowMode === "motion" || workflowMode === "pose"}
7805
+ insetCollapsed={workspaceLayout.insetCollapsed || workflowMode === "motion" || workflowMode === "pose" || workflowMode === "sculpt"}
7280
7806
  planZoom={workspaceLayout.planZoom}
7281
7807
  shotAspect={shotOutput.aspect}
7282
7808
  />
@@ -7475,9 +8001,9 @@ export default function App() {
7475
8001
  setProjectBrowserOpen(false);
7476
8002
  void openStarterScene(id, "starter", name);
7477
8003
  }}
7478
- onNew={() => {
8004
+ onNew={(name, options) => {
7479
8005
  setProjectBrowserOpen(false);
7480
- requestNewProject();
8006
+ requestNewProject(false, options);
7481
8007
  }}
7482
8008
  onClose={() => {
7483
8009
  setProjectBrowserOpen(false);
@@ -7495,7 +8021,7 @@ export default function App() {
7495
8021
  else newProject(name);
7496
8022
  }}
7497
8023
  />
7498
- <FirstSuccessGuide open={firstSuccessGuideOpen} onDismiss={() => setFirstSuccessGuideOpen(false)} />
8024
+ <FirstSuccessGuide open={firstSuccessGuideOpen} previsMode={scenesDomain.previsMode} onDismiss={() => setFirstSuccessGuideOpen(false)} />
7499
8025
  <UseCaseQuestion
7500
8026
  open={useCaseAskOpen}
7501
8027
  isKo={isKo}