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,344 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { ko } from "./locale.js";
3
+ import "./range-pin-panel.css";
4
+
5
+ export const RANGE_PIN_TRACKS = Object.freeze(["leftHand", "rightHand", "leftFoot", "rightFoot"]);
6
+
7
+ const PARTS = Object.freeze([
8
+ { id: "leftHand", label: "Left Hand", ko: "왼손", color: "var(--range-pin-hand)" },
9
+ { id: "rightHand", label: "Right Hand", ko: "오른손", color: "var(--range-pin-hand)" },
10
+ { id: "leftFoot", label: "Left Foot", ko: "왼발", color: "var(--range-pin-foot)" },
11
+ { id: "rightFoot", label: "Right Foot", ko: "오른발", color: "var(--range-pin-foot)" },
12
+ ]);
13
+
14
+ const PIN_ID_PREFIX = "pin";
15
+ const DEFAULT_BLEND_SECONDS = 0.25;
16
+ const BLEND_MIN_SECONDS = 0.1;
17
+ const BLEND_MAX_SECONDS = 2;
18
+ const BLEND_STEP_SECONDS = 0.05;
19
+
20
+ function pinId() {
21
+ if (globalThis.crypto?.randomUUID) return `${PIN_ID_PREFIX}-${globalThis.crypto.randomUUID()}`;
22
+ return `${PIN_ID_PREFIX}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
23
+ }
24
+
25
+ function clampFrame(value, frameCount) {
26
+ const max = Math.max(0, Number(frameCount || 1) - 1);
27
+ return Math.max(0, Math.min(max, Math.round(Number(value) || 0)));
28
+ }
29
+
30
+ function partFor(track) {
31
+ return PARTS.find((part) => part.id === track) ?? PARTS[1];
32
+ }
33
+
34
+ function objectName(object) {
35
+ return object?.name || object?.id || ko("Missing object", "없는 오브젝트");
36
+ }
37
+
38
+ function blendFramesForSeconds(seconds, fps) {
39
+ return Math.max(1, Math.round(Number(seconds || DEFAULT_BLEND_SECONDS) * Math.max(1, fps || 24)));
40
+ }
41
+
42
+ function secondsForBlend(blend, fps) {
43
+ return Math.max(BLEND_MIN_SECONDS, Math.min(BLEND_MAX_SECONDS, Number(blend || 1) / Math.max(1, fps || 24)));
44
+ }
45
+
46
+ function freshDraft(frame, fps) {
47
+ return {
48
+ id: pinId(),
49
+ track: "rightHand",
50
+ startFrame: clampFrame(frame, Number.MAX_SAFE_INTEGER),
51
+ endFrame: clampFrame(frame, Number.MAX_SAFE_INTEGER),
52
+ blend: blendFramesForSeconds(DEFAULT_BLEND_SECONDS, fps),
53
+ reach: "body",
54
+ targetKind: "hold",
55
+ objectId: "",
56
+ };
57
+ }
58
+
59
+ function draftFromPin(pin, fps) {
60
+ return {
61
+ id: pin.id,
62
+ track: pin.track,
63
+ startFrame: pin.startFrame,
64
+ endFrame: pin.endFrame,
65
+ blend: pin.blend,
66
+ reach: pin.reach ?? "limb",
67
+ targetKind: pin.target.space === "object" ? "object" : "hold",
68
+ objectId: pin.target.space === "object" ? pin.target.objectId : "",
69
+ };
70
+ }
71
+
72
+ function residualWarning(residuals) {
73
+ const feet = (residuals ?? []).filter((entry) => Number(entry?.feetErrorM) > 0.01).map((entry) => entry.frame);
74
+ const frames = (residuals ?? []).filter((entry) => Number(entry?.errorM) > 0.01 || Number(entry?.feetErrorM) > 0.01).map((entry) => entry.frame);
75
+ if (!frames.length) return null;
76
+ return {
77
+ frames,
78
+ label: ko(
79
+ `Can't reach on ${frames.length} frame${frames.length === 1 ? "" : "s"}`,
80
+ `${frames.length}개 프레임에 닿지 않음`,
81
+ ),
82
+ title: ko(`Residual over 1 cm on frames ${frames.join(", ")}`, `1cm를 넘는 잔여 오차: ${frames.join(", ")}프레임`)
83
+ + (feet.length ? ko(`; feet cannot stay planted on frames ${feet.join(", ")}`, `; 발 위치를 유지할 수 없는 프레임: ${feet.join(", ")}`) : ""),
84
+ };
85
+ }
86
+
87
+ function validationFor(draft, { motion, frameCount, objects }) {
88
+ if (!motion) return ko("Load a motion before creating a pin.", "핀을 만들기 전에 모션을 불러오세요.");
89
+ if (!RANGE_PIN_TRACKS.includes(draft.track)) return ko("Choose a hand or foot.", "손이나 발을 선택하세요.");
90
+ if (!Number.isInteger(Number(draft.startFrame)) || !Number.isInteger(Number(draft.endFrame))) return ko("In and Out must be whole frames.", "In과 Out은 정수 프레임이어야 해요.");
91
+ if (draft.startFrame < 0 || draft.endFrame >= frameCount) return ko(`Use frames 0–${Math.max(0, frameCount - 1)}.`, `${Math.max(0, frameCount - 1)}프레임 안에서 선택하세요.`);
92
+ if (draft.endFrame < draft.startFrame) return ko("Out must be on or after In.", "Out은 In 이후여야 해요.");
93
+ if (draft.targetKind === "object" && !draft.objectId) return ko("Choose a scene object.", "씬 오브젝트를 선택하세요.");
94
+ if (draft.targetKind === "object" && !objects.some((object) => object.id === draft.objectId)) return ko("That scene object is no longer available.", "씬 오브젝트를 찾을 수 없어요.");
95
+ if (!Number.isInteger(Number(draft.blend)) || draft.blend < 1) return ko("Blend must be at least one frame.", "Blend는 한 프레임 이상이어야 해요.");
96
+ return "";
97
+ }
98
+
99
+ function targetLabel(pin, objects) {
100
+ if (pin.target.space === "object") {
101
+ return ko(`Object · ${objectName(objects.find((object) => object.id === pin.target.objectId))}`, `오브젝트 · ${objectName(objects.find((object) => object.id === pin.target.objectId))}`);
102
+ }
103
+ return ko("Hold at In", "In 위치 고정");
104
+ }
105
+
106
+ export function RangePinPanel({
107
+ active = false,
108
+ motion = null,
109
+ frame = 0,
110
+ frameCount = 1,
111
+ fps = 24,
112
+ pins = [],
113
+ residuals = new Map(),
114
+ objects = [],
115
+ selectedPinId = null,
116
+ partPick = null,
117
+ conflictFrames = [],
118
+ overlapError = "",
119
+ onSelectPin,
120
+ onApply,
121
+ onCancel,
122
+ onDelete,
123
+ onPreviewTarget,
124
+ }) {
125
+ const [draft, setDraft] = useState(() => freshDraft(frame, fps));
126
+ const [editingId, setEditingId] = useState(null);
127
+ const [replaceConfirmed, setReplaceConfirmed] = useState(false);
128
+
129
+ const selectedPin = useMemo(() => pins.find((pin) => pin.id === selectedPinId) ?? null, [pins, selectedPinId]);
130
+ const validation = validationFor(draft, { motion, frameCount, objects });
131
+ const selectedObject = objects.find((object) => object.id === draft.objectId) ?? null;
132
+ const needsReplace = conflictFrames.length > 0;
133
+ const canApply = !validation && !overlapError && (!needsReplace || replaceConfirmed);
134
+
135
+ // React may run state updaters during render (and replay them). Keep every
136
+ // updater pure; notify App only after the draft has committed.
137
+ useEffect(() => {
138
+ if (active) onPreviewTarget?.(draft);
139
+ // The parent callback is a render-time command bridge. It is intentionally
140
+ // omitted from the dependencies so a new closure cannot retrigger preview
141
+ // state forever while the draft itself is unchanged.
142
+ }, [active, draft, frame]);
143
+
144
+ const updateDraft = useCallback((patch) => {
145
+ setDraft((current) => ({ ...current, ...patch }));
146
+ setReplaceConfirmed(false);
147
+ }, []);
148
+
149
+ useEffect(() => {
150
+ if (!active) return;
151
+ if (selectedPin) {
152
+ setEditingId(selectedPin.id);
153
+ setReplaceConfirmed(false);
154
+ const next = draftFromPin(selectedPin, fps);
155
+ setDraft(next);
156
+ return;
157
+ }
158
+ if (editingId && !pins.some((pin) => pin.id === editingId)) setEditingId(null);
159
+ if (!editingId) {
160
+ const next = freshDraft(frame, fps);
161
+ setReplaceConfirmed(false);
162
+ setDraft(next);
163
+ }
164
+ }, [active, editingId, fps, pins, selectedPin]);
165
+
166
+ useEffect(() => {
167
+ if (!active || !partPick || !RANGE_PIN_TRACKS.includes(partPick)) return;
168
+ updateDraft({ track: partPick });
169
+ }, [active, partPick, updateDraft]);
170
+
171
+ useEffect(() => {
172
+ if (!active) return undefined;
173
+ const onKeyDown = (event) => {
174
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
175
+ if (event.code === "KeyI") {
176
+ event.preventDefault();
177
+ updateDraft({ startFrame: clampFrame(frame, frameCount) });
178
+ } else if (event.code === "KeyO") {
179
+ event.preventDefault();
180
+ updateDraft({ endFrame: clampFrame(frame, frameCount) });
181
+ }
182
+ };
183
+ window.addEventListener("keydown", onKeyDown);
184
+ return () => window.removeEventListener("keydown", onKeyDown);
185
+ }, [active, frame, frameCount, updateDraft]);
186
+
187
+ const startNew = () => {
188
+ const next = freshDraft(frame, fps);
189
+ setEditingId(null);
190
+ setReplaceConfirmed(false);
191
+ onSelectPin?.(null);
192
+ setDraft(next);
193
+ };
194
+
195
+ const editPin = (pin) => {
196
+ const next = draftFromPin(pin, fps);
197
+ setEditingId(pin.id);
198
+ setReplaceConfirmed(false);
199
+ onSelectPin?.(pin.id);
200
+ setDraft(next);
201
+ };
202
+
203
+ const submit = () => {
204
+ if (!canApply) return;
205
+ onApply?.({ ...draft, id: editingId ?? draft.id, replaceExisting: replaceConfirmed });
206
+ };
207
+
208
+ const setBlendSeconds = (seconds) => updateDraft({ blend: blendFramesForSeconds(seconds, fps) });
209
+ const blendSeconds = secondsForBlend(draft.blend, fps);
210
+ const sortedPins = [...pins].sort((a, b) => a.startFrame - b.startFrame || a.endFrame - b.endFrame || a.id.localeCompare(b.id));
211
+
212
+ return (
213
+ <section className="range-pin-panel" data-testid="range-pin-panel" aria-label={ko("Range pins", "범위 고정") }>
214
+ <div className="range-pin-panel-head">
215
+ <div>
216
+ <strong>{ko("Pin", "고정")}</strong>
217
+ <span>{ko("Hold a hand or foot across a frame range", "프레임 범위 동안 손이나 발을 고정합니다")}</span>
218
+ </div>
219
+ {editingId && <button type="button" className="btn ghost small" onClick={startNew}>{ko("New", "새로 만들기")}</button>}
220
+ </div>
221
+ {!motion && <p className="inspector-hint range-pin-disabled-hint">{ko("Load a motion to enable range pins.", "범위 고정을 사용하려면 모션을 불러오세요.")}</p>}
222
+ <div className="range-pin-form" aria-disabled={!motion}>
223
+ <div className="range-pin-form-label">{ko("Part", "파츠")}</div>
224
+ <div className="range-pin-part-grid" role="radiogroup" aria-label={ko("Pin part", "고정할 파츠")}>
225
+ {PARTS.map((part) => (
226
+ <button
227
+ type="button"
228
+ key={part.id}
229
+ className={"range-pin-part" + (draft.track === part.id ? " selected" : "")}
230
+ style={{ "--range-pin-color": part.color }}
231
+ aria-pressed={draft.track === part.id}
232
+ disabled={!motion}
233
+ onClick={() => updateDraft({ track: part.id })}
234
+ >
235
+ <span className="range-pin-part-dot" aria-hidden="true" />
236
+ {ko(part.label, part.ko)}
237
+ </button>
238
+ ))}
239
+ </div>
240
+
241
+ <div className="range-pin-form-label">{ko("Range", "범위")}</div>
242
+ <div className="range-pin-frame-row">
243
+ <label className="range-pin-frame-field">
244
+ <span>{ko("In", "시작")}</span>
245
+ <input data-testid="range-pin-in" type="number" min="0" max={Math.max(0, frameCount - 1)} step="1" value={draft.startFrame} disabled={!motion} onChange={(event) => updateDraft({ startFrame: clampFrame(event.target.value, frameCount) })} />
246
+ </label>
247
+ <span className="range-pin-frame-dash" aria-hidden="true">–</span>
248
+ <label className="range-pin-frame-field">
249
+ <span>{ko("Out", "끝")}</span>
250
+ <input data-testid="range-pin-out" type="number" min="0" max={Math.max(0, frameCount - 1)} step="1" value={draft.endFrame} disabled={!motion} onChange={(event) => updateDraft({ endFrame: clampFrame(event.target.value, frameCount) })} />
251
+ </label>
252
+ </div>
253
+ <div className="range-pin-frame-actions">
254
+ <button type="button" className="btn ghost small" disabled={!motion} onClick={() => updateDraft({ startFrame: clampFrame(frame, frameCount) })}>{ko("Set In to playhead", "In을 현재 프레임으로")}</button>
255
+ <button type="button" className="btn ghost small" disabled={!motion} onClick={() => updateDraft({ endFrame: clampFrame(frame, frameCount) })}>{ko("Set Out to playhead", "Out을 현재 프레임으로")}</button>
256
+ </div>
257
+ <p className="range-pin-shortcut-hint">{ko("Keyboard: I sets In · O sets Out", "키보드: I는 In · O는 Out")}</p>
258
+
259
+ <div className="range-pin-form-label">{ko("Target", "대상")}</div>
260
+ <div className="range-pin-targets" role="radiogroup" aria-label={ko("Pin target", "고정 대상")}>
261
+ <label className="range-pin-target-option">
262
+ <input type="radio" name="range-pin-target" value="hold" checked={draft.targetKind === "hold"} disabled={!motion} onChange={() => updateDraft({ targetKind: "hold", objectId: "" })} />
263
+ <span>
264
+ <b>{ko("Hold where it is at In", "In 위치에 고정")}</b>
265
+ <small>{ko("Capture the part's world position at the In frame.", "In 프레임의 파츠 월드 위치를 사용합니다.")}</small>
266
+ </span>
267
+ </label>
268
+ <label className="range-pin-target-option">
269
+ <input type="radio" name="range-pin-target" value="object" checked={draft.targetKind === "object"} disabled={!motion || objects.length === 0} onChange={() => updateDraft({ targetKind: "object" })} />
270
+ <span>
271
+ <b>{ko("Stick to object", "오브젝트에 붙이기")}</b>
272
+ <small>{ko("Follow a scene object's transform and travel path.", "씬 오브젝트의 변환과 이동 경로를 따라갑니다.")}</small>
273
+ </span>
274
+ </label>
275
+ </div>
276
+ {objects.length === 0 && <p className="range-pin-empty-hint">{ko("Add a scene object to use an object target.", "오브젝트 대상을 사용하려면 씬에 오브젝트를 추가하세요.")}</p>}
277
+ {draft.targetKind === "object" && objects.length > 0 && (
278
+ <label className="range-pin-object-select">
279
+ <span>{ko("Scene object", "씬 오브젝트")}</span>
280
+ <select data-testid="range-pin-object" value={draft.objectId} disabled={!motion} onChange={(event) => updateDraft({ objectId: event.target.value })}>
281
+ <option value="">{ko("Choose an object", "오브젝트 선택")}</option>
282
+ {objects.map((object) => <option key={object.id} value={object.id}>{objectName(object)}</option>)}
283
+ </select>
284
+ </label>
285
+ )}
286
+ {selectedObject && <p className="range-pin-object-readout">{ko("Following", "따라가는 대상")}: {objectName(selectedObject)}</p>}
287
+
288
+ <label className="range-pin-target-option">
289
+ <input data-testid="range-pin-reach" type="checkbox" checked={draft.reach === "body"} disabled={!motion} onChange={(event) => updateDraft({ reach: event.target.checked ? "body" : "limb" })} />
290
+ <span><b>{ko("Body follows when out of reach", "손이 안 닿으면 몸도 따라가기")}</b></span>
291
+ </label>
292
+
293
+ <div className="range-pin-form-label">{ko("Blend", "블렌드")}</div>
294
+ <div className="range-pin-blend-row">
295
+ <input data-testid="range-pin-blend" type="range" min={BLEND_MIN_SECONDS} max={BLEND_MAX_SECONDS} step={BLEND_STEP_SECONDS} value={blendSeconds} disabled={!motion} onChange={(event) => setBlendSeconds(event.target.value)} />
296
+ <span className="trail-falloff-value">{blendSeconds.toFixed(2)}s ({draft.blend}f)</span>
297
+ </div>
298
+
299
+ {overlapError && <p className="range-pin-validation" role="alert">{overlapError}</p>}
300
+ {validation && <p className="range-pin-validation" role="alert">{validation}</p>}
301
+ {needsReplace && (
302
+ <div className="range-pin-replace-warning" role="alert">
303
+ <span>{ko(`Existing IK keys on ${conflictFrames.length} frame${conflictFrames.length === 1 ? "" : "s"}: ${conflictFrames.join(", ")}`, `기존 IK 키가 ${conflictFrames.length}개 프레임에 있어요: ${conflictFrames.join(", ")}`)}</span>
304
+ <button type="button" className="btn ghost small" onClick={() => setReplaceConfirmed((value) => !value)} aria-pressed={replaceConfirmed}>
305
+ {replaceConfirmed ? ko("Replace confirmed", "교체 확인됨") : ko("Replace existing keys", "기존 키 교체")}
306
+ </button>
307
+ </div>
308
+ )}
309
+ <div className="range-pin-actions">
310
+ <button type="button" className="btn ghost" disabled={!motion} onClick={onCancel}>{ko("Cancel", "취소")}</button>
311
+ <button type="button" className="btn primary" data-testid="range-pin-apply" disabled={!canApply} onClick={submit}>{editingId ? ko("Update pin", "고정 업데이트") : ko("Apply pin", "고정 적용")}</button>
312
+ </div>
313
+ </div>
314
+
315
+ <div className="range-pin-list-head">
316
+ <span>{ko("Pins", "고정 목록")}</span>
317
+ <small>{sortedPins.length}</small>
318
+ </div>
319
+ {sortedPins.length === 0 ? (
320
+ <p className="range-pin-empty-hint">{ko("No pins yet. Choose a part and apply a range.", "아직 고정이 없어요. 파츠와 범위를 정한 뒤 적용하세요.")}</p>
321
+ ) : (
322
+ <div className="range-pin-list">
323
+ {sortedPins.map((pin) => {
324
+ const part = partFor(pin.track);
325
+ const warning = residualWarning(residuals instanceof Map ? residuals.get(pin.id) : residuals?.[pin.id]);
326
+ const missingObject = pin.target.space === "object" && !objects.some((object) => object.id === pin.target.objectId);
327
+ return (
328
+ <div key={pin.id} className={"range-pin-item" + (selectedPinId === pin.id ? " selected" : "")} style={{ "--range-pin-color": part.color }}>
329
+ <button type="button" className="range-pin-item-main" onClick={() => editPin(pin)} aria-pressed={selectedPinId === pin.id}>
330
+ <span className="range-pin-item-top"><span className="range-pin-part-dot" aria-hidden="true" />{ko(part.label, part.ko)} <b>{pin.startFrame}–{pin.endFrame}</b></span>
331
+ <span className="range-pin-item-target">{missingObject ? ko("Missing object target", "오브젝트 대상 없음") : targetLabel(pin, objects)}</span>
332
+ {(warning || missingObject) && <span className="range-pin-warning" title={missingObject ? ko("The referenced object was deleted.", "참조한 오브젝트가 삭제됐어요.") : warning.title}>{missingObject ? ko("Invalid target", "대상 없음") : warning.label}</span>}
333
+ </button>
334
+ <button type="button" className="range-pin-delete" data-testid={`range-pin-delete-${pin.id}`} aria-label={ko(`Delete ${part.label} pin`, `${part.ko} 고정 삭제`)} title={ko("Delete pin", "고정 삭제")} onClick={() => onDelete?.(pin.id)}><span aria-hidden="true">×</span></button>
335
+ </div>
336
+ );
337
+ })}
338
+ </div>
339
+ )}
340
+ </section>
341
+ );
342
+ }
343
+
344
+ export { PARTS as RANGE_PIN_PARTS, partFor as rangePinPartFor, freshDraft as createRangePinDraft };
@@ -3,7 +3,7 @@ import { ko, isKo } from "./locale.js";
3
3
  export default function ResultModal({ result, copied, recordedVideoName, onClose, onCopy, onDownload, downloadDisabled = false, exportFeedback = null }) {
4
4
  const isVideo = result.mode === "video";
5
5
  const motionVideo = result.motion?.videoUrl ?? null;
6
- const isFalMotion = Boolean(motionVideo);
6
+ const isI2vMotion = Boolean(motionVideo);
7
7
  const modelLabel = result.modelLabel ?? (isVideo ? ko("your AI video tool", "AI 영상 도구") : ko("your selected image model", "선택한 이미지 모델"));
8
8
  const hasFrame = Boolean(result.frame);
9
9
 
@@ -42,12 +42,12 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
42
42
  <div className="modal-overlay" onClick={onClose}>
43
43
  <div className="modal result-modal" role="dialog" aria-modal="true" aria-labelledby="result-title" onClick={(event) => event.stopPropagation()}>
44
44
  <div className="modal-head">
45
- <h3 id="result-title">{isFalMotion ? ko("Your motion is ready", "모션이 준비됐어요") : ko("Your shot is ready", "장면이 준비됐어요")}</h3>
45
+ <h3 id="result-title">{isI2vMotion ? ko("Your motion is ready", "모션이 준비됐어요") : ko("Your shot is ready", "장면이 준비됐어요")}</h3>
46
46
  <button type="button" className="x" onClick={onClose} aria-label={ko("Close the result", "결과 닫기")}>
47
47
  ✕
48
48
  </button>
49
49
  </div>
50
- {isFalMotion ? (
50
+ {isI2vMotion ? (
51
51
  <video className="preview result-motion-video" src={motionVideo} controls playsInline preload="metadata" />
52
52
  ) : result.frameB ? (
53
53
  <div className="move-frames">
@@ -69,7 +69,7 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
69
69
  <small>{ko("Camera move made from timeline keyframes", "타임라인 키프레임으로 만든 카메라 움직임")}</small>
70
70
  </div>
71
71
  )}
72
- {isFalMotion && result.motion && <div className="result-motion-meta">
72
+ {isI2vMotion && result.motion && <div className="result-motion-meta">
73
73
  <span>{result.motion.resolution || "480P"}</span>
74
74
  {result.motion.width && result.motion.height && <span>{result.motion.width}×{result.motion.height}</span>}
75
75
  {result.motion.fps && <span>{result.motion.fps} fps</span>}
@@ -90,7 +90,7 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
90
90
  </div>
91
91
 
92
92
  {exportFeedback}
93
- {isFalMotion ? <section className="result-next" aria-labelledby="result-next-title">
93
+ {isI2vMotion ? <section className="result-next" aria-labelledby="result-next-title">
94
94
  <span className="result-next-kicker">{ko("Next · CozyClay motion source", "다음 · CozyClay 모션 소스")}</span>
95
95
  <h4 id="result-next-title">{ko("Use this video for mocap extraction", "이 영상을 모캡 추출에 사용하세요")}</h4>
96
96
  <div className="result-next-intro"><p>{ko("The fixed-camera H3 Max Turbo video is attached to the extraction panel as a motion source. Run GVHMR there, then review the resulting take in the timeline.", "고정 카메라 H3 Max Turbo 영상이 추출 패널의 모션 소스로 연결됐어요. 거기서 GVHMR을 실행한 뒤 타임라인의 테이크를 확인하세요.")}</p></div>
package/src/room.jsx CHANGED
@@ -15,8 +15,9 @@ import * as THREE from "three";
15
15
  * never meets an unbounded surface.
16
16
  */
17
17
 
18
- // Bright enough to sit clearly above the grid lines: the deck reads as a lit
19
- // surface with lines drawn ON it, not as a dark line-texture tiling to the fog.
18
+ // The deck is the grey workbench (the same neutral family as STAGE_BACKGROUND
19
+ // in grid-view.js), a step lighter than the sky so the horizon still reads,
20
+ // with the grid lines drawn ON it in a lighter grey.
20
21
  //
21
22
  // Lit, not unlit. An unlit deck renders one flat colour across its whole 500 m,
22
23
  // which costs an exported blocking frame two depth cues at once: the falloff
@@ -24,60 +25,85 @@ import * as THREE from "three";
24
25
  // subject is standing ON it rather than floating. The colour is lifted to
25
26
  // compensate for the shading the lambert term now applies, so the deck keeps
26
27
  // the same on-screen brightness it had while unlit.
27
- const FLOOR = "#fffdf7";
28
+ const FLOOR = "#5b5d63";
29
+ // The light theme's deck: the cyclorama floor. Tone mapping caps a lit
30
+ // white deck near #d4, a dull grey under a white sky, so it also glows a
31
+ // little (FLOOR_LIGHT_GLOW): it renders about #dedede near the camera and
32
+ // fades into the sky (grid-view.js STAGE_BACKGROUND_LIGHT) with distance.
33
+ // The key's contact shadow still reads, since the glow is only a fifth.
34
+ const FLOOR_LIGHT = "#ffffff";
35
+ const FLOOR_LIGHT_GLOW = 0.2;
28
36
 
29
37
  export const STAGE_SIZE = 500;
30
38
 
31
- /** Metres per grid tile; one heavy line per tile, minor lines every metre. */
39
+ /** Metres between the heavy lines; minor lines every metre. */
32
40
  const TILE_M = 10;
33
41
 
42
+ // Line ink per theme: how far a line darkens the lit deck. The light deck
43
+ // needs as much ink as the dark one to read: its lines are thin dark marks on
44
+ // a bright floor, and anything fainter vanished into it.
45
+ const GRID_INK = Object.freeze({
46
+ dark: { minor: 0.22, major: 0.42 },
47
+ light: { minor: 0.38, major: 0.5 },
48
+ });
49
+
34
50
  /**
35
51
  * The measuring grid, drawn INTO the deck rather than floating above it.
36
52
  *
37
53
  * A GridHelper is a separate object a couple of millimetres over a 500 m
38
54
  * plane, and at that separation it loses to the floor across almost the whole
39
55
  * frame — it survives only where the surface is grazed near the horizon, which
40
- * is exactly where it is useless. Baking the lines into the floor's own map
56
+ * is exactly where it is useless. Drawing the lines in the floor's own shader
41
57
  * ends the contest: the marks ARE the surface, so they cannot z-fight with it,
42
- * cannot be sorted behind it, and shade and fog exactly as the deck does.
58
+ * cannot be sorted behind it, and shade, shadow and fog exactly as the deck
59
+ * does.
60
+ *
61
+ * Computed per pixel from the world position, not baked into a tiled texture:
62
+ * a texture blurred its lines up close and mip-averaged them into a grey
63
+ * moire toward the horizon. Here every line is ~1 px wide at any distance
64
+ * (fwidth), and a line family fades out once its spacing drops under a few
65
+ * pixels, so the far deck is clean floor instead of a band of noise.
43
66
  */
44
- function makeGridTexture() {
45
- const PX = 1024;
46
- const pxPerM = PX / TILE_M;
47
- const canvas = document.createElement("canvas");
48
- canvas.width = PX;
49
- canvas.height = PX;
50
- const ctx = canvas.getContext("2d");
51
- ctx.fillStyle = "#ffffff";
52
- ctx.fillRect(0, 0, PX, PX);
53
- ctx.strokeStyle = "rgba(120, 110, 92, 0.34)";
54
- ctx.lineWidth = 2;
55
- for (let m = 1; m < TILE_M; m += 1) {
56
- const p = Math.round(m * pxPerM) + 0.5;
57
- ctx.beginPath(); ctx.moveTo(p, 0); ctx.lineTo(p, PX); ctx.stroke();
58
- ctx.beginPath(); ctx.moveTo(0, p); ctx.lineTo(PX, p); ctx.stroke();
59
- }
60
- // The ten-metre lines carry the scale read, so they stay heavier.
61
- ctx.strokeStyle = "rgba(96, 86, 68, 0.6)";
62
- ctx.lineWidth = 5;
63
- ctx.strokeRect(0, 0, PX, PX);
64
- const texture = new THREE.CanvasTexture(canvas);
65
- texture.wrapS = THREE.RepeatWrapping;
66
- texture.wrapT = THREE.RepeatWrapping;
67
- texture.repeat.set(STAGE_SIZE / TILE_M, STAGE_SIZE / TILE_M);
68
- texture.anisotropy = 8;
69
- texture.colorSpace = THREE.SRGBColorSpace;
70
- return texture;
67
+ function makeDeckMaterial(light) {
68
+ const ink = light ? GRID_INK.light : GRID_INK.dark;
69
+ const material = new THREE.MeshLambertMaterial({
70
+ color: light ? FLOOR_LIGHT : FLOOR,
71
+ emissive: light ? FLOOR_LIGHT : "#000000",
72
+ emissiveIntensity: light ? FLOOR_LIGHT_GLOW : 0,
73
+ });
74
+ material.onBeforeCompile = (shader) => {
75
+ shader.uniforms.gridInk = { value: new THREE.Vector2(ink.minor, ink.major) };
76
+ shader.vertexShader = shader.vertexShader
77
+ .replace("#include <common>", "#include <common>\nvarying vec2 vGridXZ;")
78
+ .replace("#include <begin_vertex>", "#include <begin_vertex>\nvGridXZ = (modelMatrix * vec4(transformed, 1.0)).xz;");
79
+ shader.fragmentShader = shader.fragmentShader
80
+ .replace("#include <common>", `#include <common>
81
+ varying vec2 vGridXZ;
82
+ uniform vec2 gridInk;
83
+ // 0..1 coverage of the lines every \`spacing\` metres, \`width\` px wide.
84
+ float gridLines(float spacing, float width) {
85
+ vec2 cell = vGridXZ / spacing;
86
+ vec2 perPx = fwidth(cell);
87
+ vec2 dist = abs(fract(cell - 0.5) - 0.5) / max(perPx, vec2(1e-5));
88
+ float line = 1.0 - clamp(min(dist.x, dist.y) - (width - 1.0) * 0.5, 0.0, 1.0);
89
+ // Cells under ~5 px would alias: fade the family out before they do.
90
+ float fade = 1.0 - smoothstep(0.08, 0.2, max(perPx.x, perPx.y));
91
+ return line * fade;
92
+ }`)
93
+ .replace("#include <opaque_fragment>", `outgoingLight *= 1.0 - max(gridLines(1.0, 1.0) * gridInk.x, gridLines(${TILE_M.toFixed(1)}, 1.6) * gridInk.y);
94
+ #include <opaque_fragment>`);
95
+ };
96
+ material.customProgramCacheKey = () => `deck-grid-${light ? "light" : "dark"}`;
97
+ return material;
71
98
  }
72
99
 
73
- export function Room() {
74
- const grid = useMemo(() => makeGridTexture(), []);
75
- useEffect(() => () => grid.dispose(), [grid]);
100
+ export function Room({ light = false }) {
101
+ const material = useMemo(() => makeDeckMaterial(light), [light]);
102
+ useEffect(() => () => material.dispose(), [material]);
76
103
  return (
77
104
  <group>
78
- <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow>
105
+ <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow userData={{ noInk: true }} material={material}>
79
106
  <planeGeometry args={[STAGE_SIZE, STAGE_SIZE]} />
80
- <meshLambertMaterial color={FLOOR} map={grid} />
81
107
  </mesh>
82
108
  </group>
83
109
  );
@@ -87,32 +113,34 @@ export function Room() {
87
113
  * The key is the USER'S light: `keyLight` carries its grabbable position and
88
114
  * the rig's master brightness — fill and rim ride the same dimmer so turning
89
115
  * the key down darkens the whole stage instead of flattening it. */
90
- // Warm/cool slider → light colour. 0.5 is the tuned default (#fff8e8);
91
- // 0 pulls toward cool daylight, 1 toward sunset amber.
116
+ // Warm/cool slider → light colour. 0.5 is the tuned default (neutral white,
117
+ // so the grey set and the grey clay stay grey); 0 pulls toward cool daylight,
118
+ // 1 toward sunset amber.
92
119
  export function keyLightColor(warmth = 0.5) {
93
120
  const w = Math.max(0, Math.min(1, warmth ?? 0.5));
94
- const base = new THREE.Color("#fff8e8");
121
+ const base = new THREE.Color("#ffffff");
95
122
  if (w < 0.5) return base.clone().lerp(new THREE.Color("#e8f0ff"), (0.5 - w) * 2).getStyle();
96
123
  if (w > 0.5) return base.clone().lerp(new THREE.Color("#ffc27a"), (w - 0.5) * 2).getStyle();
97
- return "#fff8e8";
124
+ return "#ffffff";
98
125
  }
99
126
 
100
127
  /**
101
- * `neutral` is the grid-view rig (Blender's workbench studio light idea):
102
- * the clay hemisphere fakes a warm bounce off the ivory deck, and with the
103
- * deck gone that bounce reads as mud against the dark void. Neutral mode
104
- * swaps it for a white sky over a void-dark ground and lifts the ambient so
105
- * authored colours stay saturated without any pretend floor.
128
+ * One neutral rig (Blender's workbench studio light idea): a white sky over
129
+ * a grey ground bounce. `neutral` is the grid view, where the deck is gone
130
+ * and the ambient is lifted so authored colours stay saturated without any
131
+ * pretend floor.
106
132
  */
107
- export function StageLights({ keyLight = { x: 6, y: 9, z: 4, intensity: 1.12, warmth: 0.5 }, neutral = false }) {
133
+ export function StageLights({ keyLight = { x: 6, y: 9, z: 4, intensity: 1.12, warmth: 0.5 }, neutral = false, light = false }) {
108
134
  const dim = keyLight.intensity / 1.12;
109
135
  return (
110
136
  <>
111
137
  <hemisphereLight
112
- args={neutral ? ["#ffffff", "#3a3d42", 0.9] : ["#fffdf6", "#d8d0c3", 0.9]}
138
+ args={neutral ? ["#ffffff", "#3a3d42", 0.9] : ["#ffffff", light ? "#d6d8de" : "#5a5d64", 0.9]}
113
139
  intensity={0.9 * Math.min(1, 0.35 + 0.65 * dim)}
114
140
  />
115
- <ambientLight intensity={(neutral ? 0.34 : 0.18) * Math.min(1, 0.35 + 0.65 * dim)} />
141
+ {/* The light stage is a bright room: more ambient, so the clay's shaded
142
+ side stays soft grey instead of going charcoal against white. */}
143
+ <ambientLight intensity={(neutral ? 0.34 : light ? 0.32 : 0.18) * Math.min(1, 0.35 + 0.65 * dim)} />
116
144
  {/* Only the key casts: one soft, unambiguous contact shadow reads as
117
145
  ground contact, while three overlapping shadows read as noise. The
118
146
  map covers the blocking area rather than the whole 500 m deck — a
@@ -136,7 +164,7 @@ export function StageLights({ keyLight = { x: 6, y: 9, z: 4, intensity: 1.12, wa
136
164
  shadow-bias={-0.0006}
137
165
  shadow-normalBias={0.02}
138
166
  />
139
- <directionalLight color="#dff6f7" position={[-6, 4, -4]} intensity={0.36 * dim} />
167
+ <directionalLight color="#eef2f4" position={[-6, 4, -4]} intensity={0.36 * dim} />
140
168
  <directionalLight color="#ffffff" position={[2, 3, 9]} intensity={0.22 * dim} />
141
169
  </>
142
170
  );