cozyclay 2.0.0 → 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 (260) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +219 -38
  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 +6 -5
  19. package/bin/cozyclay.mjs +12 -3
  20. package/bin/live/cli.mjs +2 -3
  21. package/bin/live/client.mjs +1 -1
  22. package/bin/live/studio.mjs +5 -2
  23. package/bin/mcp-runtime.mjs +12 -2
  24. package/bin/telemetry-state.mjs +3 -3
  25. package/dist/app/index.html +6 -6
  26. package/dist/assets/analytics-B41FmPYI.js +1 -0
  27. package/dist/assets/app-Do-Og5Pu.css +1 -0
  28. package/dist/assets/app-DwP-tl2x.js +4920 -0
  29. package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
  30. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
  31. package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
  32. package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
  33. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  34. package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
  35. package/dist/assets/workflow-Br-K7Srs.js +179 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/index.html +328 -222
  38. package/dist/sitemap.xml +5 -5
  39. package/dist/workflow/index.html +4 -4
  40. package/mcp/LIVE-PROTOCOL.md +23 -2
  41. package/mcp/README.md +37 -2
  42. package/mcp/ardy-prompts.mjs +1 -1
  43. package/mcp/image-file.mjs +132 -0
  44. package/mcp/live-hub.mjs +2 -2
  45. package/mcp/package.json +1 -0
  46. package/mcp/runtime/package-lock.json +7 -0
  47. package/mcp/runtime/package.json +1 -0
  48. package/mcp/server.mjs +0 -6
  49. package/mcp/tool-handlers.mjs +289 -87
  50. package/package.json +11 -2
  51. package/src/App.jsx +801 -259
  52. package/src/agent-image-client.js +94 -0
  53. package/src/analytics.js +47 -3
  54. package/src/app-stage.jsx +129 -2745
  55. package/src/ardy/auto-physics.js +6 -16
  56. package/src/ardy/collision-blockers.js +3 -7
  57. package/src/ardy/fix-collisions.js +5 -35
  58. package/src/ardy/ik-drag.js +86 -5
  59. package/src/ardy/ik-key-json.js +3 -1
  60. package/src/ardy/ik.js +15 -13
  61. package/src/ardy/motion-calibration.js +1 -1
  62. package/src/ardy/npz.js +0 -8
  63. package/src/ardy/physics-panel.jsx +3 -2
  64. package/src/ardy/physics-review.js +3 -2
  65. package/src/ardy/physics-support.js +1 -1
  66. package/src/ardy/physics-surface.js +1 -1
  67. package/src/ardy/platform-fit-panel.jsx +2 -1
  68. package/src/ardy/playback.js +1 -5
  69. package/src/ardy/range-pin.js +2 -2
  70. package/src/ardy/root-drop.js +0 -1
  71. package/src/ardy/timeline.css +196 -3
  72. package/src/ardy/timeline.jsx +117 -29
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +1 -4
  75. package/src/ardy-bridge-contract.js +281 -0
  76. package/src/asset-pane.jsx +19 -9
  77. package/src/auto-color.js +1 -1
  78. package/src/burn-in.js +1 -1
  79. package/src/camera-block.js +1 -1
  80. package/src/camera-follow.js +75 -32
  81. package/src/camera-move.js +0 -6
  82. package/src/camera-tutorial.jsx +43 -24
  83. package/src/character-kind.js +79 -0
  84. package/src/command-bus.js +76 -17
  85. package/src/commands/cast.js +31 -7
  86. package/src/commands/elements/object.js +1 -1
  87. package/src/commands/elements/shot.js +1 -1
  88. package/src/commands/elements.js +1 -1
  89. package/src/commands/export.js +3 -3
  90. package/src/commands/motion.js +2 -2
  91. package/src/commands/objects.js +105 -10
  92. package/src/commands/project.js +16 -3
  93. package/src/commands/sculpt-images.js +53 -0
  94. package/src/commands/shot.js +88 -10
  95. package/src/commands/view.js +10 -4
  96. package/src/coplanar-depth.js +216 -0
  97. package/src/cuts.js +7 -6
  98. package/src/domains/cast.js +60 -43
  99. package/src/domains/motion/motion-intent-codec.js +50 -0
  100. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  101. package/src/domains/motion/motion-video-capture.js +664 -0
  102. package/src/domains/motion.js +253 -742
  103. package/src/domains/objects.js +106 -5
  104. package/src/domains/scenes.js +89 -32
  105. package/src/domains/shots.js +33 -7
  106. package/src/error-boundary.jsx +1 -1
  107. package/src/execution-telemetry.js +5 -5
  108. package/src/facing-marks.js +14 -1
  109. package/src/first-success-guide.jsx +35 -4
  110. package/src/gizmo-claim.js +3 -1
  111. package/src/grid-floor.jsx +1 -4
  112. package/src/hierarchy-model.js +48 -35
  113. package/src/hierarchy-panel.css +1 -0
  114. package/src/hierarchy-panel.jsx +54 -102
  115. package/src/history.js +0 -2
  116. package/src/i2v-motion-client.js +1 -4
  117. package/src/ime.js +6 -0
  118. package/src/keyframe-pack.js +15 -6
  119. package/src/line-edit.js +4 -4
  120. package/src/live-control.js +6 -6
  121. package/src/matte-editor.js +2 -8
  122. package/src/matte.js +0 -19
  123. package/src/mesh-graph-clone.js +16 -2
  124. package/src/mesh-sniff.js +0 -1
  125. package/src/monotone-cubic.js +40 -0
  126. package/src/motion/generation.js +1 -1
  127. package/src/motion-store.js +4 -4
  128. package/src/motion-trail.js +5 -11
  129. package/src/multimodel-ingest.js +1 -3
  130. package/src/object-catalog.jsx +7 -5
  131. package/src/object-gizmo.jsx +45 -31
  132. package/src/object-path.js +310 -67
  133. package/src/object-travel.js +224 -0
  134. package/src/otio.js +8 -3
  135. package/src/panels/CharacterTransformPanel.jsx +24 -6
  136. package/src/panels/ObjectTransformPanel.jsx +4 -3
  137. package/src/panels/PoseImagePanel.jsx +205 -0
  138. package/src/panels/PromptBlocksPanel.jsx +3 -1
  139. package/src/panels/ProxyFigurePanel.jsx +56 -0
  140. package/src/panels/ReferenceImageField.jsx +1 -1
  141. package/src/panels/RigControlPanel.jsx +24 -3
  142. package/src/panels/RigPanel.jsx +2 -1
  143. package/src/panels/SculptPanel.jsx +405 -0
  144. package/src/panels/SubjectBox.jsx +1 -1
  145. package/src/panels/SubjectsPanel.jsx +0 -2
  146. package/src/panels/VideoCapturePanel.jsx +2 -1
  147. package/src/panels/pose-image.css +42 -0
  148. package/src/panels/pose.css +3 -0
  149. package/src/panels/sculpt.css +162 -0
  150. package/src/plan-diagram.js +184 -0
  151. package/src/planview.jsx +127 -80
  152. package/src/playground.js +3 -5
  153. package/src/pose-fade.js +77 -0
  154. package/src/pose-gizmo.css +23 -0
  155. package/src/pose-gizmo.js +96 -0
  156. package/src/posestudio.jsx +134 -83
  157. package/src/project-browser.css +53 -0
  158. package/src/project-browser.jsx +52 -3
  159. package/src/project.js +55 -24
  160. package/src/props.jsx +177 -36
  161. package/src/proxy-figure.jsx +69 -0
  162. package/src/range-pin-panel.jsx +0 -1
  163. package/src/receipt-findings.js +20 -0
  164. package/src/render-passes.js +0 -1
  165. package/src/resource-status.jsx +1 -1
  166. package/src/root-path.js +48 -0
  167. package/src/route-owner.js +52 -0
  168. package/src/scene-asset-cache.js +1 -2
  169. package/src/scene-assets.js +12 -4
  170. package/src/scene-fbx.js +1 -1
  171. package/src/scene-mesh-cache.js +0 -1
  172. package/src/scene-objects.js +319 -19
  173. package/src/scenes.js +62 -17
  174. package/src/sculpt-blueprint.js +65 -0
  175. package/src/sculpt-compare.js +384 -0
  176. package/src/sculpt-geometry.js +141 -0
  177. package/src/sculpt-mode.js +168 -0
  178. package/src/sculpt-recipe.js +385 -0
  179. package/src/sculpt-run.js +351 -0
  180. package/src/sculpt-stage.jsx +221 -0
  181. package/src/settings-menu.jsx +1 -1
  182. package/src/shell/BoardSlot.jsx +341 -0
  183. package/src/shell/BottomDock.jsx +58 -14
  184. package/src/shell/DetailsSlot.jsx +26 -6
  185. package/src/shell/MenuBar.jsx +78 -6
  186. package/src/shell/OutlinerSlot.jsx +3 -1
  187. package/src/shell/TopBar.jsx +17 -3
  188. package/src/shell/ViewportToolbar.jsx +18 -3
  189. package/src/shell/dock.css +239 -0
  190. package/src/shell/glass-regions.css +3 -2
  191. package/src/shell/log-store.js +4 -4
  192. package/src/shell/mode.css +20 -0
  193. package/src/shell/studio-shell-context.js +4 -1
  194. package/src/shot-authoring.js +69 -6
  195. package/src/stable-items.js +1 -1
  196. package/src/stage-attach-frames.js +169 -0
  197. package/src/stage-canvas-rigs.jsx +402 -0
  198. package/src/stage-capture.jsx +216 -0
  199. package/src/stage-characters.jsx +302 -0
  200. package/src/stage-editor-gizmos.jsx +1229 -0
  201. package/src/stage-labels-ko.js +204 -0
  202. package/src/stage-startup-defaults.js +182 -0
  203. package/src/starter-scenes.js +1 -1
  204. package/src/storyboard.js +24 -5
  205. package/src/studio-actions.js +42 -9
  206. package/src/studio-agent-commands.js +186 -23
  207. package/src/studio-agent-context.js +11 -9
  208. package/src/studio-agent-motion.js +2 -1
  209. package/src/studio-agent-protocol.js +56 -23
  210. package/src/studio-app-binding.js +52 -23
  211. package/src/studio-contact-sheet.js +3 -3
  212. package/src/studio-elements.js +11 -2
  213. package/src/studio-geometry-facts.js +114 -0
  214. package/src/styles.css +111 -10
  215. package/src/theme.js +1 -1
  216. package/src/timeline-fps.js +13 -0
  217. package/src/ui-scrub.js +4 -4
  218. package/src/ui.jsx +6 -4
  219. package/src/use-asset-shelf.js +60 -0
  220. package/src/use-camera-tutorial.js +50 -0
  221. package/src/use-embed-bridge.js +12 -0
  222. package/src/use-first-success-prompt.js +7 -0
  223. package/src/use-i2v-capture-modal.js +15 -0
  224. package/src/use-sculpt-mode.js +95 -0
  225. package/src/workflow/AgentPanel.jsx +110 -20
  226. package/src/workflow/WorkflowBuilder.jsx +1 -1
  227. package/src/workflow/agent-client.js +113 -45
  228. package/src/workflow/agent-panel.css +35 -0
  229. package/src/workflow/attachment-image.js +1 -1
  230. package/src/workflow/canvas-commands.js +3 -3
  231. package/src/workflow/cozy-scene-node.js +3 -3
  232. package/src/workflow/local-workflow.js +2 -2
  233. package/src/workflow/motion-input.js +3 -3
  234. package/src/workflow/node-schema.js +1 -1
  235. package/src/workflow/scene-asset-sync.js +2 -9
  236. package/src/workflow/shot-prompt-node.js +1 -1
  237. package/src/workflow/video-contract.js +1 -1
  238. package/src/workflow/workflow-resources.js +0 -8
  239. package/src/workflow-send.js +87 -0
  240. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  241. package/tools/check-eslint.mjs +57 -0
  242. package/tools/check-knip.mjs +60 -0
  243. package/tools/check-loc.mjs +54 -0
  244. package/tools/demo-worker/api-client.mjs +7 -13
  245. package/tools/demo-worker/generate.mjs +2 -4
  246. package/tools/eslint-baseline.json +23 -0
  247. package/tools/kimodo/effector-constraints.mjs +4 -4
  248. package/tools/kimodo/generate.mjs +1 -1
  249. package/tools/knip-baseline.json +331 -0
  250. package/tools/loc-baseline.json +126 -0
  251. package/tools/process-supervisor.mjs +1 -1
  252. package/tools/projflow/replay.mjs +4 -4
  253. package/tools/projflow/runner.mjs +3 -3
  254. package/tools/qa/studio-control-count.mjs +2 -0
  255. package/tools/run-tests.mjs +96 -7
  256. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  257. package/dist/assets/app-C1sdVbg4.css +0 -1
  258. package/dist/assets/app-CA55tcI3.js +0 -4876
  259. package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
  260. package/dist/assets/workflow-dNPGY9xz.js +0 -179
@@ -1,7 +1,3 @@
1
- import {
2
- I2V_MOTION_STILL_OUTPUT, I2V_MOTION_SHOT_ASPECT, I2V_MOTION_MIN_DURATION,
3
- waitForMotionJob, submitMotionJob, buildH3MotionPrompt,
4
- } from "../i2v-motion-client.js";
5
1
  import { useState, useEffect, useSyncExternalStore, useContext, useRef, useMemo } from "react";
6
2
  import { AppContext } from '../app-context.js';
7
3
  import { createDocumentStore } from '../document-store.js';
@@ -11,14 +7,11 @@ import {
11
7
  TIMELINE_FPS,
12
8
  hierarchyIdForIkFocus,
13
9
  ARDY_PRESERVE_DEFAULT,
14
- MULTIMODEL_REASONS,
15
- nextCharacterId,
16
10
  REST_BONES,
17
11
  ARDY_SEED_MAX,
18
12
  lineTrackLabel,
19
13
  ARDY_DURATION_MIN,
20
14
  buildPromptSchedule,
21
- toArdyFrameEntries,
22
15
  posePlacementFrame,
23
16
  toArdySegments,
24
17
  ARDY_FPS,
@@ -51,22 +44,15 @@ import * as THREE from "three";
51
44
  import { StudioProtocolError } from "../studio-agent-protocol.js";
52
45
  import { restorePlaybackBones, snapshotPlaybackBones, applyMotionFrame, captureArdyRoot } from "../ardy/playback.js";
53
46
  import { isKo, ko } from "../locale.js";
54
- import {
55
- isPlatformPageUrl,
56
- normalizeSourceUrl,
57
- sourceLabel,
58
- requestBridgeFootage,
59
- fetchFootageBlob,
60
- probeFootage,
61
- requestBridgeExtract,
62
- } from "../multimodel-ingest.js";
63
47
  import { characterScaleFor, loadMotionFromUrl } from "../ardy/npz.js";
64
48
  import { supportHeightForObject, OBJECT_LIBRARY } from "../scene-objects.js";
65
49
  import { applySupportRise, autoRoofDrop, applyAutoFall, applyRootDrop, normalizeRootDrop } from "../ardy/root-drop.js";
66
- import { takeAnchor, createCharacterEntry } from "../scenes.js";
50
+ import { isProxyFigure } from "../scenes.js";
51
+ import { characterCapabilities, kindRefusal, refuseRigOnly } from "../character-kind.js";
67
52
  import { retimeMotion } from "../ardy/retime.js";
68
53
  import {
69
54
  createMotionEdit,
55
+ motionEditLayout,
70
56
  renderMotionEdit,
71
57
  remapFrameKeyMap,
72
58
  remapTimelineFrame,
@@ -92,8 +78,8 @@ import {
92
78
  } from "../take-recipe.js";
93
79
  import { buildGenerationRequest, generationRefusal } from '../motion/generation.js';
94
80
  import { planPosePin } from '../ardy/pose-pin.js';
95
- import { applyTrailFalloffDelta, restoreTrailEdits, trailEditRange, worldDeltaToTrailClip } from "../motion-trail.js";
96
- import { bakeIkDragKey, chainsChangedBy, ikDragRecord, ikDragTouch } from "../ardy/ik-drag.js";
81
+ import { TRAIL_TRACKS, applyTrailFalloffDelta, restoreTrailEdits, trailEditRange, worldDeltaToTrailClip } from "../motion-trail.js";
82
+ import { bakeIkDragKey, bakeIkDragRange, chainsChangedBy, ikDragRecord, ikDragTouch } from "../ardy/ik-drag.js";
97
83
  import { ikKeyJson, ikTrackKeyFromJson } from "../ardy/ik-key-json.js";
98
84
  import { findAbsoluteIkKeyConflicts } from "../trail-key-conflicts.js";
99
85
  import { chooseIkEntryPose } from "../ik-camera.js";
@@ -103,52 +89,10 @@ import { openMotionDb, getMotion, putMotion } from "../motion-store.js";
103
89
  import { resolveMotionSource, decodeMotionResource, encodeMotionResource, sha256Hex } from "../motion-resources.js";
104
90
  import { applyRangePin, captureRangePinTarget, normalizeRangePin, rangePinTargetWorld, rangePinTracks, removeRangePinKeys } from "../ardy/range-pin.js";
105
91
  import { rangePinObjectMatrixAt } from "../range-pin-object-transform.js";
92
+ import { createMotionVideoCapture } from "./motion/motion-video-capture.js";
93
+ import { ensureRangePinState, overlappingRangePin, rangePinConflictFrames, rangePinOverlapMessage } from "./motion/motion-range-pin-rules.js";
106
94
 
107
- const sameMotionIntent = (a, b) => JSON.stringify(a) === JSON.stringify(b);
108
- const emptyMotionLayer = id => ({ id, take: null, fullTake: null, ikKeys: [], ikPins: [], ikPinResiduals: [], committedIkEdits: [], takeRecipe: null, takeVersions: [] });
109
- const keyVector = p => ({ x: p.x, y: p.y, z: p.z });
110
- const keyQuaternion = q => ({ ...keyVector(q), w: q.w });
111
- function encodeMotionKeys(keys) {
112
- return [...keys].sort(([a], [b]) => a - b).map(([frame, tracks]) => ({ frame, tracks: Object.fromEntries([...tracks].map(([track, key]) => [track, {
113
- ...(key.q ? { q: key.q.map(keyQuaternion) } : {}), ...(key.p ? { p: keyVector(key.p) } : {}),
114
- ...(key.baseQ ? { baseQ: key.baseQ.map(keyQuaternion) } : {}), ...(key.basePos ? { basePos: keyVector(key.basePos) } : {}),
115
- ...(key.chainP ? { chainP: key.chainP.map(keyVector) } : {}), ...(key.keepTranslations ? { keepTranslations: true } : {}),
116
- ...(key.blend != null ? { blend: key.blend } : {}),
117
- ...(key.pin ? { pin: key.pin } : {}),
118
- }])) }));
119
- }
120
- function decodeMotionKeys(rows) {
121
- const q = value => new THREE.Quaternion(value.x, value.y, value.z, value.w).normalize();
122
- const p = value => new THREE.Vector3(value.x, value.y, value.z);
123
- return new Map(rows.map(row => [row.frame, new Map(Object.entries(row.tracks).map(([track, key]) => [track, {
124
- q: key.q?.map(q) ?? null, p: key.p ? p(key.p) : null,
125
- ...(key.baseQ ? { baseQ: key.baseQ.map(q) } : {}), ...(key.basePos ? { basePos: p(key.basePos) } : {}),
126
- ...(key.chainP ? { chainP: key.chainP.map(p) } : {}), ...(key.keepTranslations ? { keepTranslations: true } : {}),
127
- ...(key.blend != null ? { blend: key.blend } : {}),
128
- ...(key.pin ? { pin: key.pin } : {}),
129
- }]))]));
130
- }
131
- function cloneRangePin(pin) {
132
- return {
133
- ...pin,
134
- target: pin.target.space === "world"
135
- ? { space: "world", position: [...pin.target.position] }
136
- : { space: "object", objectId: pin.target.objectId, local: [...pin.target.local] },
137
- };
138
- }
139
- function encodeRangePins(pins) {
140
- return [...(pins instanceof Map ? pins.values() : pins ?? [])].map(cloneRangePin);
141
- }
142
- function decodeRangePins(rows) {
143
- return new Map((rows ?? []).map((pin) => [pin.id, cloneRangePin(pin)]));
144
- }
145
- function encodeRangePinResiduals(values) {
146
- return [...(values instanceof Map ? values.entries() : values ?? [])].map(([id, entries]) => [id, (entries ?? []).map((entry) => ({ ...entry }))]);
147
- }
148
- function decodeRangePinResiduals(rows) {
149
- return new Map((rows ?? []).map(([id, entries]) => [id, (entries ?? []).map((entry) => ({ ...entry }))]));
150
- }
151
-
95
+ import { sameMotionIntent, emptyMotionLayer, encodeMotionKeys, decodeMotionKeys, cloneRangePin, encodeRangePins, decodeRangePins, encodeRangePinResiduals, decodeRangePinResiduals } from "./motion/motion-intent-codec.js";
152
96
  // Plain intent owns take identities and JSON keys. Decoded buffers and rig
153
97
  // preimages are copy-on-record runtime resources, never mutable store values.
154
98
  export function createMotionDomain(appContext, characters) {
@@ -158,7 +102,10 @@ export function createMotionDomain(appContext, characters) {
158
102
  const ref = entry.motionRef;
159
103
  const take = ref ? { resourceId: `restore:${entry.id}:${ref.motionId ?? ref.url}`, url: ref.url ?? null, anchorX: ref.anchorX, anchorZ: ref.anchorZ,
160
104
  rotationDeg: ref.rotationDeg, prompt: ref.prompt ?? '', ...(ref.trailEdits ? { trailEdits: ref.trailEdits } : {}), ...(ref.studioTakeId ? { studioTakeId: ref.studioTakeId } : {}) } : null;
161
- return { id: entry.id, take, fullTake: take, takeVersions: ref?.url ? [{ motionUrl: ref.url, recipe: null, savedAt: Date.now(), label: ko('Loaded', '불러옴') }] : [] };
105
+ // The saved Pose-mode / IK edits come back with the scene (scenes.js ikEdits).
106
+ const edits = entry.ikEdits;
107
+ return { id: entry.id, take, fullTake: take, takeVersions: ref?.url ? [{ motionUrl: ref.url, recipe: null, savedAt: Date.now(), label: ko('Loaded', '불러옴') }] : [],
108
+ ikKeys: edits?.keys ?? [], ikPins: edits?.pins ?? [], ikPinResiduals: edits?.pinResiduals ?? [] };
162
109
  });
163
110
  function snapshotTake(take) {
164
111
  if (!take) return null;
@@ -192,6 +139,8 @@ export function createMotionDomain(appContext, characters) {
192
139
  const visibleMotion = id => previews.get(id) ?? motionFor(id);
193
140
  const frame = () => appContext.live.state?.timeline.currentFrame ?? 0;
194
141
  const rigFor = id => appContext.shared.rigs[id];
142
+ // Rig-only tools refuse a capsule figure by name before looking for its rig.
143
+ const rigOnly = (id, feature) => refuseRigOnly(appContext.storeDomain('cast')?.read().find(row => row.id === id), feature);
195
144
  function rigSnapshots() {
196
145
  return new Map(Object.entries(appContext.shared.rigs).map(([id, rig]) => [id, appContext.shared.snapshotExportRig(rig)]));
197
146
  }
@@ -245,8 +194,10 @@ export function createMotionDomain(appContext, characters) {
245
194
  .flatMap(rows => rows.flatMap(row => [row.take?.resourceId, row.fullTake?.resourceId])).filter(Boolean));
246
195
  for (const id of takes.keys()) if (!ids.has(id)) takes.delete(id);
247
196
  }
248
- function beginAction() {
249
- rigImages.set(read(), gesture?.rigs ?? rigSnapshots());
197
+ function beginAction(preimages = null) {
198
+ // The bus passes a target id; an explicit preview commit can override
199
+ // only its rig's preimage without retaining the preview in Undo.
200
+ rigImages.set(read(), new Map([...(gesture?.rigs ?? rigSnapshots()), ...(preimages instanceof Map ? preimages : [])]));
250
201
  const session = documentStore.beginAction('motion');
251
202
  return { ...session,
252
203
  run(fn) { return session.run(() => { running++; try { return fn(); } finally { running--; } }); },
@@ -267,6 +218,7 @@ export function createMotionDomain(appContext, characters) {
267
218
  setRangePinState(id, state);
268
219
  }
269
220
  function bakeRangePin(id, spec, replaceExisting = false, projectionOnly = false) {
221
+ rigOnly(id, 'ik');
270
222
  const motion = motionFor(id), rig = rigFor(id), resolved = rig && resolveIkRig(rig);
271
223
  if (!motion || !resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take and rig for this character first.');
272
224
  const pin = normalizeRangePin(spec, { clipFrames: motion.frames }), state = rangePinState(id);
@@ -331,7 +283,8 @@ export function createMotionDomain(appContext, characters) {
331
283
  function clear(id) {
332
284
  previews.delete(id);
333
285
  writeLayer(id, { take: null, fullTake: null, ikKeys: [], ikPins: [], ikPinResiduals: [], committedIkEdits: [], takeRecipe: null });
334
- castWrite(rows => rows.map(row => row.id === id ? { ...row, scale: 1, motionRef: null } : row));
286
+ castWrite(rows => rows.map(row => row.id === id ? { ...row, scale: 1, motionRef: null,
287
+ layer: { ...row.layer, promptClips: [] } } : row));
335
288
  synchronizeTimeline();
336
289
  }
337
290
  function editSegments(id, segments) {
@@ -351,6 +304,7 @@ export function createMotionDomain(appContext, characters) {
351
304
  synchronizeTimeline();
352
305
  }
353
306
  function fix(id, scope = 'frame') {
307
+ rigOnly(id, 'collision');
354
308
  const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
355
309
  if (!resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'The character rig is not loaded.');
356
310
  const state = appContext.shared.ikStatesRef.current.get(id) ?? { ...createIkState(), ...resolved };
@@ -362,7 +316,7 @@ export function createMotionDomain(appContext, characters) {
362
316
  try {
363
317
  fixCollisionsRange({ rig, ...resolved, ikState: state, startFrame: 0, endFrame: take.frames - 1,
364
318
  applyFrame(at) { applyMotionFrame(rig, take, at); ikEvaluate(resolved.chains, state, at, resolved.fkJoints, 6); },
365
- blockersAt(at) { for (const row of read()) if (row.id !== id) appContext.shared.poseMemberAtFrame(rigFor(row.id), motionFor(row.id), appContext.shared.ikStatesRef.current.get(row.id), at, 6); return blockers(at); } });
319
+ blockersAt(at) { for (const row of read()) if (row.id !== id && rigFor(row.id)) appContext.shared.poseMemberAtFrame(rigFor(row.id), motionFor(row.id), appContext.shared.ikStatesRef.current.get(row.id), at, 6); return blockers(at); } });
366
320
  } finally { for (const snapshot of originals.values()) appContext.shared.restoreExportRig(snapshot); }
367
321
  } else {
368
322
  const result = fixCollisions(rig, resolved.chains, { ikState: state, fkJoints: resolved.fkJoints, blockers: blockers(frame()) });
@@ -388,6 +342,7 @@ export function createMotionDomain(appContext, characters) {
388
342
  return keyed ? run('ik.setKey', { characterId: id, frame: at, tracks: keyed.tracks }) : null;
389
343
  }
390
344
  function keyPose(id, at, pose) {
345
+ rigOnly(id, 'pose');
391
346
  const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
392
347
  if (!resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'The character rig is not loaded.');
393
348
  restoreBindPositions(rig); applyPose(rig, { ...REST_BONES, ...pose.bones }); applyHipsOffset(rig, pose.rootY ?? 0);
@@ -397,6 +352,7 @@ export function createMotionDomain(appContext, characters) {
397
352
  });
398
353
  }
399
354
  function editTrail(id, { track = "hips", grabFrame, radiusFrames, delta }) {
355
+ rigOnly(id, 'trails');
400
356
  const take = motionFor(id), character = appContext.storeDomain('cast').read().find(row => row.id === id);
401
357
  if (!take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take first.');
402
358
  const scale = character.scale ?? 1;
@@ -410,12 +366,14 @@ export function createMotionDomain(appContext, characters) {
410
366
  let physicsJob = 0;
411
367
  const physicsStamp = id => JSON.stringify([layer(id), appContext.storeDomain('cast').read().find(row => row.id === id)]);
412
368
  function applyPhysics(id) {
369
+ rigOnly(id, 'physics');
413
370
  const preview = physicsReviews.get(id);
414
371
  if (!preview || preview.stamp !== physicsStamp(id)) throw new StudioProtocolError('STALE_TARGET', 'Analyse this take again before applying corrections.');
415
372
  setKeys(id, preview.result.candidate.keys);
416
373
  domain.onPhysicsPreview?.(null);
417
374
  }
418
375
  async function autoPhysics(id, options, context) {
376
+ rigOnly(id, 'physics');
419
377
  const rig = rigFor(id), resolved = rig && resolveIkRig(rig), take = motionFor(id);
420
378
  if (!resolved || !take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take and character rig first.');
421
379
  const job = ++physicsJob, stamp = physicsStamp(id), original = appContext.shared.snapshotExportRig(rig);
@@ -541,7 +499,6 @@ export function useMotion(appContext) {
541
499
  return () => { window.removeEventListener('pointerdown', start, true); domain.finishGesture(true); };
542
500
  }, [domain, appContext.shared.activeChar.id]);
543
501
  const [i2vMotion, setI2vMotion] = useState({ a: null, b: null, job: null, status: "idle", error: "", instruction: "", dailyRemaining: null });
544
- /* ------------------------------ IK layer ------------------------------ */
545
502
  // IK posing for Subject 1: dragging a wrist/ankle handle FOCUSES that
546
503
  // joint and solves its chain backward (two-bone analytic IK) on top of
547
504
  // the current pose; joints never dragged stay purely on the FK pose.
@@ -554,6 +511,8 @@ export function useMotion(appContext) {
554
511
  // outside React renders so pointer moves stay cheap and the drag-end bake
555
512
  // can create a delta key over the raw clip.
556
513
  const ikDragRef = useRef(null);
514
+ const ikPreDragRef = useRef(null);
515
+ const pendingIkEditRef = useRef(null);
557
516
  const ikCameraMemoryRef = useRef(new Map());
558
517
 
559
518
  const [ikChains, setIkChains] = useState(null);
@@ -561,6 +520,7 @@ export function useMotion(appContext) {
561
520
  const [ikFkJoints, setIkFkJoints] = useState(null);
562
521
 
563
522
  const [ikFocus, setIkFocus] = useState(null);
523
+ const [pendingIkEdit, setPendingIkEdit] = useState(null);
564
524
 
565
525
  // Foot snap (ground plant): while ON, body (hips) drags keep the feet at
566
526
  // the positions captured when the drag started — the knees bend instead
@@ -600,8 +560,7 @@ export function useMotion(appContext) {
600
560
  const setCommittedIkEdits = update => domain.writeLayer(appContext.shared.activeChar.id, { committedIkEdits: typeof update === 'function' ? update(domain.layer(appContext.shared.activeChar.id).committedIkEdits) : update });
601
561
  domain.onProject = () => setIkTick(value => value + 1);
602
562
 
603
- /* --------------------- IK-mode motion trail editing ---------------------
604
- * Grabbing the viewport trajectory line deforms the loaded take with a
563
+ /* Grabbing the viewport trajectory line deforms the loaded take with a
605
564
  * smoothstep falloff around the grab frame (pure local preview). The last
606
565
  * finished drag stays pending so "Regenerate from trail edit" can send the
607
566
  * auto-derived window through the existing motionEdit pipeline. */
@@ -612,6 +571,7 @@ export function useMotion(appContext) {
612
571
  const [ikEditTool, setIkEditTool] = useState("ik");
613
572
 
614
573
  const [trailEdit, setTrailEdit] = useState(null);
574
+ const [trailTrackFocus, setTrailTrackFocus] = useState("hips");
615
575
  const [rangePinSelection, setRangePinSelection] = useState(null);
616
576
  const [rangePinPartPick, setRangePinPartPick] = useState(null);
617
577
  const [rangePinPreview, setRangePinPreview] = useState(null);
@@ -629,12 +589,89 @@ export function useMotion(appContext) {
629
589
  }
630
590
  }
631
591
 
632
- function ensureRangePinState(state) {
633
- if (!state) return state;
634
- if (!(state.pins instanceof Map)) state.pins = new Map();
635
- if (!(state.pinResiduals instanceof Map)) state.pinResiduals = new Map();
636
- return state;
592
+ function selectedIkRange(frame = appContext.shared.tlFrame) {
593
+ const count = Math.max(1, motion?.frames ?? appContext.shared.tlFrameCount ?? 1);
594
+ if (!motion) return { startFrame: 0, endFrame: count - 1 };
595
+ const edit = motion.editSegments ?? createMotionEdit(count);
596
+ const layout = motionEditLayout(edit);
597
+ const at = Math.max(0, Math.min(count - 1, Math.round(frame) || 0));
598
+ const selected = layout.find((segment) => at >= segment.timelineStart && at <= segment.timelineEnd) ?? layout[0];
599
+ return {
600
+ startFrame: Math.max(0, selected?.timelineStart ?? 0),
601
+ endFrame: Math.min(count - 1, selected?.timelineEnd ?? count - 1),
602
+ };
603
+ }
604
+
605
+ function setPendingIkEditValue(value) {
606
+ pendingIkEditRef.current = value;
607
+ setPendingIkEdit(value);
637
608
  }
609
+
610
+ function restorePendingIkState(pending) {
611
+ // A replaced take or another authored edit owns the newer state.
612
+ if (domain.layer(pending.characterId) !== pending.sourceLayer) return;
613
+ const state = pending.state;
614
+ state.keys = copyPhysicsKeys(pending.beforeKeys);
615
+ state.tracked = new Set(pending.beforeTracked);
616
+ state.targets = new Map([...pending.beforeTargets].map(([id, target]) => [id, target.clone()]));
617
+ state.plants = new Map([...pending.beforePlants].map(([id, target]) => [id, target.clone()]));
618
+ if (pending.rig) {
619
+ if (pending.beforeFrame === appContext.shared.tlFrame) appContext.shared.restoreExportRig(pending.beforeRig);
620
+ else appContext.shared.poseMemberAtFrame(pending.rig, pending.motion, state, appContext.shared.tlFrame, IK_CORRECTION_BLEND_FRAMES);
621
+ if (state.chains) ikSeedTargets(state.chains, state);
622
+ }
623
+ }
624
+
625
+ function cancelPendingIkEdit() {
626
+ const pending = pendingIkEditRef.current;
627
+ if (!pending) return false;
628
+ restorePendingIkState(pending);
629
+ setPendingIkEditValue(null);
630
+ ikDragRef.current = null;
631
+ ikPreDragRef.current = null;
632
+ setIkTick((value) => value + 1);
633
+ return true;
634
+ }
635
+
636
+ function applyPendingIkEdit() {
637
+ const pending = pendingIkEditRef.current;
638
+ if (!pending) return false;
639
+ if (pending.characterId !== appContext.shared.activeChar.id || domain.layer(pending.characterId) !== pending.sourceLayer) {
640
+ cancelPendingIkEdit();
641
+ return false;
642
+ }
643
+ const session = domain.beginAction(new Map([[pending.characterId, pending.beforeRig]]));
644
+ try {
645
+ session.run(() => domain.setKeys(pending.characterId, pending.candidate.keys));
646
+ session.commit();
647
+ } catch (error) {
648
+ session.cancel();
649
+ throw error;
650
+ }
651
+ setPendingIkEditValue(null);
652
+ ikDragRef.current = null;
653
+ ikPreDragRef.current = null;
654
+ if (pending.rig) appContext.shared.poseMemberAtFrame(pending.rig, motion, pending.state, appContext.shared.tlFrame, IK_CORRECTION_BLEND_FRAMES);
655
+ setIkTick((value) => value + 1);
656
+ appContext.notify(isKo
657
+ ? `IK 보정을 ${pending.startFrame}–${pending.endFrame}프레임에 적용했어요`
658
+ : `Applied IK correction across frames ${pending.startFrame}–${pending.endFrame}`);
659
+ return true;
660
+ }
661
+
662
+ function clearTrailTrackFocus() {
663
+ setTrailTrackFocus(null);
664
+ setIkFocus(null);
665
+ }
666
+ function selectTrailTrack(track) {
667
+ setTrailTrackFocus(track);
668
+ focusIkHandle(track);
669
+ }
670
+
671
+ useEffect(() => {
672
+ if (!ikMode || ikEditTool !== "trail") setTrailTrackFocus(TRAIL_TRACKS.some(track => track.id === ikFocus) ? ikFocus : "hips");
673
+ }, [ikMode, ikEditTool, ikFocus]);
674
+
638
675
  function rangePinObjectWorldMatrix(objectId, frame) {
639
676
  const object = appContext.shared.sceneObjects.find((entry) => entry.id === objectId);
640
677
  if (!object) return null;
@@ -649,20 +686,6 @@ export function useMotion(appContext) {
649
686
  appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, null, frame);
650
687
  if (ikChains && state.keys.size > 0) ikEvaluate(ikChains, state, frame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
651
688
  }
652
- function rangePinConflictFrames(draft, state = appContext.shared.ikStateRef.current) {
653
- if (!Number.isInteger(draft?.startFrame) || !Number.isInteger(draft?.endFrame) || draft.endFrame < draft.startFrame) return [];
654
- const tracks = rangePinTracks(draft), conflicts = [];
655
- for (let frame = draft.startFrame; frame <= draft.endFrame; frame += 1) {
656
- if (tracks.some((track) => { const key = state.keys.get(frame)?.get(track); return key && key.pin !== draft.id; })) conflicts.push(frame);
657
- }
658
- return conflicts;
659
- }
660
- function overlappingRangePin(draft, state = appContext.shared.ikStateRef.current) {
661
- return [...ensureRangePinState(state).pins.values()].find((pin) => pin.id !== draft.id && rangePinTracks(pin).some((track) => rangePinTracks(draft).includes(track)) && Math.max(pin.startFrame, draft.startFrame) <= Math.min(pin.endFrame, draft.endFrame));
662
- }
663
- function rangePinOverlapMessage(pin) {
664
- return pin ? ko(`A required body or limb track already has an overlapping pin (${pin.startFrame}–${pin.endFrame}).`, `필요한 몸통 또는 팔다리 파츠에 겹치는 고정이 있어요 (${pin.startFrame}–${pin.endFrame}).`) : "";
665
- }
666
689
  function captureRangePinTargetForDraft(draft) {
667
690
  if (!motion || !appContext.shared.activeRig || !ikChains) return null;
668
691
  const objectWorldMatrix = draft.targetKind === "object" ? (objectId, frame) => rangePinObjectWorldMatrix(objectId, frame) : null;
@@ -758,7 +781,7 @@ export function useMotion(appContext) {
758
781
  /** Write one key from its JSON form (studio-actions.js character.setIkKey):
759
782
  * each named track replaces its key at `frame` and joins the tracked set. */
760
783
  function setCharacterIkKey(characterId, frame, tracks) {
761
- appContext.shared.castMemberOf(characterId);
784
+ refuseRigOnly(appContext.shared.castMemberOf(characterId), 'ik');
762
785
  if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').setKey(characterId, frame, tracks);
763
786
  editCharacterIkKeys(characterId, (state) => {
764
787
  let entry = state.keys.get(frame);
@@ -794,11 +817,6 @@ export function useMotion(appContext) {
794
817
  return count;
795
818
  }
796
819
 
797
- /** A baked key entry in the JSON form character.setIkKey takes. */
798
- function ikKeyJson(entry) {
799
- return ikKeyJson(entry);
800
- }
801
-
802
820
  const [bridge, setBridge] = useState(null);
803
821
 
804
822
  const [bridgeChecking, setBridgeChecking] = useState(false);
@@ -833,8 +851,7 @@ export function useMotion(appContext) {
833
851
  // operator scrubbed to — the box takes any destination frame.
834
852
  const [ardyPosePlacement, setArdyPosePlacement] = useState("start");
835
853
 
836
- /* ------------------- take recipes and versions (C9/C12) -------------------
837
- * The recipe of the take that is loaded RIGHT NOW: seed + prompt blocks +
854
+ /* The recipe of the take that is loaded RIGHT NOW: seed + prompt blocks +
838
855
  * the line edits pulled on top of it. It is written in exactly one place
839
856
  * (commitTakeRecipe, on a successful run) and read in two: the request
840
857
  * assembly, which attaches it as C10 `replay`, and the version strip, which
@@ -873,9 +890,6 @@ export function useMotion(appContext) {
873
890
 
874
891
  const [ardyOutcome, setArdyOutcome] = useState(null);
875
892
 
876
- // Timeline frames for the configured duration: [0, duration*TIMELINE_FPS-1].
877
- const maxDst = Math.max(0, Math.round(ardyDuration) * TIMELINE_FPS - 1);
878
-
879
893
  // Wave-2 gate: the bridge only routes lineEdit once M4's routing lands.
880
894
  // Until the /ardy/health payload says so, the request is never sent —
881
895
  // today's bridge ignores unknown fields, so an ungated POST would quietly
@@ -884,6 +898,13 @@ export function useMotion(appContext) {
884
898
 
885
899
  // Loaded motion: decoded arrays plus the world anchor captured at load.
886
900
  const motion = domain.visibleMotion(appContext.shared.activeChar.id);
901
+ useEffect(() => {
902
+ const pending = pendingIkEditRef.current;
903
+ if (pending && (pending.characterId !== appContext.shared.activeChar.id
904
+ || pending.rig !== appContext.shared.activeRig || pending.motion !== motion
905
+ || pending.sourceLayer !== domain.layer(pending.characterId)
906
+ || !ikMode || ikEditTool !== "ik")) cancelPendingIkEdit();
907
+ }, [appContext.shared.activeChar.id, appContext.shared.activeRig, motion, pinLayer, ikMode, ikEditTool]);
887
908
  const rangePinRebuildRef = useRef(null);
888
909
  const rangePinSourceStamp = JSON.stringify([pinLayer.take, appContext.shared.activeChar.id]);
889
910
  // A fit is invalidated by a different take, trim or preview, not by key
@@ -924,7 +945,6 @@ export function useMotion(appContext) {
924
945
 
925
946
  const [motionError, setMotionError] = useState("");
926
947
 
927
- /* ------------------------- video capture (ingest) ---------------------- */
928
948
  const [multiModelUrl, setMultiModelUrl] = useState("");
929
949
 
930
950
  const [multiModelSource, setMultiModelSource] = useState(null);
@@ -965,8 +985,8 @@ export function useMotion(appContext) {
965
985
  appContext.shared.setTlFrame((f) => Math.max(0, Math.min(f + delta, appContext.shared.frameCountRef.current - 1)));
966
986
  }
967
987
 
968
- /* --------------------------- motion playback ---------------------------- */
969
988
  function leaveIkMode() {
989
+ cancelPendingIkEdit();
970
990
  const poserCam = appContext.shared.poserCamRef?.current;
971
991
  if (ikMode && poserCam && appContext.shared.activeChar?.id) {
972
992
  const look = appContext.shared.poserLook?.current ?? {};
@@ -995,257 +1015,6 @@ export function useMotion(appContext) {
995
1015
  appContext.shared.restoreRef.current = { rig, bones: snapshotPlaybackBones(rig) };
996
1016
  }
997
1017
 
998
- /* ---------------------------- video capture ----------------------------
999
- * Footage in, takes out. The ingest downloads and decodes a source so the
1000
- * timeline is sized by what was actually read; extraction then turns it
1001
- * into one take per tracked performer. Take 0 belongs to the ACTIVE
1002
- * character's layer, the rest are landed on their own cast members. */
1003
- // A picked file is already local bytes: no download stage, straight to the
1004
- // probe that produces the timeline numbers.
1005
- function chooseMultiModelFile(event) {
1006
- const file = event.target.files?.[0] ?? null;
1007
- if (!file) return;
1008
- setMultiModelUrl("");
1009
- ingestFootage({ kind: "file", name: file.name, blob: file, bytes: file.size });
1010
- }
1011
-
1012
- async function pasteMultiModelUrl() {
1013
- try {
1014
- const text = await navigator.clipboard.readText();
1015
- if (!text.trim()) return;
1016
- setMultiModelUrl(text.trim());
1017
- setMultiModelStatus("idle");
1018
- } catch {
1019
- appContext.notify(isKo ? "클립보드를 읽지 못했어요 — 직접 붙여넣어 주세요" : "Clipboard is unavailable — paste into the field directly");
1020
- }
1021
- }
1022
-
1023
- function useMultiModelUrl() {
1024
- const raw = multiModelUrl.trim();
1025
- // A platform page (YouTube, Vimeo, …) is never browser-fetchable, but
1026
- // the dev bridge can fetch it server-side. With the bridge up the
1027
- // address goes there; without it the named refusal below stands.
1028
- if (isPlatformPageUrl(raw) && bridge?.ok) {
1029
- ingestPlatformFootage(raw);
1030
- return;
1031
- }
1032
- const normalized = normalizeSourceUrl(raw);
1033
- if (!normalized.ok) {
1034
- setMultiModelStatus("error");
1035
- setMultiModelStage("error");
1036
- setMultiModelError(MULTIMODEL_REASONS[normalized.reason]?.[isKo ? 1 : 0] ?? normalized.reason);
1037
- return;
1038
- }
1039
- ingestFootage({ kind: "url", name: sourceLabel(normalized.url), url: normalized.url });
1040
- }
1041
-
1042
- /** Platform page → bridge download (yt-dlp + normalize) → the local
1043
- * /ardy/footage/… address rides the ordinary ingest unchanged. */
1044
- async function ingestPlatformFootage(pageUrl) {
1045
- const run = appContext.shared.multiModelRunRef.current + 1;
1046
- appContext.shared.multiModelRunRef.current = run;
1047
- const live = () => appContext.shared.multiModelRunRef.current === run;
1048
- setMultiModelSource({ kind: "url", name: sourceLabel(pageUrl), url: pageUrl });
1049
- setMultiModelFootage(null);
1050
- setMultiModelError("");
1051
- setMultiModelStatus("busy");
1052
- setMultiModelStage("fetching");
1053
- setMultiModelProgress(null);
1054
- setMultiModelTake(null);
1055
- setMultiModelExtract("idle");
1056
- setMultiModelExtractProgress(null);
1057
- setMultiModelExtractError("");
1058
- try {
1059
- const footage = await requestBridgeFootage(pageUrl, {
1060
- onProgress: ({ ratio }) => {
1061
- if (live()) setMultiModelProgress(Number.isFinite(ratio) ? ratio : null);
1062
- },
1063
- });
1064
- if (!live()) return;
1065
- ingestFootage({ kind: "url", name: footage.title || sourceLabel(pageUrl), url: footage.url, fps: footage.fps, bridgeId: footage.footage ?? null });
1066
- } catch (error) {
1067
- if (!live()) return;
1068
- const code = error?.message ?? String(error);
1069
- setMultiModelStage("error");
1070
- setMultiModelStatus("error");
1071
- setMultiModelProgress(null);
1072
- setMultiModelError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
1073
- }
1074
- }
1075
-
1076
- /** Download (when remote), decode, measure, then size the timeline from what
1077
- * was actually read. Each run carries a token so a slow first source can
1078
- * never land its numbers after a second one replaced it. */
1079
- async function ingestFootage(source, commandContext = null) {
1080
- const run = appContext.shared.multiModelRunRef.current + 1;
1081
- appContext.shared.multiModelRunRef.current = run;
1082
- const live = () => appContext.shared.multiModelRunRef.current === run;
1083
- setMultiModelSource(source);
1084
- setMultiModelFootage(null);
1085
- setMultiModelError("");
1086
- setMultiModelStatus("busy");
1087
- setMultiModelProgress(null);
1088
- // A take baked from the PREVIOUS clip must not read as this one's
1089
- // result, so the extraction state resets with the source.
1090
- setMultiModelTake(null);
1091
- setMultiModelExtract("idle");
1092
- setMultiModelExtractProgress(null);
1093
- setMultiModelExtractError("");
1094
- try {
1095
- let blob = source.blob ?? null;
1096
- let bytes = source.bytes ?? 0;
1097
- if (!blob) {
1098
- setMultiModelStage("fetching");
1099
- const downloaded = await fetchFootageBlob(source.url, {
1100
- onProgress: ({ ratio }) => {
1101
- if (live()) setMultiModelProgress(ratio);
1102
- },
1103
- });
1104
- if (!live()) return;
1105
- blob = downloaded.blob;
1106
- bytes = downloaded.bytes;
1107
- }
1108
- setMultiModelStage("probing");
1109
- if (appContext.shared.multiModelObjectUrlRef.current) URL.revokeObjectURL(appContext.shared.multiModelObjectUrlRef.current);
1110
- const objectUrl = URL.createObjectURL(blob);
1111
- appContext.shared.multiModelObjectUrlRef.current = objectUrl;
1112
- const probed = await probeFootage(objectUrl, {
1113
- createVideo: () => document.createElement("video"),
1114
- // The bridge normalized the clip and DECLARED its rate; a probe
1115
- // that re-guesses it would overrule a measurement with a guess.
1116
- knownFps: Number.isFinite(source.fps) ? source.fps : null,
1117
- });
1118
- if (!live()) return;
1119
- commandContext?.check();
1120
- const footage = { ...probed, bytes, objectUrl, blob, bridgeId: source.bridgeId ?? null };
1121
- setMultiModelFootage(footage);
1122
- setMultiModelStage("ready");
1123
- setMultiModelStatus("ready");
1124
- setMultiModelProgress(1);
1125
- // The timeline is the point: the playhead now spans the footage that
1126
- // was actually decoded, at the rate that was actually measured.
1127
- appContext.shared.setTlFps(footage.fps);
1128
- appContext.shared.setTlFrameCount(footage.frames);
1129
- appContext.shared.setTlFrame(0);
1130
- appContext.shared.setTlPlaying(false);
1131
- appContext.notify((isKo, ko) => isKo
1132
- ? `${source.name} 인제스트됨 — ${footage.frames}프레임 @ ${footage.fps} fps`
1133
- : `Ingested ${source.name} — ${footage.frames} frames @ ${footage.fps} fps`);
1134
- return footage;
1135
- } catch (error) {
1136
- if (commandContext && (commandContext.signal.aborted || error.code === "STALE_TARGET")) throw error;
1137
- if (!live()) return;
1138
- const code = error?.message ?? String(error);
1139
- setMultiModelStage("error");
1140
- setMultiModelStatus("error");
1141
- setMultiModelProgress(null);
1142
- setMultiModelError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
1143
- }
1144
- }
1145
-
1146
- /** Extract motion from the ingested footage. With the bridge up this goes
1147
- * to the GPU box (GVHMR: whole-clip temporal context, real 3D body
1148
- * prior — previs-grade). If the bridge is unavailable or is configured for
1149
- * another backend, extraction stops with a named error. */
1150
- async function extractMultiModelMotion() {
1151
- if (!bridge?.ok) {
1152
- setMultiModelExtract("error");
1153
- setMultiModelExtractError(MULTIMODEL_REASONS["extract-bridge-required"]?.[isKo ? 1 : 0] ?? "extract-bridge-required");
1154
- return;
1155
- }
1156
- if (bridge.extractionBackend !== "gvhmr") {
1157
- setMultiModelExtract("error");
1158
- setMultiModelExtractError(MULTIMODEL_REASONS["extract-backend-unsupported"]?.[isKo ? 1 : 0] ?? "extract-backend-unsupported");
1159
- return;
1160
- }
1161
- return extractMultiModelMotionGpu();
1162
- }
1163
-
1164
- async function extractMultiModelMotionGpu() {
1165
- const footage = multiModelFootage;
1166
- if (!footage || multiModelExtract === "running") return;
1167
- const run = appContext.shared.multiModelRunRef.current;
1168
- const live = () => appContext.shared.multiModelRunRef.current === run;
1169
- setMultiModelExtract("running");
1170
- setMultiModelExtractProgress(null);
1171
- setMultiModelExtractError("");
1172
- setMultiModelTake(null);
1173
- try {
1174
- const done = await requestBridgeExtract(
1175
- footage.bridgeId ? { footage: footage.bridgeId } : footage.blob,
1176
- {
1177
- onProgress: ({ ratio }) => {
1178
- if (live()) setMultiModelExtractProgress(Number.isFinite(ratio) ? ratio : null);
1179
- },
1180
- }
1181
- );
1182
- if (!live()) return;
1183
- if (done.quality && done.quality.pass === false) {
1184
- const failed = Array.isArray(done.quality.checks)
1185
- ? done.quality.checks.filter((check) => check && check.pass === false).map((check) => check.name).join(", ")
1186
- : "quality";
1187
- appContext.notify(isKo
1188
- ? `모캡 품질 경고: ${failed || "검증 실패"} — 결과는 로드하지만 보정이 필요합니다`
1189
- : `Mocap quality warning: ${failed || "validation failed"} — loaded for review, correction required`);
1190
- }
1191
- // One take per tracked performer. An older bridge sends a single
1192
- // motionUrl and no list; that is the same thing with one entry.
1193
- const takes = Array.isArray(done.takes) && done.takes.length
1194
- ? done.takes
1195
- : [{ motionUrl: done.motionUrl, personScale: done.personScale, offsetX: 0, offsetZ: 0 }];
1196
- const label = multiModelSource?.name ?? "extracted take";
1197
- // The cast can change while the GPU works, so the destination is read
1198
- // now, once, and every take is placed against THIS character.
1199
- const active = appContext.live.characters.find((entry) => entry.id === appContext.shared.activeChar.id) ?? appContext.shared.activeChar;
1200
- // Take 0 arrives as an ordinary motion npz; loadMotion decodes,
1201
- // retimes to the 24 fps timeline, snapshots the rig baseline and
1202
- // applies the stature stored in the take itself — the body matches
1203
- // the FILMED person because the file carries the measurement, not
1204
- // because this handler remembered to re-apply it afterwards.
1205
- const fallbackScale = Number.isFinite(takes[0].personScale) ? takes[0].personScale : done.personScale;
1206
- let personScale = await loadMotion(takes[0].motionUrl ?? done.motionUrl, label, active.rot, null, active.id, null, { personScaleFallback: fallbackScale });
1207
- if (!live()) return;
1208
- // loadMotion reports the stature it applied — 1 for a take that
1209
- // stores none, nothing at all if the load failed. A failed load is
1210
- // not a finished extraction: say so with the named reason instead
1211
- // of a receipt for a take nobody can play.
1212
- if (!Number.isFinite(personScale)) throw new Error("extract-convert-failed");
1213
- // Only a take that stores NO stature gets the fallback for an npz
1214
- // that predates person_scale (or an older bridge): the response
1215
- // still carries the estimate, clamped the same way, because a bad
1216
- // leg estimate must never produce a giant or a gnome.
1217
- const declared = Number.isFinite(takes[0].personScale) ? takes[0].personScale : done.personScale;
1218
- if (personScale === 1 && Number.isFinite(declared)) {
1219
- personScale = characterScaleFor(null, declared);
1220
- }
1221
- // The clip itself is session-only; this reference is what a save
1222
- // keeps and restoreMotionRefs re-fetches on the next session.
1223
- const leadRef = {
1224
- url: takes[0].motionUrl ?? done.motionUrl,
1225
- prompt: label,
1226
- rotationDeg: active.rot,
1227
- anchorX: active.x,
1228
- anchorZ: active.z,
1229
- };
1230
- if (!appContext.storeDomain('motion')) appContext.shared.publishStudioCharacters((list) => list.map((entry) => entry.id === active.id
1231
- ? { ...entry, scale: personScale, motionRef: leadRef }
1232
- : entry));
1233
- const placed = await deliverExtraTakes(takes.slice(1), active, label);
1234
- if (!live()) return;
1235
- const persons = 1 + placed;
1236
- setMultiModelTake({ frames: done.frames, fps: done.fps, gpu: true, personScale, persons, trajectory: done.performance?.trajectory, segmentation: done.segmentation ?? done.performance?.segmentation ?? takes[0]?.segmentation ?? null, quality: done.quality ?? takes[0]?.quality ?? null });
1237
- setMultiModelExtract("done");
1238
- appContext.notify(isKo
1239
- ? `GPU 모션 추출됨 — ${done.frames}프레임 @ ${done.fps} fps${persons > 1 ? ` · ${persons}명` : ""} · 인물 스케일 ×${personScale.toFixed(2)}`
1240
- : `GPU motion extracted — ${done.frames} frames @ ${done.fps} fps${persons > 1 ? ` · ${persons} performers` : ""} · person scale ×${personScale.toFixed(2)}`);
1241
- } catch (error) {
1242
- if (!live()) return;
1243
- const code = error?.message ?? String(error);
1244
- setMultiModelExtract("error");
1245
- setMultiModelExtractError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
1246
- }
1247
- }
1248
-
1249
1018
  /** Land takes 1..N-1 on the rest of the cast. Each extra take is its OWN
1250
1019
  * layer: it goes to that entry's sessionMotion, NOT through the editing
1251
1020
  * buffer, which holds the active character's clip alone. Returns how many
@@ -1268,104 +1037,6 @@ export function useMotion(appContext) {
1268
1037
  worldScale,
1269
1038
  });
1270
1039
 
1271
- async function deliverExtraTakes(extras, active, label) {
1272
- const decoded = await Promise.all(extras.map(async (take, index) => {
1273
- if (typeof take?.motionUrl !== "string" || !take.motionUrl) return null;
1274
- try {
1275
- // Inbound boundary, exactly like every other clip: decode, then
1276
- // retime onto the production clock before anything counts frames.
1277
- const anchor = takeAnchor(active, take.offsetX, take.offsetZ);
1278
- const clip = retimeMotion(await loadMotionFromUrl(take.motionUrl), TIMELINE_FPS);
1279
- const scale = characterScaleFor(clip, take.personScale);
1280
- const raised = applyAuthoredSupportRise(clip, { ...anchor, y: active.y ?? 0 }, active.rot, scale);
1281
- const staging = autoRoofDrop(
1282
- raised,
1283
- { x: anchor.x, z: anchor.z, y: active.y ?? 0, rotationDeg: active.rot },
1284
- authoredSupportDescriptors(),
1285
- { worldScale: scale },
1286
- );
1287
- const stagedClip = staging ? applyAutoFall(raised, staging, { worldScale: scale }) : raised;
1288
- return {
1289
- url: take.motionUrl,
1290
- prompt: `${label} · ${index + 2}`,
1291
- rotationDeg: active.rot,
1292
- anchor,
1293
- // A second performer is a DIFFERENT body: their take carries
1294
- // their own stature, and the response estimate is only the
1295
- // fallback for an npz that stores none.
1296
- scale: characterScaleFor(clip, take.personScale),
1297
- clip: stagedClip,
1298
- };
1299
- } catch {
1300
- return null; // one unreadable take never voids the others
1301
- }
1302
- }));
1303
- const usable = decoded.filter(Boolean);
1304
- if (!usable.length) return 0;
1305
- const owned = appContext.storeDomain('motion');
1306
- // Plan against the cast as it stands, so ids are decided once and the
1307
- // full-take map can be seeded with them.
1308
- const list = appContext.live.characters;
1309
- const taken = new Set([active.id]);
1310
- let idPool = list;
1311
- const assignments = usable.map((take) => {
1312
- // Reuse a visible cast member with no clip of its own before adding
1313
- // another body to the set.
1314
- const reuse = list.find((entry) => !entry.hidden && !taken.has(entry.id) && !entry.sessionMotion && !entry.motionRef);
1315
- const id = reuse ? reuse.id : nextCharacterId(idPool);
1316
- if (!reuse) idPool = [...idPool, { id }];
1317
- taken.add(id);
1318
- return {
1319
- id,
1320
- spawn: !reuse,
1321
- patch: {
1322
- hidden: false,
1323
- x: take.anchor.x,
1324
- z: take.anchor.z,
1325
- rot: take.rotationDeg,
1326
- scale: take.scale,
1327
- motionRef: {
1328
- url: take.url,
1329
- prompt: take.prompt,
1330
- rotationDeg: take.rotationDeg,
1331
- anchorX: take.anchor.x,
1332
- anchorZ: take.anchor.z,
1333
- },
1334
- sessionMotion: {
1335
- ...take.clip,
1336
- url: take.url,
1337
- prompt: take.prompt,
1338
- anchorX: take.anchor.x,
1339
- anchorZ: take.anchor.z,
1340
- anchorFrame: 0,
1341
- rotationDeg: take.rotationDeg,
1342
- editSegments: createMotionEdit(take.clip.frames),
1343
- },
1344
- },
1345
- };
1346
- });
1347
- const place = current => {
1348
- let next = current;
1349
- for (const { id, spawn, patch } of assignments) {
1350
- next = spawn && !next.some((entry) => entry.id === id)
1351
- ? [...next, { ...createCharacterEntry({ id, model: active.model, pose: DEFAULT_POSE, subject: "a person" }, next.length), ...patch }]
1352
- : next.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry));
1353
- }
1354
- return next;
1355
- };
1356
- if (owned) owned.runPrepared(active.id, () => {
1357
- owned.castWrite(place);
1358
- for (const { id, patch } of assignments) { owned.replace(id, patch.sessionMotion); owned.persistTake(patch.sessionMotion, patch.motionRef); }
1359
- return { affectedIds: assignments.map(row => row.id) };
1360
- });
1361
- else {
1362
- appContext.shared.publishStudioCharacters(place);
1363
- // Their takes are trimmable the moment they become the active layer.
1364
- for (const { id, patch } of assignments) appContext.shared.motionFullRef.current.set(id, patch.sessionMotion);
1365
- }
1366
- return assignments.length;
1367
- }
1368
-
1369
1040
  function commitLoadedTake(characterId, take, { recipe = null, job = null, promptClips = null, scale = characterScaleFor(take) } = {}) {
1370
1041
  const owned = appContext.storeDomain('motion');
1371
1042
  const previous = owned.layer(characterId);
@@ -1406,6 +1077,7 @@ export function useMotion(appContext) {
1406
1077
  setMotionBusy(true);
1407
1078
  setMotionError("");
1408
1079
  try {
1080
+ refuseRigOnly(appContext.live.characters.find((entry) => entry.id === targetCharacterId), "take");
1409
1081
  // Inbound boundary: an ARDY take (20 fps) or a filmed one (30/60)
1410
1082
  // becomes a production-clock clip here, once, before anything on
1411
1083
  // the timeline counts its frames. Same-rate input rides through.
@@ -1512,8 +1184,8 @@ export function useMotion(appContext) {
1512
1184
  }
1513
1185
  }
1514
1186
 
1515
- /** Drop the ACTIVE character's take, its IK corrections and the stature the
1516
- * take imposed. One Ctrl+Z entry brings all three back; nothing to clear
1187
+ /** Drop the ACTIVE character's take, its IK corrections, prompt blocks and
1188
+ * the stature the take imposed. One Ctrl+Z entry brings all four back; nothing to clear
1517
1189
  * records nothing, so the shortcut never becomes a dead press.
1518
1190
  *
1519
1191
  * The pose flows (studio Apply/Reset, pose tiles, photo pose) call this
@@ -1559,7 +1231,7 @@ export function useMotion(appContext) {
1559
1231
  // volume on every other frame of the walk.
1560
1232
  const poseOtherCastMembers = (frame) => {
1561
1233
  for (const entry of appContext.shared.characters) {
1562
- if (entry.id === appContext.shared.activeChar.id) continue;
1234
+ if (entry.id === appContext.shared.activeChar.id || isProxyFigure(entry)) continue;
1563
1235
  appContext.shared.poseMemberAtFrame(appContext.shared.rigs[entry.id], entry.sessionMotion, appContext.shared.ikStatesRef.current.get(entry.id), frame, IK_CORRECTION_BLEND_FRAMES);
1564
1236
  }
1565
1237
  };
@@ -1612,7 +1284,7 @@ export function useMotion(appContext) {
1612
1284
  }
1613
1285
  setIkMode(true);
1614
1286
  appContext.notify(motion
1615
- ? ko("Pose mode — correct the motion; drag end keys the fix at this frame", "포즈 모드 — 모션을 보정합니다. 드래그를 끝내면 이 프레임에 보정 키가 찍혀요")
1287
+ ? ko("Pose mode — preview the correction, then apply it to the selected motion block", "포즈 모드 — 보정을 미리 본 뒤 선택한 모션 구간에 적용하세요")
1616
1288
  : ko("Pose mode — drag handles in the main view; the shot camera stays frozen in the inset", "포즈 모드 — 메인 뷰에서 핸들을 드래그하세요. 샷 카메라는 인셋에 고정됩니다"));
1617
1289
  return;
1618
1290
  }
@@ -1627,8 +1299,26 @@ export function useMotion(appContext) {
1627
1299
  // Drag solve, routed by handle kind: chain targets solve the two-bone
1628
1300
  // chain toward the target; mid joints reposition the elbow/knee with both
1629
1301
  // ends pinned (the handle snaps to the clamped position); FK joints swing
1630
- // toward the pointer. Keys are baked on drag END — see ikDragEnd.
1302
+ // toward the pointer. The solved pose stays a preview until Apply.
1631
1303
  function ikSolve(kind, trackId, targetWorld) {
1304
+ const state = appContext.shared.ikStateRef.current;
1305
+ const ikChains = state.chains;
1306
+ const ikFkJoints = state.fkJoints;
1307
+ if (!ikDragRef.current) {
1308
+ const pending = pendingIkEditRef.current;
1309
+ ikPreDragRef.current = pending ?? {
1310
+ characterId: appContext.shared.activeChar.id,
1311
+ beforeFrame: appContext.shared.tlFrame,
1312
+ sourceLayer: domain.layer(appContext.shared.activeChar.id),
1313
+ state, rig: appContext.shared.activeRig, motion,
1314
+ beforeRig: appContext.shared.snapshotExportRig(appContext.shared.activeRig),
1315
+ beforeKeys: snapshotIkKeys(state),
1316
+ beforeTracked: new Set(state.tracked),
1317
+ beforeTargets: new Map([...state.targets].map(([id, target]) => [id, target.clone()])),
1318
+ beforePlants: new Map([...state.plants].map(([id, target]) => [id, target.clone()])),
1319
+ };
1320
+ ikDragRef.current = { frame: appContext.shared.tlFrame, ids: new Set(pending?.ids ?? []) };
1321
+ }
1632
1322
  ikDragRef.current = ikDragRecord(ikDragRef.current, appContext.shared.tlFrame);
1633
1323
  const touch = (id) => ikDragTouch(appContext.shared.ikStateRef.current, ikDragRef.current, id);
1634
1324
  if (kind === "chain") {
@@ -1696,18 +1386,47 @@ export function useMotion(appContext) {
1696
1386
  solveSwingAngle(joint, targetWorld.axis, targetWorld.angle, targetWorld.startQuat, targetWorld.startParentQuat);
1697
1387
  }
1698
1388
 
1699
- // Drag end: key the dragged part's local rotations at the playhead, so a
1700
- // scrub away and back restores the dragged pose exactly (slerp).
1389
+ // Drag end: retain the solved pose as a preview. Apply creates keys at both
1390
+ // boundaries of the selected timeline motion segment.
1701
1391
  function ikDragEnd() {
1702
1392
  appContext.shared.ikBodyDragRef.current = false;
1703
1393
  const drag = ikDragRef.current;
1704
1394
  ikDragRef.current = null;
1705
- // One entry per drag: the pointermoves only moved bones, the keys map is
1706
- // untouched until this bake — the key it sets records the pre-drag keys.
1707
- if (appContext.storeDomain('motion') || ikChains) keyIkPoseAtPlayhead(
1708
- motion ? (drag ? drag.ids : null) : null,
1709
- );
1710
- setIkTick((n) => n + 1);
1395
+ if (!motion) {
1396
+ keyIkPoseAtPlayhead();
1397
+ ikPreDragRef.current = null;
1398
+ setIkTick((n) => n + 1);
1399
+ return;
1400
+ }
1401
+ if (!drag?.ids?.size) {
1402
+ ikPreDragRef.current = null;
1403
+ return;
1404
+ }
1405
+ const state = appContext.shared.ikStateRef.current;
1406
+ const raw = (rawRig, at) => appContext.shared.poseMemberAtFrame(rawRig, motion, null, at);
1407
+ const keyed = bakeIkDragKey(state.chains, state.fkJoints, drag.frame, drag.ids, raw);
1408
+ if (!keyed) {
1409
+ ikPreDragRef.current = null;
1410
+ return;
1411
+ }
1412
+ const before = ikPreDragRef.current;
1413
+ const range = pendingIkEditRef.current ?? selectedIkRange(drag.frame);
1414
+ const next = copyPhysicsKeys(before.beforeKeys);
1415
+ const boundaries = bakeIkDragRange(state.chains, state.fkJoints, keyed, range.startFrame, range.endFrame, drag.ids, raw);
1416
+ for (const [at, entry] of boundaries) {
1417
+ if (!entry) continue;
1418
+ let frameEntry = next.get(at);
1419
+ if (!frameEntry) next.set(at, (frameEntry = new Map()));
1420
+ for (const [track, key] of entry) frameEntry.set(track, { ...key, blend: IK_CORRECTION_BLEND_FRAMES });
1421
+ }
1422
+ setPendingIkEditValue({
1423
+ ...before,
1424
+ frame: drag.frame,
1425
+ startFrame: range.startFrame,
1426
+ endFrame: range.endFrame,
1427
+ ids: [...drag.ids],
1428
+ candidate: { ...state, keys: next, tracked: new Set([...state.tracked, ...drag.ids]) },
1429
+ });
1711
1430
  }
1712
1431
 
1713
1432
  /** Bake the current tracked rotations at the playhead into a scratch layer
@@ -2046,35 +1765,6 @@ export function useMotion(appContext) {
2046
1765
  return true;
2047
1766
  }
2048
1767
 
2049
- function downloadArdyPose() {
2050
- const rig = appContext.shared.posedRig();
2051
- if (!rig) {
2052
- appContext.notify(ko("Character not loaded yet", "캐릭터가 아직 로드되지 않았어요"));
2053
- return;
2054
- }
2055
- trackFeature("export_pose");
2056
- const pose = buildArdyPose({
2057
- rig,
2058
- camRef: appContext.shared.shotCamRef,
2059
- look: appContext.shared.look,
2060
- fovDeg: appContext.shared.fovDeg,
2061
- slate: slateLine(appContext.shared.shot),
2062
- // rigName follows the posed character's actual model below
2063
- rigName: appContext.shared.posingChar?.model ?? appContext.shared.charA.model,
2064
- root: captureArdyRoot(rig),
2065
- });
2066
- const blob = new Blob([JSON.stringify(pose, null, 2)], { type: "application/json" });
2067
- const url = URL.createObjectURL(blob);
2068
- const a = document.createElement("a");
2069
- a.href = url;
2070
- a.download = "cozyclay-pose.json";
2071
- document.body.appendChild(a);
2072
- a.click();
2073
- a.remove();
2074
- URL.revokeObjectURL(url);
2075
- appContext.notify(ko("Pose exported", "포즈 내보내기 완료"));
2076
- }
2077
-
2078
1768
  function recheckMotionHealth() {
2079
1769
  setBridgeChecking(true);
2080
1770
  return appContext.shared.bridgeRefreshRef.current().finally(() => setBridgeChecking(false));
@@ -2128,6 +1818,10 @@ export function useMotion(appContext) {
2128
1818
  * buys the full step count. */
2129
1819
  function runLineEdit() {
2130
1820
  if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) return;
1821
+ if (isProxyFigure(appContext.shared.activeChar)) {
1822
+ appContext.notify(kindRefusal("lineEdit", isKo));
1823
+ return;
1824
+ }
2131
1825
  const generationRequest = requestMotionGeneration("line_edit", "edit", { lineEdit: true });
2132
1826
  if (!appContext.shared.takeSourceUrl) {
2133
1827
  appContext.notify(ko("The current take has no bridge source — generate it once before editing a path", "현재 테이크에 브리지 원본이 없어요 — 궤적을 편집하기 전에 한 번 생성하세요"));
@@ -2214,6 +1908,7 @@ export function useMotion(appContext) {
2214
1908
  if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) throw generationRefusal('TARGET_BUSY', 'A motion generation is already running.');
2215
1909
  if (appContext.shared.linePreviewUrl) throw generationRefusal('TARGET_NOT_READY', previewBlockingReason(en => en), previewBlockingReason(ko));
2216
1910
  const character = appContext.storeDomain('cast').read().find(row => row.id === args.characterId);
1911
+ refuseRigOnly(character, 'motion');
2217
1912
  const active = character.id === appContext.shared.loadedLayerCharRef.current;
2218
1913
  const posing = active && appContext.shared.posing;
2219
1914
  const rig = posing ? appContext.shared.posedRig() : appContext.shared.rigs[character.id];
@@ -2257,9 +1952,12 @@ export function useMotion(appContext) {
2257
1952
  });
2258
1953
  }
2259
1954
 
2260
- /* --------------------- trail drag -> preview -> regen -------------------- */
2261
- function onTrailDragStart({ grabFrame } = {}) {
1955
+ function onTrailDragStart({ track, grabFrame } = {}) {
2262
1956
  if (!motion) return;
1957
+ if (track) {
1958
+ setTrailTrackFocus(track);
1959
+ focusIkHandle(track);
1960
+ }
2263
1961
  const frame = Math.round(grabFrame ?? appContext.shared.tlFrame);
2264
1962
  appContext.shared.setTlFrame(frame);
2265
1963
  // The pre-drag take is both the deformation base (repeated moves re-derive
@@ -2329,6 +2027,10 @@ export function useMotion(appContext) {
2329
2027
  * deformed line contributes the grab-frame pose as a root guide. */
2330
2028
  function runTrailRegeneration() {
2331
2029
  if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) return;
2030
+ if (isProxyFigure(appContext.shared.activeChar)) {
2031
+ appContext.notify(kindRefusal("trails", isKo));
2032
+ return;
2033
+ }
2332
2034
  const request = requestMotionGeneration("trail", "edit", { motionEdit: true });
2333
2035
  if (!trailEdit) return;
2334
2036
  // Same rule as runArdy: motionEdit rewrites a span of THE take, and a
@@ -2419,8 +2121,7 @@ export function useMotion(appContext) {
2419
2121
  setTrailEdit(null);
2420
2122
  }
2421
2123
 
2422
- /* ------------------------- motion job queue ---------------------------
2423
- * One box, one job at a time: explicit entry points suppress duplicate
2124
+ /* One box, one job at a time: explicit entry points suppress duplicate
2424
2125
  * requests through queueing and execution. The
2425
2126
  * payload is frozen at enqueue time; completion delivers the clip to the
2426
2127
  * REQUESTING character's layer, not whoever happens to be selected then. */
@@ -2533,8 +2234,7 @@ export function useMotion(appContext) {
2533
2234
  }
2534
2235
  }
2535
2236
 
2536
- /* ------------------- recipe + version bookkeeping (C9/C12) ----------------
2537
- * ONE writer for both. Every take that reaches the app came out of a job,
2237
+ /* ONE writer for both. Every take that reaches the app came out of a job,
2538
2238
  * so a job's completion is the only place where "what is this take made of"
2539
2239
  * can be answered honestly, and the answer is checkpointed next to the
2540
2240
  * motionUrl in the same breath. Nothing else may write takeRecipeRef except
@@ -2652,8 +2352,7 @@ export function useMotion(appContext) {
2652
2352
  }
2653
2353
  }
2654
2354
 
2655
- /* ---------------------- the two edit entries (C12) ------------------------
2656
- * Scene blocks the shot with Kimodo; Refine pulls one joint's path with
2355
+ /* Scene blocks the shot with Kimodo; Refine pulls one joint's path with
2657
2356
  * ProjFlow. Everything else the pipeline can do is one of those two said
2658
2357
  * more precisely, and both are reachable from the take itself instead of
2659
2358
  * from a foldout the artist has to remember to open.
@@ -2707,12 +2406,16 @@ export function useMotion(appContext) {
2707
2406
  }
2708
2407
 
2709
2408
  function refineDisabledReason() {
2409
+ if (!characterCapabilities(appContext.shared.activeChar).lineEdit) return kindRefusal("lineEdit", isKo);
2710
2410
  if (!motion) return ko("No take yet — block a scene first", "아직 테이크가 없어요 — 먼저 장면을 만들어 주세요");
2711
2411
  if (!motion.url) return ko("This take has no bridge source — generate it once before refining", "이 테이크에는 브리지 원본이 없어요 — 한 번 생성해야 다듬을 수 있어요");
2712
2412
  return "";
2713
2413
  }
2714
2414
 
2715
2415
  function sceneDisabledReason() {
2416
+ // A standing capability, so it outranks the bridge probe: a capsule figure
2417
+ // cannot generate whatever the backend says.
2418
+ if (!characterCapabilities(appContext.shared.activeChar).motion) return kindRefusal("motion", isKo);
2716
2419
  if (bridge === null || bridgeChecking) return motionReadinessMessage("loading");
2717
2420
  if (generationBusy) return ko("A generation is already running", "이미 생성이 돌고 있어요");
2718
2421
  // NOT a line-edit preview, deliberately. Every other reason here is a
@@ -2914,244 +2617,51 @@ export function useMotion(appContext) {
2914
2617
  function cancelArdy() {
2915
2618
  appContext.shared.ardyAbortRef.current?.abort();
2916
2619
  }
2917
- function captureI2vStill() {
2918
- // H3 480P renders 832x480. The still is captured at exactly that canvas
2919
- // (x2) regardless of the Studio's shot ratio; markI2vPose also switches
2920
- // the viewport to the matching ratio so what the user framed is what
2921
- // gets sent.
2922
- const captured = appContext.shared.captureLiveFraming({ output: I2V_MOTION_STILL_OUTPUT });
2923
- if (!captured?.dataUrl?.startsWith("data:image/")) throw new Error(ko("The shot renderer is not ready.", "렌더러가 준비되지 않았어요."));
2924
- if (captured.width !== I2V_MOTION_STILL_OUTPUT.width || captured.height !== I2V_MOTION_STILL_OUTPUT.height) {
2925
- throw new Error(ko(`The H3 480P reference must be captured at ${I2V_MOTION_STILL_OUTPUT.width}×${I2V_MOTION_STILL_OUTPUT.height}.`, `H3 480P 참조 캡처는 ${I2V_MOTION_STILL_OUTPUT.width}×${I2V_MOTION_STILL_OUTPUT.height}이어야 해요.`));
2926
- }
2927
- // H3 must see the same complete subject in both endpoints. A clipped
2928
- // foot or head makes the model invent the missing geometry during the
2929
- // transition, which is exactly the bad motion this flow is meant to avoid.
2930
- const rig = appContext.live.state.rigs?.[appContext.shared.activeChar.id];
2931
- const cam = appContext.shared.shotCamRef.current;
2932
- if (!rig || !cam) throw new Error(ko("The full-body frame is not ready yet. Wait a moment and try the reference capture again.", "전신 프레임을 확인할 수 없어 참조를 캡처할 수 없어요. 잠시 후 다시 시도하세요."));
2933
- rig.updateWorldMatrix(true, true);
2934
- cam.updateMatrixWorld(true);
2935
- const bounds = new THREE.Box3().setFromObject(rig);
2936
- const corners = [
2937
- new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z),
2938
- new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
2939
- new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z),
2940
- new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
2941
- new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z),
2942
- new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
2943
- new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z),
2944
- new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
2945
- ].map((corner) => corner.project(cam));
2946
- const margin = 0.94;
2947
- const clipped = corners.some((corner) =>
2948
- corner.z < -1 || corner.z > 1 || Math.abs(corner.x) > margin || Math.abs(corner.y) > margin
2949
- );
2950
- if (clipped) {
2951
- throw new Error(ko(
2952
- "The full character is not inside the A/B reference. In the shot view, pull the camera back until the head and both feet are visible, then capture again.",
2953
- "A/B 참조에 캐릭터 전신이 다 안 들어왔어요. 샷 시점에서 머리와 양발이 화면 안에 들어오도록 카메라를 뒤로 빼고 다시 캡처하세요."
2954
- ));
2955
- }
2956
- if (!appContext.shared.i2vMotionSegmentationReady || !Array.isArray(captured.partColours) || captured.partColours.length === 0) {
2957
- throw new Error(ko("Enable View → Body part colours → Shaded before capturing an A/B reference.", "A/B 참조는 View에서 부위 색상 → 음영을 켜야 캡처할 수 있어요."));
2958
- }
2959
- return { ...captured, framing: appContext.shared.captureCurrentFraming() };
2960
- }
2961
- /** Put the viewport on the Fal canvas and hand the fly controls to the
2962
- * shot camera, so the user composes the A/B reference on exactly the
2963
- * 832x480 frame the clip will have. Idempotent; capture does not need it. */
2964
- function enterI2vFraming() {
2965
- appContext.shared.runStudioAction("stage.setFilmback", { shotAspect: I2V_MOTION_SHOT_ASPECT });
2966
- if (!appContext.shared.lookThroughShot) appContext.shared.enterShotLook();
2967
- }
2968
- function markI2vPose(slot) {
2969
- try {
2970
- if (slot === "b" && i2vMotion.a && framingDistance(i2vMotion.a.framing, appContext.shared.captureCurrentFraming()) > 0.001) {
2971
- throw new Error(ko("The camera moved between A and B. Capture both refs with the same camera framing.", "A와 B 사이에서 카메라가 이동했어요. 같은 카메라 프레이밍으로 다시 캡처하세요."));
2972
- }
2973
- const still = captureI2vStill();
2974
- // The capture is already on the Fal canvas; make the viewport agree so
2975
- // the user sees the frame that was just sent.
2976
- appContext.shared.runStudioAction("stage.setFilmback", { shotAspect: I2V_MOTION_SHOT_ASPECT });
2977
- setI2vMotion((current) => ({ ...current, [slot]: still, status: "idle", error: "" }));
2978
- if (slot === "a") appContext.shared.setI2vMotionCameraUnlocked(false);
2979
- appContext.notify(isKo ? `포즈 ${slot.toUpperCase()} 캡처됨 · ${still.width}×${still.height}` : `Pose ${slot.toUpperCase()} captured · ${still.width}×${still.height}`);
2980
- } catch (error) {
2981
- setI2vMotion((current) => ({ ...current, error: error.message, status: "error" }));
2982
- }
2983
- }
2984
- function clearI2vPose(slot) {
2985
- setI2vMotion((current) => ({ ...current, [slot]: null, status: "idle", error: "", job: null }));
2986
- if (slot === "a") appContext.shared.setI2vMotionCameraUnlocked(false);
2987
- }
2988
- function clearI2vMotion() {
2989
- setI2vMotion({ a: null, b: null, job: null, status: "idle", error: "", instruction: "", promptOverride: "", duration: I2V_MOTION_MIN_DURATION, dailyRemaining: null });
2990
- appContext.shared.setI2vMotionCameraUnlocked(false);
2991
- }
2992
- function restoreI2vCamera() {
2993
- const framing = i2vMotion.a?.framing;
2994
- const camera = appContext.shared.shotCamRef.current;
2995
- if (!framing || !camera) return;
2996
- camera.position.set(framing.pos.x, framing.pos.y, framing.pos.z);
2997
- camera.rotation.order = "YXZ";
2998
- camera.rotation.set(framing.pitch, framing.yaw, 0);
2999
- camera.fov = framing.fovDeg;
3000
- camera.updateProjectionMatrix();
3001
- appContext.shared.look.current.yaw = framing.yaw;
3002
- appContext.shared.look.current.pitch = framing.pitch;
3003
- appContext.shared.shotCameraPosRef.current = { ...framing.pos };
3004
- appContext.shared.setCameraPos({ ...framing.pos });
3005
- appContext.shared.setFovDeg(framing.fovDeg);
3006
- appContext.shared.setI2vMotionCameraUnlocked(false);
3007
- setI2vMotion((current) => ({ ...current, error: "", status: "idle" }));
3008
- appContext.notify(isKo ? "A 캡처 카메라로 복원했어요." : "Restored the camera used for A.");
3009
- }
3010
- function framingDistance(a, b) {
3011
- if (!a || !b) return Infinity;
3012
- return Math.max(
3013
- Math.abs(a.pos.x - b.pos.x), Math.abs(a.pos.y - b.pos.y), Math.abs(a.pos.z - b.pos.z),
3014
- Math.abs(a.yaw - b.yaw), Math.abs(a.pitch - b.pitch), Math.abs(a.fovDeg - b.fovDeg),
3015
- );
3016
- }
3017
- /** The AI motion card's lock line: AI video motion is not enabled for this account. */
3018
- function showI2vMotionLock() {
3019
- setI2vMotion((current) => ({ ...current, error: ko("AI motion generation is locked during QA.", "AI 모션 생성은 QA 중 잠겨 있어요."), status: "error" }));
3020
- }
3021
- /** The AI motion card shows every failure itself. For motion.generateFromVideo the
3022
- * answer says what happened: `{ failed }` with the reason in English, or the
3023
- * finished job, the footage it was ingested as (null when ingest failed) and
3024
- * the account's daily generations left. */
3025
- async function generateI2vMotion(kind = "interpolate", instructionOverride = null, commandContext = null) {
3026
- // One explicit request on the motion:* contract (#466). While the route is
3027
- // gated, the lock itself is the demand signal. Telemetry never decides.
3028
- const motionRequest = startMotionRequest({
3029
- surface: !commandContext ? "fal_card" : commandContext.origin === "mcp" || commandContext.origin === "live" ? "mcp" : "agent",
3030
- input_mode: kind === "interpolate" ? "a_to_b" : "still",
3031
- });
3032
- if (!appContext.shared.i2vMotionEnabled) {
3033
- motionRequest.block("locked");
3034
- showI2vMotionLock();
3035
- return { failed: "AI video motion (Fal) is not enabled for this account." };
3036
- }
3037
- let source = i2vMotion;
3038
- if (kind === "act" && !source.a) {
3039
- try { source = { ...source, a: captureI2vStill() }; setI2vMotion((current) => ({ ...current, a: source.a })); }
3040
- catch (error) {
3041
- motionRequest.block("missing_input");
3042
- setI2vMotion((current) => ({ ...current, error: error.message, status: "error" }));
3043
- return { failed: "Could not capture the character's pose frame: the full body must be inside the shot frame, shaded part colours must be on (view.setPartColours { mode: \"shaded\" }), and the renderer and rig must be ready." };
3044
- }
3045
- }
3046
- if (kind === "interpolate" && (!source.a || !source.b)) {
3047
- motionRequest.block("missing_input");
3048
- setI2vMotion((current) => ({ ...current, error: ko("Capture both A and B poses first.", "A와 B 포즈를 먼저 캡처하세요."), status: "error" }));
3049
- return { failed: "Capture both A and B poses first." };
3050
- }
3051
- if (!source.a?.partColours || (kind === "interpolate" && !source.b?.partColours)) {
3052
- motionRequest.block("missing_input");
3053
- setI2vMotion((current) => ({ ...current, error: ko("Recapture A/B refs with shaded body-part segmentation enabled.", "색 세그멘테이션이 포함된 음영 A/B 참조를 다시 캡처하세요."), status: "error" }));
3054
- return { failed: "The captured pose frame has no shaded body-part segmentation; the user must recapture it in the Fal card with shaded part colours on." };
3055
- }
3056
- if (kind === "interpolate" && framingDistance(source.a.framing, source.b.framing) > 0.001) {
3057
- motionRequest.block("missing_input");
3058
- setI2vMotion((current) => ({ ...current, error: ko("The camera changed between A and B. Capture both poses with the same camera.", "A와 B 사이에서 카메라가 바뀌었어요. 같은 카메라로 다시 캡처하세요."), status: "error" }));
3059
- return { failed: "The camera changed between poses A and B; capture both with the same camera." };
3060
- }
3061
- // A hand-edited prompt wins verbatim; otherwise build from the description.
3062
- // Interpolate now honours the description too (#380): the bare pose
3063
- // difference lets the model invent the transition and produces junk.
3064
- const description = instructionOverride || source.instruction || "";
3065
- const prompt = source.promptOverride?.trim()
3066
- ? source.promptOverride.trim()
3067
- : buildH3MotionPrompt(description || (kind === "interpolate" ? "" : "Make the character perform the requested action."), { interpolate: kind === "interpolate" });
3068
- setI2vMotion((current) => ({ ...current, status: "submitting", error: "", job: null }));
3069
- motionRequest.pass("fal");
3070
- motionRequest.start();
3071
- try {
3072
- const fetchImpl = commandContext ? (url, options) => fetch(url, { ...options, signal: commandContext.signal }) : undefined;
3073
- const submitted = await submitMotionJob({
3074
- kind,
3075
- stillA: source.a?.dataUrl,
3076
- stillB: source.b?.dataUrl,
3077
- still: source.a?.dataUrl,
3078
- prompt,
3079
- duration: source.duration ?? I2V_MOTION_MIN_DURATION,
3080
- }, fetchImpl);
3081
- const id = submitted?.job?.id;
3082
- if (!id) throw Object.assign(new Error(ko("The server did not return a motion job ID.", "생성 작업 ID를 받지 못했어요.")), { reason: "The motion server did not return a job id." });
3083
- setI2vMotion((current) => ({ ...current, status: "queued", job: submitted.job, dailyRemaining: submitted.dailyRemaining }));
3084
- const finished = await waitForMotionJob(id, {
3085
- fetchImpl,
3086
- onUpdate: (job) => setI2vMotion((current) => ({ ...current, job, status: job?.status ?? current.status })),
3087
- });
3088
- commandContext?.check();
3089
- const job = finished?.job;
3090
- if (job?.status !== "done") throw Object.assign(new Error(job?.error || ko("AI motion generation failed.", "AI 모션 생성에 실패했어요.")), job?.error ? {} : { reason: "The AI video generation failed." });
3091
- motionRequest.succeed();
3092
- setI2vMotion((current) => ({ ...current, job, status: "done", dailyRemaining: finished.dailyRemaining }));
3093
- let footage = null;
3094
- if (job.video?.url) {
3095
- const motionSource = { kind: "url", url: job.video.url, name: `Fal H3 Max Turbo · ${job.resolution}` };
3096
- setMultiModelSource(motionSource);
3097
- // Put the completed clip through the same probe/ingest path as a
3098
- // manually supplied URL so GVHMR sees measured fps, duration and
3099
- // a ready extraction card without another generation request.
3100
- footage = (await ingestFootage(motionSource, commandContext)) ?? null;
3101
- appContext.shared.setResult({
3102
- mode: "video",
3103
- modelLabel: "Fal H3 Max Turbo",
3104
- prompt,
3105
- frame: source.a?.dataUrl ?? null,
3106
- videoUrl: job.video.url,
3107
- motion: {
3108
- videoUrl: job.video.url,
3109
- resolution: job.resolution,
3110
- width: job.width,
3111
- height: job.height,
3112
- fps: job.fps,
3113
- duration: job.resultDuration ?? job.duration,
3114
- cost: job.cost,
3115
- },
3116
- });
3117
- appContext.shared.setResultOpen(true);
3118
- // Applied = the clip is in the Studio, ready for GVHMR extraction.
3119
- motionRequest.apply();
3120
- // The result modal and the studio modal are both z-30; never stack them.
3121
- appContext.shared.setI2vMotionStudioOpen(false);
3122
- appContext.notify((isKo, ko) => isKo ? "AI 영상이 준비됐어요 · 추출 패널에서 GVHMR을 실행하세요" : "AI video is ready · run GVHMR from the extraction panel");
3123
- }
3124
- return { job, footage, dailyRemaining: finished.dailyRemaining ?? null };
3125
- } catch (error) {
3126
- const cancelled = Boolean(commandContext && (commandContext.signal.aborted || error?.code === "STALE_TARGET"));
3127
- // 429 is the account's daily cap (workers/api daily_cap).
3128
- motionRequest.fail(error, cancelled ? "aborted" : error?.status === 429 ? "quota" : "generation_failed");
3129
- if (cancelled) throw error;
3130
- setI2vMotion((current) => ({ ...current, status: "error", error: error.message || String(error) }));
3131
- return { failed: error.reason ?? `The AI video generation failed: ${error.message || error}` };
3132
- }
3133
- }
3134
- /** Why the chip cannot start an AI video motion now, as the user reads it:
3135
- * one already running, or none left today. The lock has its own AI motion card line. */
3136
- function i2vMotionUnavailable() {
3137
- if (!["idle", "done", "error", "failed"].includes(i2vMotion.status)) return ko("A generation is already running", "이미 생성이 돌고 있어요");
3138
- if (i2vMotion.dailyRemaining === 0) return ko("No AI video motion generations left today", "오늘 남은 AI 영상 모션 생성이 없어요");
3139
- return null;
3140
- }
3141
- function generateI2vMotionFromUi(instruction) {
3142
- if (!appContext.shared.i2vMotionEnabled) {
3143
- // Locked, the action's availability check refuses before
3144
- // generateI2vMotion runs, so the chip records its own lock (#466).
3145
- startMotionRequest({ surface: "agent", input_mode: "still" }).block("locked");
3146
- showI2vMotionLock();
3147
- } else {
3148
- // The chip clears the typed instruction when clicked, so a refusal says why.
3149
- const reason = i2vMotionUnavailable();
3150
- if (reason) { appContext.notify(reason); return null; }
3151
- }
3152
- return appContext.shared.runStudioAction("motion.generateFromVideo", { instruction });
3153
- }
3154
- function updateI2vMotionQuota(dailyRemaining) { setI2vMotion((current) => ({ ...current, dailyRemaining })); }
2620
+ const {
2621
+ chooseMultiModelFile,
2622
+ pasteMultiModelUrl,
2623
+ useMultiModelUrl,
2624
+ ingestPlatformFootage,
2625
+ ingestFootage,
2626
+ extractMultiModelMotion,
2627
+ extractMultiModelMotionGpu,
2628
+ deliverExtraTakes,
2629
+ captureI2vStill,
2630
+ enterI2vFraming,
2631
+ markI2vPose,
2632
+ clearI2vPose,
2633
+ clearI2vMotion,
2634
+ restoreI2vCamera,
2635
+ framingDistance,
2636
+ showI2vMotionLock,
2637
+ generateI2vMotion,
2638
+ i2vMotionUnavailable,
2639
+ generateI2vMotionFromUi,
2640
+ updateI2vMotionQuota,
2641
+ } = createMotionVideoCapture({
2642
+ appContext,
2643
+ bridge,
2644
+ i2vMotion,
2645
+ setI2vMotion,
2646
+ multiModelUrl,
2647
+ multiModelSource,
2648
+ multiModelFootage,
2649
+ multiModelExtract,
2650
+ setMultiModelUrl,
2651
+ setMultiModelSource,
2652
+ setMultiModelStatus,
2653
+ setMultiModelStage,
2654
+ setMultiModelProgress,
2655
+ setMultiModelFootage,
2656
+ setMultiModelError,
2657
+ setMultiModelTake,
2658
+ setMultiModelExtract,
2659
+ setMultiModelExtractProgress,
2660
+ setMultiModelExtractError,
2661
+ loadMotion,
2662
+ authoredSupportDescriptors,
2663
+ applyAuthoredSupportRise,
2664
+ });
3155
2665
  domain.setVideoDraft = patch => setI2vMotion(current => ({ ...current, ...patch }));
3156
2666
  domain.requestLineEdit = runLineEdit;
3157
2667
  domain.requestTrailRegeneration = runTrailRegeneration;
@@ -3189,7 +2699,8 @@ export function useMotion(appContext) {
3189
2699
  bodyContact, setBodyContact, IK_CORRECTION_BLEND_FRAMES, autoPhysicsRunning, setAutoPhysicsRunning,
3190
2700
  physicsPreview, setPhysicsPreview, physicsShow, physicsProgress, physicsOptions, setPhysicsOptions,
3191
2701
  ikTick, setIkTick, committedIkEdits, setCommittedIkEdits, trailFalloffS, setTrailFalloffS, showTrails,
3192
- setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
2702
+ setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailTrackFocus, selectTrailTrack, clearTrailTrackFocus, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
2703
+ pendingIkEdit, applyPendingIkEdit, cancelPendingIkEdit,
3193
2704
  rangePinApplySpec: (pin) => {
3194
2705
  domain.run('motion.rangePin.apply', { characterId: appContext.shared.activeChar.id, pin });
3195
2706
  return { residuals: decodeRangePinResiduals(domain.layer(appContext.shared.activeChar.id).ikPinResiduals).get(pin.id) };