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
@@ -9,9 +9,23 @@
9
9
  * objects with cloned arrays and never touch the caller's take.
10
10
  */
11
11
 
12
- import { CSKEL27_JOINTS } from "./ardy/cskel27.js";
12
+ import { CSKEL27_JOINTS, CSKEL27_PARENTS } from "./ardy/cskel27.js";
13
+ import { motionJointPositions, motionJointPositionsAt } from "./ardy/playback.js";
14
+ import { renderMotionEdit } from "./ardy/motion-edit.js";
15
+
16
+ /** Metres per rig unit: Character scales the Mixamo centimetre rig by 0.01 * stature. */
17
+ const RIG_UNIT_METRES = 0.01;
13
18
 
14
19
  const JOINTS = CSKEL27_JOINTS.length;
20
+ const jointIndex = (name) => CSKEL27_JOINTS.indexOf(name);
21
+
22
+ /** Limb trail track -> two-bone chain [root, mid, effector] its drag bends. */
23
+ const LIMB_CHAINS = {
24
+ leftHand: ["LeftArm", "LeftForeArm", "LeftHand"],
25
+ rightHand: ["RightArm", "RightForeArm", "RightHand"],
26
+ leftFoot: ["LeftUpLeg", "LeftLeg", "LeftFoot"],
27
+ rightFoot: ["RightUpLeg", "RightLeg", "RightFoot"],
28
+ };
15
29
 
16
30
  /**
17
31
  * The always-drawn trail tracks: root plus every IK chain endpoint and the
@@ -74,17 +88,42 @@ function toWorldXZ(basis, x, z) {
74
88
  ];
75
89
  }
76
90
 
91
+ /** Rendered rig-space samples of one joint, or null when the rig drives no bone for it. */
92
+ function renderedSamples(motion, joint, rig) {
93
+ if (!rig) return null;
94
+ const samples = motionJointPositions(rig, motion);
95
+ return samples && Number.isFinite(samples.positions[joint * 3]) ? samples : null;
96
+ }
97
+
77
98
  /**
78
- * World-space polyline of one cskel27 joint's posed position across the take.
99
+ * World-space polyline of one cskel27 joint across the take.
79
100
  * Returns a flat [x0,y0,z0, x1,y1,z1, ...] array of length frames*3.
80
101
  * `baseY` is the character entry's stage height (roof scenes ride above 0).
102
+ * With `rig`, the points are where playback RENDERS that joint's bone on this
103
+ * rig (rig proportions, prep scale, bind offsets); without it, the clip's
104
+ * own posedJoints (the space pins and the wire use).
81
105
  */
82
- export function jointTrailPoints(motion, jointName = "Hips", { baseY = 0, scale = 1 } = {}) {
106
+ export function jointTrailPoints(motion, jointName = "Hips", { baseY = 0, scale = 1, rig = null } = {}) {
83
107
  if (!motion?.posedJoints || !(motion.frames > 0)) return null;
84
108
  const joint = CSKEL27_JOINTS.indexOf(jointName);
85
109
  if (joint < 0) return null;
86
110
  const basis = anchorBasis(motion);
87
111
  const out = new Float32Array(motion.frames * 3);
112
+ const rendered = renderedSamples(motion, joint, rig);
113
+ if (rendered) {
114
+ // The Character group: anchor position, clip yaw, 0.01 * stature scale.
115
+ // Rig space is already anchor-relative (applyMotionFrame rebases).
116
+ const unit = RIG_UNIT_METRES * scale;
117
+ for (let f = 0; f < motion.frames; f += 1) {
118
+ const po = (f * JOINTS + joint) * 3;
119
+ const x = rendered.positions[po] * unit;
120
+ const z = rendered.positions[po + 2] * unit;
121
+ out[f * 3] = basis.anchorX + x * basis.cos + z * basis.sin;
122
+ out[f * 3 + 1] = baseY + rendered.positions[po + 1] * unit;
123
+ out[f * 3 + 2] = basis.anchorZ - x * basis.sin + z * basis.cos;
124
+ }
125
+ return out;
126
+ }
88
127
  for (let f = 0; f < motion.frames; f += 1) {
89
128
  const po = (f * JOINTS + joint) * 3;
90
129
  const [wx, wz] = toWorldXZ(basis, motion.posedJoints[po], motion.posedJoints[po + 2]);
@@ -152,6 +191,63 @@ export function worldDeltaToClip(motion, delta) {
152
191
  };
153
192
  }
154
193
 
194
+ /**
195
+ * World drag delta on a drawn (rendered) trail -> the clip delta
196
+ * applyTrailFalloffDelta takes, so the RENDERED effector follows the pointer.
197
+ *
198
+ * Playback turns the rig's own bones by the clip's rotations and adds rotated
199
+ * bind offsets, so a clip move is not a rendered move (the y-bot forearm is
200
+ * 27.6 cm against the clip's 23.3; the Mixamo head bone sits near the neck
201
+ * pivot). Rather than model that, keep the clip delta along the pointer (as
202
+ * before) and solve its LENGTH through real playback: the gain that brings
203
+ * the rendered effector closest to the pointer (least squares, so it never
204
+ * overshoots). A full 3D inverse was rejected: limb reach makes some
205
+ * directions unreachable and chasing them sends the clip delta far off.
206
+ * The gain is capped at 1.5: rig/clip limb ratios sit well inside it, and a
207
+ * bone that needs more (the Mixamo head bone rides ~11 cm from the neck
208
+ * pivot, 17 degrees per rendered cm) is a lever the edit cannot drive 1:1
209
+ * without spinning the part. Without a rig: yaw + stature only.
210
+ */
211
+ export function worldDeltaToTrailClip(motion, delta, { track = "hips", grabFrame = 0, radiusFrames = 0, rig = null, scale = 1 } = {}) {
212
+ const stature = Number.isFinite(scale) && scale > 1e-9 ? scale : 1;
213
+ const plain = worldDeltaToClip(motion, { x: delta.x / stature, y: delta.y / stature, z: delta.z / stature });
214
+ const distance = Math.hypot(delta.x, delta.y, delta.z);
215
+ const joint = jointIndex(TRAIL_TRACKS.find((item) => item.id === track)?.joint ?? "");
216
+ const samples = rig && joint >= 0 && motion?.rotMats && distance > 1e-6 ? motionJointPositions(rig, motion) : null;
217
+ const frame = Math.max(0, Math.min((motion?.frames ?? 1) - 1, Math.round(grabFrame) || 0));
218
+ const base = samples?.positions.subarray((frame * JOINTS + joint) * 3, (frame * JOINTS + joint) * 3 + 3);
219
+ if (!base || !Number.isFinite(base[0])) return plain;
220
+ const basis = anchorBasis(motion);
221
+ const unit = RIG_UNIT_METRES * stature;
222
+ const target = [delta.x, delta.y, delta.z];
223
+ const scaled = (gain) => ({ x: plain.x * gain, y: plain.y * gain, z: plain.z * gain });
224
+ // Rendered world displacement of the effector at the grab for a gain.
225
+ const reached = (gain) => {
226
+ const edited = applyTrailFalloffDelta(motion, { track, grabFrame: frame, radiusFrames, clipDelta: scaled(gain) });
227
+ const p = motionJointPositionsAt(rig, edited, frame).subarray(joint * 3, joint * 3 + 3);
228
+ const x = (p[0] - base[0]) * unit;
229
+ const z = (p[2] - base[2]) * unit;
230
+ return [x * basis.cos + z * basis.sin, (p[1] - base[1]) * unit, -x * basis.sin + z * basis.cos];
231
+ };
232
+ const MIN_GAIN = 0.1;
233
+ const MAX_GAIN = 1.5;
234
+ // Locally reached ~ gain * v, so the best gain is gain * (D.r)/(r.r);
235
+ // iterate that (the reach limit bends the curve) and keep the closest.
236
+ let gain = 1;
237
+ let best = { gain: 1, miss: Infinity };
238
+ for (let i = 0; i < 4; i += 1) {
239
+ const r = reached(gain);
240
+ const miss = len(sub(target, r));
241
+ if (miss < best.miss) best = { gain, miss };
242
+ const rr = dot(r, r);
243
+ if (!(rr > 1e-12) || miss < 1e-4) break;
244
+ const next = Math.max(MIN_GAIN, Math.min(MAX_GAIN, (gain * dot(target, r)) / rr));
245
+ if (Math.abs(next - gain) < 1e-4) break;
246
+ gain = next;
247
+ }
248
+ return scaled(best.gain);
249
+ }
250
+
155
251
  /** Smoothstep falloff: 1 at the grab frame, 0 at/beyond the radius. */
156
252
  export function falloffWeight(distanceFrames, radiusFrames) {
157
253
  if (!(radiusFrames > 0)) return distanceFrames === 0 ? 1 : 0;
@@ -172,17 +268,278 @@ export function trailEditRange(frameCount, grabFrame, radiusFrames) {
172
268
  };
173
269
  }
174
270
 
271
+ /* --- small vector / row-major 3x3 helpers for the limb and head solves --- */
272
+
273
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
274
+ const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
275
+ const scale3 = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
276
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
277
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
278
+ const len = (a) => Math.hypot(a[0], a[1], a[2]);
279
+ function unit(a) {
280
+ const l = len(a);
281
+ return l > 1e-9 ? scale3(a, 1 / l) : null;
282
+ }
283
+ function matMul(a, b) {
284
+ const out = new Array(9);
285
+ for (let r = 0; r < 3; r += 1) {
286
+ for (let c = 0; c < 3; c += 1) {
287
+ out[r * 3 + c] = a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c];
288
+ }
289
+ }
290
+ return out;
291
+ }
292
+ const matT = (m) => [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]];
293
+ const matVec = (m, v) => [
294
+ m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
295
+ m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
296
+ m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
297
+ ];
298
+ /** Inverse of a row-major 3x3 (callers keep it well conditioned). */
299
+ function inverse3(m) {
300
+ const [a, b, c, d, e, f, g, h, i] = m;
301
+ const A = e * i - f * h, B = f * g - d * i, C = d * h - e * g;
302
+ const det = a * A + b * B + c * C;
303
+ return [A, c * h - b * i, b * f - c * e, B, a * i - c * g, c * d - a * f, C, b * g - a * h, a * e - b * d].map((v) => v / det);
304
+ }
305
+ /** Shortest-arc rotation taking unit `a` onto unit `b` (Rodrigues). */
306
+ function arcRotation(a, b) {
307
+ const c = dot(a, b);
308
+ if (c < -1 + 1e-9) {
309
+ const axis = unit(cross(a, Math.abs(a[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]));
310
+ const [x, y, z] = axis;
311
+ return [2 * x * x - 1, 2 * x * y, 2 * x * z, 2 * x * y, 2 * y * y - 1, 2 * y * z, 2 * x * z, 2 * y * z, 2 * z * z - 1];
312
+ }
313
+ const [x, y, z] = cross(a, b);
314
+ const k = 1 / (1 + c);
315
+ return [
316
+ 1 - k * (y * y + z * z), -z + k * x * y, y + k * x * z,
317
+ z + k * x * y, 1 - k * (x * x + z * z), -x + k * y * z,
318
+ -y + k * x * z, x + k * y * z, 1 - k * (x * x + y * y),
319
+ ];
320
+ }
321
+
322
+ /** Joint `root` and everything below it in the cskel27 tree. */
323
+ function subtreeOf(root) {
324
+ return CSKEL27_JOINTS.map((_, j) => j).filter((j) => {
325
+ for (let k = j; k !== null; k = CSKEL27_PARENTS[k]) if (k === root) return true;
326
+ return false;
327
+ });
328
+ }
329
+
330
+ /** Per-frame accessors over a take's flat posedJoints / rotMats (locals). */
331
+ function frameAccess(posedJoints, rotMats, f) {
332
+ const globals = new Map();
333
+ const local = (j) => Array.from(rotMats.subarray((f * JOINTS + j) * 9, (f * JOINTS + j) * 9 + 9));
334
+ const global = (j) => {
335
+ if (!globals.has(j)) {
336
+ const parent = CSKEL27_PARENTS[j];
337
+ globals.set(j, parent === null ? local(j) : matMul(global(parent), local(j)));
338
+ }
339
+ return globals.get(j);
340
+ };
341
+ return {
342
+ pos: (j) => {
343
+ const po = (f * JOINTS + j) * 3;
344
+ return [posedJoints[po], posedJoints[po + 1], posedJoints[po + 2]];
345
+ },
346
+ setPos: (j, p) => posedJoints.set(p, (f * JOINTS + j) * 3),
347
+ global,
348
+ /** Write joint j's LOCAL rotation so its global becomes `g` (parent untouched). */
349
+ setGlobal: (j, g, parentGlobal = CSKEL27_PARENTS[j] === null ? null : global(CSKEL27_PARENTS[j])) => {
350
+ rotMats.set(parentGlobal ? matMul(matT(parentGlobal), g) : g, (f * JOINTS + j) * 9);
351
+ },
352
+ };
353
+ }
354
+
355
+ /** One bend-side guide from the source window, never from the dragged target. */
356
+ function limbWindowNormal(motion, [a, b, c], startFrame, endFrame, grabFrame, radiusFrames) {
357
+ let sum = [0, 0, 0];
358
+ for (let f = startFrame; f < endFrame; f += 1) {
359
+ const access = frameAccess(motion.posedJoints, null, f);
360
+ const normal = cross(sub(access.pos(b), access.pos(a)), sub(access.pos(c), access.pos(a)));
361
+ // Area weighting gives nearly straight (ill-conditioned) frames no vote.
362
+ sum = add(sum, scale3(normal, falloffWeight(f - grabFrame, radiusFrames)));
363
+ }
364
+ return unit(sum);
365
+ }
366
+
367
+ /** Beyond reach, the bend plane turns at most this far from the source frame's plane. */
368
+ const OVERREACH_PLANE_LIMIT = Math.PI / 6;
369
+ /** Excess distance, as a fraction of reach, over which that limit eases in. */
370
+ const OVERREACH_EASE = 0.25;
371
+
372
+ /** Rotate unit `from` toward unit `to` by at most `radians`. */
373
+ function limitNormal(from, to, radians) {
374
+ const angle = Math.acos(Math.max(-1, Math.min(1, dot(from, to))));
375
+ if (angle <= radians) return to;
376
+ const tangent = unit(sub(to, scale3(from, dot(from, to))));
377
+ return tangent ? add(scale3(from, Math.cos(radians)), scale3(tangent, Math.sin(radians))) : from;
378
+ }
379
+
380
+ /**
381
+ * Two-bone solve: follow a reachable target exactly, using one window guide.
382
+ * An unreachable target cannot be met anyway, so there the bend plane is
383
+ * held within OVERREACH_PLANE_LIMIT of its pre-drag plane, easing in from the
384
+ * reach boundary so crossing it does not pop.
385
+ */
386
+ function bendLimbFrame(access, [a, b, c], descendants, offset, guide) {
387
+ const A = access.pos(a);
388
+ const B = access.pos(b);
389
+ const C = access.pos(c);
390
+ const l1 = len(sub(B, A));
391
+ const l2 = len(sub(C, B));
392
+ if (len(offset) < 1e-9) return;
393
+ const AB = sub(B, A);
394
+ const AC = sub(C, A);
395
+ const oldNormal = unit(cross(AB, AC));
396
+ let toTarget = sub(add(C, offset), A);
397
+ let dir = unit(toTarget) ?? unit(AC);
398
+ if (!dir) return;
399
+ const reference = guide ?? oldNormal ?? unit(cross(dir, Math.abs(dir[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]));
400
+ let normal = unit(sub(reference, scale3(dir, dot(reference, dir))))
401
+ ?? unit(cross(dir, Math.abs(dir[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]));
402
+ const excess = (len(toTarget) - (l1 + l2)) / ((l1 + l2) * OVERREACH_EASE);
403
+ const turn = oldNormal ? Math.acos(Math.max(-1, Math.min(1, dot(oldNormal, normal)))) : 0;
404
+ if (excess > 0 && turn > OVERREACH_PLANE_LIMIT) {
405
+ const t = Math.min(1, excess);
406
+ const capped = limitNormal(oldNormal, normal, turn - (turn - OVERREACH_PLANE_LIMIT) * t * t * (3 - 2 * t));
407
+ // Aim the straightened limb at the target's projection into that plane.
408
+ const projected = sub(toTarget, scale3(capped, dot(toTarget, capped)));
409
+ if (unit(projected)) {
410
+ normal = capped;
411
+ toTarget = projected;
412
+ dir = unit(projected);
413
+ }
414
+ }
415
+ // Leave a sub-millimetre bend at full reach so its side stays defined.
416
+ const d = Math.min(l1 + l2 - 1e-6, Math.max(Math.abs(l1 - l2) + 1e-6, len(toTarget)));
417
+ if (!(d > 1e-9)) return;
418
+ const along = (l1 * l1 - l2 * l2 + d * d) / (2 * d);
419
+ const h = Math.sqrt(Math.max(0, l1 * l1 - along * along));
420
+ const pole = unit(cross(dir, normal));
421
+ const B2 = add(A, add(scale3(dir, along), scale3(pole, h)));
422
+ const C2 = add(A, scale3(dir, d));
423
+ const move = sub(C2, C);
424
+ for (const j of descendants) access.setPos(j, add(access.pos(j), move));
425
+ access.setPos(b, B2);
426
+ if (!access.rotMats) return;
427
+ // Transport each segment by its shortest arc, not by the bend frame:
428
+ // rotating the frame added axial twist even when the elbow hardly moved.
429
+ const swing = (from, to) => arcRotation(unit(from), unit(to));
430
+ const ga = matMul(swing(AB, sub(B2, A)), access.global(a));
431
+ const gb = matMul(swing(sub(C, B), sub(C2, B2)), access.global(b));
432
+ const gc = access.global(c);
433
+ access.setGlobal(a, ga);
434
+ access.setGlobal(b, gb, ga);
435
+ access.setGlobal(c, gc, gb);
436
+ }
437
+
438
+ /** Head drag for one frame: swing Neck->Head about the neck toward the target. */
439
+ function swingHeadFrame(access, neck, head, subtree, offset) {
440
+ const N = access.pos(neck);
441
+ const H = access.pos(head);
442
+ const from = unit(sub(H, N));
443
+ const to = unit(sub(add(H, offset), N));
444
+ if (!from || !to) return;
445
+ const q = arcRotation(from, to);
446
+ for (const j of subtree) access.setPos(j, add(N, matVec(q, sub(access.pos(j), N))));
447
+ if (access.rotMats) access.setGlobal(neck, matMul(q, access.global(neck)));
448
+ }
449
+
450
+ /** Validate and own the small JSON recipe stored beside the source motion ID. */
451
+ export function normalizeTrailEdits(value) {
452
+ if (value == null) return null;
453
+ const fail = () => { throw new TypeError("Invalid motion trail edit recipe"); };
454
+ if (value.version !== 1 || !Number.isInteger(value.frames) || value.frames < 1
455
+ || !(value.fps > 0) || !Number.isFinite(value.fps)
456
+ || !Array.isArray(value.edits) || value.edits.length > 4096) fail();
457
+ const edits = value.edits.map((edit) => {
458
+ if (!TRAIL_TRACKS.some(({ id }) => id === edit?.track)
459
+ || !Number.isInteger(edit.grabFrame) || edit.grabFrame < 0 || edit.grabFrame >= value.frames
460
+ || !Number.isInteger(edit.radiusFrames) || edit.radiusFrames < 0
461
+ || !["x", "y", "z"].every((axis) => Number.isFinite(edit.clipDelta?.[axis]))) fail();
462
+ return { track: edit.track, grabFrame: edit.grabFrame, radiusFrames: edit.radiusFrames,
463
+ clipDelta: { x: edit.clipDelta.x, y: edit.clipDelta.y, z: edit.clipDelta.z } };
464
+ });
465
+ let segments = null;
466
+ if (value.segments != null) {
467
+ if (!Array.isArray(value.segments) || !value.segments.length || value.segments.length > 4096) fail();
468
+ segments = value.segments.map((segment) => {
469
+ if (typeof segment?.id !== "string" || !Number.isInteger(segment.sourceStart) || segment.sourceStart < 0
470
+ || !Number.isInteger(segment.sourceEnd) || segment.sourceEnd < segment.sourceStart
471
+ || !Number.isFinite(segment.speed) || segment.speed < 0.1 || segment.speed > 4) fail();
472
+ return { id: segment.id, sourceStart: segment.sourceStart, sourceEnd: segment.sourceEnd, speed: segment.speed };
473
+ });
474
+ }
475
+ return { version: 1, frames: value.frames, fps: value.fps, segments, edits };
476
+ }
477
+
478
+ function rememberTrailEdit(source, edited, edit) {
479
+ const previous = source.trailEdits;
480
+ return { ...edited, trailEdits: {
481
+ version: 1, frames: source.frames, fps: source.fps,
482
+ segments: previous?.segments ?? source.editSegments?.map((segment) => ({ ...segment })) ?? null,
483
+ edits: [...(previous?.edits ?? []), { ...edit, clipDelta: { ...edit.clipDelta } }],
484
+ } };
485
+ }
486
+
487
+ /** Rebuild the arrays AND the retarget reference after project/cache decoding. */
488
+ export function restoreTrailEdits(motion, stored) {
489
+ const recipe = normalizeTrailEdits(stored);
490
+ if (!recipe) return motion;
491
+ if (recipe.segments?.some((segment) => segment.sourceEnd >= motion.frames)) {
492
+ throw new RangeError("Motion trail source segment exceeds the take");
493
+ }
494
+ let restored = recipe.segments ? renderMotionEdit(motion, recipe.segments) : motion;
495
+ if (restored.frames !== recipe.frames || restored.fps !== recipe.fps) {
496
+ throw new RangeError("Motion trail recipe does not match the take clock");
497
+ }
498
+ for (const edit of recipe.edits) restored = applyTrailFalloffDelta(restored, edit);
499
+ return restored;
500
+ }
501
+
175
502
  /**
176
- * Deform a take by a clip-space delta centred on `grabFrame`: every frame in
177
- * the falloff window shifts by weight(f) * delta on rootPos AND posedJoints
178
- * (applyMotionFrame skins from posedJoints; rootPos feeds camera/subject
179
- * sampling — both must agree or the preview tears). Returns a new motion.
503
+ * Deform a take by a clip-space delta centred on `grabFrame`, weighted per
504
+ * frame by the smoothstep falloff. `track` is the dragged TRAIL_TRACKS id:
505
+ * - "hips" (default): the whole body shifts by weight(f) * delta on rootPos
506
+ * AND posedJoints (applyMotionFrame skins from posedJoints; rootPos feeds
507
+ * camera/subject sampling — both must agree or the preview tears).
508
+ * - a hand/foot: only that limb bends (bendLimbFrame).
509
+ * - "head": only the head swings about the neck (swingHeadFrame).
510
+ * Limb and head edits rewrite rotMats too, since playback reads rotations.
511
+ * Returns a new motion; the caller's arrays are never written.
180
512
  */
181
- export function applyTrailFalloffDelta(motion, { grabFrame, radiusFrames, clipDelta }) {
513
+ export function applyTrailFalloffDelta(motion, { track = "hips", grabFrame, radiusFrames, clipDelta }) {
182
514
  if (!motion?.posedJoints || !motion?.rootPos || !(motion.frames > 0)) return motion;
183
515
  const { startFrame, endFrame } = trailEditRange(motion.frames, grabFrame, radiusFrames);
184
516
  const rootPos = motion.rootPos.slice();
185
517
  const posedJoints = motion.posedJoints.slice();
518
+ const chain = LIMB_CHAINS[track]?.map(jointIndex);
519
+ if (chain || track === "head") {
520
+ const rotMats = motion.rotMats ? motion.rotMats.slice() : null;
521
+ const neck = jointIndex("Neck");
522
+ const head = jointIndex("Head");
523
+ const moved = subtreeOf(chain ? chain[2] : head).filter((j) => !chain || j !== chain[2]);
524
+ const guide = chain ? limbWindowNormal(motion, chain, startFrame, endFrame, grabFrame, radiusFrames) : null;
525
+ for (let f = startFrame; f < endFrame; f += 1) {
526
+ const w = falloffWeight(f - grabFrame, radiusFrames);
527
+ if (w <= 0) continue;
528
+ const access = { ...frameAccess(posedJoints, rotMats, f), rotMats };
529
+ const offset = [clipDelta.x * w, clipDelta.y * w, clipDelta.z * w];
530
+ if (chain) bendLimbFrame(access, chain, [chain[2], ...moved], offset, guide);
531
+ else swingHeadFrame(access, neck, head, moved, offset);
532
+ }
533
+ const edited = rotMats ? { ...motion, rootPos, posedJoints, rotMats } : { ...motion, rootPos, posedJoints };
534
+ if (chain && rotMats) {
535
+ // One immutable reference, not a chain of preview/drag snapshots.
536
+ edited.trailRetarget = {
537
+ base: motion.trailRetarget?.base ?? motion,
538
+ chains: [...new Set([...(motion.trailRetarget?.chains ?? []), track])],
539
+ };
540
+ }
541
+ return rememberTrailEdit(motion, edited, { track, grabFrame, radiusFrames, clipDelta });
542
+ }
186
543
  for (let f = startFrame; f < endFrame; f += 1) {
187
544
  const w = falloffWeight(f - grabFrame, radiusFrames);
188
545
  if (w <= 0) continue;
@@ -199,7 +556,7 @@ export function applyTrailFalloffDelta(motion, { grabFrame, radiusFrames, clipDe
199
556
  posedJoints[po + 2] += dz;
200
557
  }
201
558
  }
202
- return { ...motion, rootPos, posedJoints };
559
+ return rememberTrailEdit(motion, { ...motion, rootPos, posedJoints }, { track, grabFrame, radiusFrames, clipDelta });
203
560
  }
204
561
 
205
562
  /**
@@ -461,7 +461,13 @@ export default function ObjectGizmo({ object, objects = [], mode = "move", snap
461
461
  * Escape-clears-selection handler; with no drag open it returns without
462
462
  * touching the event, so App still gets its deselect (§7). */
463
463
  const onKeyDown = (event) => {
464
- if ((event.code !== "Escape" && event.key !== "Escape") || !dragRef.current) return;
464
+ if (!dragRef.current) return;
465
+ // Settle before App chooses between owned and native undo histories.
466
+ if ((event.ctrlKey || event.metaKey) && (event.code === "KeyZ" || event.code === "KeyY")) {
467
+ endDrag(true);
468
+ return;
469
+ }
470
+ if (event.code !== "Escape" && event.key !== "Escape") return;
465
471
  endDrag(false);
466
472
  event.preventDefault();
467
473
  event.stopPropagation();
package/src/otio.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // OpenTimelineIO cut-list export. OTIO is JSON, so this module deliberately
2
2
  // has no OTIO dependency: the schema names below are the wire contract.
3
3
 
4
+ import { isEffectivelyHidden } from "./scene-objects.js";
4
5
  import { sampleAt } from "./sample-at.js";
5
6
  import { TIMELINE_FRAME_FPS } from "./scenes.js";
6
7
  import { usedSensorHeightMm } from "./shot.js";
@@ -71,7 +72,7 @@ function blockingAt(scene, frame) {
71
72
  ...(Array.isArray(characters) ? characters : [])
72
73
  .filter((character) => character && character.hidden !== true)
73
74
  .map((character, index) => characterBlocking(scene, character, index, frame)),
74
- ...objects.filter(Boolean).map(objectBlocking),
75
+ ...objects.filter((object) => object && !isEffectivelyHidden(object, objects, Array.isArray(characters) ? characters : [])).map(objectBlocking),
75
76
  ];
76
77
  }
77
78
 
@@ -0,0 +1,60 @@
1
+ import Foldout from "./Foldout.jsx";
2
+ import { ko, isKo } from "../locale.js";
3
+ import { Field } from "../ui.jsx";
4
+ import { VIDEO_MODEL_PRESETS } from "../model-presets.js";
5
+ import { useBus } from "../app-context.js";
6
+
7
+ export default function CameraPanel({ isCameraSelection, shot, moveSequence, cameraKeys, activeShot }) {
8
+ const { run } = useBus();
9
+ return (
10
+ <Foldout hidden={!isCameraSelection} title={ko("Camera", "카메라")}>
11
+ <div className="readout v2-details-readout">
12
+ <span title={ko("camera to subject", "카메라와 피사체 거리")}>{shot.distance.toFixed(2)} m</span>
13
+ <span title={ko("nearest prime on the cropped filmback", "크롭된 필름백 기준 가장 가까운 단렌즈")}>{shot.focalMm} mm</span>
14
+ <span title={ko("angle relative to the subject's eyes", "피사체 눈높이 기준 각도")}>{shot.elevationDeg.toFixed(0)}°</span>
15
+ </div>
16
+ <h3 className="move-head v2-details-subsection-title">{ko("Move keys", "움직임 키")}</h3>
17
+ {moveSequence ? (
18
+ <div className="move-slate v2-details-value-box" title={ko("derived from the keyframings, not chosen from a list", "목록에서 고른 값이 아니라 키프레임에서 계산된 움직임입니다")}>
19
+ {moveSequence.displaySlate} · {moveSequence.spanS}{ko("s", "초")}
20
+ </div>
21
+ ) : (
22
+ <div className="move-slate v2-details-value-box">
23
+ {cameraKeys.length === 1
24
+ ? (isKo ? `프레임 ${cameraKeys[0].frame}부터 고정 샷 — 샷 블록 아래 빈 줄을 클릭해 움직임을 추가하세요` : `locked-off hold from frame ${cameraKeys[0].frame} — click the empty lower strip in a Shot block to add a move`)
25
+ : ko("click a Shot block's lower strip to key the current framing at that frame", "샷 블록 아래 빈 줄을 클릭하면 해당 프레임에 현재 프레이밍을 저장합니다")}
26
+ </div>
27
+ )}
28
+
29
+ <h3 className="move-head v2-details-subsection-title">{ko("Follow cam", "팔로우 카메라")}</h3>
30
+ <p className="camera-editor-pointer">
31
+ {activeShot
32
+ ? ko(`Editing ${activeShot.name} in the timeline camera bar below.`, `아래 타임라인 카메라 바에서 ${activeShot.name}을 편집합니다.`)
33
+ : ko("Select a Shot block below to edit its camera.", "아래에서 샷 블록을 선택하면 카메라를 편집할 수 있습니다.")}
34
+ </p>
35
+
36
+ {/* Which generator this cut is being made FOR. Nothing here
37
+ re-times or re-crops the shot — the timeline simply warns
38
+ when the cut runs past the target's clip length or leaves
39
+ its delivery aspects. */}
40
+ <h3 className="move-head v2-details-subsection-title">{ko("Target model", "타깃 모델")}</h3>
41
+ <p className="inspector-hint v2-details-hint">
42
+ {ko("The timeline flags this shot when the cut runs past the model's clip length or leaves its delivery ratios. Nothing is re-timed or re-cropped.", "컷 길이나 화면 비율이 모델 한계를 벗어나면 타임라인이 표시해줘요. 자동으로 재조정하지는 않습니다.")}
43
+ </p>
44
+ <Field label={ko("Cut for", "맞출 모델")}>
45
+ <select
46
+ data-shot-target-model
47
+ aria-label={ko("Target video model", "타깃 영상 모델")}
48
+ disabled={!activeShot}
49
+ value={activeShot?.targetModel ?? ""}
50
+ onChange={(event) => run('shot.set', { id: activeShot.id, set: { targetModel: event.target.value || null } })}
51
+ >
52
+ <option value="">{ko("None", "없음")}</option>
53
+ {VIDEO_MODEL_PRESETS.map((entry) => (
54
+ <option key={entry.id} value={entry.id}>{entry.name}</option>
55
+ ))}
56
+ </select>
57
+ </Field>
58
+ </Foldout>
59
+ );
60
+ }
@@ -0,0 +1,48 @@
1
+ import Foldout from "./Foldout.jsx";
2
+ import { ko } from "../locale.js";
3
+ import { Vector3Row, Slider } from "../ui.jsx";
4
+ import { useCastTransaction } from '../domains/cast.js';
5
+
6
+ export default function CharacterTransformPanel({ workflowMode, isCharacterSelection, activeChar }) {
7
+ const { run, begin, commit } = useCastTransaction();
8
+ return (
9
+ <Foldout
10
+ key={workflowMode === "motion" ? "placement" : "transform"}
11
+ hidden={!isCharacterSelection}
12
+ defaultOpen={workflowMode === "motion"}
13
+ title={workflowMode === "motion" ? ko("Placement", "배치") : ko("Transform", "변환")}
14
+ >
15
+ {workflowMode === "motion" ? (
16
+ <div className="placement-fields v2-details-panel">
17
+ <p className="inspector-hint">
18
+ {ko("Stage position — does not change the take", "무대 위치 — 테이크는 바꾸지 않습니다")}
19
+ </p>
20
+ <Vector3Row
21
+ label={ko("Position", "위치")}
22
+ fields={[
23
+ { axis: "X", value: activeChar.x, step: 0.05, precision: 2, scrubRange: 5, onChange: (x) => run('character.update', { characterId: activeChar.id, patch: { x } }), onScrubStart: begin, onScrubEnd: commit },
24
+ { axis: "Z", value: activeChar.z, step: 0.05, precision: 2, scrubRange: 5, onChange: (z) => run('character.update', { characterId: activeChar.id, patch: { z } }), onScrubStart: begin, onScrubEnd: commit },
25
+ ]}
26
+ />
27
+ <Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={activeChar.rot ?? 0} unit="°" onChange={(rot) => run('character.update', { characterId: activeChar.id, patch: { rot } })} />
28
+ </div>
29
+ ) : (
30
+ <>
31
+ <p className="inspector-hint">
32
+ {ko("Edit the selected subject's placement, turn and size. Drag the Transform tool in the viewport for direct manipulation.", "선택한 인물의 위치·회전·크기를 편집합니다. 뷰포트의 변환 도구를 드래그해 바로 조작할 수도 있어요.")}
33
+ </p>
34
+ <Vector3Row
35
+ label={ko("Position", "위치")}
36
+ fields={[
37
+ { axis: "X", value: activeChar.x, step: 0.05, precision: 2, scrubRange: 5, onChange: (x) => run('character.update', { characterId: activeChar.id, patch: { x } }), onScrubStart: begin, onScrubEnd: commit },
38
+ { axis: "Y", value: activeChar.y ?? 0, step: 0.05, precision: 2, scrubRange: 5, onChange: (y) => run('character.update', { characterId: activeChar.id, patch: { y } }), onScrubStart: begin, onScrubEnd: commit },
39
+ { axis: "Z", value: activeChar.z, step: 0.05, precision: 2, scrubRange: 5, onChange: (z) => run('character.update', { characterId: activeChar.id, patch: { z } }), onScrubStart: begin, onScrubEnd: commit },
40
+ ]}
41
+ />
42
+ <Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={activeChar.rot ?? 0} unit="°" onChange={(rot) => run('character.update', { characterId: activeChar.id, patch: { rot } })} />
43
+ <Slider compact label={ko("Scale", "크기")} min={0.2} max={3} step={0.05} value={activeChar.scale ?? 1} unit="×" onChange={(scale) => run('character.update', { characterId: activeChar.id, patch: { scale } })} />
44
+ </>
45
+ )}
46
+ </Foldout>
47
+ );
48
+ }
@@ -0,0 +1,40 @@
1
+ import { ko } from "../locale.js";
2
+ import { useStageTransaction } from "../domains/stage.js";
3
+ import Foldout from "./Foldout.jsx";
4
+ import { Field } from "../ui.jsx";
5
+ import ReferenceImageField from "./ReferenceImageField.jsx";
6
+
7
+ export default function EnvironmentPanel(props) {
8
+ const { run, begin, commit } = useStageTransaction();
9
+ return (
10
+ <Foldout hidden={props.selectedHierarchyId !== "environment"} title={ko("Environment", "환경")}>
11
+ <label className="check details-toggle-row">
12
+ <span>{ko("I have an environment sheet", "환경 시트가 있어요")}</span>
13
+ <input type="checkbox" checked={props.hasEnvSheet} onChange={(event) => run("stage.setEnvironment", { hasEnvSheet: event.target.checked })} />
14
+ </label>
15
+ {!props.hasEnvSheet && (
16
+ <Field label={ko("Environment description", "환경 설명")}>
17
+ <input type="text" value={props.environment} onChange={(event) => run("run.update", { txId: begin("stage.setEnvironment"), args: { environment: event.target.value } })} onBlur={commit} />
18
+ </Field>
19
+ )}
20
+ <Field label={ko("Look / style", "룩 / 스타일")}>
21
+ <input type="text" value={props.style} onChange={(event) => run("run.update", { txId: begin("stage.setStyle"), args: { style: event.target.value } })} onBlur={commit} />
22
+ </Field>
23
+ <ReferenceImageField
24
+ label={ko("Environment reference", "환경 참고 이미지")}
25
+ hint={ko(
26
+ "A picture of this location. It travels with every framing capture so a render takes its materials, palette and lighting from the real place.",
27
+ "이 장소의 사진입니다. 모든 프레이밍 캐프처에 함께 실려 재질·색감·조명을 실제 장소에서 가져옵니다.",
28
+ )}
29
+ value={props.environmentImage}
30
+ alt={ko("Environment reference", "환경 참고 이미지")}
31
+ inputProps={{ "data-environment-image-input": "" }}
32
+ onPick={(dataUrl) => {
33
+ run("stage.setEnvironment", { environmentImage: dataUrl });
34
+ props.setToast(ko("Environment reference set", "환경 참고 이미지를 설정했어요"));
35
+ }}
36
+ onClear={() => run("stage.setEnvironment", { environmentImage: null })}
37
+ />
38
+ </Foldout>
39
+ );
40
+ }
@@ -0,0 +1,32 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import "./details.css";
3
+
4
+ export default function Foldout({ title, hidden, defaultOpen = true, openSignal = 0, children }) {
5
+ const [open, setOpen] = useState(defaultOpen);
6
+ const cardRef = useRef(null);
7
+ // A collapsed panel must still be reachable from elsewhere: selecting a
8
+ // prompt block on the timeline has to reveal the panel that edits it, or
9
+ // the click looks like it did nothing. Opening alone is not enough — the
10
+ // panel can sit below the fold of a long Inspector — so it is scrolled into
11
+ // view as well. The signal only ever opens; it never closes a panel.
12
+ useEffect(() => {
13
+ if (openSignal <= 0) return undefined;
14
+ setOpen(true);
15
+ // One frame later: the body has to exist before it can be scrolled to.
16
+ const raf = requestAnimationFrame(() => {
17
+ cardRef.current?.scrollIntoView({ block: "nearest" });
18
+ });
19
+ return () => cancelAnimationFrame(raf);
20
+ }, [openSignal]);
21
+ return (
22
+ <section ref={cardRef} className={"card foldout v2-details-section" + (open ? " open" : "")} hidden={hidden}>
23
+ <h3>
24
+ <button type="button" className="foldout-head v2-details-foldout-head" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
25
+ <span className="foldout-arrow v2-details-foldout-arrow" data-open={open ? "true" : "false"} aria-hidden="true" />
26
+ <span className="foldout-title v2-details-foldout-title">{title}</span>
27
+ </button>
28
+ </h3>
29
+ {open && <div className="foldout-body v2-details-foldout-body">{children}</div>}
30
+ </section>
31
+ );
32
+ }