cozyclay 1.9.0 → 2.0.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 (286) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/LIVE-PROTOCOL.md +1 -1
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. package/src/scene-history.js +0 -129
@@ -0,0 +1,3219 @@
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
+ import { useState, useEffect, useSyncExternalStore, useContext, useRef, useMemo } from "react";
6
+ import { AppContext } from '../app-context.js';
7
+ import { createDocumentStore } from '../document-store.js';
8
+ import { useDocumentDomain } from '../store/use-document-store.js';
9
+ import { createPhysicsProgress } from "../ardy/physics-panel.jsx";
10
+ import {
11
+ TIMELINE_FPS,
12
+ hierarchyIdForIkFocus,
13
+ ARDY_PRESERVE_DEFAULT,
14
+ MULTIMODEL_REASONS,
15
+ nextCharacterId,
16
+ REST_BONES,
17
+ ARDY_SEED_MAX,
18
+ lineTrackLabel,
19
+ ARDY_DURATION_MIN,
20
+ buildPromptSchedule,
21
+ toArdyFrameEntries,
22
+ posePlacementFrame,
23
+ toArdySegments,
24
+ ARDY_FPS,
25
+ toArdyFrame,
26
+ } from "../app-stage.jsx";
27
+ import { copyPhysicsKeys, physicsKeyStamp, reviewAutoPhysics } from "../ardy/physics-review.js";
28
+ import { fitPlatforms } from "../ardy/platform-fit.js";
29
+ import {
30
+ createIkState,
31
+ ikTouch,
32
+ ikKeyframes,
33
+ ikRemoveKeyframe,
34
+ ikSeedTargets,
35
+ clampIkTargetToFloor,
36
+ solveIk,
37
+ MID_TRACKS,
38
+ solveMidJoint,
39
+ solveEffectorSwing,
40
+ ikPlantFeet,
41
+ solveHipsTranslateToFloor,
42
+ solveHipsTranslate,
43
+ solveSwingAngle,
44
+ ikSolvePlantedFeet,
45
+ applyBodyContact,
46
+ ikBakeKeyframe,
47
+ ikEvaluate,
48
+ resolveIkRig,
49
+ } from "../ardy/ik.js";
50
+ import * as THREE from "three";
51
+ import { StudioProtocolError } from "../studio-agent-protocol.js";
52
+ import { restorePlaybackBones, snapshotPlaybackBones, applyMotionFrame, captureArdyRoot } from "../ardy/playback.js";
53
+ 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
+ import { characterScaleFor, loadMotionFromUrl } from "../ardy/npz.js";
64
+ import { supportHeightForObject, OBJECT_LIBRARY } from "../scene-objects.js";
65
+ import { applySupportRise, autoRoofDrop, applyAutoFall, applyRootDrop, normalizeRootDrop } from "../ardy/root-drop.js";
66
+ import { takeAnchor, createCharacterEntry } from "../scenes.js";
67
+ import { retimeMotion } from "../ardy/retime.js";
68
+ import {
69
+ createMotionEdit,
70
+ renderMotionEdit,
71
+ remapFrameKeyMap,
72
+ remapTimelineFrame,
73
+ } from "../ardy/motion-edit.js";
74
+ import { DEFAULT_POSE, restoreBindPositions, applyPose, applyHipsOffset } from "../poses.js";
75
+ import { normalizeMotionCalibration, applyMotionCalibration } from "../ardy/motion-calibration.js";
76
+ import { collisionBlockers } from "../ardy/collision-blockers.js";
77
+ import { fixCollisions, fixCollisionsRange } from "../ardy/fix-collisions.js";
78
+ import { trackFeature, startMotionRequest, motionPreflightReason, trackActivation } from "../analytics.js";
79
+ import { buildArdyPose } from "../ardy/export.js";
80
+ import { slateLine } from "../shot.js";
81
+ import { motionReadiness } from "../motion-readiness.js";
82
+ import { motionReadinessMessage } from "../motion-readiness-ui.jsx";
83
+ import {
84
+ resolveSeed,
85
+ stripSourceMotion,
86
+ blocksFromRequest,
87
+ replayPayload,
88
+ freshRecipe,
89
+ withLineEdit,
90
+ pushTakeVersion,
91
+ TAKE_VERSIONS_MAX,
92
+ } from "../take-recipe.js";
93
+ import { buildGenerationRequest, generationRefusal } from '../motion/generation.js';
94
+ 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";
97
+ import { ikKeyJson, ikTrackKeyFromJson } from "../ardy/ik-key-json.js";
98
+ import { findAbsoluteIkKeyConflicts } from "../trail-key-conflicts.js";
99
+ import { chooseIkEntryPose } from "../ik-camera.js";
100
+ import { generate as ardyGenerate } from "../ardy/client.js";
101
+ import { isLineEditUnsupported } from "../line-edit.js";
102
+ import { openMotionDb, getMotion, putMotion } from "../motion-store.js";
103
+ import { resolveMotionSource, decodeMotionResource, encodeMotionResource, sha256Hex } from "../motion-resources.js";
104
+ import { applyRangePin, captureRangePinTarget, normalizeRangePin, rangePinTargetWorld, rangePinTracks, removeRangePinKeys } from "../ardy/range-pin.js";
105
+ import { rangePinObjectMatrixAt } from "../range-pin-object-transform.js";
106
+
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
+
152
+ // Plain intent owns take identities and JSON keys. Decoded buffers and rig
153
+ // preimages are copy-on-record runtime resources, never mutable store values.
154
+ export function createMotionDomain(appContext, characters) {
155
+ const takes = new Map(), decoded = new WeakMap(), rigImages = new WeakMap(), previews = new Map(), prepared = new Map();
156
+ let running = 0;
157
+ const references = entries => entries.map(entry => {
158
+ const ref = entry.motionRef;
159
+ const take = ref ? { resourceId: `restore:${entry.id}:${ref.motionId ?? ref.url}`, url: ref.url ?? null, anchorX: ref.anchorX, anchorZ: ref.anchorZ,
160
+ 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', '불러옴') }] : [] };
162
+ });
163
+ function snapshotTake(take) {
164
+ if (!take) return null;
165
+ if (take.resourceId) return { ...take };
166
+ const resourceId = crypto.randomUUID(), copy = structuredClone(take);
167
+ takes.set(resourceId, copy);
168
+ return { resourceId, frames: take.frames, fps: take.fps, url: take.url ?? null, prompt: take.prompt ?? '',
169
+ anchorX: take.anchorX ?? 0, anchorZ: take.anchorZ ?? 0, anchorFrame: take.anchorFrame ?? 0, rotationDeg: take.rotationDeg ?? 0,
170
+ ...(take.trailEdits ? { trailEdits: take.trailEdits } : {}),
171
+ editSegments: take.editSegments ?? createMotionEdit(take.frames), studioTakeId: take.studioTakeId ?? resourceId };
172
+ }
173
+ const normalize = rows => rows.map(row => ({ ...emptyMotionLayer(row.id), ...row,
174
+ take: snapshotTake(row.take), fullTake: snapshotTake(row.fullTake ?? row.take),
175
+ ikKeys: row.ikKeys ?? [], ikPins: row.ikPins ?? [], ikPinResiduals: row.ikPinResiduals ?? [], committedIkEdits: row.committedIkEdits ?? [] }));
176
+ let native = createDocumentStore({ owned: { motion: normalize(references(characters)) } });
177
+ const listeners = new Set(), notify = () => { for (const listener of listeners) listener(); };
178
+ let release = native.subscribe(notify), viewRevision = 0;
179
+ const documentStore = { ...Object.fromEntries(Object.keys(native).map(key => [key, (...args) => native[key](...args)])),
180
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); } };
181
+ const read = () => documentStore.read('motion');
182
+ const layer = id => read().find(row => row.id === id) ?? emptyMotionLayer(id);
183
+ function materialize(take) {
184
+ if (!take || !takes.has(take.resourceId)) return null;
185
+ if (!decoded.has(take)) {
186
+ const { resourceId, ...metadata } = take;
187
+ decoded.set(take, { ...takes.get(resourceId), ...metadata });
188
+ }
189
+ return decoded.get(take);
190
+ }
191
+ const motionFor = id => materialize(layer(id).take), fullMotionFor = id => materialize(layer(id).fullTake);
192
+ const visibleMotion = id => previews.get(id) ?? motionFor(id);
193
+ const frame = () => appContext.live.state?.timeline.currentFrame ?? 0;
194
+ const rigFor = id => appContext.shared.rigs[id];
195
+ function rigSnapshots() {
196
+ return new Map(Object.entries(appContext.shared.rigs).map(([id, rig]) => [id, appContext.shared.snapshotExportRig(rig)]));
197
+ }
198
+ let publishedIds = new Set();
199
+ function project() {
200
+ const cast = appContext.storeDomain('cast'), states = appContext.shared.ikStatesRef.current, savedRigs = rigImages.get(read());
201
+ const ids = new Set([...publishedIds, ...read().map(row => row.id)]);
202
+ for (const id of ids) {
203
+ const row = layer(id), take = visibleMotion(id), full = fullMotionFor(id);
204
+ cast.publishMotion(id, take);
205
+ if (full) appContext.shared.motionFullRef.current.set(id, full); else appContext.shared.motionFullRef.current.delete(id);
206
+ const state = states.get(id) ?? createIkState();
207
+ state.keys = decodeMotionKeys(row.ikKeys); state.tracked = new Set([...state.keys.values()].flatMap(entry => [...entry.keys()]));
208
+ state.pins = decodeRangePins(row.ikPins);
209
+ state.pinResiduals = decodeRangePinResiduals(row.ikPinResiduals);
210
+ states.set(id, state);
211
+ const rig = rigFor(id);
212
+ if (rig) {
213
+ const resolved = resolveIkRig(rig); if (resolved) Object.assign(state, resolved, { rig });
214
+ if (savedRigs?.has(id) && !previews.has(id)) appContext.shared.restoreExportRig(savedRigs.get(id));
215
+ else if (resolved) {
216
+ if (take) applyMotionFrame(rig, take, Math.min(frame(), take.frames - 1));
217
+ else {
218
+ const character = cast.read().find(entry => entry.id === id);
219
+ restoreBindPositions(rig); applyPose(rig, { ...REST_BONES, ...(character?.pose ?? DEFAULT_POSE).bones });
220
+ applyHipsOffset(rig, character?.pose?.rootY ?? 0);
221
+ }
222
+ ikEvaluate(resolved.chains, state, frame(), resolved.fkJoints, take ? 6 : 0);
223
+ }
224
+ }
225
+ }
226
+ publishedIds = new Set(read().map(row => row.id));
227
+ const id = appContext.shared.loadedLayerCharRef.current;
228
+ appContext.shared.ikStateRef.current = states.get(id) ?? createIkState();
229
+ appContext.shared.bufferRef.current = { ...appContext.shared.bufferRef.current, motion: visibleMotion(id), ik: appContext.shared.ikStateRef.current };
230
+ appContext.shared.takeRecipeRef.current = layer(id).takeRecipe;
231
+ domain.onProject?.(); viewRevision++;
232
+ }
233
+ const unsubscribe = documentStore.subscribe(project);
234
+ function write(update) {
235
+ return documentStore.write('motion', before => {
236
+ const next = normalize(typeof update === 'function' ? update(before) : update);
237
+ return sameMotionIntent(before, next) ? before : next;
238
+ });
239
+ }
240
+ function writeLayer(id, patch) {
241
+ return write(rows => rows.some(row => row.id === id) ? rows.map(row => row.id === id ? { ...row, ...patch } : row) : [...rows, { ...emptyMotionLayer(id), ...patch }]);
242
+ }
243
+ function pruneTakes() {
244
+ const history = documentStore.history(), ids = new Set([read(), ...[...history.past, history.present, ...history.future].map(entry => entry.snapshot.motion)]
245
+ .flatMap(rows => rows.flatMap(row => [row.take?.resourceId, row.fullTake?.resourceId])).filter(Boolean));
246
+ for (const id of takes.keys()) if (!ids.has(id)) takes.delete(id);
247
+ }
248
+ function beginAction() {
249
+ rigImages.set(read(), gesture?.rigs ?? rigSnapshots());
250
+ const session = documentStore.beginAction('motion');
251
+ return { ...session,
252
+ run(fn) { return session.run(() => { running++; try { return fn(); } finally { running--; } }); },
253
+ commit() { const result = session.commit(); rigImages.set(read(), rigSnapshots()); pruneTakes(); return result; },
254
+ };
255
+ }
256
+ function setKeys(id, keys) { return writeLayer(id, { ikKeys: encodeMotionKeys(keys) }); }
257
+ function setRangePinState(id, state) {
258
+ return writeLayer(id, { ikKeys: encodeMotionKeys(state.keys), ikPins: encodeRangePins(state.pins), ikPinResiduals: encodeRangePinResiduals(state.pinResiduals) });
259
+ }
260
+ function rangePinState(id) {
261
+ const row = layer(id), keys = decodeMotionKeys(row.ikKeys);
262
+ return { ...createIkState(), keys, tracked: new Set([...keys.values()].flatMap(entry => [...entry.keys()])), pins: decodeRangePins(row.ikPins), pinResiduals: decodeRangePinResiduals(row.ikPinResiduals) };
263
+ }
264
+ function removePin(id, pinId) {
265
+ const state = rangePinState(id);
266
+ removeRangePinKeys(state, pinId); state.pins.delete(pinId); state.pinResiduals.delete(pinId);
267
+ setRangePinState(id, state);
268
+ }
269
+ function bakeRangePin(id, spec, replaceExisting = false, projectionOnly = false) {
270
+ const motion = motionFor(id), rig = rigFor(id), resolved = rig && resolveIkRig(rig);
271
+ if (!motion || !resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take and rig for this character first.');
272
+ const pin = normalizeRangePin(spec, { clipFrames: motion.frames }), state = rangePinState(id);
273
+ const tracks = rangePinTracks(pin);
274
+ const overlap = [...state.pins.values()].find(other => other.id !== pin.id && rangePinTracks(other).some(track => tracks.includes(track)) && Math.max(pin.startFrame, other.startFrame) <= Math.min(pin.endFrame, other.endFrame));
275
+ if (overlap) throw new StudioProtocolError('INVALID_ARGUMENT', 'A required track already has an overlapping pin.');
276
+ for (let at = pin.startFrame; at <= pin.endFrame; at++) {
277
+ const entry = state.keys.get(at); if (!entry) continue;
278
+ for (const track of tracks) if (entry.has(track) && entry.get(track).pin !== pin.id) {
279
+ if (!replaceExisting) throw new StudioProtocolError('INVALID_ARGUMENT', 'Confirm Replace existing keys before applying this pin.');
280
+ entry.delete(track);
281
+ }
282
+ if (!entry.size) state.keys.delete(at);
283
+ }
284
+ const objectWorldMatrix = (objectId, at) => {
285
+ const object = appContext.shared.sceneObjects.find(entry => entry.id === objectId);
286
+ if (!object) return null;
287
+ const local = rangePinObjectMatrixAt(object, at, { frameCount: motion.frames, fps: motion.fps }, new THREE.Matrix4());
288
+ const parent = object.attach && appContext.shared.attachFrameRef?.current?.(object.attach.characterId, object.attach.bone ?? null, new THREE.Matrix4());
289
+ return parent ? parent.multiply(local) : local;
290
+ };
291
+ const applyRaw = at => appContext.shared.poseMemberAtFrame(rig, motion, null, at);
292
+ const savedKeys = state.keys;
293
+ let result;
294
+ try {
295
+ result = applyRangePin({ ...resolved, ikState: state, pin, objectWorldMatrix, applyRaw,
296
+ applyLayer(at) { applyRaw(at); ikEvaluate(resolved.chains, state, at, resolved.fkJoints, 6); } });
297
+ } finally { state.keys = savedKeys; }
298
+ removeRangePinKeys(state, pin.id);
299
+ for (const [at, entry] of result.entries) {
300
+ let frameEntry = state.keys.get(at); if (!frameEntry) state.keys.set(at, (frameEntry = new Map()));
301
+ for (const [track, key] of entry) { frameEntry.set(track, key); state.tracked.add(track); }
302
+ }
303
+ state.pins.set(pin.id, cloneRangePin(pin));
304
+ state.pinResiduals.set(pin.id, result.residuals.map(entry => ({ ...entry })));
305
+ // A reload refreshes only the projected bake; authored edits use the bus.
306
+ if (projectionOnly) Object.assign(appContext.shared.ikStatesRef.current.get(id), state);
307
+ else setRangePinState(id, state);
308
+ return { residuals: result.residuals };
309
+ }
310
+ function editKeys(id, mutate) {
311
+ const state = { ...createIkState(), keys: decodeMotionKeys(layer(id).ikKeys) };
312
+ state.tracked = new Set([...state.keys.values()].flatMap(entry => [...entry.keys()]));
313
+ const result = mutate(state); setKeys(id, state.keys); return result;
314
+ }
315
+ function setKey(id, at, tracks) {
316
+ editKeys(id, state => {
317
+ const incoming = decodeMotionKeys([{ frame: at, tracks }]).get(at), entry = state.keys.get(at) ?? new Map();
318
+ for (const [track, key] of incoming) entry.set(track, key); state.keys.set(at, entry);
319
+ });
320
+ }
321
+ function castWrite(update) { return appContext.recordAction('cast', () => appContext.storeDomain('cast').write(update), null, true); }
322
+ function synchronizeTimeline() { appContext.storeDomain('cast').syncTimeline(); }
323
+ function replace(id, take, { fullTake = take, ikKeys = [], ikPins = [], ikPinResiduals = [],
324
+ recipe = take ? { seed: null, blocks: [{ prompt: take.prompt ?? '', duration: take.frames / take.fps }], lineEdits: [] } : null,
325
+ versions = take?.url ? pushTakeVersion(layer(id).takeVersions, { motionUrl: take.url, recipe, savedAt: Date.now(), label: ko('Loaded', '불러옴') }, TAKE_VERSIONS_MAX) : layer(id).takeVersions,
326
+ } = {}) {
327
+ previews.delete(id);
328
+ writeLayer(id, { take: snapshotTake(take), fullTake: snapshotTake(fullTake), ikKeys, ikPins, ikPinResiduals, takeRecipe: recipe, takeVersions: versions, committedIkEdits: [] });
329
+ synchronizeTimeline();
330
+ }
331
+ function clear(id) {
332
+ previews.delete(id);
333
+ 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));
335
+ synchronizeTimeline();
336
+ }
337
+ function editSegments(id, segments) {
338
+ const full = fullMotionFor(id), take = motionFor(id);
339
+ if (!full || !take) throw new StudioProtocolError('TARGET_NOT_READY', 'This character has no take to edit.');
340
+ const previous = take.editSegments, rendered = { ...renderMotionEdit(full, segments), url: null };
341
+ const keys = remapFrameKeyMap(decodeMotionKeys(layer(id).ikKeys), previous, segments);
342
+ writeLayer(id, { take: snapshotTake(rendered), ikKeys: encodeMotionKeys(keys) });
343
+ castWrite(rows => rows.map(row => row.id !== id ? row : { ...row, layer: { ...row.layer,
344
+ promptClips: row.layer.promptClips.flatMap(clip => {
345
+ const start = remapTimelineFrame(previous, segments, clip.startFrame), end = remapTimelineFrame(previous, segments, clip.endFrame);
346
+ if (start === null && end === null) return [];
347
+ const startFrame = Math.max(0, Math.min(start ?? 0, rendered.frames - 1));
348
+ const endFrame = Math.max(0, Math.min(end ?? rendered.frames - 1, rendered.frames - 1));
349
+ return startFrame <= endFrame ? [{ ...clip, startFrame, endFrame }] : [];
350
+ }) } }));
351
+ synchronizeTimeline();
352
+ }
353
+ function fix(id, scope = 'frame') {
354
+ const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
355
+ if (!resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'The character rig is not loaded.');
356
+ const state = appContext.shared.ikStatesRef.current.get(id) ?? { ...createIkState(), ...resolved };
357
+ const blockers = at => collisionBlockers({ rigs: appContext.shared.rigs, activeId: id, characterIds: appContext.live.characters,
358
+ sceneObjects: appContext.ports.read().objects, library: OBJECT_LIBRARY, frame: at, take: { frameCount: motionFor(id)?.frames ?? 1, fps: 24 } });
359
+ if (scope === 'clip') {
360
+ const take = motionFor(id); if (!take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take first.');
361
+ const originals = rigSnapshots();
362
+ try {
363
+ fixCollisionsRange({ rig, ...resolved, ikState: state, startFrame: 0, endFrame: take.frames - 1,
364
+ 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); } });
366
+ } finally { for (const snapshot of originals.values()) appContext.shared.restoreExportRig(snapshot); }
367
+ } else {
368
+ const result = fixCollisions(rig, resolved.chains, { ikState: state, fkJoints: resolved.fkJoints, blockers: blockers(frame()) });
369
+ if (!result.supported) throw new StudioProtocolError('TARGET_NOT_READY', 'This rig does not support collision cleanup.');
370
+ if (result.changed) ikBakeKeyframe(resolved.chains, state, frame(), resolved.fkJoints, result.touched, null, result.baseQuats);
371
+ }
372
+ setKeys(id, state.keys);
373
+ }
374
+ function bakeCurrentKey(id, at, dragIds = null) {
375
+ const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
376
+ if (!resolved) return null;
377
+ const take = motionFor(id);
378
+ if (dragIds) {
379
+ const keyed = bakeIkDragKey(resolved.chains, resolved.fkJoints, at, dragIds, take
380
+ ? (rawRig) => applyMotionFrame(rawRig, take, at)
381
+ : null);
382
+ if (keyed && take) for (const key of keyed.values()) key.blend = 6;
383
+ return keyed ? run('ik.setKey', { characterId: id, frame: at, tracks: ikKeyJson(keyed) }) : null;
384
+ }
385
+ const scratch = { ...createIkState(), tracked: new Set(appContext.shared.ikStatesRef.current.get(id)?.tracked) };
386
+ ikBakeKeyframe(resolved.chains, scratch, at, resolved.fkJoints);
387
+ const keyed = encodeMotionKeys(scratch.keys)[0];
388
+ return keyed ? run('ik.setKey', { characterId: id, frame: at, tracks: keyed.tracks }) : null;
389
+ }
390
+ function keyPose(id, at, pose) {
391
+ const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
392
+ if (!resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'The character rig is not loaded.');
393
+ restoreBindPositions(rig); applyPose(rig, { ...REST_BONES, ...pose.bones }); applyHipsOffset(rig, pose.rootY ?? 0);
394
+ editKeys(id, state => {
395
+ state.tracked = new Set([...resolved.chains.keys(), ...resolved.fkJoints.keys()]);
396
+ ikBakeKeyframe(resolved.chains, state, at, resolved.fkJoints);
397
+ });
398
+ }
399
+ function editTrail(id, { track = "hips", grabFrame, radiusFrames, delta }) {
400
+ const take = motionFor(id), character = appContext.storeDomain('cast').read().find(row => row.id === id);
401
+ if (!take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take first.');
402
+ const scale = character.scale ?? 1;
403
+ const rig = rigFor(id);
404
+ const clipDelta = worldDeltaToTrailClip(take, delta, { track, grabFrame, radiusFrames, rig, scale });
405
+ const edited = snapshotTake(applyTrailFalloffDelta(take, { track, grabFrame, radiusFrames, clipDelta }));
406
+ writeLayer(id, { take: edited });
407
+ castWrite(rows => rows.map(row => row.id === id ? { ...row, motionRef: { ...row.motionRef, trailEdits: edited.trailEdits } } : row));
408
+ }
409
+ const physicsReviews = new Map();
410
+ let physicsJob = 0;
411
+ const physicsStamp = id => JSON.stringify([layer(id), appContext.storeDomain('cast').read().find(row => row.id === id)]);
412
+ function applyPhysics(id) {
413
+ const preview = physicsReviews.get(id);
414
+ if (!preview || preview.stamp !== physicsStamp(id)) throw new StudioProtocolError('STALE_TARGET', 'Analyse this take again before applying corrections.');
415
+ setKeys(id, preview.result.candidate.keys);
416
+ domain.onPhysicsPreview?.(null);
417
+ }
418
+ async function autoPhysics(id, options, context) {
419
+ const rig = rigFor(id), resolved = rig && resolveIkRig(rig), take = motionFor(id);
420
+ if (!resolved || !take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take and character rig first.');
421
+ const job = ++physicsJob, stamp = physicsStamp(id), original = appContext.shared.snapshotExportRig(rig);
422
+ let lastYield = performance.now();
423
+ const check = () => { context.check(); if (job !== physicsJob) throw new StudioProtocolError('STALE_TARGET', 'Physics analysis was superseded.'); };
424
+ domain.onPhysicsRunning?.(true); domain.onPhysicsPreview?.(null);
425
+ try {
426
+ check();
427
+ const result = await reviewAutoPhysics({ rig, ...resolved, motion: take, sourceKeys: decodeMotionKeys(layer(id).ikKeys),
428
+ applyRaw: at => applyMotionFrame(rig, take, at), sceneObjects: appContext.ports.read().objects,
429
+ ...options, cache: appContext.shared.physicsSourceCacheRef.current, onProgress: value => domain.onPhysicsProgress?.(value),
430
+ yieldFrame: async () => {
431
+ check(); if (performance.now() - lastYield < 16) return;
432
+ appContext.shared.restoreExportRig(original);
433
+ await new Promise(resolve => { const channel = new MessageChannel(); channel.port1.onmessage = () => { channel.port1.close(); channel.port2.close(); resolve(); }; channel.port2.postMessage(0); });
434
+ lastYield = performance.now(); check();
435
+ },
436
+ });
437
+ appContext.shared.restoreExportRig(original); check();
438
+ physicsReviews.set(id, { result, stamp });
439
+ if (options.apply) context.commit(() => applyPhysics(id)); else domain.onPhysicsPreview?.(result);
440
+ return { before: result.before, after: result.after, changedFrames: result.changedFrames.length, warnings: result.warnings, unresolved: result.unresolved };
441
+ } finally {
442
+ if (job === physicsJob) { if (stamp === physicsStamp(id)) appContext.shared.restoreExportRig(original); domain.onPhysicsRunning?.(false); }
443
+ }
444
+ }
445
+ function runPrepared(characterId, apply) {
446
+ if (running) return apply();
447
+ let result;
448
+ const token = crypto.randomUUID(); prepared.set(token, () => { result = apply(); return result; });
449
+ try { run('motion.applyPrepared', { characterId, token }); return result; }
450
+ finally { prepared.delete(token); }
451
+ }
452
+ function persistTake(take, motionRef) {
453
+ if (!take?.sourceBytes) return;
454
+ (async () => {
455
+ let record = appContext.shared.motionEncodingCacheRef.current.get(take.sourceBytes);
456
+ if (!record) { record = await encodeMotionResource(take.sourceBytes, { prompt: motionRef?.prompt, sourceUrl: motionRef?.url }); appContext.shared.motionEncodingCacheRef.current.set(take.sourceBytes, record); }
457
+ appContext.shared.projectMotionsRef.current.set(record.motionId.toLowerCase(), record);
458
+ const db = await openMotionDb(); try { await putMotion(db, record); } finally { db.close(); }
459
+ })().catch(error => console.warn('[cozyclay] could not cache motions', error));
460
+ }
461
+ function run(id, args) {
462
+ const result = appContext.bus.run(id, args);
463
+ const checked = receipt => { if (!receipt.ok) throw new StudioProtocolError(receipt.code, receipt.message); return receipt; };
464
+ return result?.then ? result.then(checked) : checked(result);
465
+ }
466
+ let gesture = null;
467
+ function finishGesture(cancel = false) {
468
+ if (!gesture) return;
469
+ const active = gesture; gesture = null; active.unsubscribe?.(); active.release?.();
470
+ if (cancel && !active.txId) for (const image of active.rigs.values()) appContext.shared.restoreExportRig(image);
471
+ if (active.txId) return run(cancel ? 'run.cancel' : 'run.commit', { txId: active.txId });
472
+ }
473
+ function beginGesture() {
474
+ if (gesture) return;
475
+ const commit = () => finishGesture(), cancel = () => finishGesture(true), key = event => { if (event.key === 'Escape') cancel(); };
476
+ const events = { pointerup: commit, pointercancel: cancel, blur: commit, keydown: key };
477
+ for (const [name, handler] of Object.entries(events)) globalThis.window?.addEventListener?.(name, handler);
478
+ gesture = { rigs: rigSnapshots(), release() { for (const [name, handler] of Object.entries(events)) globalThis.window?.removeEventListener?.(name, handler); } };
479
+ }
480
+ function edit(id, args) {
481
+ if (!gesture) return run(id, args);
482
+ if (gesture.id && gesture.id !== id) { finishGesture(); beginGesture(); }
483
+ if (!gesture.txId) {
484
+ gesture.id = id; gesture.txId = run('run.begin', { id, args }).txId;
485
+ gesture.unsubscribe = appContext.bus.subscribe(event => { if (event.type === 'transaction.cancelled' && event.txId === gesture?.txId) { const current = gesture; gesture = null; current.unsubscribe(); current.release(); } });
486
+ }
487
+ return run('run.update', { txId: gesture.txId, args });
488
+ }
489
+ const domain = { documentStore, read, write, layer, writeLayer, motionFor, fullMotionFor, visibleMotion, snapshotTake, project,
490
+ setKeys, setRangePinState, bakeRangePin, removePin, editKeys, setKey, replace, clear, editSegments, fix, castWrite, run, edit, beginGesture, finishGesture, beginAction, runPrepared, persistTake,
491
+ insideAction: () => running > 0, bakeCurrentKey, keyPose, editTrail, autoPhysics, applyPhysics,
492
+ applyPrepared(token) { const apply = prepared.get(token); if (!apply) throw new StudioProtocolError('STALE_TARGET', 'Prepared motion is no longer available.'); return apply(); },
493
+ bindRender(context) { appContext = context; },
494
+ viewRevision: () => viewRevision, canUndo: id => documentStore.canUndo(id),
495
+ stepHistory: redo => { finishGesture(); return Boolean((redo ? documentStore.redo : documentStore.undo)()); },
496
+ document: () => ({ motion: read().map(row => {
497
+ const take = motionFor(row.id);
498
+ return take ? { ...row, take: { ...row.take, frames: take.frames, fps: take.fps, editSegments: take.editSegments } } : row;
499
+ }) }), sceneSlice: slices => slices.motion ?? references(slices.cast),
500
+ publish: rows => write(rows), commitDraft: write,
501
+ switchLayer(id) { appContext.shared.loadedLayerCharRef.current = id; project(); notify(); },
502
+ snapshotLegacy: () => read(), restoreLegacy: write,
503
+ snapshotTarget(id) {
504
+ const state = appContext.shared.readStudioState(), keys = decodeMotionKeys(layer(id).ikKeys), rig = rigFor(id);
505
+ return { character: state.characters.find(row => row.id === id), fullMotion: fullMotionFor(id),
506
+ ikState: { ...createIkState(), keys, tracked: new Set([...keys.values()].flatMap(entry => [...entry.keys()])) },
507
+ frameCount: state.frameCount, committedIkEdits: layer(id).committedIkEdits, renderer: rig ? appContext.shared.snapshotExportRig(rig) : null };
508
+ },
509
+ removeLayer(id) { write(rows => rows.filter(row => row.id !== id)); },
510
+ preview(id, take) { if (take) previews.set(id, take); else previews.delete(id); project(); notify(); },
511
+ hydrate(id, take, reference) {
512
+ const current = appContext.storeDomain('cast').read().find(row => row.id === id);
513
+ const descriptor = layer(id).take;
514
+ if (!descriptor || !sameMotionIntent(current?.motionRef, reference)) return false;
515
+ takes.set(descriptor.resourceId, structuredClone(take));
516
+ for (const rows of [read(), ...documentStore.history().past.map(entry => entry.snapshot.motion), ...documentStore.history().future.map(entry => entry.snapshot.motion)]) {
517
+ for (const row of rows) for (const value of [row.take, row.fullTake]) if (value?.resourceId === descriptor.resourceId) decoded.delete(value);
518
+ }
519
+ notify(); return true;
520
+ },
521
+ load(rows) { finishGesture(true); previews.clear(); release(); native.dispose(); native = createDocumentStore({ owned: { motion: normalize(rows) } }); release = native.subscribe(notify); pruneTakes(); notify(); },
522
+ dispose() { finishGesture(true); unregister(); unsubscribe(); release(); native.dispose(); listeners.clear(); takes.clear(); },
523
+ };
524
+ const unregister = appContext.registerStoreDomain('motion', domain);
525
+ return domain;
526
+ }
527
+
528
+ export function useMotionCommands() {
529
+ const app = useContext(AppContext), owner = app.storeDomain('motion');
530
+ return { run: (id, args = {}) => owner.run(id, { characterId: app.storeDomain('cast').activeId, ...args }) };
531
+ }
532
+
533
+ export function useMotion(appContext) {
534
+ const [domain] = useState(() => appContext.storeDomain('motion') ?? createMotionDomain(appContext, appContext.shared.characters));
535
+ domain.bindRender(appContext);
536
+ useDocumentDomain(domain.documentStore, 'motion');
537
+ useSyncExternalStore(domain.documentStore.subscribe, domain.viewRevision, domain.viewRevision);
538
+ useEffect(() => {
539
+ domain.switchLayer(appContext.shared.activeChar.id);
540
+ const start = () => domain.beginGesture(); window.addEventListener('pointerdown', start, true);
541
+ return () => { window.removeEventListener('pointerdown', start, true); domain.finishGesture(true); };
542
+ }, [domain, appContext.shared.activeChar.id]);
543
+ const [i2vMotion, setI2vMotion] = useState({ a: null, b: null, job: null, status: "idle", error: "", instruction: "", dailyRemaining: null });
544
+ /* ------------------------------ IK layer ------------------------------ */
545
+ // IK posing for Subject 1: dragging a wrist/ankle handle FOCUSES that
546
+ // joint and solves its chain backward (two-bone analytic IK) on top of
547
+ // the current pose; joints never dragged stay purely on the FK pose.
548
+ // Keys land on the Full-Body lane as sparse per-chain world targets and
549
+ // evaluate as the playhead moves. State lives in a ref — it changes
550
+ // every drag tick and must not re-render the scene; ikTick re-renders
551
+ // only the timeline markers.
552
+ const [ikMode, setIkMode] = useState(false);
553
+ // A drag records only the tracks it actually writes. The record is kept
554
+ // outside React renders so pointer moves stay cheap and the drag-end bake
555
+ // can create a delta key over the raw clip.
556
+ const ikDragRef = useRef(null);
557
+ const ikCameraMemoryRef = useRef(new Map());
558
+
559
+ const [ikChains, setIkChains] = useState(null);
560
+
561
+ const [ikFkJoints, setIkFkJoints] = useState(null);
562
+
563
+ const [ikFocus, setIkFocus] = useState(null);
564
+
565
+ // Foot snap (ground plant): while ON, body (hips) drags keep the feet at
566
+ // the positions captured when the drag started — the knees bend instead
567
+ // of the feet sinking through the floor. Toggleable in the timeline.
568
+ const [footSnap, setFootSnap] = useState(true);
569
+
570
+ const [bodyContact, setBodyContact] = useState(true);
571
+
572
+ // How far (in frames) a correction eases back to the underlying motion
573
+ // outside its keyed range. 6 frames @ 24 fps = 0.25 s — long enough to
574
+ // hide the seam, short enough that a mid-clip fix stays visibly local.
575
+ const IK_CORRECTION_BLEND_FRAMES = 6;
576
+
577
+ const [autoPhysicsRunning, setAutoPhysicsRunning] = useState(false);
578
+
579
+ const [physicsPreview, setPhysicsPreview] = useState(null);
580
+
581
+ const [physicsShow, setPhysicsShow] = useState(true);
582
+
583
+ const [physicsProgress] = useState(createPhysicsProgress);
584
+
585
+ const setPhysicsProgress = physicsProgress.set;
586
+
587
+ const [physicsOptions, setPhysicsOptions] = useState({ overrides: [], protectedFrames: [], strength: 1 });
588
+
589
+ const [platformFitRunning, setPlatformFitRunning] = useState(false);
590
+ const [platformFitLast, setPlatformFitLast] = useState(null);
591
+ const [platformFitProgress] = useState(createPhysicsProgress);
592
+ const platformFitProgressSet = platformFitProgress.set;
593
+ const platformFitJobRef = useRef(0);
594
+ const platformFitAppliedRef = useRef(null);
595
+ const [platformFitApplied, setPlatformFitApplied] = useState(false);
596
+
597
+ const [ikTick, setIkTick] = useState(0);
598
+
599
+ const committedIkEdits = domain.layer(appContext.shared.activeChar.id).committedIkEdits;
600
+ const setCommittedIkEdits = update => domain.writeLayer(appContext.shared.activeChar.id, { committedIkEdits: typeof update === 'function' ? update(domain.layer(appContext.shared.activeChar.id).committedIkEdits) : update });
601
+ domain.onProject = () => setIkTick(value => value + 1);
602
+
603
+ /* --------------------- IK-mode motion trail editing ---------------------
604
+ * Grabbing the viewport trajectory line deforms the loaded take with a
605
+ * smoothstep falloff around the grab frame (pure local preview). The last
606
+ * finished drag stays pending so "Regenerate from trail edit" can send the
607
+ * auto-derived window through the existing motionEdit pipeline. */
608
+ const [trailFalloffS, setTrailFalloffS] = useState(0.5);
609
+
610
+ const [showTrails, setShowTrails] = useState(true);
611
+
612
+ const [ikEditTool, setIkEditTool] = useState("ik");
613
+
614
+ const [trailEdit, setTrailEdit] = useState(null);
615
+ const [rangePinSelection, setRangePinSelection] = useState(null);
616
+ const [rangePinPartPick, setRangePinPartPick] = useState(null);
617
+ const [rangePinPreview, setRangePinPreview] = useState(null);
618
+ const pinLayer = domain.layer(appContext.shared.activeChar.id);
619
+ const rangePins = useMemo(() => encodeRangePins(pinLayer.ikPins), [pinLayer.ikPins]);
620
+ const rangePinResiduals = useMemo(() => decodeRangePinResiduals(pinLayer.ikPinResiduals), [pinLayer.ikPinResiduals]);
621
+
622
+ const trailFalloffFrames = Math.max(1, Math.round(trailFalloffS * TIMELINE_FPS));
623
+
624
+ function focusIkHandle(focus) {
625
+ setIkFocus(focus);
626
+ const hierarchyId = hierarchyIdForIkFocus(focus, appContext.shared.rowIdForCharIndex(appContext.shared.activeCharIndex));
627
+ if (hierarchyId) {
628
+ appContext.shared.setSelectedHierarchyId(hierarchyId);
629
+ }
630
+ }
631
+
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;
637
+ }
638
+ function rangePinObjectWorldMatrix(objectId, frame) {
639
+ const object = appContext.shared.sceneObjects.find((entry) => entry.id === objectId);
640
+ if (!object) return null;
641
+ const local = rangePinObjectMatrixAt(object, frame, { frameCount: motion?.frames ?? appContext.shared.tlFrameCount, fps: motion?.fps ?? appContext.shared.tlFps }, new THREE.Matrix4());
642
+ if (!object.attach) return local;
643
+ const attachRef = appContext.shared.attachFrameRef?.current;
644
+ if (!attachRef) return local;
645
+ const parent = attachRef(object.attach.characterId, object.attach.bone ?? null, new THREE.Matrix4());
646
+ return parent ? parent.multiply(local) : local;
647
+ }
648
+ function rangePinApplyFrame(frame, state = appContext.shared.ikStateRef.current) {
649
+ appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, null, frame);
650
+ if (ikChains && state.keys.size > 0) ikEvaluate(ikChains, state, frame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
651
+ }
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
+ function captureRangePinTargetForDraft(draft) {
667
+ if (!motion || !appContext.shared.activeRig || !ikChains) return null;
668
+ const objectWorldMatrix = draft.targetKind === "object" ? (objectId, frame) => rangePinObjectWorldMatrix(objectId, frame) : null;
669
+ const target = captureRangePinTarget({
670
+ chains: ikChains, track: draft.track, frame: draft.startFrame,
671
+ applyFrame: (frame) => rangePinApplyFrame(frame),
672
+ space: draft.targetKind === "object" ? "object" : "world",
673
+ objectId: draft.targetKind === "object" ? draft.objectId : null,
674
+ objectWorldMatrix,
675
+ });
676
+ const pin = normalizeRangePin({ id: draft.id, track: draft.track, startFrame: draft.startFrame, endFrame: draft.endFrame, blend: draft.blend, reach: draft.reach ?? "body", target }, { clipFrames: motion.frames });
677
+ return { pin, targetWorld: rangePinTargetWorld(pin, appContext.shared.tlFrame, { objectWorldMatrix }) };
678
+ }
679
+ function previewRangePinDraft(draft) {
680
+ const state = ensureRangePinState(appContext.shared.ikStateRef.current);
681
+ const conflictFrames = rangePinConflictFrames(draft, state);
682
+ const overlapping = overlappingRangePin(draft, state);
683
+ try {
684
+ const resolved = captureRangePinTargetForDraft(draft);
685
+ setRangePinPreview({ draft, track: draft.track, target: resolved?.targetWorld?.toArray() ?? null, conflictFrames, overlapError: rangePinOverlapMessage(overlapping) });
686
+ } catch {
687
+ setRangePinPreview({ draft, track: draft.track, target: null, conflictFrames, overlapError: rangePinOverlapMessage(overlapping) });
688
+ }
689
+ }
690
+ function applyRangePinDraft(draft) {
691
+ if (!motion || !appContext.shared.activeRig || !ikChains) return;
692
+ const state = ensureRangePinState(appContext.shared.ikStateRef.current);
693
+ const overlap = overlappingRangePin(draft, state);
694
+ if (overlap) { appContext.notify(rangePinOverlapMessage(overlap)); return; }
695
+ const conflicts = rangePinConflictFrames(draft, state);
696
+ if (conflicts.length && !draft.replaceExisting) { appContext.notify(ko("Confirm Replace existing keys before applying this pin.", "이 고정을 적용하려면 기존 키 교체를 확인하세요.")); return; }
697
+ try {
698
+ const resolved = captureRangePinTargetForDraft(draft);
699
+ domain.run('motion.rangePin.apply', { characterId: appContext.shared.activeChar.id, pin: resolved.pin, replaceExisting: Boolean(draft.replaceExisting) });
700
+ setRangePinSelection(resolved.pin.id);
701
+ setRangePinPreview({ draft: { ...draft, id: resolved.pin.id }, track: resolved.pin.track, target: resolved.targetWorld.toArray(), conflictFrames: [], overlapError: "" });
702
+ appContext.shared.setTlPlaying(false);
703
+ appContext.notify(isKo ? `${resolved.pin.track} 고정을 적용했어요 · ${resolved.pin.startFrame}–${resolved.pin.endFrame}프레임` : `Applied ${resolved.pin.track} pin · frames ${resolved.pin.startFrame}–${resolved.pin.endFrame}`);
704
+ } catch (error) { appContext.notify(ko(`Could not apply pin — ${error.message}`, `고정을 적용하지 못했어요 — ${error.message}`)); }
705
+ }
706
+ function deleteRangePin(pinId) {
707
+ const state = ensureRangePinState(appContext.shared.ikStateRef.current), pin = state.pins.get(pinId);
708
+ if (!pin) return;
709
+ domain.run('motion.rangePin.remove', { characterId: appContext.shared.activeChar.id, pinId });
710
+ // Persisting the command updates the document before React publishes the
711
+ // next render. Clear the live evaluator now too, so a draft preview made
712
+ // during that transition cannot report the deleted pin as overlapping.
713
+ const liveState = ensureRangePinState(appContext.shared.ikStateRef.current);
714
+ removeRangePinKeys(liveState, pinId);
715
+ liveState.pins.delete(pinId);
716
+ liveState.pinResiduals.delete(pinId);
717
+ liveState.tracked = new Set([...liveState.keys.values()].flatMap((entry) => [...entry.keys()]));
718
+ setRangePinSelection(null); setRangePinPreview(null); setIkTick((value) => value + 1);
719
+ appContext.notify(isKo ? `${pin.track} 고정을 삭제했어요` : `Deleted ${pin.track} pin`);
720
+ }
721
+
722
+ /** Deep copy of an IK state's key map: frame → Map(trackId → {q,p}), with
723
+ * every quaternion/position cloned so a snapshot never shares references
724
+ * with the live rig state (a later bake would otherwise rewrite history). */
725
+ function snapshotIkKeys(ikState) {
726
+ return copyPhysicsKeys(ikState?.keys ?? new Map());
727
+ }
728
+
729
+ function editIkKeys(mutate) {
730
+ const before = snapshotIkKeys(appContext.shared.ikStateRef.current);
731
+ const result = mutate();
732
+ const owned = appContext.storeDomain('motion');
733
+ if (owned) owned.setKeys(appContext.shared.loadedLayerCharRef.current, appContext.shared.ikStateRef.current.keys);
734
+ else appContext.shared.markSemanticEdit("pose", before, appContext.shared.ikStateRef.current.keys);
735
+ return result;
736
+ }
737
+
738
+ /* One IK-key core for every cast member, shared by the Key button, a pose
739
+ * drag's bake, the Full-Body lane's delete and run_action. The loaded
740
+ * layer's keys live on the live IK state, every other character's on its
741
+ * stored one (created on its first key). */
742
+ function ikStateFor(characterId) {
743
+ if (characterId === appContext.shared.loadedLayerCharRef.current) return appContext.shared.ikStateRef.current;
744
+ let state = appContext.shared.ikStatesRef.current.get(characterId);
745
+ if (!state) appContext.shared.ikStatesRef.current.set(characterId, (state = createIkState()));
746
+ return state;
747
+ }
748
+
749
+ function editCharacterIkKeys(characterId, mutate) {
750
+ if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').editKeys(characterId, mutate);
751
+ const state = ikStateFor(characterId);
752
+ const before = snapshotIkKeys(state);
753
+ mutate(state);
754
+ appContext.shared.markSemanticEdit("pose", before, state.keys);
755
+ setIkTick((value) => value + 1);
756
+ }
757
+
758
+ /** Write one key from its JSON form (studio-actions.js character.setIkKey):
759
+ * each named track replaces its key at `frame` and joins the tracked set. */
760
+ function setCharacterIkKey(characterId, frame, tracks) {
761
+ appContext.shared.castMemberOf(characterId);
762
+ if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').setKey(characterId, frame, tracks);
763
+ editCharacterIkKeys(characterId, (state) => {
764
+ let entry = state.keys.get(frame);
765
+ if (!entry) state.keys.set(frame, (entry = new Map()));
766
+ for (const [track, key] of Object.entries(tracks)) {
767
+ entry.set(track, ikTrackKeyFromJson(key));
768
+ ikTouch(state, track);
769
+ }
770
+ });
771
+ }
772
+
773
+ function removeCharacterIkKey(characterId, frame) {
774
+ const character = appContext.shared.castMemberOf(characterId);
775
+ const state = ikStateFor(characterId);
776
+ if (!state.keys.has(frame)) {
777
+ const keyed = ikKeyframes(state);
778
+ throw new StudioProtocolError("STALE_TARGET", `${character.subject || character.id} has no IK key at frame ${frame}${keyed.length ? `; keyed frames: ${keyed.join(", ")}` : ""}.`);
779
+ }
780
+ editCharacterIkKeys(characterId, (target) => ikRemoveKeyframe(target, frame));
781
+ }
782
+
783
+ function clearCharacterIkKeys(characterId) {
784
+ appContext.shared.castMemberOf(characterId);
785
+ const count = ikStateFor(characterId).keys.size;
786
+ if (!count) return 0;
787
+ editCharacterIkKeys(characterId, (target) => {
788
+ target.keys.clear();
789
+ target.tracked.clear();
790
+ target.plants.clear();
791
+ target.pins?.clear();
792
+ target.pinResiduals?.clear();
793
+ });
794
+ return count;
795
+ }
796
+
797
+ /** A baked key entry in the JSON form character.setIkKey takes. */
798
+ function ikKeyJson(entry) {
799
+ return ikKeyJson(entry);
800
+ }
801
+
802
+ const [bridge, setBridge] = useState(null);
803
+
804
+ const [bridgeChecking, setBridgeChecking] = useState(false);
805
+
806
+ const [motionSetupReveal, setMotionSetupReveal] = useState(0);
807
+
808
+ const [motionSetupKind, setMotionSetupKind] = useState("prompt");
809
+
810
+ const [ardyPrompt, setArdyPrompt] = useState("");
811
+
812
+ const [ardyDuration, setArdyDuration] = useState(4);
813
+
814
+ // Optional native-ARDY seed: empty string = omit from the request (the
815
+ // box picks a fresh random one each run); otherwise a plain integer in
816
+ // 0..2**31-1 to reproduce a result.
817
+ const [ardySeed, setArdySeed] = useState("");
818
+
819
+ // How much of the loaded take a regeneration keeps (scheduled inpainting).
820
+ // 1 = hold the take everywhere the user did not edit, 0 = ignore it and
821
+ // generate fresh. Only ever consulted when the take still has a bridge
822
+ // source to preserve FROM, so the control renders with the take, not with
823
+ // the panel.
824
+ const [preserveStrength, setPreserveStrength] = useState(ARDY_PRESERVE_DEFAULT);
825
+
826
+ // Off by default on purpose: pinning runs the box's pose mode, which builds
827
+ // on a fixed reference base, so it is a choice the operator makes when they
828
+ // actually want the pose in the generated clip.
829
+ const [ardyStartFromPose, setArdyStartFromPose] = useState(false);
830
+
831
+ // WHERE the pose lands in the clip. "start" leaves from it, "end" arrives at
832
+ // it, "middle" passes through it, "playhead" places it on the frame the
833
+ // operator scrubbed to — the box takes any destination frame.
834
+ const [ardyPosePlacement, setArdyPosePlacement] = useState("start");
835
+
836
+ /* ------------------- take recipes and versions (C9/C12) -------------------
837
+ * The recipe of the take that is loaded RIGHT NOW: seed + prompt blocks +
838
+ * the line edits pulled on top of it. It is written in exactly one place
839
+ * (commitTakeRecipe, on a successful run) and read in two: the request
840
+ * assembly, which attaches it as C10 `replay`, and the version strip, which
841
+ * stores a copy beside every motionUrl so clicking v1 restores v1's recipe
842
+ * and not the one the artist has since edited into existence.
843
+ * The ref shadows the state because the recipe is consulted from inside an
844
+ * async job completion, where a stale closure would silently record the
845
+ * previous take's edits against this take's url. */
846
+ const takeRecipe = domain.layer(appContext.shared.activeChar.id).takeRecipe;
847
+ const setTakeRecipe = value => domain.writeLayer(appContext.shared.activeChar.id, { takeRecipe: value });
848
+ const takeVersions = domain.layer(appContext.shared.activeChar.id).takeVersions;
849
+ const setTakeVersions = update => domain.writeLayer(appContext.shared.activeChar.id, { takeVersions: typeof update === 'function' ? update(domain.layer(appContext.shared.activeChar.id).takeVersions) : update });
850
+
851
+ // Which C10 replay entries came back failed or boundary-warned. Non-blocking
852
+ // by contract: the take generated, one refinement may not have survived it,
853
+ // and that is worth a line next to the take rather than a toast that scrolls
854
+ // away before the artist has looked at the result.
855
+ const [replayNotices, setReplayNotices] = useState([]);
856
+
857
+ // Whether the Scene entry's action menu is open. The Refine entry needs no
858
+ // equivalent — it IS its action.
859
+ const [sceneMenuOpen, setSceneMenuOpen] = useState(false);
860
+
861
+ const [ardyRunning, setArdyRunning] = useState(false);
862
+
863
+ const [ardyStatus, setArdyStatus] = useState("");
864
+
865
+ // Keep the latest ARDY status inline with the Prompt Blocks controls. The
866
+ // former bottom Console history was removed because it duplicated this state
867
+ // and exposed an editor surface that is not part of the production workflow.
868
+ function reportArdyStatus(line) {
869
+ setArdyStatus(line);
870
+ }
871
+
872
+ const [ardyReport, setArdyReport] = useState(null);
873
+
874
+ const [ardyOutcome, setArdyOutcome] = useState(null);
875
+
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
+ // Wave-2 gate: the bridge only routes lineEdit once M4's routing lands.
880
+ // Until the /ardy/health payload says so, the request is never sent —
881
+ // today's bridge ignores unknown fields, so an ungated POST would quietly
882
+ // return a fresh unrelated take instead of an edit.
883
+ const [lineEditBackend, setLineEditBackend] = useState(false);
884
+
885
+ // Loaded motion: decoded arrays plus the world anchor captured at load.
886
+ const motion = domain.visibleMotion(appContext.shared.activeChar.id);
887
+ const rangePinRebuildRef = useRef(null);
888
+ const rangePinSourceStamp = JSON.stringify([pinLayer.take, appContext.shared.activeChar.id]);
889
+ // A fit is invalidated by a different take, trim or preview, not by key
890
+ // writes: every layer write re-snapshots the take descriptor, so the decoded
891
+ // `motion` object is new after the fit's own commit even though the take is
892
+ // unchanged. Key the reset on the descriptor's content instead.
893
+ const platformFitPreview = motion === domain.motionFor(appContext.shared.activeChar.id) ? null : motion;
894
+ useEffect(() => {
895
+ platformFitJobRef.current += 1;
896
+ setPlatformFitRunning(false);
897
+ setPlatformFitLast(null);
898
+ platformFitProgressSet(0);
899
+ platformFitAppliedRef.current = null;
900
+ setPlatformFitApplied(false);
901
+ }, [rangePinSourceStamp, platformFitPreview, appContext.shared.activeRig]);
902
+ // Object-bound pins follow edited prop transforms and paths, as in the
903
+ // source branch. Rebuilding derived keys must not create an undo entry.
904
+ useEffect(() => {
905
+ if (!motion || !ikChains || rangePinRebuildRef.current === rangePinSourceStamp) return;
906
+ const timer = setTimeout(() => {
907
+ rangePinRebuildRef.current = rangePinSourceStamp;
908
+ const state = ensureRangePinState(appContext.shared.ikStateRef.current);
909
+ for (const pin of [...state.pins.values()].filter(pin => pin.target.space === "object")) {
910
+ if (!appContext.shared.sceneObjects.some(object => object.id === pin.target.objectId)) {
911
+ removeRangePinKeys(state, pin.id);
912
+ state.pinResiduals.set(pin.id, []);
913
+ } else {
914
+ try { domain.bakeRangePin(appContext.shared.activeChar.id, pin, false, true); }
915
+ catch { /* A prop can be unavailable while its transform remounts. */ }
916
+ }
917
+ }
918
+ }, 150);
919
+ return () => clearTimeout(timer);
920
+ }, [rangePinSourceStamp, ikChains]);
921
+
922
+
923
+ const [motionBusy, setMotionBusy] = useState(false);
924
+
925
+ const [motionError, setMotionError] = useState("");
926
+
927
+ /* ------------------------- video capture (ingest) ---------------------- */
928
+ const [multiModelUrl, setMultiModelUrl] = useState("");
929
+
930
+ const [multiModelSource, setMultiModelSource] = useState(null);
931
+
932
+ const [multiModelStatus, setMultiModelStatus] = useState("idle");
933
+
934
+ // The ingest is a real download + decode, so it owns real progress and a
935
+ // real receipt: the footage numbers below come from the decoded media.
936
+ const [multiModelStage, setMultiModelStage] = useState("idle");
937
+
938
+ const [multiModelProgress, setMultiModelProgress] = useState(null);
939
+
940
+ const [multiModelFootage, setMultiModelFootage] = useState(null);
941
+
942
+ const [multiModelError, setMultiModelError] = useState("");
943
+
944
+ const [multiModelTake, setMultiModelTake] = useState(null);
945
+
946
+ const [multiModelExtract, setMultiModelExtract] = useState("idle");
947
+
948
+ const [multiModelExtractProgress, setMultiModelExtractProgress] = useState(null);
949
+
950
+ const [multiModelExtractError, setMultiModelExtractError] = useState("");
951
+
952
+ function advanceFrame(steps = 1) {
953
+ const count = Math.max(1, Math.floor(steps));
954
+ const previewEnd = appContext.shared.cameraPreviewEndRef.current;
955
+ if (previewEnd != null && appContext.shared.tlFrameRef.current + count >= previewEnd) {
956
+ appContext.shared.cameraPreviewEndRef.current = null;
957
+ appContext.shared.setTlFrame(previewEnd);
958
+ appContext.shared.setTlPlaying(false);
959
+ return;
960
+ }
961
+ appContext.shared.setTlFrame((f) => (f + count) % appContext.shared.frameCountRef.current);
962
+ }
963
+
964
+ function stepFrame(delta) {
965
+ appContext.shared.setTlFrame((f) => Math.max(0, Math.min(f + delta, appContext.shared.frameCountRef.current - 1)));
966
+ }
967
+
968
+ /* --------------------------- motion playback ---------------------------- */
969
+ function leaveIkMode() {
970
+ const poserCam = appContext.shared.poserCamRef?.current;
971
+ if (ikMode && poserCam && appContext.shared.activeChar?.id) {
972
+ const look = appContext.shared.poserLook?.current ?? {};
973
+ ikCameraMemoryRef.current.set(appContext.shared.activeChar.id, {
974
+ position: poserCam.position.clone(),
975
+ quaternion: poserCam.quaternion.clone(),
976
+ yaw: look.yaw ?? poserCam.rotation.y,
977
+ pitch: look.pitch ?? poserCam.rotation.x,
978
+ });
979
+ }
980
+ setIkMode(false);
981
+ setIkFocus(null);
982
+ }
983
+
984
+ /** Hand `rig` over to playback. Every path that starts a clip — a loaded
985
+ * take, a browser-baked one — does the same two things: drop out of IK
986
+ * EDIT mode (playback is the new context; the IK KEYS stay and keep
987
+ * correcting the clip layer-style) and swap the pre-playback bone
988
+ * baseline, so a re-load never snapshots mid-animation and clearing
989
+ * always returns to the blocking pose. */
990
+ function beginPlaybackOn(rig) {
991
+ leaveIkMode();
992
+ const previous = appContext.shared.restoreRef.current;
993
+ appContext.shared.restoreRef.current = null;
994
+ if (previous) restorePlaybackBones(previous.rig, previous.bones);
995
+ appContext.shared.restoreRef.current = { rig, bones: snapshotPlaybackBones(rig) };
996
+ }
997
+
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
+ /** Land takes 1..N-1 on the rest of the cast. Each extra take is its OWN
1250
+ * layer: it goes to that entry's sessionMotion, NOT through the editing
1251
+ * buffer, which holds the active character's clip alone. Returns how many
1252
+ * performers actually landed. */
1253
+ const authoredSupportDescriptors = () => appContext.shared.sceneObjects.map((object) => ({
1254
+ x: object.x,
1255
+ z: object.z,
1256
+ rotDeg: object.rot ?? 0,
1257
+ supportY: (object.y ?? 0) + supportHeightForObject(object) * (object.scaleY ?? 1),
1258
+ topY: (object.y ?? 0) + supportHeightForObject(object) * (object.scaleY ?? 1),
1259
+ width: (object.footprint?.width ?? 0) * (object.scaleX ?? 1),
1260
+ depth: (object.footprint?.depth ?? 0) * (object.scaleZ ?? 1),
1261
+ }));
1262
+
1263
+ const applyAuthoredSupportRise = (clip, anchor, rotationDeg, worldScale = characterScaleFor(clip)) => applySupportRise(clip, authoredSupportDescriptors(), {
1264
+ subjectX: anchor.x,
1265
+ subjectY: anchor.y ?? 0,
1266
+ subjectZ: anchor.z,
1267
+ rotationDeg,
1268
+ worldScale,
1269
+ });
1270
+
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
+ function commitLoadedTake(characterId, take, { recipe = null, job = null, promptClips = null, scale = characterScaleFor(take) } = {}) {
1370
+ const owned = appContext.storeDomain('motion');
1371
+ const previous = owned.layer(characterId);
1372
+ const imported = recipe ?? (job ? previous.takeRecipe : { seed: null, blocks: [{ prompt: take.prompt, duration: take.frames / take.fps }], lineEdits: [] });
1373
+ const versions = recipe || job ? previous.takeVersions : pushTakeVersion(previous.takeVersions, {
1374
+ motionUrl: take.url, recipe: imported, savedAt: Date.now(), label: ko('Loaded', '불러옴'),
1375
+ }, TAKE_VERSIONS_MAX);
1376
+ owned.replace(characterId, take, { recipe: imported, versions });
1377
+ const motionRef = { url: take.url, prompt: take.prompt, rotationDeg: take.rotationDeg, anchorX: take.anchorX, anchorZ: take.anchorZ,
1378
+ calibration: take.sceneCalibration, ...(take.trailEdits ? { trailEdits: take.trailEdits } : {}), ...(take.motionId ? { motionId: take.motionId } : {}) };
1379
+ owned.castWrite(rows => rows.map(row => row.id === characterId ? { ...row, scale, motionRef,
1380
+ layer: promptClips ? { ...row.layer, promptClips } : row.layer } : row));
1381
+ if (job) {
1382
+ commitTakeRecipe(job, take.url);
1383
+ if (job.hasBlockEdits) owned.writeLayer(characterId, { committedIkEdits: job.committedEditKeys });
1384
+ }
1385
+ owned.persistTake(take, motionRef);
1386
+ }
1387
+
1388
+ // Decoded motion + the world anchor: frame 0 always starts at Subject 1.
1389
+ // Authored root destinations are generated by ARDY as sparse constraints,
1390
+ // so playback consumes the returned trajectory without coordinate warping.
1391
+ async function loadMotion(
1392
+ url,
1393
+ prompt,
1394
+ rotationDeg = appContext.shared.charA.rot,
1395
+ drop = null,
1396
+ targetCharacterId = appContext.shared.activeChar.id,
1397
+ targetPromptClips = null,
1398
+ // `preview: true` means "put this clip on screen, do not treat it as a
1399
+ // new take". The line-edit preview loop swaps the viewport several times
1400
+ // a minute, and every announcement this function normally makes — the
1401
+ // load toast, the auto-drop toast, clearing the IK keys, snapping the
1402
+ // playhead back to 0 — is an announcement about a take CHANGING. A
1403
+ // preview is the same take seen a second time, so it makes none of them.
1404
+ { preview = false, calibration = null, tutorialEpoch = null, commandContext = null, recipe = null, job = null, personScaleFallback = null } = {},
1405
+ ) {
1406
+ setMotionBusy(true);
1407
+ setMotionError("");
1408
+ try {
1409
+ // Inbound boundary: an ARDY take (20 fps) or a filmed one (30/60)
1410
+ // becomes a production-clock clip here, once, before anything on
1411
+ // the timeline counts its frames. Same-rate input rides through.
1412
+ // A drop is staging applied to the clip itself, so it happens at
1413
+ // the same boundary — trims and IK then see the dropped take.
1414
+ const retimed = retimeMotion(await loadMotionFromUrl(url), TIMELINE_FPS);
1415
+ if (retimed.sourceBytes) retimed.motionId = await sha256Hex(retimed.sourceBytes);
1416
+ if (tutorialEpoch !== null && tutorialEpoch !== appContext.shared.tutorialProjectEpochRef.current) return null;
1417
+ const normalizedCalibration = normalizeMotionCalibration(calibration);
1418
+ // Scene yaw/XY translation belong to the character's scene transform.
1419
+ // Applying them to both the arrays and the Character group would rotate
1420
+ // the trajectory twice and leave rotMats facing the old direction.
1421
+ const playbackCalibration = { ...normalizedCalibration, yawDeg: 0, offsetX: 0, offsetZ: 0 };
1422
+ // Scene calibration is optional metadata from the capture boundary. It
1423
+ // runs before support/fall staging so every downstream measurement uses
1424
+ // the same scene-space coordinates.
1425
+ const raw = applyMotionCalibration(retimed, playbackCalibration).motion;
1426
+ // Staging descriptors are authored in scene metres while decoded
1427
+ // trajectories are canonical-body units. Resolve stature before any
1428
+ // support or fall math so a 0.8x/1.2x performer still lands exactly on
1429
+ // the same authored surface after playback multiplies the clip.
1430
+ const motionScale = characterScaleFor(raw);
1431
+ const targetCharacter = appContext.live.characters.find((entry) => entry.id === targetCharacterId);
1432
+ if (!targetCharacter) throw new Error(`Motion target ${targetCharacterId} no longer exists.`);
1433
+ const sceneAnchorX = targetCharacter.x + normalizedCalibration.offsetX;
1434
+ const sceneAnchorZ = targetCharacter.z + normalizedCalibration.offsetZ;
1435
+ const sceneRotationDeg = rotationDeg + normalizedCalibration.yawDeg;
1436
+ const rig = appContext.shared.rigs[targetCharacter.id] ?? await appContext.shared.waitForRig(targetCharacter.id);
1437
+ if (tutorialEpoch !== null && tutorialEpoch !== appContext.shared.tutorialProjectEpochRef.current) return null;
1438
+ // No explicit drop staged: a character standing on a raised object
1439
+ // whose take walks off the edge falls on its own — ARDY motion is
1440
+ // flat-ground, so the stage supplies the gravity.
1441
+ const supports = authoredSupportDescriptors();
1442
+ // A support's top is scene data, never guessed from the motion. Apply
1443
+ // only when the clip shows an upward root trend entering its footprint;
1444
+ // ordinary deck walks remain byte-for-byte unchanged.
1445
+ const raised = drop ? raw : applySupportRise(raw, supports, {
1446
+ subjectX: sceneAnchorX,
1447
+ subjectY: targetCharacter.y ?? 0,
1448
+ subjectZ: sceneAnchorZ,
1449
+ rotationDeg: sceneRotationDeg,
1450
+ worldScale: motionScale,
1451
+ });
1452
+ const staging = drop ?? autoRoofDrop(
1453
+ raised,
1454
+ { x: sceneAnchorX, z: sceneAnchorZ, y: targetCharacter.y ?? 0, rotationDeg: sceneRotationDeg },
1455
+ supports,
1456
+ { worldScale: motionScale },
1457
+ );
1458
+ const decoded = drop ? applyRootDrop(raised, staging, { worldScale: motionScale }) : applyAutoFall(raised, staging, { worldScale: motionScale });
1459
+ if (!drop && staging && !preview) {
1460
+ appContext.notify((isKo, ko) => ko(
1461
+ `Auto drop staged: the take leaves its support at ${staging.fromS.toFixed(1)}s and falls ${staging.meters.toFixed(1)}m`,
1462
+ `자동 낙하 적용: ${staging.fromS.toFixed(1)}초에 지지면을 벗어나 ${staging.meters.toFixed(1)}m 낙하`,
1463
+ ));
1464
+ }
1465
+ const targetStillExists = appContext.live.characters.some((entry) => entry.id === targetCharacter.id);
1466
+ if (!targetStillExists) throw new Error(`Motion target ${targetCharacterId} no longer exists.`);
1467
+ const apply = () => {
1468
+ const bufferOwnsTarget = targetCharacter.id === appContext.shared.loadedLayerCharRef.current;
1469
+ beginPlaybackOn(rig);
1470
+ // THE INVARIANT: the take's travel assumes the character is scaled.
1471
+ // Extraction divided the root translation by the filmed person's
1472
+ // stature, so the clip and that stature have to be applied together
1473
+ // or every stride overshoots by the same factor and the feet skate.
1474
+ // The scale rides INSIDE the npz, so this one line covers every path
1475
+ // that loads a motion; an ARDY-generated take stores none and is
1476
+ // canonical, 1.
1477
+ const measuredScale = characterScaleFor(decoded);
1478
+ const scale = measuredScale === 1 && Number.isFinite(personScaleFallback) ? characterScaleFor(null, personScaleFallback) : measuredScale;
1479
+ const loaded = {
1480
+ // Identity calibration retains the legacy frame-zero anchorX: targetCharacter.x
1481
+ // and anchorZ: targetCharacter.z contract; calibrated takes use the scene anchor.
1482
+ // Capture the exact prompt this motion was generated from; the
1483
+ // timeline keeps showing it even if the input field is edited
1484
+ // afterwards.
1485
+ prompt: typeof prompt === "string" ? prompt : "",
1486
+ ...decoded,
1487
+ url,
1488
+ anchorX: sceneAnchorX,
1489
+ anchorZ: sceneAnchorZ,
1490
+ anchorFrame: 0,
1491
+ rotationDeg: sceneRotationDeg,
1492
+ sceneCalibration: normalizedCalibration,
1493
+ editSegments: createMotionEdit(decoded.frames),
1494
+ };
1495
+ const owned = appContext.storeDomain('motion');
1496
+ if (preview) owned.preview(targetCharacter.id, url === owned.motionFor(targetCharacter.id)?.url ? null : loaded);
1497
+ else {
1498
+ commitLoadedTake(targetCharacter.id, loaded, { recipe, job, promptClips: targetPromptClips, scale });
1499
+ if (bufferOwnsTarget) { appContext.shared.setTlFrame(0); appContext.shared.setTlPlaying(false); }
1500
+ setMotionError('');
1501
+ }
1502
+ return scale;
1503
+ };
1504
+ return preview ? apply() : commandContext ? commandContext.commit(apply)
1505
+ : appContext.storeDomain('motion').runPrepared(targetCharacterId, apply);
1506
+ } catch (err) {
1507
+ if (tutorialEpoch !== null && tutorialEpoch !== appContext.shared.tutorialProjectEpochRef.current) return null;
1508
+ setMotionError(err?.message || String(err));
1509
+ throw err;
1510
+ } finally {
1511
+ setMotionBusy(false);
1512
+ }
1513
+ }
1514
+
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
1517
+ * records nothing, so the shortcut never becomes a dead press.
1518
+ *
1519
+ * The pose flows (studio Apply/Reset, pose tiles, photo pose) call this
1520
+ * first and then write the pose: the snapshot taken here predates both, so
1521
+ * one undo restores the take AND the pose it replaced. */
1522
+ function clearMotion() {
1523
+ return appContext.storeDomain('motion').clear(appContext.shared.activeChar.id);
1524
+ }
1525
+
1526
+ /** Cut the ACTIVE character's take to [start, end] of the CURRENT view.
1527
+ * Offsets compose, so a second cut still slices the original take; a cut
1528
+ * take drops its bridge url — its frames no longer match the source npz,
1529
+ * and IK-edit regeneration must not pretend they do. Per-character layers
1530
+ * mean the cut lands on this layer only; nobody else's clip moves. */
1531
+ function applyMotionTrim(start, end) {
1532
+ return appContext.storeDomain('motion').edit('motion.trim', { characterId: appContext.shared.activeChar.id, start, end });
1533
+ }
1534
+
1535
+ function resetMotionTrim() {
1536
+ return appContext.storeDomain('motion').run('motion.resetTrim', { characterId: appContext.shared.activeChar.id });
1537
+ }
1538
+
1539
+ function cutMotionAtPlayhead() {
1540
+ return appContext.storeDomain('motion').run('motion.cut', { characterId: appContext.shared.activeChar.id, frame: appContext.shared.tlFrame });
1541
+ }
1542
+
1543
+ function changeMotionSegmentSpeed(id, speed) {
1544
+ return appContext.storeDomain('motion').run('motion.setSegmentSpeed', { characterId: appContext.shared.activeChar.id, id, speed });
1545
+ }
1546
+
1547
+ /** Drop one Full-Body segment from the take. The removal composes like a
1548
+ * trim: the source frames stay untouched, so the trim-reset path (right-click
1549
+ * an outer handle) still restores the whole take. */
1550
+ function removeMotionSegmentById(id) {
1551
+ return appContext.storeDomain('motion').run('motion.removeSegment', { characterId: appContext.shared.activeChar.id, id });
1552
+ }
1553
+
1554
+ // Everyone EXCEPT the active character, posed at an absolute frame from
1555
+ // their own session clips and their STORED IK corrections (#77). Factored
1556
+ // out of the effect below because the whole-clip Fix Collisions pass needs
1557
+ // the same thing: the other bodies are static blockers, and a blocker
1558
+ // sampled from a rig still standing at the playhead would block the wrong
1559
+ // volume on every other frame of the walk.
1560
+ const poseOtherCastMembers = (frame) => {
1561
+ for (const entry of appContext.shared.characters) {
1562
+ if (entry.id === appContext.shared.activeChar.id) continue;
1563
+ appContext.shared.poseMemberAtFrame(appContext.shared.rigs[entry.id], entry.sessionMotion, appContext.shared.ikStatesRef.current.get(entry.id), frame, IK_CORRECTION_BLEND_FRAMES);
1564
+ }
1565
+ };
1566
+
1567
+ function toggleIkMode() {
1568
+ const next = !ikMode;
1569
+ // Authoring modes are mutually exclusive: IK mode swaps the main pane to
1570
+ // the poser camera, which would silently invalidate any pulled path
1571
+ // anyway. Leaving the line mode explicitly says so instead.
1572
+ if (next && appContext.shared.lineEditMode) appContext.shared.exitLineEditMode();
1573
+ if (next) {
1574
+ // Always enter on the safe, detailed IK tool. Trail editing is an
1575
+ // explicit second tool and must never leave the regular handles
1576
+ // locked when the user re-enters IK mode.
1577
+ setIkEditTool("ik");
1578
+ // With a motion loaded, IK edits ON TOP of it: the motion is the
1579
+ // rough base layer, the IK keys the correction layer. Every frame
1580
+ // applies the clip first and the keyed corrections after, so the
1581
+ // composite is what gets pinned for re-generation. Pause playback
1582
+ // so a running playhead cannot fight the drag.
1583
+ appContext.shared.setTlPlaying(false);
1584
+ // Self-heal the ref after a hot-reload with an older state shape:
1585
+ // a missing `tracked` set would throw on the first drag.
1586
+ if (!appContext.shared.ikStateRef.current.tracked) appContext.shared.ikStateRef.current = createIkState();
1587
+ appContext.shared.ikStateRef.current.chains = ikChains;
1588
+ appContext.shared.ikStateRef.current.fkJoints = ikFkJoints;
1589
+ appContext.shared.ikStateRef.current.rig = appContext.shared.activeRig;
1590
+ // Handles open exactly on the effectors of the CURRENT pose —
1591
+ // non-destructive entry. (The evaluate effect applies the keyed
1592
+ // pose at this frame right after ikMode flips, so re-seating on
1593
+ // frame changes is handled there.)
1594
+ if (ikChains) ikSeedTargets(ikChains, appContext.shared.ikStateRef.current);
1595
+ // The main view switches to the poser camera: start it exactly on
1596
+ // the shot camera's pose so nothing jumps, then navigation moves
1597
+ // the POSER only — the shot camera (inset) stays frozen.
1598
+ const shotCam = appContext.shared.shotCamRef.current;
1599
+ const editorCam = appContext.shared.editorCamRef?.current;
1600
+ const poserCam = appContext.shared.poserCamRef.current;
1601
+ const pose = chooseIkEntryPose({
1602
+ rememberedPose: ikCameraMemoryRef.current.get(appContext.shared.activeChar.id) ?? null,
1603
+ editorPose: editorCam ? { position: editorCam.position, quaternion: editorCam.quaternion, yaw: appContext.shared.editorLook?.current?.yaw, pitch: appContext.shared.editorLook?.current?.pitch } : null,
1604
+ shotPose: shotCam ? { position: shotCam.position, quaternion: shotCam.quaternion, yaw: shotCam.rotation.y, pitch: shotCam.rotation.x } : null,
1605
+ lookThroughShot: appContext.shared.lookThroughShot,
1606
+ });
1607
+ if (pose && poserCam) {
1608
+ poserCam.position.copy(pose.position);
1609
+ poserCam.quaternion.copy(pose.quaternion);
1610
+ poserCam.rotation.order = "YXZ";
1611
+ appContext.shared.poserLook.current = { yaw: pose.yaw ?? poserCam.rotation.y, pitch: pose.pitch ?? poserCam.rotation.x };
1612
+ }
1613
+ setIkMode(true);
1614
+ appContext.notify(motion
1615
+ ? ko("Pose mode — correct the motion; drag end keys the fix at this frame", "포즈 모드 — 모션을 보정합니다. 드래그를 끝내면 이 프레임에 보정 키가 찍혀요")
1616
+ : ko("Pose mode — drag handles in the main view; the shot camera stays frozen in the inset", "포즈 모드 — 메인 뷰에서 핸들을 드래그하세요. 샷 카메라는 인셋에 고정됩니다"));
1617
+ return;
1618
+ }
1619
+ // Exit: the keyed pose stays — the evaluate effect re-applies the
1620
+ // current frame's keyed rotations the moment ikMode flips, so nothing
1621
+ // the user authored is lost by toggling. Untracked/unkeyed parts keep
1622
+ // their current (FK) pose.
1623
+ leaveIkMode();
1624
+ appContext.notify(ko("Pose mode off — keyed poses keep playing", "포즈 모드 꺼짐 — 키로 찍은 포즈는 계속 재생됩니다"));
1625
+ }
1626
+
1627
+ // Drag solve, routed by handle kind: chain targets solve the two-bone
1628
+ // chain toward the target; mid joints reposition the elbow/knee with both
1629
+ // ends pinned (the handle snaps to the clamped position); FK joints swing
1630
+ // toward the pointer. Keys are baked on drag END — see ikDragEnd.
1631
+ function ikSolve(kind, trackId, targetWorld) {
1632
+ ikDragRef.current = ikDragRecord(ikDragRef.current, appContext.shared.tlFrame);
1633
+ const touch = (id) => ikDragTouch(appContext.shared.ikStateRef.current, ikDragRef.current, id);
1634
+ if (kind === "chain") {
1635
+ const chain = appContext.shared.ikStateRef.current.chains?.get(trackId);
1636
+ if (!chain) return;
1637
+ touch(trackId);
1638
+ const clampedTarget = bodyContact ? clampIkTargetToFloor(trackId, targetWorld, 0, ikChains?.get(trackId)?.contactHeights ?? ikChains?.values().next().value?.contactHeights) : targetWorld;
1639
+ appContext.shared.ikStateRef.current.targets.set(trackId, clampedTarget.clone());
1640
+ solveIk(chain, clampedTarget);
1641
+ return;
1642
+ }
1643
+ if (kind === "mid") {
1644
+ // Mid tracks reference their parent chain through MID_TRACKS.
1645
+ const midDef = MID_TRACKS.find((t) => t.id === trackId);
1646
+ const chain = midDef ? appContext.shared.ikStateRef.current.chains?.get(midDef.chain) : null;
1647
+ if (!chain) return;
1648
+ touch(chain.track.id);
1649
+ solveMidJoint(chain, bodyContact ? clampIkTargetToFloor(trackId, targetWorld, 0, chain.contactHeights) : targetWorld);
1650
+ return;
1651
+ }
1652
+ // Effector swing: the rotation ring on a focused hand/foot. Rotates
1653
+ // only the end bone — the solved limb position is untouched — and the
1654
+ // bake on drag end now stores b2's quaternion with the chain's.
1655
+ if (kind === "swing") {
1656
+ const chain = appContext.shared.ikStateRef.current.chains?.get(trackId);
1657
+ if (!chain || !targetWorld?.axis) return;
1658
+ touch(trackId);
1659
+ solveEffectorSwing(chain, targetWorld.axis, targetWorld.angle, targetWorld.startQuat, targetWorld.startParentQuat);
1660
+ return;
1661
+ }
1662
+ // Body root (hips): arrow drags translate ({ worldDelta, startLocalPos
1663
+ // }), the centre sphere swings ({ axis, angle, startQuat, ... }). With
1664
+ // foot snap ON the feet stay at the positions captured when the drag
1665
+ // started — the legs re-solve after every hips transform so the knees
1666
+ // bend instead of the feet sinking through the floor.
1667
+ if (kind === "body") {
1668
+ const joint = ikFkJoints?.get(trackId);
1669
+ if (!joint) return;
1670
+ touch(trackId);
1671
+ if (footSnap && !appContext.shared.ikBodyDragRef.current && ikChains) {
1672
+ // Capture the plant points once, BEFORE the first hips move.
1673
+ ikPlantFeet(ikChains, appContext.shared.ikStateRef.current);
1674
+ appContext.shared.ikBodyDragRef.current = true;
1675
+ }
1676
+ if (targetWorld?.worldDelta && targetWorld?.startLocalPos) {
1677
+ if (bodyContact) solveHipsTranslateToFloor(joint, targetWorld.worldDelta, targetWorld.startLocalPos, 0, ikChains?.get("leftHand")?.contactHeights);
1678
+ else solveHipsTranslate(joint, targetWorld.worldDelta, targetWorld.startLocalPos);
1679
+ } else if (targetWorld?.axis) solveSwingAngle(joint, targetWorld.axis, targetWorld.angle, targetWorld.startQuat, targetWorld.startParentQuat);
1680
+ if (footSnap && ikChains) {
1681
+ ikSolvePlantedFeet(ikChains, appContext.shared.ikStateRef.current);
1682
+ // the planted re-solve wrote the leg bones — key them too
1683
+ touch("leftFoot");
1684
+ touch("rightFoot");
1685
+ }
1686
+ if (bodyContact && ikChains) {
1687
+ for (const id of chainsChangedBy(ikChains, () => applyBodyContact(ikChains, ikFkJoints, 0, { skipFeet: footSnap }))) touch(id);
1688
+ }
1689
+ return;
1690
+ }
1691
+ // FK swing: targetWorld is the trackball payload { axis, angle,
1692
+ // startQuat, startParentQuat } from the drag layer.
1693
+ const joint = ikFkJoints?.get(trackId);
1694
+ if (!joint || !targetWorld?.axis) return;
1695
+ touch(trackId);
1696
+ solveSwingAngle(joint, targetWorld.axis, targetWorld.angle, targetWorld.startQuat, targetWorld.startParentQuat);
1697
+ }
1698
+
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).
1701
+ function ikDragEnd() {
1702
+ appContext.shared.ikBodyDragRef.current = false;
1703
+ const drag = ikDragRef.current;
1704
+ 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);
1711
+ }
1712
+
1713
+ /** Bake the current tracked rotations at the playhead into a scratch layer
1714
+ * and set them as a key through the shared registry. A bake only writes
1715
+ * TRACKED parts: with nothing dragged yet there is no key, nothing is
1716
+ * dispatched and Ctrl+Z never goes dead. */
1717
+ function keyIkPoseAtPlayhead(dragIds = null) {
1718
+ if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').bakeCurrentKey(appContext.shared.activeChar.id, appContext.shared.tlFrame, dragIds);
1719
+ const scratch = { ...createIkState(), tracked: new Set(appContext.shared.ikStateRef.current.tracked) };
1720
+ ikBakeKeyframe(ikChains, scratch, appContext.shared.tlFrame, ikFkJoints);
1721
+ const baked = scratch.keys.get(appContext.shared.tlFrame);
1722
+ return baked ? appContext.shared.runStudioAction("character.setIkKey", { characterId: appContext.shared.activeChar.id, frame: appContext.shared.tlFrame, tracks: ikKeyJson(baked) }) : null;
1723
+ }
1724
+
1725
+ // Manual key: bake the current tracked rotations at the playhead.
1726
+ function ikAddKeyframe() {
1727
+ if (!ikChains) return;
1728
+ if (!keyIkPoseAtPlayhead()) return;
1729
+ appContext.notify(isKo ? `${appContext.shared.tlFrame}프레임에 전신 IK 키를 추가했어요` : `Full-body IK key at frame ${appContext.shared.tlFrame}`);
1730
+ }
1731
+
1732
+ // Self-collision cleanup: push interpenetrating body parts apart with the
1733
+ // IK solver, then bake the fix as an ordinary IK correction key so it
1734
+ // survives scrubs, undo, and blends back into the clip outside its range.
1735
+ //
1736
+ // The result has THREE outcomes, not two, and each gets its own sentence.
1737
+ // "supported: false" means the rig has no capsule proxies to build at all
1738
+ // — a non-Mixamo skeleton — and reporting that as "no collisions" would
1739
+ // be a lie the user cannot act on: they would keep clicking a button that
1740
+ // silently does nothing. The buttons below are disabled in that case, so
1741
+ // this branch is the belt to that suspenders (a rig can be swapped under
1742
+ // a stale render).
1743
+ /**
1744
+ * Everything OUTSIDE the active character that a limb has to stay out of:
1745
+ * the other cast members' bodies (capsules, built from the rigs AS THEY ARE
1746
+ * POSED at the moment of the call) and every scene object (an upright box
1747
+ * on its footprint). Ids are namespaced `char:<id>:<capsule>` / `obj:<id>`,
1748
+ * so a penetration label names the thing that was hit.
1749
+ *
1750
+ * `frame` re-samples travel paths — a prop walking a route stands somewhere
1751
+ * else on every frame — but the CAST is read live from the scene graph, so
1752
+ * a caller walking a clip must pose the others at that frame first (see
1753
+ * runFixCollisionsRange's blockersAt).
1754
+ */
1755
+ const externalBlockers = (frame = appContext.shared.tlFrame) => collisionBlockers({
1756
+ rigs: appContext.shared.rigs,
1757
+ activeId: appContext.shared.activeChar.id,
1758
+ // The CAST is the authority on who is on stage, not the rig map: undo can
1759
+ // put the cast list back to one subject while the rig mounted for the
1760
+ // removed one is still in `rigs`, and a ghost body would go on blocking
1761
+ // limbs that pass through empty space.
1762
+ characterIds: appContext.shared.characters,
1763
+ sceneObjects: appContext.shared.sceneObjects,
1764
+ library: OBJECT_LIBRARY,
1765
+ frame,
1766
+ take: { frameCount: appContext.shared.tlFrameCount, fps: appContext.shared.tlFps },
1767
+ });
1768
+
1769
+ function runFixCollisions() {
1770
+ if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').run('motion.fixCollisions', { characterId: appContext.shared.activeChar.id, scope: 'frame' });
1771
+ if (!ikChains || !appContext.shared.activeRig) return;
1772
+ // A rig swap leaves one render where ikChains still describes the old
1773
+ // skeleton; solving the new rig with them would push the wrong bones.
1774
+ if (appContext.shared.ikStateRef.current.rig !== appContext.shared.activeRig) return;
1775
+ // The set as it stands right now: the other bodies at this frame's pose
1776
+ // and the props at this frame's placement.
1777
+ const result = fixCollisions(appContext.shared.activeRig, ikChains, { ikState: appContext.shared.ikStateRef.current, fkJoints: ikFkJoints, blockers: externalBlockers(appContext.shared.tlFrame) });
1778
+ if (!result.supported) {
1779
+ appContext.notify(ko("This rig doesn't support collision cleanup", "이 리그는 신체 관통 정리를 지원하지 않아요"));
1780
+ return;
1781
+ }
1782
+ if (!result.changed) {
1783
+ appContext.notify(ko("No body collisions at this frame", "이 프레임에는 신체 관통이 없어요"));
1784
+ return;
1785
+ }
1786
+ editIkKeys(() => ikBakeKeyframe(ikChains, appContext.shared.ikStateRef.current, appContext.shared.tlFrame, ikFkJoints, result.touched, null, result.baseQuats));
1787
+ setIkTick((n) => n + 1);
1788
+ appContext.notify(result.residual > 1e-4
1789
+ ? ko(`Collisions reduced (residual ${(result.residual * 100).toFixed(1)} cm)`, `관통을 줄였어요 (잔여 ${(result.residual * 100).toFixed(1)} cm)`)
1790
+ : ko(`Collisions fixed at frame ${appContext.shared.tlFrame}`, `프레임 ${appContext.shared.tlFrame}의 관통을 정리했어요`));
1791
+ }
1792
+
1793
+ // Whole-clip variant: walk the motion frame by frame, clean each pose and
1794
+ // key ONLY the frames that changed, so a clean clip stays keyless.
1795
+ function runFixCollisionsRange() {
1796
+ if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').run('motion.fixCollisions', { characterId: appContext.shared.activeChar.id, scope: 'clip' });
1797
+ if (!ikChains || !appContext.shared.activeRig || !motion) return;
1798
+ if (appContext.shared.ikStateRef.current.rig !== appContext.shared.activeRig) return;
1799
+ // Screened before the undo entry: an unsupported rig would record an
1800
+ // undo step for a walk that keys nothing, leaving a no-op in history.
1801
+ if (!appContext.shared.collisionCleanupSupported) {
1802
+ appContext.notify(ko("This rig doesn't support collision cleanup", "이 리그는 신체 관통 정리를 지원하지 않아요"));
1803
+ return;
1804
+ }
1805
+ const currentFrame = appContext.shared.tlFrame;
1806
+ const applyFrame = (frame) => {
1807
+ applyMotionFrame(appContext.shared.activeRig, motion, frame);
1808
+ ikEvaluate(ikChains, appContext.shared.ikStateRef.current, frame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
1809
+ };
1810
+ // The other bodies move too. blockersAt runs AFTER applyFrame(frame), so
1811
+ // it poses the rest of the cast at that same frame — their own clips and
1812
+ // their own stored IK layers, the very pass the viewport renders with —
1813
+ // and only then samples their capsules. Without this the blockers would
1814
+ // describe everyone frozen at the playhead, which is a wrong obstacle on
1815
+ // every frame but one.
1816
+ const blockersAt = (frame) => {
1817
+ poseOtherCastMembers(frame);
1818
+ return externalBlockers(frame);
1819
+ };
1820
+ let keyed = [];
1821
+ let unresolved = [];
1822
+ try {
1823
+ const walked = editIkKeys(() => fixCollisionsRange({
1824
+ rig: appContext.shared.activeRig,
1825
+ chains: ikChains,
1826
+ ikState: appContext.shared.ikStateRef.current,
1827
+ fkJoints: ikFkJoints,
1828
+ startFrame: 0,
1829
+ endFrame: motion.frames - 1,
1830
+ applyFrame,
1831
+ blockersAt,
1832
+ }));
1833
+ // The frames the walk keyed. `unresolved` — the frames whose residual
1834
+ // survived every pass — is ADDITIVE: read it defensively off either
1835
+ // shape so this keeps working before and after the driver grows it.
1836
+ keyed = Array.isArray(walked) ? walked : walked?.keyed ?? [];
1837
+ unresolved = (Array.isArray(walked) ? walked.unresolved : walked?.unresolved) ?? [];
1838
+ } finally {
1839
+ // The restore is the pass's CLEANUP, not its epilogue: a throw mid-walk
1840
+ // would otherwise leave the active rig and the rest of the cast frozen
1841
+ // at whatever frame it died on, which is a wrong-looking set the user
1842
+ // cannot scrub out of without touching the playhead.
1843
+ applyFrame(currentFrame);
1844
+ poseOtherCastMembers(currentFrame);
1845
+ setIkTick((n) => n + 1);
1846
+ }
1847
+ // Residual is worth saying out loud: a limb pinned between two blockers
1848
+ // (another body and a prop, say) can come out of the walk still touching,
1849
+ // and silence would read as "all clean".
1850
+ const stillPenetrating = unresolved.length
1851
+ ? ko(` · ${unresolved.length} frame(s) still penetrate`, ` · ${unresolved.length}개 프레임은 남아 있어요`)
1852
+ : "";
1853
+ // "No body collisions" must never share a sentence with "still
1854
+ // penetrate": a converged pass over an unfixable clip has nothing more
1855
+ // to do, which is a different statement from the clip being clean.
1856
+ appContext.notify((keyed.length
1857
+ ? ko(`Fixed collisions on ${keyed.length} frame(s)`, `${keyed.length}개 프레임의 관통을 정리했어요`)
1858
+ : unresolved.length
1859
+ ? ko("Nothing more to fix", "더 고칠 수 있는 게 없어요")
1860
+ : ko("No body collisions in the clip", "클립에 신체 관통이 없어요")) + stillPenetrating);
1861
+ }
1862
+
1863
+ function changePhysicsOptions(next) {
1864
+ setPhysicsOptions(next); setPhysicsPreview(null); setIkTick((n) => n + 1);
1865
+ }
1866
+
1867
+ function showPhysicsPreview(show) { setPhysicsShow(show); setIkTick((n) => n + 1); }
1868
+
1869
+ function cancelPhysicsPreview() { setPhysicsPreview(null); setIkTick((n) => n + 1); }
1870
+
1871
+ function applyPhysicsPreview() {
1872
+ if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').run('motion.applyPhysics', { characterId: appContext.shared.activeChar.id });
1873
+ if (!physicsPreview || physicsPreview.sourceStamp !== physicsKeyStamp(appContext.shared.ikStateRef.current.keys)) return;
1874
+ editIkKeys(() => { appContext.shared.ikStateRef.current.keys = copyPhysicsKeys(physicsPreview.candidate.keys); });
1875
+ appContext.shared.ikStateRef.current.tracked = new Set(physicsPreview.candidate.tracked);
1876
+ appContext.shared.autoPhysicsRunRef.current = { motion, rig: appContext.shared.activeRig, stamp: physicsKeyStamp(appContext.shared.ikStateRef.current.keys) };
1877
+ setPhysicsPreview(null); setIkTick((n) => n + 1);
1878
+ appContext.notify(ko("AutoPhysics applied · Undo restores the original", "오토피직스를 적용했어요 · 실행 취소로 원본 복구"));
1879
+ }
1880
+
1881
+ async function runAutoPhysics() {
1882
+ if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').run('motion.autoPhysics', { characterId: appContext.shared.activeChar.id, ...physicsOptions, apply: false });
1883
+ if (autoPhysicsRunning || !ikChains || !appContext.shared.activeRig || !motion || appContext.shared.ikStateRef.current.rig !== appContext.shared.activeRig) return null;
1884
+ const previous = appContext.shared.autoPhysicsRunRef.current;
1885
+ if (previous?.motion === motion && previous.rig === appContext.shared.activeRig && previous.stamp === physicsKeyStamp(appContext.shared.ikStateRef.current.keys)) {
1886
+ appContext.notify(ko("Already applied. Undo to review this correction again.", "이미 적용했어요. 실행 취소 후 다시 비교할 수 있어요.")); return null;
1887
+ }
1888
+ const job = ++appContext.shared.physicsJobRef.current, frame = appContext.shared.tlFrame;
1889
+ const sourceKeys = copyPhysicsKeys(appContext.shared.ikStateRef.current.keys), stamp = physicsKeyStamp(sourceKeys);
1890
+ let lastYieldAt = Date.now(), yieldWaitMs = 0, yieldCount = 0;
1891
+ const restore = () => { appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, appContext.shared.ikStateRef.current, frame, IK_CORRECTION_BLEND_FRAMES); };
1892
+ appContext.shared.setTlPlaying(false); setAutoPhysicsRunning(true); setPhysicsProgress(0); setPhysicsPreview(null);
1893
+ try {
1894
+ const result = await reviewAutoPhysics({ rig: appContext.shared.activeRig, motion, chains: ikChains, fkJoints: ikFkJoints, sourceKeys,
1895
+ applyRaw: (f) => appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, null, f), sceneObjects: appContext.shared.sceneObjects, ...physicsOptions,
1896
+ cache: appContext.shared.physicsSourceCacheRef.current,
1897
+ onProgress: setPhysicsProgress,
1898
+ yieldFrame: async () => {
1899
+ if (appContext.shared.physicsJobRef.current !== job) throw new Error("Analysis cancelled after changing the character or motion");
1900
+ // A batch is a cancellation checkpoint, not necessarily a paint/
1901
+ // event-loop boundary. Yield on a time budget, not every 12 frames.
1902
+ if (Date.now() - lastYieldAt < 16) return;
1903
+ restore();
1904
+ const queuedAt = Date.now();
1905
+ // Yield CPU work without waiting for a paint. requestAnimationFrame
1906
+ // can be throttled/paused in an occluded tab, stretching a seconds-
1907
+ // long solve into minutes. MessageChannel also lets input run.
1908
+ await new Promise((resolve) => {
1909
+ const channel = new MessageChannel();
1910
+ channel.port1.onmessage = () => { channel.port1.close(); channel.port2.close(); resolve(); };
1911
+ channel.port2.postMessage(0);
1912
+ });
1913
+ lastYieldAt = Date.now(); yieldWaitMs += lastYieldAt - queuedAt; yieldCount += 1;
1914
+ if (appContext.shared.physicsJobRef.current !== job) throw new Error("Analysis cancelled after changing the character or motion");
1915
+ },
1916
+ });
1917
+ Object.assign(result.performance, { yieldWaitMs, yieldCount });
1918
+ if (appContext.shared.physicsJobRef.current !== job || physicsKeyStamp(appContext.shared.ikStateRef.current.keys) !== stamp) return null;
1919
+ setPhysicsPreview(result); setPhysicsShow(true);
1920
+ return { before: result.before, after: result.after, warnings: result.warnings, unresolved: result.unresolved, contacts: result.contacts.spans };
1921
+ } catch (error) {
1922
+ if (appContext.shared.physicsJobRef.current === job) appContext.notify(ko(`AutoPhysics: ${error.message}`, `오토피직스: ${error.message}`));
1923
+ return null;
1924
+ } finally {
1925
+ if (appContext.shared.physicsJobRef.current === job) { restore(); setAutoPhysicsRunning(false); setIkTick((n) => n + 1); }
1926
+ }
1927
+ }
1928
+
1929
+ async function platformFitRun(id, context, { removal = false, preview = false } = {}) {
1930
+ const rig = appContext.shared.rigs[id], resolved = rig && resolveIkRig(rig), take = domain.motionFor(id);
1931
+ if (!resolved || !take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take and character rig first.');
1932
+ if (platformFitRunning) throw new StudioProtocolError('TARGET_BUSY', 'A platform fit is already running.');
1933
+ const job = ++platformFitJobRef.current;
1934
+ const sourceKeys = copyPhysicsKeys(decodeMotionKeys(domain.layer(id).ikKeys));
1935
+ const sourceTracked = new Set([...sourceKeys.values()].flatMap(entry => [...entry.keys()]));
1936
+ const stamp = physicsKeyStamp(sourceKeys);
1937
+ const prior = platformFitAppliedRef.current;
1938
+ const stacked = prior && prior.id === id && prior.stamp === stamp ? prior : null;
1939
+ const frame = appContext.shared.tlFrame;
1940
+ const restore = () => appContext.shared.poseMemberAtFrame(rig, take, appContext.shared.ikStatesRef.current.get(id), frame, 6);
1941
+ let lastYield = performance.now();
1942
+ const check = () => { context?.check?.(); if (platformFitJobRef.current !== job) throw new StudioProtocolError('STALE_TARGET', 'Platform fit was superseded.'); };
1943
+ setPlatformFitRunning(true); platformFitProgressSet(0); setPlatformFitLast(null);
1944
+ try {
1945
+ const result = await fitPlatforms({
1946
+ rig: rig, motion: take, chains: resolved.chains, fkJoints: resolved.fkJoints, sourceKeys,
1947
+ applyRaw: at => appContext.shared.poseMemberAtFrame(rig, take, null, at),
1948
+ sceneObjects: removal ? [] : (appContext.ports.read?.().objects ?? appContext.shared.sceneObjects),
1949
+ onProgress: platformFitProgressSet,
1950
+ yieldFrame: async () => {
1951
+ check(); if (performance.now() - lastYield < 16) return;
1952
+ restore();
1953
+ await new Promise(resolve => { const channel = new MessageChannel(); channel.port1.onmessage = () => { channel.port1.close(); channel.port2.close(); resolve(); }; channel.port2.postMessage(0); });
1954
+ lastYield = performance.now(); check();
1955
+ },
1956
+ });
1957
+ check();
1958
+ const last = { summary: result.summary, steps: result.steps, changedFrames: result.changedFrames };
1959
+ if (removal) {
1960
+ setPlatformFitLast(last);
1961
+ if (result.changedFrames.length) context.commit(() => domain.setKeys(id, result.candidate.keys));
1962
+ platformFitAppliedRef.current = null; setPlatformFitApplied(false); setPlatformFitLast(null);
1963
+ return { ...last, removed: result.changedFrames.length > 0 };
1964
+ }
1965
+ if (!preview) {
1966
+ if (result.changedFrames.length) context.commit(() => domain.setKeys(id, result.candidate.keys));
1967
+ platformFitAppliedRef.current = { id, before: stacked?.before ?? sourceKeys, tracked: stacked?.tracked ?? sourceTracked, stamp: physicsKeyStamp(decodeMotionKeys(domain.layer(id).ikKeys)) };
1968
+ setPlatformFitLast(last); setPlatformFitApplied(true);
1969
+ return last;
1970
+ }
1971
+ platformFitAppliedRef.current = { preview: { id, sourceStamp: stamp, sourceKeys, sourceTracked, candidate: result.candidate, last }, before: stacked?.before ?? sourceKeys, tracked: stacked?.tracked ?? sourceTracked, stamp };
1972
+ setPlatformFitLast({ ...last, candidate: result.candidate });
1973
+ setPlatformFitApplied(false);
1974
+ return last;
1975
+ } finally {
1976
+ if (platformFitJobRef.current === job) { restore(); setPlatformFitRunning(false); }
1977
+ }
1978
+ }
1979
+
1980
+ function platformFitApply(id, context) {
1981
+ const preview = platformFitAppliedRef.current?.preview;
1982
+ if (!preview || preview.id !== id || platformFitRunning) return { applied: false };
1983
+ const current = physicsKeyStamp(decodeMotionKeys(domain.layer(id).ikKeys));
1984
+ if (current !== preview.sourceStamp) throw new StudioProtocolError('STALE_TARGET', 'The platform preview is out of date. Analyse this take again before applying.');
1985
+ context.commit(() => domain.setKeys(id, copyPhysicsKeys(preview.candidate.keys)));
1986
+ platformFitAppliedRef.current = { id, before: preview.sourceKeys, tracked: preview.sourceTracked, stamp: physicsKeyStamp(decodeMotionKeys(domain.layer(id).ikKeys)) };
1987
+ setPlatformFitApplied(true);
1988
+ setPlatformFitLast({ ...preview.last, candidate: undefined });
1989
+ return { applied: true, changedFrames: preview.last.changedFrames };
1990
+ }
1991
+
1992
+ function platformFitRemove(id, context, { apply = false } = {}) {
1993
+ if (apply) return platformFitApply(id, context);
1994
+ const applied = platformFitAppliedRef.current;
1995
+ if (applied?.preview?.id === id) {
1996
+ platformFitAppliedRef.current = null; setPlatformFitLast(null); setPlatformFitApplied(false);
1997
+ return { removed: false, cancelled: true };
1998
+ }
1999
+ if (!applied || applied.id !== id || platformFitRunning) return { removed: false };
2000
+ const current = physicsKeyStamp(decodeMotionKeys(domain.layer(id).ikKeys));
2001
+ if (current === applied.stamp) {
2002
+ context.commit(() => domain.setKeys(id, copyPhysicsKeys(applied.before)));
2003
+ platformFitAppliedRef.current = null; setPlatformFitApplied(false); setPlatformFitLast(null);
2004
+ return { removed: true };
2005
+ }
2006
+ return platformFitRun(id, context, { removal: true });
2007
+ }
2008
+
2009
+ function ikDeleteKeyframe(frame) {
2010
+ if (!appContext.shared.ikStateRef.current.keys.has(frame)) return;
2011
+ appContext.shared.runStudioAction("character.removeIkKey", { characterId: appContext.shared.activeChar.id, frame });
2012
+ }
2013
+
2014
+ /** With IK mode on over a loaded take, a pose pick is a CORRECTION, not a
2015
+ * replacement: write the saved pose onto the rig and bake every IK part
2016
+ * into a full-body key at the current frame. The take survives, and the
2017
+ * key blends back into the clip outside its window exactly like a dragged
2018
+ * key would. Returns false when there is nothing to key against so the
2019
+ * caller can fall through to the plain pose-apply path. */
2020
+ function ikApplyPoseAsKey(pose) {
2021
+ if (!appContext.shared.activeRig || !motion) return false;
2022
+ if (appContext.storeDomain('motion')) { appContext.storeDomain('motion').run('ik.applyPose', { characterId: appContext.shared.activeChar.id, frame: appContext.shared.tlFrame, pose }); return true; }
2023
+ if (!ikChains) return false;
2024
+ // The clip's positional skinning left per-bone translations the FK pose
2025
+ // math never produced. The bake below stores every FK joint's position
2026
+ // (p) as-is, so posing rotations over those clip translations would key
2027
+ // a torn-apart body — bind translations first, ALWAYS.
2028
+ restoreBindPositions(appContext.shared.activeRig);
2029
+ // Reset-then-pose, the same shape the Character effect applies: unlisted
2030
+ // joints return to rest instead of keeping stale limbs from the clip.
2031
+ applyPose(appContext.shared.activeRig, { ...REST_BONES, ...pose.bones });
2032
+ // The hips' measured height rides into the bake: the hips FK joint keys
2033
+ // its local position (p), so a crouched pose keys a crouched body.
2034
+ applyHipsOffset(appContext.shared.activeRig, pose.rootY ?? 0);
2035
+ // The pose authors the whole body, so every part is tracked — an
2036
+ // untracked chain would silently keep the clip's limb.
2037
+ for (const id of ikChains.keys()) ikTouch(appContext.shared.ikStateRef.current, id);
2038
+ if (ikFkJoints) for (const id of ikFkJoints.keys()) ikTouch(appContext.shared.ikStateRef.current, id);
2039
+ editIkKeys(() => ikBakeKeyframe(ikChains, appContext.shared.ikStateRef.current, appContext.shared.tlFrame, ikFkJoints));
2040
+ // Handles re-seat on the posed effectors, ready to drag into a refinement.
2041
+ ikSeedTargets(ikChains, appContext.shared.ikStateRef.current);
2042
+ setIkTick((n) => n + 1);
2043
+ appContext.notify(isKo
2044
+ ? `${appContext.shared.tlFrame}프레임에 포즈를 전신 IK 보정 키로 추가했어요 — 모션은 그대로예요`
2045
+ : `Pose keyed as a full-body IK correction at frame ${appContext.shared.tlFrame} — the take stays`);
2046
+ return true;
2047
+ }
2048
+
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
+ function recheckMotionHealth() {
2079
+ setBridgeChecking(true);
2080
+ return appContext.shared.bridgeRefreshRef.current().finally(() => setBridgeChecking(false));
2081
+ }
2082
+
2083
+ function requestMotionGeneration(surface, inputMode, body = {}) {
2084
+ const request = startMotionRequest({ surface, input_mode: inputMode });
2085
+ const options = { body, lineEditSupported: lineEditBackend };
2086
+ // Record known readiness refusals even if existing input validation returns
2087
+ // early. A pass waits for the fully packaged payload at the queue boundary.
2088
+ // The queue independently checks readiness; telemetry failure cannot
2089
+ // enable or disable generation.
2090
+ if (motionPreflightReason(bridge, options)) {
2091
+ request.preflight(bridge, options);
2092
+ const readiness = motionReadiness(bridge, options);
2093
+ appContext.notify(motionReadinessMessage(readiness), ({ loading: "Checking motion generation…", ready: "Ready for this motion request", not_configured: "No motion backend configured", unsupported_route: "This route cannot run the selected motion request" })[readiness] ?? "The motion backend is unavailable");
2094
+ }
2095
+ return request;
2096
+ }
2097
+
2098
+ // Optional native-ARDY seed. Empty = request omits seed; the raw string
2099
+ // is kept as typed (trimmed only). runArdy validates the bridge contract
2100
+ // (integer in 0..2**31-1) right before the request and toasts on a
2101
+ // violation, so an invalid seed can never reach the bridge silently.
2102
+ function changeArdySeed(value) {
2103
+ setArdySeed(value.trim());
2104
+ }
2105
+
2106
+ /** THE SEED RULE (contract C9), enforced in ONE place so no take-creating
2107
+ * call site can forget it: an empty field is rolled here, a typed one is
2108
+ * kept exactly as typed, and either way a concrete integer comes back to be
2109
+ * both SENT and RECORDED. A take whose seed was never written down cannot
2110
+ * be rebuilt from its recipe, which is the one promise the whole recipe
2111
+ * model rests on. Returns null after toasting when the typed value violates
2112
+ * the bridge contract — the caller must then abandon the request. */
2113
+ function takeSeed() {
2114
+ try {
2115
+ return resolveSeed(ardySeed, ARDY_SEED_MAX);
2116
+ } catch {
2117
+ appContext.notify((isKo, ko) => isKo ? `Seed는 0..${ARDY_SEED_MAX} 범위의 정수여야 해요. 비워 두면 자동으로 선택됩니다` : `Seed must be an integer in 0..${ARDY_SEED_MAX} — clear it to let the box pick one`);
2118
+ return null;
2119
+ }
2120
+ }
2121
+
2122
+ /** CONFIRM the pull: the full-quality run of exactly what the preview has
2123
+ * been showing. Its own run mode — the body carries lineEdit and NOTHING
2124
+ * else authored, because C6 makes it exclusive with preserve, waypoints,
2125
+ * segments, regenerateSegments and motionEdit — and it is the one path that
2126
+ * commits a recipe and a version. Same builder, same SESSION SEED and same
2127
+ * curve as the last preview; only `preview: true` is absent, which is what
2128
+ * buys the full step count. */
2129
+ function runLineEdit() {
2130
+ if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) return;
2131
+ const generationRequest = requestMotionGeneration("line_edit", "edit", { lineEdit: true });
2132
+ if (!appContext.shared.takeSourceUrl) {
2133
+ appContext.notify(ko("The current take has no bridge source — generate it once before editing a path", "현재 테이크에 브리지 원본이 없어요 — 궤적을 편집하기 전에 한 번 생성하세요"));
2134
+ return;
2135
+ }
2136
+ // No curve object means no edit — an untouched path is the take's own
2137
+ // trajectory, and sending it would ask the box to spend eight seconds
2138
+ // reproducing what is already there.
2139
+ if (!appContext.shared.lineEditPayload) {
2140
+ appContext.notify(ko(
2141
+ "Draw along the path, pull a dot, or pin a moment first",
2142
+ "먼저 궤적을 따라 그리거나, 점을 잡아당기거나, 순간을 찍어 주세요",
2143
+ ));
2144
+ return;
2145
+ }
2146
+ if (!lineEditBackend) {
2147
+ appContext.notify(ko(
2148
+ "The line-editing backend is not connected yet",
2149
+ "라인 편집 백엔드가 아직 연결 전이에요",
2150
+ ));
2151
+ return;
2152
+ }
2153
+ const request = appContext.shared.buildLineEditRequest(appContext.shared.lineEditPayload);
2154
+ if (!request.ok) {
2155
+ if (request.message) appContext.notify(request.message);
2156
+ return;
2157
+ }
2158
+ const { body, lineEdit, seed } = request;
2159
+ // The take being edited, not the draft that may be on screen: a preview
2160
+ // is a picture and must never become anyone's lineage.
2161
+ const source = appContext.shared.linePreviewSource;
2162
+ const queued = enqueueMotionJob({
2163
+ request: generationRequest,
2164
+ charId: source?.charId ?? appContext.shared.activeChar.id,
2165
+ charIndex: appContext.shared.activeCharIndex,
2166
+ prompt: body.prompt,
2167
+ body,
2168
+ hasBlockEdits: false,
2169
+ committedEditKeys: [],
2170
+ rootRotationDeg: source?.rotationDeg ?? motion.rotationDeg ?? appContext.shared.activeChar.rot,
2171
+ anchor: { x: motion.anchorX ?? appContext.shared.activeChar.x, z: motion.anchorZ ?? appContext.shared.activeChar.z },
2172
+ ikState: null,
2173
+ recipeIntent: "lineEdit",
2174
+ recipeSeed: seed,
2175
+ // sourceMotion is dropped on the way into the recipe: replay rebinds
2176
+ // it to whatever take it is re-applied to.
2177
+ // (stripSourceMotion keeps only C10's replay keys, so `preview` — when
2178
+ // the object came back from a draft build — cannot leak into a recipe.)
2179
+ recipeLineEdit: stripSourceMotion({ ...lineEdit, sourceMotion: undefined, seed }),
2180
+ recipeLabel: isKo ? `다듬기 · ${lineTrackLabel(appContext.shared.lineTrack)}` : `Refine · ${lineTrackLabel(appContext.shared.lineTrack)}`,
2181
+ });
2182
+ if (!queued) return;
2183
+ appContext.shared.generationPendingRef.current = true;
2184
+ // The pull has left the building. The curve stays (it is the reference
2185
+ // the next edit starts from) but its deformation is released, so the
2186
+ // Generate button goes back to needing a fresh pull instead of inviting
2187
+ // a second identical run while the first is still queued. The undo
2188
+ // stack goes with it: restoring a pull that is already generating would
2189
+ // invite the identical run this reset exists to prevent.
2190
+ //
2191
+ // This also ENDS THE PREVIEW SESSION: the draft comes off the viewport
2192
+ // (the real result will land on it in a couple of seconds, and until it
2193
+ // does the take on screen should be the take that exists) and the seed
2194
+ // is released, so the next pull is a new piece of work with a new roll.
2195
+ appContext.shared.clearLineEdit();
2196
+ }
2197
+
2198
+ function runAllPromptBlocks(commandContext = null) {
2199
+ const characterId = appContext.storeDomain('cast').activeId;
2200
+ return commandContext ? generateMotion({ characterId }, commandContext)
2201
+ : appContext.bus.run('motion.generate', { characterId });
2202
+ }
2203
+
2204
+ // The UI's uncommitted prompt/fresh choice is read synchronously at admission,
2205
+ // just like its seed and pose controls. It never survives into another run.
2206
+ let uiGenerationIntent = null;
2207
+ function runArdy({ promptOverride = ardyPrompt, durationOverride = ardyDuration, promptClipsOverride = [], fresh = false } = {}) {
2208
+ uiGenerationIntent = { prompt: promptOverride, blocks: promptClipsOverride, fresh };
2209
+ try { return appContext.bus.run('motion.generate', { characterId: appContext.storeDomain('cast').activeId, durationSeconds: Math.round(Number(durationOverride)) || ARDY_DURATION_MIN }); }
2210
+ finally { uiGenerationIntent = null; }
2211
+ }
2212
+
2213
+ async function generateMotion(args, commandContext) {
2214
+ if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) throw generationRefusal('TARGET_BUSY', 'A motion generation is already running.');
2215
+ if (appContext.shared.linePreviewUrl) throw generationRefusal('TARGET_NOT_READY', previewBlockingReason(en => en), previewBlockingReason(ko));
2216
+ const character = appContext.storeDomain('cast').read().find(row => row.id === args.characterId);
2217
+ const active = character.id === appContext.shared.loadedLayerCharRef.current;
2218
+ const posing = active && appContext.shared.posing;
2219
+ const rig = posing ? appContext.shared.posedRig() : appContext.shared.rigs[character.id];
2220
+ if (!rig) throw generationRefusal('TARGET_NOT_READY', 'Character not loaded yet', ko('Character not loaded yet', '캐릭터가 아직 로드되지 않았어요'));
2221
+ const take = domain.motionFor(character.id), state = ikStateFor(character.id);
2222
+ const intent = commandContext.origin === 'ui' ? uiGenerationIntent : null;
2223
+ const blocks = (intent?.blocks ?? character.layer.promptClips).filter(clip => clip.text.trim()).sort((a, b) => a.startFrame - b.startFrame);
2224
+ const prompt = intent?.prompt ?? blocks[0]?.text ?? (active ? ardyPrompt : '');
2225
+ let seed;
2226
+ try { seed = resolveSeed(args.seed ?? (active ? ardySeed : ''), ARDY_SEED_MAX); }
2227
+ catch (error) { throw generationRefusal('INVALID_ARGUMENT', error.message); }
2228
+ const input = { character, prompt, blocks, seed, durationSeconds: args.durationSeconds ?? (blocks.length ? Math.max(ARDY_DURATION_MIN, Math.ceil(Math.max(...blocks.map(clip => clip.endFrame)) / TIMELINE_FPS)) : ardyDuration),
2229
+ waypoints: character.layer.waypoints, motion: take, ikKeys: state.keys,
2230
+ startFromPose: ardyStartFromPose, posePlacement: ardyPosePlacement, frame: appContext.shared.tlFrame,
2231
+ preserveStrength, recipe: domain.layer(character.id).takeRecipe, fresh: intent?.fresh ?? false };
2232
+ const plan = buildGenerationRequest(input), resolved = resolveIkRig(rig);
2233
+ const image = appContext.shared.snapshotExportRig(rig);
2234
+ let poses;
2235
+ try {
2236
+ poses = plan.constraintFrames.map(frame => {
2237
+ if (take) applyMotionFrame(rig, take, frame);
2238
+ if (resolved && state.keys.size) ikEvaluate(resolved.chains, state, frame, resolved.fkJoints, take ? IK_CORRECTION_BLEND_FRAMES : 0);
2239
+ return { frame, pose: buildArdyPose({ rig, camRef: appContext.shared.shotCamRef, look: appContext.shared.look,
2240
+ fovDeg: appContext.shared.fovDeg, slate: slateLine(appContext.shared.shot), rigName: posing ? appContext.shared.posingChar?.model ?? character.model : character.model, root: captureArdyRoot(rig) }) };
2241
+ });
2242
+ } finally { appContext.shared.restoreExportRig(image); }
2243
+ const built = buildGenerationRequest({ ...input, poses });
2244
+ for (const warning of built.warnings) appContext.notify(warning);
2245
+ const request = requestMotionGeneration('timeline', take?.url && state.keys.size ? 'edit' : ardyStartFromPose ? 'pose' : 'prompt');
2246
+ commandContext.check();
2247
+ return new Promise((resolve, reject) => {
2248
+ const job = { request, commandContext, commandCompletion: { resolve, reject }, charId: character.id,
2249
+ charIndex: appContext.live.characters.findIndex(row => row.id === character.id), prompt: built.body.prompt,
2250
+ body: built.body, hasBlockEdits: built.hasBlockEdits, committedEditKeys: built.committedEditKeys,
2251
+ rootRotationDeg: built.rootRotationDeg, anchor: { x: character.x, z: character.z }, ikState: built.hasBlockEdits ? state : null,
2252
+ recipeIntent: built.hasBlockEdits ? 'carry' : 'fresh', recipeSeed: seed,
2253
+ recipeLabel: built.hasBlockEdits ? ko('Block fix', '블록 수정') : built.hasPromptSchedule ? ko('Blocks', '블록 생성')
2254
+ : input.fresh ? ko('New', '새로 만들기') : take?.url ? ko('Again', '다시 뽑기') : ko('Generate', '생성') };
2255
+ appContext.shared.generationPendingRef.current = enqueueMotionJob(job) === true;
2256
+ if (!appContext.shared.generationPendingRef.current) reject(generationRefusal('TARGET_NOT_READY', 'The motion backend cannot run this request.'));
2257
+ });
2258
+ }
2259
+
2260
+ /* --------------------- trail drag -> preview -> regen -------------------- */
2261
+ function onTrailDragStart({ grabFrame } = {}) {
2262
+ if (!motion) return;
2263
+ const frame = Math.round(grabFrame ?? appContext.shared.tlFrame);
2264
+ appContext.shared.setTlFrame(frame);
2265
+ // The pre-drag take is both the deformation base (repeated moves re-derive
2266
+ // from it, so deltas never accumulate) and the undo snapshot.
2267
+ appContext.shared.trailBaseMotionRef.current = motion;
2268
+ appContext.storeDomain('motion').beginGesture();
2269
+ }
2270
+
2271
+ /** World drag delta -> clip delta, shedding the character's stature scale
2272
+ * (the trail is drawn scaled by it). */
2273
+ function trailClipDelta(base, delta, track = "hips", grabFrame = appContext.shared.tlFrame) {
2274
+ const statureScale = appContext.shared.activeChar.scale ?? 1;
2275
+ return worldDeltaToTrailClip(base, delta, { track, grabFrame, radiusFrames: trailFalloffFrames, rig: appContext.shared.activeRig, scale: statureScale });
2276
+ }
2277
+
2278
+ /** Per-rAF drag preview. Deliberately React-free: the deformed take lands in
2279
+ * a ref and on the rig directly, so a drag never re-renders the app. The
2280
+ * trail/highlight lines are rewritten in place by MotionTrails itself. */
2281
+ function onTrailDragPreview({ track, grabFrame, delta }) {
2282
+ const base = appContext.shared.trailBaseMotionRef.current;
2283
+ if (!base) return;
2284
+ const deformed = applyTrailFalloffDelta(base, {
2285
+ grabFrame,
2286
+ radiusFrames: trailFalloffFrames,
2287
+ track, clipDelta: trailClipDelta(base, delta, track, grabFrame),
2288
+ });
2289
+ appContext.shared.trailPreviewMotionRef.current = deformed;
2290
+ const rig = appContext.shared.activeRig;
2291
+ if (!rig) return;
2292
+ applyMotionFrame(rig, deformed, Math.round(grabFrame));
2293
+ if (ikChains && appContext.shared.ikStateRef.current.keys.size > 0) {
2294
+ ikEvaluate(ikChains, appContext.shared.ikStateRef.current, Math.round(grabFrame), ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
2295
+ }
2296
+ }
2297
+
2298
+ function onTrailDragEnd({ track, grabFrame, delta }) {
2299
+ const base = appContext.shared.trailBaseMotionRef.current;
2300
+ const deformed = appContext.shared.trailPreviewMotionRef.current;
2301
+ appContext.shared.trailBaseMotionRef.current = null;
2302
+ appContext.shared.trailPreviewMotionRef.current = null;
2303
+ const size = delta ? Math.hypot(delta.x, delta.y, delta.z) : 0;
2304
+ if (!base || !deformed || size < 0.01) {
2305
+ // A sub-centimetre nudge is a mis-grab, not an authored edit: the
2306
+ // motion state never changed, so only the rig pose needs restoring.
2307
+ if (base && appContext.shared.activeRig) {
2308
+ applyMotionFrame(appContext.shared.activeRig, base, appContext.shared.tlFrame);
2309
+ if (ikChains && appContext.shared.ikStateRef.current.keys.size > 0) {
2310
+ ikEvaluate(ikChains, appContext.shared.ikStateRef.current, appContext.shared.tlFrame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
2311
+ }
2312
+ }
2313
+ setTrailEdit(null);
2314
+ return;
2315
+ }
2316
+ const owned = appContext.storeDomain('motion');
2317
+ if (base !== owned.motionFor(appContext.shared.activeChar.id)) { owned.project(); throw new StudioProtocolError('STALE_TARGET', 'The take changed during the trail drag.'); }
2318
+ owned.run('motion.editTrail', { characterId: appContext.shared.activeChar.id, track, grabFrame, radiusFrames: trailFalloffFrames, delta });
2319
+ owned.finishGesture();
2320
+ setTrailEdit({ track, grabFrame, radiusFrames: trailFalloffFrames, clipDelta: trailClipDelta(base, delta, track, grabFrame) });
2321
+ const { startFrame, endFrame } = trailEditRange(base.frames, grabFrame, trailFalloffFrames);
2322
+ const conflictFrames = findAbsoluteIkKeyConflicts({ keys: appContext.shared.ikStateRef.current.keys, track, startFrame, endFrame });
2323
+ if (conflictFrames.length) appContext.notify(`${ko("Absolute IK keys in this range override the path edit — remove or re-key them", "이 구간의 절대 IK 키가 궤적 수정을 덮어써요 — 해당 키를 지우거나 다시 찍어주세요")} ${isKo ? `프레임 ${conflictFrames.join(", ")}` : `frames ${conflictFrames.join(", ")}`}`);
2324
+ }
2325
+
2326
+ /** Send the pending trail edit through the existing motionEdit pipeline:
2327
+ * the regen window is auto-derived from the grab + falloff, explicit IK
2328
+ * keys inside the window ride as hard constraints (their tracks), and the
2329
+ * deformed line contributes the grab-frame pose as a root guide. */
2330
+ function runTrailRegeneration() {
2331
+ if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) return;
2332
+ const request = requestMotionGeneration("trail", "edit", { motionEdit: true });
2333
+ if (!trailEdit) return;
2334
+ // Same rule as runArdy: motionEdit rewrites a span of THE take, and a
2335
+ // draft on the viewport is not it.
2336
+ if (appContext.shared.linePreviewUrl) {
2337
+ appContext.notify(previewBlockingReason());
2338
+ return;
2339
+ }
2340
+ if (!motion?.url) {
2341
+ appContext.notify(ko("The current motion has no bridge source; generate the prompt blocks once before regenerating a trail edit", "현재 모션에 브리지 원본이 없어요. 궤적 수정을 재생성하려면 프롬프트 블록을 먼저 한 번 생성하세요"));
2342
+ return;
2343
+ }
2344
+ const rig = appContext.shared.activeRig;
2345
+ if (!rig) {
2346
+ appContext.notify(ko("Character not loaded yet", "캐릭터가 아직 로드되지 않았어요"));
2347
+ return;
2348
+ }
2349
+ const { startFrame, endFrame } = trailEditRange(motion.frames, trailEdit.grabFrame, trailEdit.radiusFrames);
2350
+ const frames = [...new Set([
2351
+ trailEdit.grabFrame,
2352
+ ...appContext.shared.ikFrames.filter((frame) => frame >= startFrame && frame < endFrame),
2353
+ ])].sort((a, b) => a - b);
2354
+ const currentFrame = appContext.shared.tlFrame;
2355
+ const entries = [];
2356
+ for (const frame of frames) {
2357
+ applyMotionFrame(rig, motion, frame);
2358
+ if (ikChains && appContext.shared.ikStateRef.current.keys.size > 0) {
2359
+ ikEvaluate(ikChains, appContext.shared.ikStateRef.current, frame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
2360
+ }
2361
+ const pose = buildArdyPose({
2362
+ rig,
2363
+ camRef: appContext.shared.shotCamRef,
2364
+ look: appContext.shared.look,
2365
+ fovDeg: appContext.shared.fovDeg,
2366
+ slate: slateLine(appContext.shared.shot),
2367
+ rigName: appContext.shared.activeChar.model,
2368
+ root: captureArdyRoot(rig),
2369
+ });
2370
+ const wireFrame = toArdyFrame(frame);
2371
+ if (entries.length && wireFrame <= entries[entries.length - 1].frame) continue;
2372
+ const ikTracks = [...(appContext.shared.ikStateRef.current.keys.get(frame)?.keys() || [])];
2373
+ if (frame === trailEdit.grabFrame && trailEdit.track && !ikTracks.includes(trailEdit.track)) ikTracks.push(trailEdit.track);
2374
+ entries.push({ frame: wireFrame, timelineFrame: frame, tracks: ikTracks.length ? ikTracks : ["hips"], pose });
2375
+ }
2376
+ applyMotionFrame(rig, motion, currentFrame);
2377
+ if (ikChains && appContext.shared.ikStateRef.current.keys.size > 0) {
2378
+ ikEvaluate(ikChains, appContext.shared.ikStateRef.current, currentFrame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
2379
+ }
2380
+ const prompt = (motion.prompt || "").trim() || "A person continues the motion naturally.";
2381
+ const body = {
2382
+ prompt,
2383
+ duration: motion.frames / TIMELINE_FPS,
2384
+ posePin: true,
2385
+ motionEdit: {
2386
+ sourceMotion: motion.url,
2387
+ startFrame: toArdyFrame(startFrame),
2388
+ endFrame: toArdyFrame(endFrame),
2389
+ contextBefore: 40,
2390
+ contextAfter: 20,
2391
+ edits: entries.map(({ frame, tracks, pose }) => ({ frame, tracks, pose })),
2392
+ },
2393
+ };
2394
+ // THE SEED RULE (C9) — a trail regeneration writes a new take too, so its
2395
+ // seed is rolled, sent and recorded like every other take-creating run.
2396
+ const seed = takeSeed();
2397
+ if (seed === null) return;
2398
+ body.seed = seed;
2399
+ const queued = enqueueMotionJob({
2400
+ request,
2401
+ charId: appContext.shared.activeChar.id,
2402
+ charIndex: appContext.shared.activeCharIndex,
2403
+ prompt,
2404
+ body,
2405
+ hasBlockEdits: true,
2406
+ committedEditKeys: entries.map(({ timelineFrame, tracks }) => ({ frame: timelineFrame, tracks })),
2407
+ rootRotationDeg: motion.rotationDeg ?? appContext.shared.activeChar.rot,
2408
+ anchor: { x: motion.anchorX ?? appContext.shared.activeChar.x, z: motion.anchorZ ?? appContext.shared.activeChar.z },
2409
+ ikState: appContext.shared.ikStateRef.current,
2410
+ // motionEdit rewrites a span of the loaded take; the recipe travels
2411
+ // forward unchanged because there is no recipe field that could
2412
+ // describe the splice (C10 excludes motionEdit from replay outright).
2413
+ recipeIntent: "carry",
2414
+ recipeSeed: seed,
2415
+ recipeLabel: ko("Trail fix", "궤적 수정"),
2416
+ });
2417
+ if (!queued) return;
2418
+ appContext.shared.generationPendingRef.current = true;
2419
+ setTrailEdit(null);
2420
+ }
2421
+
2422
+ /* ------------------------- motion job queue ---------------------------
2423
+ * One box, one job at a time: explicit entry points suppress duplicate
2424
+ * requests through queueing and execution. The
2425
+ * payload is frozen at enqueue time; completion delivers the clip to the
2426
+ * REQUESTING character's layer, not whoever happens to be selected then. */
2427
+ const [genQueue, setGenQueue] = useState([]);
2428
+
2429
+ function enqueueMotionJob(spec) {
2430
+ const options = { body: spec.body, lineEditSupported: lineEditBackend };
2431
+ spec.request.preflight(bridge, options);
2432
+ if (motionPreflightReason(bridge, options)) return;
2433
+ const id = `gen-${++appContext.shared.genJobSeq.current}`;
2434
+ setGenQueue((queue) => [...queue, { id, status: "queued", ...spec }]);
2435
+ appContext.notify((isKo, ko) => isKo ? `인물 ${spec.charIndex + 1} 모션 생성을 대기열에 넣었어요` : `Queued motion generation for Subject ${spec.charIndex + 1}`);
2436
+ return true;
2437
+ }
2438
+
2439
+ async function executeMotionJob(job) {
2440
+ job.commandContext?.check();
2441
+ const controller = new AbortController();
2442
+ const abort = () => controller.abort(job.commandContext.signal.reason);
2443
+ job.commandContext?.signal.addEventListener("abort", abort, { once: true });
2444
+ appContext.shared.ardyAbortRef.current = controller;
2445
+ setArdyRunning(true);
2446
+ reportArdyStatus(ko("connecting…", "연결 중…"));
2447
+ setArdyReport(null);
2448
+ setArdyOutcome(null);
2449
+ // Replay notices belong to ONE run; the next run re-earns them.
2450
+ setReplayNotices([]);
2451
+ const request = job.request;
2452
+ request.start();
2453
+ controller.signal.addEventListener("abort", () => request.fail(new DOMException("", "AbortError")), { once: true });
2454
+ let editCommitReport = null;
2455
+ try {
2456
+ const done = await ardyGenerate(
2457
+ job.body,
2458
+ (event) => {
2459
+ if (event.event === "status") reportArdyStatus(event.message);
2460
+ else if (event.event === "report") {
2461
+ setArdyReport(event.report);
2462
+ if (job.hasBlockEdits) editCommitReport = event.report;
2463
+ // C10's per-entry replay report. Only the entries worth
2464
+ // acting on are kept: a refinement that failed outright,
2465
+ // or one whose range straddles an internal block
2466
+ // boundary (where block N+1 was conditioned on N's
2467
+ // PRE-edit tail, so the replay is approximate rather
2468
+ // than exact). Both are non-blocking — the take exists.
2469
+ if (Array.isArray(event.report.replay)) {
2470
+ setReplayNotices(event.report.replay.filter((entry) => entry?.ok === false || entry?.boundaryWarning === true));
2471
+ }
2472
+ }
2473
+ },
2474
+ { signal: controller.signal },
2475
+ );
2476
+ if (
2477
+ job.hasBlockEdits &&
2478
+ (
2479
+ editCommitReport?.commit_verified !== true ||
2480
+ !job.body.motionEdit.edits.every((entry) =>
2481
+ editCommitReport.committed_keys?.includes(entry.frame)
2482
+ )
2483
+ )
2484
+ ) {
2485
+ throw new Error(ko("The generated motion came back without verified authored pose keys", "생성된 모션에 검증된 수동 포즈 키가 없어요"));
2486
+ }
2487
+ job.commandContext?.check();
2488
+ if (!done.motionUrl) throw generationRefusal('TARGET_NOT_READY', 'The generator finished without a motion artifact.');
2489
+ setArdyOutcome({ ok: true, output: done.output, bytes: done.bytes, motionUrl: done.motionUrl, rotationDeg: job.rootRotationDeg });
2490
+ request.succeed();
2491
+ trackActivation("motion");
2492
+ // Fetch and decode the real npz right away; decode errors are shown
2493
+ // in the card, playback is never faked. The clip lands on the
2494
+ // REQUESTING character, not whoever is selected now.
2495
+ if (done.motionUrl) {
2496
+ await deliverMotion(job, done.motionUrl);
2497
+ if (!controller.signal.aborted && appContext.live.characters.some((entry) => entry.id === job.charId)) request.apply();
2498
+ if (!appContext.storeDomain('motion')) commitTakeRecipe(job, done.motionUrl);
2499
+ }
2500
+ if (!appContext.storeDomain('motion') && job.hasBlockEdits && job.ikState) {
2501
+ // Timeline frames, not the wire frames in body.motionEdit.edits:
2502
+ // these light up the IK markers on the production clock.
2503
+ setCommittedIkEdits((current) => [...current, ...job.committedEditKeys]);
2504
+ job.ikState.keys.clear();
2505
+ job.ikState.tracked.clear();
2506
+ job.ikState.plants.clear();
2507
+ setIkTick((value) => value + 1);
2508
+ }
2509
+ appContext.notify((isKo, ko) => isKo ? `인물 ${job.charIndex + 1} 모션 생성됨` : `Motion generated for Subject ${job.charIndex + 1}`);
2510
+ } catch (err) {
2511
+ // Wave-2 gate, second line of defence. The capability preflight
2512
+ // normally stops a line edit before it is sent, but a bridge that
2513
+ // advertises the route and then 400s on the field (a half-landed
2514
+ // wave 2, an older sidecar behind the proxy) must read as "not
2515
+ // connected yet", not as a red generation failure the user could
2516
+ // act on.
2517
+ if (job.body.lineEdit && isLineEditUnsupported(err?.message)) {
2518
+ appContext.notify(ko(
2519
+ "The line-editing backend is not connected yet",
2520
+ "라인 편집 백엔드가 아직 연결 전이에요",
2521
+ ));
2522
+ }
2523
+ setArdyOutcome({
2524
+ ok: false,
2525
+ message: err?.name === "AbortError" ? ko("Cancelled", "취소됨") : err?.message || String(err),
2526
+ });
2527
+ request.fail(err, job.body.lineEdit && isLineEditUnsupported(err?.message) ? "unsupported_route" : undefined);
2528
+ throw err;
2529
+ } finally {
2530
+ setArdyRunning(false);
2531
+ appContext.shared.ardyAbortRef.current = null;
2532
+ job.commandContext?.signal.removeEventListener("abort", abort);
2533
+ }
2534
+ }
2535
+
2536
+ /* ------------------- recipe + version bookkeeping (C9/C12) ----------------
2537
+ * ONE writer for both. Every take that reaches the app came out of a job,
2538
+ * so a job's completion is the only place where "what is this take made of"
2539
+ * can be answered honestly, and the answer is checkpointed next to the
2540
+ * motionUrl in the same breath. Nothing else may write takeRecipeRef except
2541
+ * loadTakeVersion, which restores a checkpoint rather than authoring one. */
2542
+ function commitTakeRecipe(job, motionUrl) {
2543
+ const owned = appContext.storeDomain('motion');
2544
+ const base = owned ? owned.layer(job.charId).takeRecipe : appContext.shared.takeRecipeRef.current;
2545
+ let next = base;
2546
+ if (job.recipeIntent === "fresh") {
2547
+ // A regeneration that carried `replay` produced a take that ALREADY
2548
+ // contains those edits, so they stay on the recipe. Resetting
2549
+ // lineEdits to [] here would make the second regeneration lose what
2550
+ // the first one preserved — the exact failure replay exists to fix.
2551
+ next = freshRecipe({
2552
+ seed: job.recipeSeed,
2553
+ blocks: blocksFromRequest(job.body, ARDY_FPS),
2554
+ lineEdits: job.body.replay ?? [],
2555
+ });
2556
+ } else if (job.recipeIntent === "lineEdit") {
2557
+ // A take imported by url (?motion=, a reload) has no recipe of its own.
2558
+ // The edit's body carries the take's prompt and length, so a
2559
+ // best-effort single block is recorded rather than dropping the
2560
+ // refinement on the floor; only the SEED is a guess, and it is the
2561
+ // one this edit actually ran with. The seedless placeholder recipe an
2562
+ // imported take is given (seedLoadedTake) counts as "no recipe" for
2563
+ // the seed specifically: adopting this edit's seed is what turns it
2564
+ // into something that can replay at all.
2565
+ const seeded = Number.isInteger(base?.seed)
2566
+ ? base
2567
+ : freshRecipe({
2568
+ seed: job.recipeSeed,
2569
+ blocks: base?.blocks?.length ? base.blocks : blocksFromRequest(job.body, ARDY_FPS),
2570
+ lineEdits: base?.lineEdits ?? [],
2571
+ });
2572
+ next = withLineEdit(seeded, job.recipeLineEdit);
2573
+ }
2574
+ if (owned) {
2575
+ owned.writeLayer(job.charId, { takeRecipe: next, takeVersions: pushTakeVersion(owned.layer(job.charId).takeVersions, {
2576
+ motionUrl, recipe: next, savedAt: Date.now(), label: job.recipeLabel ?? '',
2577
+ }, TAKE_VERSIONS_MAX) });
2578
+ return;
2579
+ }
2580
+ appContext.shared.takeRecipeRef.current = next;
2581
+ setTakeRecipe(next);
2582
+ setTakeVersions((list) => pushTakeVersion(list, {
2583
+ motionUrl,
2584
+ recipe: next,
2585
+ savedAt: Date.now(),
2586
+ label: job.recipeLabel ?? "",
2587
+ }, TAKE_VERSIONS_MAX));
2588
+ }
2589
+
2590
+ /* A take can also arrive WITHOUT a job behind it — ?motion=<url>, the shipped
2591
+ * demo clip, a scene reload that re-fetches a stored motionRef. Those takes
2592
+ * used to leave the version strip empty, so the first refinement had nothing
2593
+ * to walk back to and the artist's only checkpoint was the thing they had
2594
+ * just overwritten. They get a v1 like everything else.
2595
+ *
2596
+ * WHAT IS HONESTLY KNOWN is the take's url, its prompt and its length —
2597
+ * NOT its seed, which was rolled on the box in a session nobody here
2598
+ * witnessed. So the placeholder recipe carries `seed: null` and every reader
2599
+ * treats that as "this take cannot be rebuilt": no request may attach it as
2600
+ * C10 `replay` (a replay whose base is a different random take re-applies
2601
+ * refinements to a stranger), and the first real edit adopts its own seed in
2602
+ * commitTakeRecipe. A checkpoint you can return to beats a recipe you can
2603
+ * replay, and this gets the first without pretending to the second. */
2604
+ function seedLoadedTake(url, prompt, frames) {
2605
+ if (appContext.storeDomain('motion')) return;
2606
+ if (!url || appContext.shared.takeRecipeRef.current) return;
2607
+ const recipe = Object.freeze({
2608
+ seed: null,
2609
+ blocks: Object.freeze([Object.freeze({
2610
+ prompt: typeof prompt === "string" ? prompt : "",
2611
+ duration: frames > 0 ? frames / TIMELINE_FPS : 0,
2612
+ })]),
2613
+ lineEdits: Object.freeze([]),
2614
+ });
2615
+ appContext.shared.takeRecipeRef.current = recipe;
2616
+ setTakeRecipe(recipe);
2617
+ setTakeVersions((list) => pushTakeVersion(list, {
2618
+ motionUrl: url,
2619
+ recipe,
2620
+ savedAt: Date.now(),
2621
+ label: ko("Loaded", "불러옴"),
2622
+ }, TAKE_VERSIONS_MAX));
2623
+ }
2624
+
2625
+ /** Click a chip: that motionUrl becomes the active take through the SAME
2626
+ * delivery path a fresh generation result travels, and the recipe saved
2627
+ * beside it becomes the current one. Nothing is truncated — editing from an
2628
+ * old version pushes a NEW version on top, so no click can destroy work. */
2629
+ async function loadTakeVersion(entry) {
2630
+ if (!entry?.motionUrl || motionBusy) return;
2631
+ if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').run('motion.loadVersion', { characterId: appContext.shared.activeChar.id, motionUrl: entry.motionUrl });
2632
+ if (entry.motionUrl === appContext.shared.takeSourceUrl) return;
2633
+ // A pull in hand was authored against the take that is leaving. The
2634
+ // preview is dropped WITHOUT reverting: this call is already loading a
2635
+ // different take, and a revert would race it with a reload of the one
2636
+ // being left behind.
2637
+ appContext.shared.cancelLinePreview({ revert: false });
2638
+ if (appContext.shared.lineEditMode) appContext.shared.clearLineEdit();
2639
+ setReplayNotices([]);
2640
+ appContext.shared.takeRecipeRef.current = entry.recipe ?? null;
2641
+ setTakeRecipe(entry.recipe ?? null);
2642
+ try {
2643
+ await deliverMotion({
2644
+ charId: appContext.shared.activeChar.id,
2645
+ prompt: entry.recipe?.blocks?.[0]?.prompt ?? motion?.prompt ?? "",
2646
+ rootRotationDeg: motion?.rotationDeg ?? appContext.shared.activeChar.rot,
2647
+ anchor: { x: motion?.anchorX ?? appContext.shared.activeChar.x, z: motion?.anchorZ ?? appContext.shared.activeChar.z },
2648
+ calibration: motion?.sceneCalibration ?? null,
2649
+ }, entry.motionUrl);
2650
+ } catch {
2651
+ /* loadMotion already surfaced the decode failure in the panel */
2652
+ }
2653
+ }
2654
+
2655
+ /* ---------------------- the two edit entries (C12) ------------------------
2656
+ * Scene blocks the shot with Kimodo; Refine pulls one joint's path with
2657
+ * ProjFlow. Everything else the pipeline can do is one of those two said
2658
+ * more precisely, and both are reachable from the take itself instead of
2659
+ * from a foldout the artist has to remember to open.
2660
+ *
2661
+ * WHY THE REASONS ARE FUNCTIONS, not toasts. An action the artist cannot
2662
+ * take must say so BEFORE the click, in place, next to the button. A toast
2663
+ * fired after the click teaches nothing: it arrives once, scrolls away, and
2664
+ * leaves the button looking identical to the ones that work. Each reason
2665
+ * below is rendered as a line under its entry AND as data-disabled-reason,
2666
+ * which is also what the CDP surface gate reads. */
2667
+ function selectedMotionReadiness({ fresh = false, clips = appContext.shared.promptClips } = {}) {
2668
+ const authored = clips.filter((clip) => clip.text.trim()).sort((a, b) => a.startFrame - b.startFrame);
2669
+ const prompt = authored[0]?.text.trim() || ardyPrompt.trim();
2670
+ const duration = motion && appContext.shared.ikFrames.length > 0 ? motion.frames / motion.fps
2671
+ : authored.length ? Math.max(ARDY_DURATION_MIN, Math.ceil(Math.max(...authored.map((clip) => clip.endFrame)) / TIMELINE_FPS))
2672
+ : Math.round(Number(ardyDuration)) || ARDY_DURATION_MIN;
2673
+ const clipFrames = duration * TIMELINE_FPS;
2674
+ const segments = buildPromptSchedule(authored, clipFrames, prompt);
2675
+ const hasPromptSchedule = segments.length > 1;
2676
+ const editedSegments = motion?.url && hasPromptSchedule
2677
+ ? segments.filter((segment) => appContext.shared.ikFrames.some((frame) => frame >= segment.startFrame && frame < segment.endFrame))
2678
+ : [];
2679
+ const hasBlockEdits = editedSegments.length > 0;
2680
+ const pinPlan = planPosePin({
2681
+ startFromPose: ardyStartFromPose,
2682
+ poseFrame: posePlacementFrame(ardyPosePlacement, clipFrames, appContext.shared.tlFrame),
2683
+ hasPromptSchedule, hasBlockEdits, waypointMode: appContext.shared.waypointMode, ikFrames: appContext.shared.ikFrames, clipFrames, segments, editedSegments,
2684
+ });
2685
+ // Only the capability-bearing fields are needed here; the queue still
2686
+ // preflights the actual frozen request before any generation HTTP call.
2687
+ const body = { prompt, duration, posePin: pinPlan.pin };
2688
+ if (hasBlockEdits) body.motionEdit = {};
2689
+ else if (hasPromptSchedule) body.segments = toArdySegments(segments);
2690
+ if (appContext.shared.waypointMode) body.waypoints = [{}];
2691
+ const recipe = appContext.shared.takeRecipeRef.current;
2692
+ if (!fresh && !hasBlockEdits && Number.isInteger(recipe?.seed)) body.replay = replayPayload(recipe);
2693
+ if (!fresh && !hasBlockEdits && !hasPromptSchedule && motion?.url && preserveStrength > 0
2694
+ && Math.abs(motion.frames / TIMELINE_FPS - duration) <= 1 / ARDY_FPS + 1e-9) {
2695
+ const blocks = blocksFromRequest(body, ARDY_FPS);
2696
+ if (recipe?.blocks?.length === blocks.length
2697
+ && recipe.blocks.every((block, index) => block.prompt.trim() === blocks[index].prompt.trim())) body.preserve = {};
2698
+ }
2699
+ return motionReadiness(bridge, { body, lineEditSupported: lineEditBackend });
2700
+ }
2701
+
2702
+ const generationBusy = ardyRunning || genQueue.some((job) => job.status === "queued" || job.status === "running");
2703
+
2704
+ function openMotionSetup(kind = "prompt") {
2705
+ setMotionSetupKind(kind);
2706
+ setMotionSetupReveal((value) => value + 1);
2707
+ }
2708
+
2709
+ function refineDisabledReason() {
2710
+ if (!motion) return ko("No take yet — block a scene first", "아직 테이크가 없어요 — 먼저 장면을 만들어 주세요");
2711
+ if (!motion.url) return ko("This take has no bridge source — generate it once before refining", "이 테이크에는 브리지 원본이 없어요 — 한 번 생성해야 다듬을 수 있어요");
2712
+ return "";
2713
+ }
2714
+
2715
+ function sceneDisabledReason() {
2716
+ if (bridge === null || bridgeChecking) return motionReadinessMessage("loading");
2717
+ if (generationBusy) return ko("A generation is already running", "이미 생성이 돌고 있어요");
2718
+ // NOT a line-edit preview, deliberately. Every other reason here is a
2719
+ // standing capability the entry should be greyed for; a draft on the
2720
+ // viewport lasts a second and a half, and a reason line appearing and
2721
+ // vanishing under the Scene button RESIZES THE TAKE BAR — which shortens
2722
+ // the stage, which changes the camera aspect, which the drift watcher
2723
+ // reads as "the view moved".
2724
+ //
2725
+ // THE TEETH ARE OUT OF THAT TRAP: drift no longer discards anything, so a
2726
+ // take-bar resize now costs at most a flicker of the ghosted paint and the
2727
+ // hint while the aspect settles — it used to kill the pull ~400 ms after
2728
+ // every draft landed. The refusal still lives in runArdy /
2729
+ // runTrailRegeneration rather than here, because a reason line that
2730
+ // appears and vanishes under the pointer is its own small nuisance and
2731
+ // nothing is gained by moving it back.
2732
+ return "";
2733
+ }
2734
+
2735
+ /** The one sentence every take-consuming action says while a draft is up. */
2736
+ function previewBlockingReason(localize = ko) {
2737
+ return localize(
2738
+ "A line-edit preview is on the viewport — press Generate to keep it, or undo (Ctrl/Cmd+Z) to drop it",
2739
+ "라인 편집 미리보기가 떠 있어요 — 생성으로 확정하거나 Ctrl/Cmd+Z로 되돌린 뒤에 쓰세요",
2740
+ );
2741
+ }
2742
+
2743
+ function sceneGenerateDisabledReason() {
2744
+ return sceneDisabledReason()
2745
+ || (ardyPrompt.trim() || appContext.shared.promptClips.some((clip) => clip.text.trim())
2746
+ ? ""
2747
+ : ko("Describe the motion first — press 4 and add a prompt with + on the Prompt lane", "먼저 어떤 동작인지 적어 주세요 — 4를 누르고 프롬프트 레인의 +로 프롬프트를 추가하세요"));
2748
+ }
2749
+
2750
+ /** Taking it AGAIN needs no fresh wording: the loaded take already knows what
2751
+ * it was asked for, so its own prompt is the fallback (the same fallback
2752
+ * runLineEdit uses). What it does need is a take to re-take. */
2753
+ function sceneAgainPrompt() {
2754
+ return ardyPrompt.trim() || (motion?.prompt ?? "").trim();
2755
+ }
2756
+
2757
+ function sceneAgainDisabledReason() {
2758
+ return sceneDisabledReason()
2759
+ || (motion?.url ? "" : ko("Nothing to redo yet — make a take first", "다시 뽑을 테이크가 없어요 — 먼저 한 번 만들어 주세요"))
2760
+ || (sceneAgainPrompt() || appContext.shared.promptClips.some((clip) => clip.text.trim())
2761
+ ? ""
2762
+ : ko("This take carries no prompt — add a block and describe it", "이 테이크에는 프롬프트가 없어요 — 블록을 추가하고 동작을 적어 주세요"));
2763
+ }
2764
+
2765
+ /** ONE CLICK from a loaded take into drag mode. The pull itself is authored
2766
+ * on the viewport, but its controls live under the character's Inspector, so
2767
+ * selecting that character and revealing the panel happen HERE rather than
2768
+ * being three clicks the artist has to find first. */
2769
+ function enterRefineMode() {
2770
+ const reason = refineDisabledReason();
2771
+ if (reason) {
2772
+ appContext.notify(reason);
2773
+ return;
2774
+ }
2775
+ setSceneMenuOpen(false);
2776
+ appContext.shared.selectActiveCharacterInHierarchy();
2777
+ appContext.shared.revealPromptBlocks();
2778
+ appContext.shared.toggleLineEditMode();
2779
+ }
2780
+
2781
+ /** Take it again — same blocks, same lineage, refinements replayed. Authored
2782
+ * blocks go through the batch path so the schedule survives; a single-prompt
2783
+ * take goes straight through runArdy. */
2784
+ function runSceneAgain() {
2785
+ if (appContext.shared.promptClips.some((clip) => clip.text.trim())) runAllPromptBlocks();
2786
+ else runArdy({ promptOverride: sceneAgainPrompt() });
2787
+ }
2788
+
2789
+ /** Add a block — the timeline's own add-block gesture, said as a button. */
2790
+ function addSceneBlock() {
2791
+ appContext.shared.addPromptClip(appContext.shared.tlFrame);
2792
+ appContext.shared.selectActiveCharacterInHierarchy();
2793
+ appContext.shared.revealPromptBlocks();
2794
+ }
2795
+
2796
+ /** Hand a finished clip to the layer that asked for it: the buffer when
2797
+ * the requester is still active, its stored session motion otherwise. A
2798
+ * lightweight motionRef is persisted with the entry either way, so the
2799
+ * clip can be re-fetched after a reload. */
2800
+ async function deliverMotion(job, motionUrl) {
2801
+ const calibration = job.calibration ?? job.sceneCalibration ?? null;
2802
+ const normalizedCalibration = normalizeMotionCalibration(calibration);
2803
+ const sceneAnchorX = job.anchor.x + normalizedCalibration.offsetX;
2804
+ const sceneAnchorZ = job.anchor.z + normalizedCalibration.offsetZ;
2805
+ const sceneRotationDeg = job.rootRotationDeg + normalizedCalibration.yawDeg;
2806
+ const motionRef = {
2807
+ url: motionUrl,
2808
+ prompt: job.prompt,
2809
+ rotationDeg: sceneRotationDeg,
2810
+ anchorX: sceneAnchorX,
2811
+ anchorZ: sceneAnchorZ,
2812
+ };
2813
+ if (calibration && typeof calibration === "object") motionRef.calibration = normalizedCalibration;
2814
+ if (!appContext.storeDomain('motion') && !job.commandContext) appContext.shared.publishStudioCharacters((list) => list.map((entry) => entry.id === job.charId ? { ...entry, motionRef } : entry));
2815
+ if (job.charId === appContext.shared.loadedLayerCharRef.current) {
2816
+ await loadMotion(motionUrl, job.prompt, job.rootRotationDeg, null, job.charId, null, { calibration, commandContext: job.commandContext, job });
2817
+ if (!appContext.storeDomain('motion') && job.commandContext) appContext.shared.publishStudioCharacters(appContext.live.characters.map(entry => entry.id === job.charId ? { ...entry, motionRef } : entry));
2818
+ return;
2819
+ }
2820
+ // Inbound boundary for a clip delivered to a non-active layer.
2821
+ const retimed = retimeMotion(await loadMotionFromUrl(motionUrl), TIMELINE_FPS);
2822
+ if (retimed.sourceBytes) retimed.motionId = await sha256Hex(retimed.sourceBytes);
2823
+ const decoded = applyMotionCalibration(retimed, { ...normalizedCalibration, yawDeg: 0, offsetX: 0, offsetZ: 0 }).motion;
2824
+ const clip = {
2825
+ ...decoded,
2826
+ url: motionUrl,
2827
+ prompt: job.prompt,
2828
+ anchorX: sceneAnchorX,
2829
+ anchorZ: sceneAnchorZ,
2830
+ anchorFrame: 0,
2831
+ rotationDeg: sceneRotationDeg,
2832
+ sceneCalibration: normalizedCalibration,
2833
+ editSegments: createMotionEdit(decoded.frames),
2834
+ };
2835
+ if (calibration && typeof calibration === "object") clip.sceneCalibration = normalizedCalibration;
2836
+ // Same stature rule as loadMotion, on the layer that asked for the clip.
2837
+ const scale = characterScaleFor(decoded);
2838
+ const apply = () => {
2839
+ if (appContext.storeDomain('motion')) return commitLoadedTake(job.charId, clip, { job });
2840
+ appContext.shared.motionFullRef.current.set(job.charId, clip);
2841
+ const next = appContext.live.characters.map(entry => entry.id === job.charId ? { ...entry, scale, sessionMotion: clip, motionRef } : entry);
2842
+ appContext.shared.publishStudioCharacters(next);
2843
+ };
2844
+ if (job.commandContext) job.commandContext.commit(apply);
2845
+ else if (appContext.storeDomain('motion')) appContext.storeDomain('motion').runPrepared(job.charId, apply);
2846
+ else apply();
2847
+ }
2848
+
2849
+ /** After a scene (re)load, re-fetch every persisted clip reference and
2850
+ * rebuild the session motions. The bridge may be gone — failures just
2851
+ * leave the character posed, never an error the user must act on. */
2852
+ async function restoreMotionRefs(list) {
2853
+ const epoch = ++appContext.shared.restoreEpochRef.current;
2854
+ const motions = appContext.shared.projectMotionsRef.current;
2855
+ try { const db = await openMotionDb(); const ids = [...new Set(list.map((entry) => entry.motionRef?.motionId?.toLowerCase()).filter(Boolean))]; const cached = await Promise.all(ids.map((id) => getMotion(db, id))); cached.filter(Boolean).forEach((record) => motions.set(record.motionId.toLowerCase(), record)); db.close(); } catch (error) { console.warn("[cozyclay] could not restore motion cache", error); }
2856
+ for (const entry of list) {
2857
+ const source = resolveMotionSource(entry.motionRef, motions);
2858
+ if (!entry.motionRef) continue;
2859
+ if (source.kind === "missing") {
2860
+ appContext.notify(isKo ? `저장된 모션이 누락되었습니다 (${entry.subject || entry.id})` : `Saved motion is missing for ${entry.subject || entry.id}`);
2861
+ continue;
2862
+ }
2863
+ const load = source.kind === "embedded" ? decodeMotionResource(source.record) : loadMotionFromUrl(source.url);
2864
+ load.then((raw) => {
2865
+ if (epoch !== appContext.shared.restoreEpochRef.current) return;
2866
+ // Inbound boundary: a re-fetched clip is retimed exactly like a
2867
+ // freshly generated one, so a reload cannot resurrect 20 fps frames.
2868
+ const sourceUrl = source.kind === "url" ? source.url : entry.motionRef?.url;
2869
+ const retimed = retimeMotion(raw, TIMELINE_FPS);
2870
+ const normalizedCalibration = normalizeMotionCalibration(entry.motionRef.calibration);
2871
+ const decoded = applyMotionCalibration(retimed, { ...normalizedCalibration, yawDeg: 0, offsetX: 0, offsetZ: 0 }).motion;
2872
+ const clip = {
2873
+ ...decoded,
2874
+ url: sourceUrl,
2875
+ sourceBytes: raw.sourceBytes,
2876
+ prompt: entry.motionRef.prompt,
2877
+ anchorX: entry.motionRef.anchorX,
2878
+ anchorZ: entry.motionRef.anchorZ,
2879
+ anchorFrame: 0,
2880
+ rotationDeg: entry.motionRef.rotationDeg,
2881
+ sceneCalibration: normalizedCalibration,
2882
+ editSegments: createMotionEdit(decoded.frames),
2883
+ };
2884
+ if (entry.motionRef.calibration) clip.sceneCalibration = entry.motionRef.calibration;
2885
+ if (entry.motionRef.trailEdits) Object.assign(clip, restoreTrailEdits(clip, entry.motionRef.trailEdits));
2886
+ if (entry.motionRef.studioTakeId) clip.studioTakeId = entry.motionRef.studioTakeId;
2887
+ appContext.storeDomain('motion').hydrate(entry.id, clip, entry.motionRef);
2888
+ }).catch((error) => {
2889
+ if (epoch !== appContext.shared.restoreEpochRef.current) return;
2890
+ appContext.shared.setProjectManifest((current) => {
2891
+ const id = entry.motionRef?.motionId?.toLowerCase();
2892
+ if (!id || !current?.items?.some((item) => item.kind === "motion" && item.id === id)) return current;
2893
+ const items = current.items.map((item) => item.kind === "motion" && item.id === id
2894
+ ? { ...item, status: "missing", url: undefined }
2895
+ : item);
2896
+ const totals = { embedded: 0, external: 0, missing: 0, bytes: 0 };
2897
+ for (const item of items) {
2898
+ totals[item.status] += 1;
2899
+ if (Number.isFinite(item.bytes)) totals.bytes += item.bytes;
2900
+ }
2901
+ return { items, totals, missing: items.filter((item) => item.status === "missing") };
2902
+ });
2903
+ // A saved take that fails to refetch used to vanish silently — the
2904
+ // user would find a merely posed character and assume their motion
2905
+ // was lost. Name it and offer the reload path.
2906
+ const subject = entry.subject || entry.id;
2907
+ appContext.notify(isKo
2908
+ ? `저장된 모션을 다시 불러오지 못했어요 (${subject}) [${error?.code || "decode"}]`
2909
+ : `Saved motion could not be restored for ${subject} [${error?.code || "decode"}]`);
2910
+ });
2911
+ }
2912
+ }
2913
+
2914
+ function cancelArdy() {
2915
+ appContext.shared.ardyAbortRef.current?.abort();
2916
+ }
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 })); }
3155
+ domain.setVideoDraft = patch => setI2vMotion(current => ({ ...current, ...patch }));
3156
+ domain.requestLineEdit = runLineEdit;
3157
+ domain.requestTrailRegeneration = runTrailRegeneration;
3158
+ domain.generate = generateMotion;
3159
+ domain.isGenerating = () => appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning;
3160
+ domain.onPhysicsRunning = setAutoPhysicsRunning;
3161
+ domain.onPhysicsProgress = setPhysicsProgress;
3162
+ domain.onPhysicsPreview = result => { setPhysicsPreview(result); setPhysicsShow(true); setIkTick(value => value + 1); };
3163
+ domain.platformFitRun = platformFitRun;
3164
+ domain.platformFitRemove = platformFitRemove;
3165
+ domain.beginPlayback = id => { const rig = appContext.shared.rigs[id]; if (rig) beginPlaybackOn(rig); };
3166
+ domain.loadRemote = async (args, context) => {
3167
+ const entry = domain.layer(args.characterId).takeVersions.find(version => version.motionUrl === args.motionUrl);
3168
+ if (args.motionUrl && !entry) throw new StudioProtocolError('TARGET_NOT_READY', 'This take version is no longer available.');
3169
+ if (args.drop != null && !normalizeRootDrop(args.drop)) throw new StudioProtocolError('INVALID_ARGUMENT', 'Invalid drop.');
3170
+ const toTimeline = frame => Math.round(frame * TIMELINE_FPS / ARDY_FPS);
3171
+ const clips = args.blocks?.length ? args.blocks.map(block => {
3172
+ if (block.endFrame <= block.startFrame) throw new StudioProtocolError('INVALID_RANGE', 'Prompt blocks must have a positive range.');
3173
+ return { id: crypto.randomUUID(), startFrame: toTimeline(block.startFrame), endFrame: toTimeline(block.endFrame), text: block.prompt };
3174
+ }) : null;
3175
+ const character = appContext.storeDomain('cast').read().find(row => row.id === args.characterId);
3176
+ const commandContext = !clips ? context : { ...context, commit: apply => context.commit(() => {
3177
+ const result = apply();
3178
+ appContext.recordAction('cast', () => appContext.storeDomain('cast').extendTimeline(Math.max(...clips.map(clip => clip.endFrame))), null, true);
3179
+ return result;
3180
+ }) };
3181
+ await loadMotion(args.url ?? args.motionUrl, args.prompt ?? entry?.recipe?.blocks?.map(block => block.prompt).join(' ') ?? domain.motionFor(args.characterId)?.prompt ?? '', character.rot,
3182
+ args.drop ?? null, args.characterId, clips, { commandContext, recipe: entry?.recipe });
3183
+ };
3184
+ appContext.updateActionPorts({ clearMotionNative: clearMotion, setCharacterIkKey, removeCharacterIkKey, clearCharacterIkKeys });
3185
+ return {
3186
+ ...domain,
3187
+ i2vMotion, setI2vMotion, captureI2vStill, enterI2vFraming, markI2vPose, clearI2vPose, clearI2vMotion, restoreI2vCamera, framingDistance, showI2vMotionLock, generateI2vMotion, i2vMotionUnavailable, generateI2vMotionFromUi, updateI2vMotionQuota,
3188
+ ikMode, ikChains, setIkChains, ikFkJoints, setIkFkJoints, ikFocus, setIkFocus, footSnap, setFootSnap,
3189
+ bodyContact, setBodyContact, IK_CORRECTION_BLEND_FRAMES, autoPhysicsRunning, setAutoPhysicsRunning,
3190
+ physicsPreview, setPhysicsPreview, physicsShow, physicsProgress, physicsOptions, setPhysicsOptions,
3191
+ ikTick, setIkTick, committedIkEdits, setCommittedIkEdits, trailFalloffS, setTrailFalloffS, showTrails,
3192
+ setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
3193
+ rangePinApplySpec: (pin) => {
3194
+ domain.run('motion.rangePin.apply', { characterId: appContext.shared.activeChar.id, pin });
3195
+ return { residuals: decodeRangePinResiduals(domain.layer(appContext.shared.activeChar.id).ikPinResiduals).get(pin.id) };
3196
+ },
3197
+ rangePins, rangePinResiduals, rangePinSelection, setRangePinSelection, rangePinPartPick, setRangePinPartPick, rangePinPreview, previewRangePinDraft, applyRangePinDraft, deleteRangePin,
3198
+ setCharacterIkKey, removeCharacterIkKey, clearCharacterIkKeys, bridge, setBridge, bridgeChecking,
3199
+ motionSetupReveal, motionSetupKind, setArdyPrompt, setArdyDuration, ardySeed, preserveStrength,
3200
+ setPreserveStrength, takeRecipe, takeVersions, replayNotices, sceneMenuOpen, setSceneMenuOpen,
3201
+ ardyRunning, ardyStatus, ardyOutcome, lineEditBackend, setLineEditBackend, motion, motionBusy,
3202
+ multiModelUrl, setMultiModelUrl, multiModelSource, setMultiModelSource, multiModelStatus,
3203
+ multiModelStage, multiModelProgress, multiModelFootage, multiModelError, multiModelTake,
3204
+ multiModelExtract, multiModelExtractProgress, multiModelExtractError, advanceFrame, stepFrame,
3205
+ leaveIkMode, beginPlaybackOn, chooseMultiModelFile, pasteMultiModelUrl, useMultiModelUrl, ingestFootage,
3206
+ extractMultiModelMotion, deliverExtraTakes, loadMotion, clearMotion: () => appContext.bus.run('motion.clear', { characterId: appContext.shared.activeChar.id }), clearMotionNative: clearMotion, applyMotionTrim, resetMotionTrim, cutMotionAtPlayhead,
3207
+ changeMotionSegmentSpeed, removeMotionSegmentById, poseOtherCastMembers, toggleIkMode, ikSolve,
3208
+ ikDragEnd, ikAddKeyframe, externalBlockers, runFixCollisions, runFixCollisionsRange,
3209
+ changePhysicsOptions, showPhysicsPreview, cancelPhysicsPreview, applyPhysicsPreview, runAutoPhysics,
3210
+ platformFitRun, platformFitApply, platformFitRemove, platformFitRunning, platformFitLast, platformFitProgress, platformFitApplied,
3211
+ ikDeleteKeyframe, ikApplyPoseAsKey, recheckMotionHealth, changeArdySeed, takeSeed,
3212
+ runLineEdit: () => domain.run('motion.commitLineEdit', { characterId: appContext.shared.activeChar.id }),
3213
+ runAllPromptBlocks, runArdy, onTrailDragStart, onTrailDragPreview, onTrailDragEnd,
3214
+ runTrailRegeneration: () => domain.run('motion.regenerateTrail', { characterId: appContext.shared.activeChar.id }),
3215
+ genQueue, setGenQueue, executeMotionJob, seedLoadedTake, loadTakeVersion, selectedMotionReadiness,
3216
+ generationBusy, openMotionSetup, refineDisabledReason, sceneDisabledReason, sceneGenerateDisabledReason,
3217
+ sceneAgainDisabledReason, enterRefineMode, runSceneAgain, addSceneBlock, restoreMotionRefs, cancelArdy,
3218
+ };
3219
+ }