cozyclay 1.10.0 → 2.0.2

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 (275) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/bin/agent/agent-routes.mjs +82 -72
  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/codex-auth.mjs +2 -1
  10. package/bin/cozyclay.mjs +38 -11
  11. package/bin/live/cli.mjs +22 -3
  12. package/bin/mcp-runtime.mjs +45 -20
  13. package/bin/telemetry-state.mjs +5 -2
  14. package/bin/update-check.mjs +15 -0
  15. package/dist/app/index.html +6 -6
  16. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  17. package/dist/assets/app-BxBLC6GT.js +4876 -0
  18. package/dist/assets/app-CJdxvnrl.css +1 -0
  19. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  20. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  21. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  22. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  23. package/dist/assets/shot-prompt-hN0j_O-p.js +17 -0
  24. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  25. package/dist/assets/workflow-B5XZwVfe.js +179 -0
  26. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  27. package/dist/cozyclay-package.json +1 -1
  28. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  29. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  30. package/dist/fonts/README.md +13 -9
  31. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  33. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  35. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  36. package/dist/index.html +7 -7
  37. package/dist/privacy/index.html +13 -8
  38. package/dist/sitemap.xml +2 -2
  39. package/dist/workflow/index.html +5 -5
  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 +219 -376
  44. package/package.json +2 -1
  45. package/src/App.jsx +1275 -9086
  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/fix-collisions.js +10 -66
  51. package/src/ardy/ik-drag.js +101 -0
  52. package/src/ardy/ik-key-json.js +43 -0
  53. package/src/ardy/ik.js +97 -67
  54. package/src/ardy/physics-panel.css +2 -0
  55. package/src/ardy/physics-panel.jsx +17 -5
  56. package/src/ardy/platform-fit-panel.css +64 -0
  57. package/src/ardy/platform-fit-panel.jsx +40 -0
  58. package/src/ardy/platform-fit.js +326 -0
  59. package/src/ardy/playback.js +166 -20
  60. package/src/ardy/range-pin.js +299 -0
  61. package/src/ardy/timeline-coordinates.js +26 -0
  62. package/src/ardy/timeline.css +1214 -0
  63. package/src/ardy/timeline.jsx +152 -41
  64. package/src/ardy/waypoints.js +3 -3
  65. package/src/asset-pane.css +462 -0
  66. package/src/asset-pane.jsx +461 -232
  67. package/src/camera-tutorial.jsx +38 -18
  68. package/src/command-bus.js +349 -0
  69. package/src/commands/ai.js +31 -0
  70. package/src/commands/cast.js +140 -0
  71. package/src/commands/elements/character.js +13 -0
  72. package/src/commands/elements/motion.js +14 -0
  73. package/src/commands/elements/object.js +22 -0
  74. package/src/commands/elements/scene.js +16 -0
  75. package/src/commands/elements/shot.js +9 -0
  76. package/src/commands/elements/stage.js +11 -0
  77. package/src/commands/elements.js +155 -0
  78. package/src/commands/export.js +20 -0
  79. package/src/commands/index.js +40 -0
  80. package/src/commands/motion.js +231 -0
  81. package/src/commands/objects.js +174 -0
  82. package/src/commands/project.js +53 -0
  83. package/src/commands/scene.js +81 -0
  84. package/src/commands/shared.js +19 -0
  85. package/src/commands/shot.js +103 -0
  86. package/src/commands/stage.js +17 -0
  87. package/src/commands/view.js +42 -0
  88. package/src/document-store.js +144 -0
  89. package/src/domains/cast.js +1007 -0
  90. package/src/domains/motion.js +3219 -0
  91. package/src/domains/objects.js +932 -0
  92. package/src/domains/scenes.js +823 -0
  93. package/src/domains/shots.js +404 -0
  94. package/src/domains/stage.js +93 -0
  95. package/src/dualview.jsx +113 -57
  96. package/src/facing-marks.js +3 -0
  97. package/src/first-success-guide.jsx +14 -14
  98. package/src/grid-view.js +18 -5
  99. package/src/hierarchy-panel.css +386 -0
  100. package/src/hierarchy-panel.jsx +88 -19
  101. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  102. package/src/i2v-motion-studio.jsx +180 -0
  103. package/src/ik-camera.js +3 -0
  104. package/src/main.jsx +6 -0
  105. package/src/motion/generation.js +105 -0
  106. package/src/motion-readiness-ui.jsx +4 -4
  107. package/src/motion-readiness.js +11 -0
  108. package/src/motion-trail.js +366 -9
  109. package/src/object-gizmo.jsx +7 -1
  110. package/src/panels/CameraPanel.jsx +60 -0
  111. package/src/panels/CharacterTransformPanel.jsx +48 -0
  112. package/src/panels/EnvironmentPanel.jsx +40 -0
  113. package/src/panels/Foldout.jsx +32 -0
  114. package/src/panels/LightPanel.jsx +24 -0
  115. package/src/panels/ObjectTransformPanel.jsx +554 -0
  116. package/src/panels/PosePanel.jsx +97 -0
  117. package/src/panels/ProjectPanel.jsx +33 -0
  118. package/src/panels/PromptBlocksPanel.jsx +442 -0
  119. package/src/panels/PropsPanel.jsx +70 -0
  120. package/src/panels/ReferenceImageField.jsx +91 -0
  121. package/src/panels/RigControlPanel.jsx +78 -0
  122. package/src/panels/RigPanel.jsx +37 -0
  123. package/src/panels/SubjectBox.jsx +47 -0
  124. package/src/panels/SubjectsPanel.jsx +39 -0
  125. package/src/panels/VideoCapturePanel.jsx +180 -0
  126. package/src/panels/details.css +1171 -0
  127. package/src/panels/motion.css +197 -0
  128. package/src/panels/pose.css +39 -0
  129. package/src/planview.jsx +60 -31
  130. package/src/posestudio.jsx +59 -4
  131. package/src/project-browser.css +1073 -0
  132. package/src/project-browser.jsx +288 -123
  133. package/src/range-pin-object-transform.js +19 -0
  134. package/src/range-pin-panel.css +518 -0
  135. package/src/range-pin-panel.jsx +344 -0
  136. package/src/result-modal.jsx +5 -5
  137. package/src/room.jsx +79 -51
  138. package/src/scenes.js +9 -8
  139. package/src/semantic-edit.js +2 -0
  140. package/src/settings-menu.jsx +39 -105
  141. package/src/shell/BottomDock.jsx +450 -0
  142. package/src/shell/DetailsSlot.jsx +513 -0
  143. package/src/shell/LibrarySlot.jsx +86 -0
  144. package/src/shell/MenuBar.jsx +601 -0
  145. package/src/shell/OutlinerSlot.jsx +50 -0
  146. package/src/shell/PreferencesDialog.jsx +538 -0
  147. package/src/shell/PreferencesSlot.jsx +40 -0
  148. package/src/shell/StatusBar.jsx +125 -0
  149. package/src/shell/StudioShell.jsx +92 -0
  150. package/src/shell/TopBar.jsx +129 -0
  151. package/src/shell/ViewportToolbar.jsx +656 -0
  152. package/src/shell/agent-glass.css +296 -0
  153. package/src/shell/dock.css +93 -0
  154. package/src/shell/glass-regions.css +985 -0
  155. package/src/shell/glass.css +687 -0
  156. package/src/shell/log-store.js +52 -0
  157. package/src/shell/mode.css +9 -0
  158. package/src/shell/preferences.css +651 -0
  159. package/src/shell/shell.css +282 -0
  160. package/src/shell/studio-shell-context.js +19 -0
  161. package/src/shell/topbar.css +435 -0
  162. package/src/shell/viewport.css +451 -0
  163. package/src/store/authored-intent.js +22 -0
  164. package/src/store/runtime-adapters.js +92 -0
  165. package/src/store/scene-stage.js +21 -0
  166. package/src/store/use-document-store.js +14 -0
  167. package/src/studio-actions.js +210 -0
  168. package/src/studio-agent-commands.js +38 -268
  169. package/src/studio-agent-context.js +38 -9
  170. package/src/studio-agent-motion.js +122 -378
  171. package/src/studio-agent-protocol.js +66 -30
  172. package/src/studio-app-binding.js +379 -0
  173. package/src/studio-contact-sheet.js +73 -0
  174. package/src/studio-elements.js +58 -51
  175. package/src/styles/themes.css +200 -0
  176. package/src/styles/tokens.css +69 -0
  177. package/src/styles.css +456 -1748
  178. package/src/theme.js +44 -0
  179. package/src/timeline-extent.js +16 -0
  180. package/src/timeline-fps.js +13 -0
  181. package/src/trail-key-conflicts.js +39 -0
  182. package/src/trail-pick.js +59 -0
  183. package/src/ui.jsx +8 -8
  184. package/src/use-case-question.jsx +66 -0
  185. package/src/workflow/AgentPanel.jsx +86 -71
  186. package/src/workflow/agent-client.js +12 -2
  187. package/src/workflow/agent-panel.css +34 -32
  188. package/src/workflow/cozy-scene-node.css +2 -0
  189. package/src/workflow/workflow.css +2 -0
  190. package/tools/ardy/bridge.mjs +164 -146
  191. package/tools/ardy/visual-qa.mjs +5 -5
  192. package/tools/bench/EXP3.md +61 -0
  193. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  194. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  195. package/tools/bench/cclay_bench_runner.py +42 -0
  196. package/tools/bench/cube-contact.mjs +162 -0
  197. package/tools/bench/exp3.mjs +152 -0
  198. package/tools/bench/extract-bench-lib.mjs +216 -0
  199. package/tools/bench/extract-bench.mjs +302 -0
  200. package/tools/bench/fal-generate.mjs +88 -0
  201. package/tools/bench/fit/README.md +189 -0
  202. package/tools/bench/fit/camera.mjs +59 -0
  203. package/tools/bench/fit/contact.mjs +419 -0
  204. package/tools/bench/fit/footlock.mjs +234 -0
  205. package/tools/bench/fit/motion.mjs +85 -0
  206. package/tools/bench/fit/pin.mjs +27 -0
  207. package/tools/bench/fit/remote.mjs +105 -0
  208. package/tools/bench/fit-bench.mjs +126 -0
  209. package/tools/bench/fit-sanity.mjs +45 -0
  210. package/tools/bench/metrics.mjs +313 -0
  211. package/tools/bench/obs/depth.mjs +258 -0
  212. package/tools/bench/obs/extrinsics.mjs +175 -0
  213. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  214. package/tools/bench/obs/ground.mjs +294 -0
  215. package/tools/bench/obs/heading.mjs +151 -0
  216. package/tools/bench/obs/ladder.mjs +555 -0
  217. package/tools/bench/obs/mannequin-betas.json +124 -0
  218. package/tools/bench/obs/remote.mjs +83 -0
  219. package/tools/bench/obs/rest_joints.py +52 -0
  220. package/tools/bench/obs/ybot-targets.mjs +49 -0
  221. package/tools/bench/obs-bench.mjs +520 -0
  222. package/tools/bench/score.mjs +343 -0
  223. package/tools/bench/summarize.mjs +86 -0
  224. package/tools/dev/pages/privacy.html +13 -8
  225. package/tools/gt-render/browser.mjs +159 -0
  226. package/tools/gt-render/camera-math.mjs +276 -0
  227. package/tools/gt-render/page.mjs +267 -0
  228. package/tools/gt-render/render.mjs +514 -0
  229. package/tools/gt-render/scene-box.mjs +31 -0
  230. package/tools/gt-render/take-transform.mjs +87 -0
  231. package/tools/qa/css-rule-usage.mjs +283 -0
  232. package/tools/qa/studio-control-count.mjs +70 -32
  233. package/tools/run-tests.mjs +60 -3
  234. package/tools/track/DESIGN.md +513 -0
  235. package/tools/track/backfill-provenance.mjs +116 -0
  236. package/tools/track/budget.mjs +8 -0
  237. package/tools/track/check-rig.mjs +209 -0
  238. package/tools/track/diagnostics.schema.json +60 -0
  239. package/tools/track/export-rig.mjs +256 -0
  240. package/tools/track/fallback.mjs +7 -0
  241. package/tools/track/fk-parity-fixture.mjs +161 -0
  242. package/tools/track/gate.mjs +349 -0
  243. package/tools/track/masks.mjs +318 -0
  244. package/tools/track/metrics.mjs +201 -0
  245. package/tools/track/publish-obs.mjs +108 -0
  246. package/tools/track/py/check_env.py +61 -0
  247. package/tools/track/py/eval_lr.py +229 -0
  248. package/tools/track/py/lr_viterbi.py +294 -0
  249. package/tools/track/py/masks.py +405 -0
  250. package/tools/track/py/objective.py +382 -0
  251. package/tools/track/py/rig.py +508 -0
  252. package/tools/track/py/scene.py +326 -0
  253. package/tools/track/py/test_joint_indices.py +123 -0
  254. package/tools/track/py/test_lr_viterbi.py +195 -0
  255. package/tools/track/py/test_masks.py +229 -0
  256. package/tools/track/py/test_rig.py +288 -0
  257. package/tools/track/py/test_scene.py +354 -0
  258. package/tools/track/py/test_track.py +328 -0
  259. package/tools/track/py/track.py +411 -0
  260. package/tools/track/remote.mjs +140 -0
  261. package/tools/track/rig-dump.mjs +227 -0
  262. package/tools/track/run-box-tests.mjs +14 -0
  263. package/tools/track/run-box.mjs +168 -0
  264. package/tools/track/setup-box.sh +59 -0
  265. package/tools/track/study-2d.mjs +728 -0
  266. package/dist/assets/analytics-B1hnH66c.js +0 -1
  267. package/dist/assets/app-Cu1cexZR.css +0 -1
  268. package/dist/assets/app-Dxdh8kds.js +0 -4861
  269. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  270. package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
  271. package/dist/assets/workflow-CJd-PDtV.js +0 -179
  272. package/dist/assets/workflow-DsudxKHF.css +0 -1
  273. package/src/fal-motion-studio.jsx +0 -180
  274. package/src/scene-history.js +0 -129
  275. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
@@ -0,0 +1,656 @@
1
+ import { useEffect, useLayoutEffect, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { useStudioShell } from "./studio-shell-context.js";
4
+ import { ko } from "../locale.js";
5
+ import { PRESETS, SHOT_ASPECT_PRESETS } from "../app-stage.jsx";
6
+ import { CAMERA_PRESETS } from "../camera-move.js";
7
+ import { PRIME_SET } from "../shot.js";
8
+ import { CatalogueEntries } from "../object-catalog.jsx";
9
+ import { CHARACTER_MODEL_IDS } from "../scenes.js";
10
+ import { elementByPath } from "../studio-elements.js";
11
+ import { saveAutoColor } from "../auto-color.js";
12
+ import { trackFeature } from "../analytics.js";
13
+ import "./viewport.css";
14
+
15
+ // G1: the single mode+tool pill. Keys 1-4 and W/E/R are bound in App's
16
+ // keydown handler; these buttons are the same doors for the pointer.
17
+ const MODES = [
18
+ { id: "scene", key: "1", label: ko("Stage", "배치") },
19
+ { id: "pose", key: "2", label: ko("Pose", "포즈") },
20
+ { id: "camera", key: "3", label: ko("Camera", "카메라") },
21
+ { id: "motion", key: "4", label: ko("Motion", "모션") },
22
+ ];
23
+
24
+ // #570 line icons (16px, 1.3 stroke) for the floating rails.
25
+ const ICON = {
26
+ scene: "M8 2 13.25 5v6L8 14 2.75 11V5z M2.75 5 8 8l5.25-3 M8 8v6",
27
+ pose: "M8 2.25a1.5 1.5 0 1 1 0 3 1.5 1.5 0 1 1 0-3z M5 7.25h6 M8 7.25v3.5 M6.25 13.75 8 10.75l1.75 3",
28
+ camera: "M2 5.75A1.5 1.5 0 0 1 3.5 4.25h6A1.5 1.5 0 0 1 11 5.75v4.5a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 2 10.25z M11 7l3-1.5v5L11 9",
29
+ motion: "M1.75 9.5c1.5 0 2-4 3.25-4s1.75 5 3 5 1.75-5 3-5 1.75 4 3.25 4",
30
+ move: "M8 2v12 M2 8h12 M6.25 3.75 8 2l1.75 1.75 M6.25 12.25 8 14l1.75-1.75 M3.75 6.25 2 8l1.75 1.75 M12.25 6.25 14 8l-1.75 1.75",
31
+ rotate: "M13.25 8A5.25 5.25 0 1 1 11.7 4.3 M13.25 2.5v2.75H10.5",
32
+ scale: "M2.5 8.5v4a1 1 0 0 0 1 1h4 M8.5 2.5h5v5 M13.5 2.5 7.5 8.5",
33
+ ik: "M2.5 13.5 6.5 9l3 2.2 4-7.7 M6.5 9h.01 M9.5 11.2h.01",
34
+ trail: "M2 12.5c3 0 3-9 6-9s3 9 6 9",
35
+ pin: "M8 14.5v-4 M5 2h6 M6.2 2v3.6L4.5 10.5h7L9.8 5.6V2",
36
+ blocks: "M2 3.5h7.5v3.5H2z M6.5 9h7.5v3.5H6.5z",
37
+ refine: "M10.5 2.5l3 3L6 13H3v-3z",
38
+ plus: "M8 3.25v9.5 M3.25 8h9.5",
39
+ grid: "M2.5 5.75h11 M2.5 10.25h11 M5.75 2.5v11 M10.25 2.5v11",
40
+ angle: "M2.5 13.5h11 M2.5 13.5 11 5 M7 13.5a4.5 4.5 0 0 0-1.3-3.2",
41
+ view: "M2.75 5 8 2.5 13.25 5v6L8 13.5 2.75 11z M8 8v5.5 M2.75 5 8 8l5.25-3",
42
+ shading: "M8 2.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 1 1 0-11z M4.5 5.5a4 4 0 0 1 3-1.5",
43
+ show: "M1.75 8S4 3.75 8 3.75 14.25 8 14.25 8 12 12.25 8 12.25 1.75 8 1.75 8z M8 6.25a1.75 1.75 0 1 1 0 3.5 1.75 1.75 0 1 1 0-3.5z",
44
+ speed: "M2.5 11a5.5 5.5 0 1 1 11 0 M8 11l2.5-3",
45
+ };
46
+
47
+ function RailIcon({ name, size = 16 }) {
48
+ return (
49
+ <svg className="vp-icon" width={size} height={size} viewBox="0 0 16 16" aria-hidden="true">
50
+ <path d={ICON[name]} />
51
+ </svg>
52
+ );
53
+ }
54
+
55
+ // The gizmo's snap increments (TRANSLATE_SNAP / ROTATE_SNAP in
56
+ // src/scene-objects.js). They are fixed, so the value group names them.
57
+ const SNAP_MOVE = "5cm";
58
+ const SNAP_TURN = "5°";
59
+
60
+ // Editor lens range: shotsDomain.changeLens and the FOV slider take 14-90°.
61
+ const FOV_MIN = 14;
62
+ const FOV_MAX = 90;
63
+
64
+ // "+ Add › Character" stands the new body one metre along X per existing
65
+ // character, inside the same stage clamp the Content drag applies.
66
+ const CHARACTER_BOUNDS = elementByPath("character.position").gizmo;
67
+ const characterSpawnX = (count) => Math.min(CHARACTER_BOUNDS.max.x, Math.max(CHARACTER_BOUNDS.min.x, count));
68
+
69
+ const trimNumber = (value, digits = 2) => String(Number(value.toFixed(digits)));
70
+
71
+ /** One open overlay menu at a time; outside pointerdown or Escape closes it. */
72
+ function useOverlayMenu() {
73
+ const [open, setOpen] = useState(null);
74
+ useEffect(() => {
75
+ if (!open) return undefined;
76
+ const onPointerDown = (event) => {
77
+ if (event.target instanceof Element && event.target.closest(`[data-vp-menu="${open}"]`)) return;
78
+ setOpen(null);
79
+ };
80
+ const onKeyDown = (event) => {
81
+ if (event.key !== "Escape") return;
82
+ document.querySelector(`[data-vp-menu="${open}"] > button`)?.focus();
83
+ setOpen(null);
84
+ };
85
+ document.addEventListener("pointerdown", onPointerDown);
86
+ window.addEventListener("keydown", onKeyDown);
87
+ return () => {
88
+ document.removeEventListener("pointerdown", onPointerDown);
89
+ window.removeEventListener("keydown", onKeyDown);
90
+ };
91
+ }, [open]);
92
+ return {
93
+ open,
94
+ close: () => setOpen(null),
95
+ toggle: (id) => setOpen((current) => (current === id ? null : id)),
96
+ };
97
+ }
98
+
99
+ function MenuItem({ checked, role = "menuitemradio", className = "", children, ...props }) {
100
+ return (
101
+ <button type="button" role={role} aria-checked={checked} className={"vp-menu-item " + className} {...props}>
102
+ <span className="vp-menu-mark" aria-hidden="true">{checked ? "✓" : ""}</span>
103
+ {children}
104
+ </button>
105
+ );
106
+ }
107
+
108
+ /** The inset card's header (violet dot, shot name, lens) lives in App's
109
+ * `.vp-shot-preview-tag`; this region only fills in the words it shows. */
110
+ function ShotPreviewHeader({ name, lens }) {
111
+ const [host, setHost] = useState(null);
112
+ useLayoutEffect(() => {
113
+ setHost(document.querySelector(".vp-shot-preview .vp-shot-preview-tag"));
114
+ }, []);
115
+ if (!host) return null;
116
+ return createPortal(
117
+ <span className="vp-shot-preview-meta" data-testid="shot-preview-header">
118
+ <span className="vp-shot-preview-name">{name}</span>
119
+ <span className="vp-shot-preview-lens">{lens}</span>
120
+ </span>,
121
+ host,
122
+ );
123
+ }
124
+
125
+ export default function ViewportToolbar() {
126
+ const {
127
+ workflowMode, selectWorkflowMode, poseRefusal, gizmoMode, setGizmoMode, snapEnabled,
128
+ setSnapEnabled, preset, applyPreset, cameraPresetId, i2vMotionCameraLocked,
129
+ runStudioAction, shotAspectKey, fovDeg, shotsDomain, shot, shots, activeShot,
130
+ setNonce, workspaceLayout, viewMenuTriggerRef, viewMenuOpen,
131
+ setViewMenuOpen, viewLooksActive, gridView, setGridView,
132
+ autoColor, setAutoColor, isCharacterSelection, partColoursChoice, embedMode,
133
+ agentCollapsed, addSceneObject, spawnCharacter, characters, flySpeed,
134
+ ikEditTool, setIkEditTool, showTrails, setShowTrails, motion, setRangePinPartPick,
135
+ trailFalloffS, setTrailFalloffS, lineEditMode, exitLineEditMode, enterRefineMode,
136
+ refineDisabledReason,
137
+ } = useStudioShell();
138
+ const menu = useOverlayMenu();
139
+
140
+ const setAutoColorOn = (next) => {
141
+ if (next === autoColor) return;
142
+ saveAutoColor(next);
143
+ trackFeature("auto_color");
144
+ setAutoColor(next);
145
+ };
146
+
147
+ const refineReason = lineEditMode ? null : refineDisabledReason();
148
+ const tools = {
149
+ scene: [
150
+ { key: "W", id: "move", name: ko("Move", "이동"), active: gizmoMode === "move", pick: () => setGizmoMode("move") },
151
+ { key: "E", id: "rotate", name: ko("Rotate", "회전"), active: gizmoMode === "rotate", pick: () => setGizmoMode("rotate") },
152
+ { key: "R", id: "scale", name: ko("Scale", "크기"), active: gizmoMode === "scale", pick: () => setGizmoMode("scale") },
153
+ ],
154
+ pose: [
155
+ { key: "W", id: "ik", name: ko("Pose fix (IK parts)", "포즈 수정 (IK 파츠)"), active: ikEditTool === "ik", pick: () => setIkEditTool("ik") },
156
+ {
157
+ key: "E", id: "trail", name: ko("Path fix (motion trail)", "경로 수정 (궤적선)"), active: ikEditTool === "trail",
158
+ refused: showTrails ? null : ko("Turn Trails on in Show to edit the motion path", "경로를 수정하려면 표시에서 궤적선을 켜세요"),
159
+ pick: () => setIkEditTool("trail"),
160
+ },
161
+ {
162
+ key: "R", id: "pin", name: ko("Pin (range pin)", "고정 (범위 고정)"), active: ikEditTool === "pin",
163
+ refused: motion ? null : ko("Pinning needs a motion take", "고정하려면 모션 테이크가 필요해요"),
164
+ pick: () => { setIkEditTool("pin"); setRangePinPartPick(null); },
165
+ },
166
+ ],
167
+ camera: [
168
+ { key: "W", id: "move", name: ko("Move camera", "카메라 이동"), active: gizmoMode === "move", pick: () => setGizmoMode("move") },
169
+ { key: "E", id: "rotate", name: ko("Rotate camera", "카메라 회전"), active: gizmoMode === "rotate", pick: () => setGizmoMode("rotate") },
170
+ ],
171
+ motion: [
172
+ {
173
+ key: "W", id: "blocks", name: ko("Blocks (select and move prompt blocks)", "블록 (프롬프트 블록 선택·이동)"), active: !lineEditMode,
174
+ pick: () => { if (lineEditMode) exitLineEditMode(); },
175
+ },
176
+ {
177
+ key: "E", id: "refine", name: ko("Refine (edit the path line)", "다듬기 (경로선 편집)"), active: lineEditMode,
178
+ refused: refineReason, pick: () => { if (!lineEditMode) enterRefineMode(); },
179
+ },
180
+ ],
181
+ }[workflowMode] ?? [];
182
+
183
+ // Lens choices are the real primes that fit the editor's FOV range, turned
184
+ // into a vertical FOV on the current filmback (the shot.js lens relation).
185
+ const lensChoices = PRIME_SET
186
+ .map((mm) => ({ mm, fov: (2 * Math.atan(shot.usedSensorHeightMm / (2 * mm)) * 180) / Math.PI }))
187
+ .filter(({ fov }) => fov >= FOV_MIN && fov <= FOV_MAX);
188
+ const aspectLabel = SHOT_ASPECT_PRESETS[shotAspectKey]?.label ?? shotAspectKey;
189
+
190
+ const valueGroup = workflowMode === "scene" ? (
191
+ <button
192
+ type="button"
193
+ className={"vp-value vp-snap-value" + (snapEnabled ? " on" : "")}
194
+ data-testid="snap-toggle"
195
+ aria-pressed={snapEnabled}
196
+ title={ko(
197
+ `Grid snapping ${snapEnabled ? "on" : "off"} (${SNAP_MOVE} · ${SNAP_TURN}) — click to toggle, hold Ctrl during a drag to invert`,
198
+ `그리드 스냅 ${snapEnabled ? "켜짐" : "꺼짐"} (${SNAP_MOVE} · ${SNAP_TURN}) — 클릭해 전환, 드래그 중 Ctrl을 누르면 반대로 작동`,
199
+ )}
200
+ onClick={() => setSnapEnabled((value) => !value)}
201
+ >
202
+ <span className="vp-value-part"><RailIcon name="grid" size={14} />{SNAP_MOVE}</span>
203
+ <span className="vp-value-part"><RailIcon name="angle" size={14} />{SNAP_TURN}</span>
204
+ </button>
205
+ ) : workflowMode === "pose" ? (
206
+ <div className="vp-menu-wrap" data-vp-menu="influence">
207
+ <button
208
+ type="button"
209
+ className="vp-value"
210
+ aria-haspopup="dialog"
211
+ aria-expanded={menu.open === "influence"}
212
+ title={ko("Influence range — how far an edit blends into nearby frames", "영향 범위 — 수정이 주변 프레임에 섞이는 길이")}
213
+ onClick={() => menu.toggle("influence")}
214
+ >
215
+ {trimNumber(trailFalloffS)}s
216
+ </button>
217
+ {menu.open === "influence" && (
218
+ <div className="vp-menu vp-slider-popover" role="dialog" aria-label={ko("Influence range", "영향 범위")}>
219
+ <input
220
+ type="range"
221
+ min={0.1}
222
+ max={2}
223
+ step={0.1}
224
+ value={trailFalloffS}
225
+ aria-label={ko("Influence range", "영향 범위")}
226
+ onChange={(event) => setTrailFalloffS(Number(event.target.value))}
227
+ />
228
+ <output>{trimNumber(trailFalloffS)}s</output>
229
+ </div>
230
+ )}
231
+ </div>
232
+ ) : workflowMode === "camera" ? (
233
+ <>
234
+ <div className="vp-menu-wrap" data-vp-menu="lens">
235
+ <button
236
+ type="button"
237
+ className="vp-value"
238
+ data-testid="lens-value"
239
+ aria-haspopup="menu"
240
+ aria-expanded={menu.open === "lens"}
241
+ title={ko("Lens — pick a prime or set the field of view", "렌즈 — 단렌즈를 고르거나 화각을 조절")}
242
+ onClick={() => menu.toggle("lens")}
243
+ >
244
+ {shot.focalMm}mm
245
+ </button>
246
+ {menu.open === "lens" && (
247
+ <div className="vp-menu" role="menu" aria-label={ko("Lens", "렌즈")}>
248
+ {lensChoices.map(({ mm, fov }) => (
249
+ <MenuItem
250
+ key={mm}
251
+ data-lens={mm}
252
+ checked={shot.focalMm === mm}
253
+ disabled={i2vMotionCameraLocked}
254
+ onClick={() => { shotsDomain.changeLens(fov); menu.close(); }}
255
+ >
256
+ {mm}mm
257
+ </MenuItem>
258
+ ))}
259
+ <label className="viewport-fov-control">
260
+ <span>FOV</span>
261
+ <input
262
+ type="range"
263
+ min={FOV_MIN}
264
+ max={FOV_MAX}
265
+ step="1"
266
+ value={fovDeg}
267
+ disabled={i2vMotionCameraLocked}
268
+ onChange={(event) => shotsDomain.changeLens(Number(event.target.value))}
269
+ />
270
+ <output>{Math.round(fovDeg)}°</output>
271
+ </label>
272
+ </div>
273
+ )}
274
+ </div>
275
+ <div className="vp-menu-wrap" data-vp-menu="ratio">
276
+ <button
277
+ type="button"
278
+ className="vp-value"
279
+ data-testid="ratio-value"
280
+ aria-haspopup="menu"
281
+ aria-expanded={menu.open === "ratio"}
282
+ title={ko("Output aspect ratio", "출력 화면 비율")}
283
+ onClick={() => menu.toggle("ratio")}
284
+ >
285
+ {aspectLabel.replace(/:1$/, "")}
286
+ </button>
287
+ {menu.open === "ratio" && (
288
+ <div className="vp-menu" role="menu" aria-label={ko("Output aspect ratio", "출력 화면 비율")}>
289
+ {Object.entries(SHOT_ASPECT_PRESETS).map(([key, value]) => (
290
+ <MenuItem
291
+ key={key}
292
+ data-aspect={key}
293
+ checked={key === shotAspectKey}
294
+ title={value.title}
295
+ onClick={() => { runStudioAction("stage.setFilmback", { shotAspect: key }); menu.close(); }}
296
+ >
297
+ {value.label}
298
+ </MenuItem>
299
+ ))}
300
+ </div>
301
+ )}
302
+ </div>
303
+ </>
304
+ ) : null;
305
+
306
+ return (
307
+ <div className="viewport-titlebar" data-testid="viewport-overlays">
308
+ <div className="vp-overlay-left">
309
+ <div className="vp-menu-wrap" data-vp-menu="add">
310
+ <button
311
+ type="button"
312
+ className="vp-pill vp-add-trigger add-object-trigger"
313
+ data-testid="viewport-add"
314
+ aria-haspopup="menu"
315
+ aria-expanded={menu.open === "add"}
316
+ title={ko("Add an object, a character or a camera", "오브젝트, 캐릭터, 카메라 추가")}
317
+ onClick={() => menu.toggle("add")}
318
+ >
319
+ <span className="vp-add-plus" aria-hidden="true"><RailIcon name="plus" /></span>
320
+ <span className="vp-label">{ko("Add", "추가")}</span>
321
+ </button>
322
+ {menu.open === "add" && (
323
+ <div className="vp-menu add-object-menu" role="menu" aria-label={ko("Add", "추가")}>
324
+ <CatalogueEntries onPick={(kind) => { addSceneObject(kind); menu.close(); }} />
325
+ <div className="add-object-group">
326
+ <span className="add-object-heading">{ko("Scene", "장면")}</span>
327
+ <button
328
+ type="button"
329
+ role="menuitem"
330
+ className="add-object-item"
331
+ data-add="character"
332
+ onClick={() => { spawnCharacter(CHARACTER_MODEL_IDS[0], characterSpawnX(characters.length), 0); menu.close(); }}
333
+ >
334
+ <span className="add-object-swatch vp-add-swatch-cast" aria-hidden="true" />
335
+ <span>{ko("Character", "캐릭터")}</span>
336
+ </button>
337
+ <button
338
+ type="button"
339
+ role="menuitem"
340
+ className="add-object-item"
341
+ data-add="camera"
342
+ onClick={() => { runStudioAction("shot.create"); menu.close(); }}
343
+ >
344
+ <span className="add-object-swatch vp-add-swatch-camera" aria-hidden="true" />
345
+ <span>{ko("Camera", "카메라")}</span>
346
+ </button>
347
+ </div>
348
+ </div>
349
+ )}
350
+ </div>
351
+
352
+ {tools.length > 0 && <span className="vp-toolbar-divider" aria-hidden="true" />}
353
+ <div className="vp-tool-keys" role="group" aria-label={ko("Tools", "도구")} data-transform-controls>
354
+ {tools.map((tool) => (
355
+ <button
356
+ type="button"
357
+ key={tool.key}
358
+ data-tool-key={tool.key}
359
+ data-tool={tool.id}
360
+ className={"vp-tool-key" + (tool.active ? " active" : "")}
361
+ aria-pressed={tool.active}
362
+ aria-disabled={tool.refused ? true : undefined}
363
+ aria-label={tool.name}
364
+ title={tool.refused || `${tool.name} (${tool.key})`}
365
+ onClick={() => { if (!tool.refused) tool.pick(); }}
366
+ >
367
+ <RailIcon name={tool.id} />
368
+ <span className="vp-tool-letter">{tool.key}</span>
369
+ </button>
370
+ ))}
371
+ </div>
372
+ {valueGroup && (
373
+ <>
374
+ <span className="vp-toolbar-divider" aria-hidden="true" />
375
+ <div className="vp-values">{valueGroup}</div>
376
+ </>
377
+ )}
378
+ </div>
379
+
380
+ <div className="vp-mode-toolbar" role="toolbar" data-testid="mode-toolbar" aria-label={ko("Mode and tools", "모드와 도구")}>
381
+ <div className="vp-mode-keys" role="tablist" aria-label={ko("Workflow", "작업 모드")}>
382
+ {MODES.map((mode) => {
383
+ const active = workflowMode === mode.id;
384
+ const refused = mode.id === "pose" ? poseRefusal : null;
385
+ return (
386
+ <button
387
+ type="button"
388
+ role="tab"
389
+ key={mode.id}
390
+ data-mode-key={mode.key}
391
+ data-mode={mode.id}
392
+ className={"vp-mode-key" + (active ? " active" : "")}
393
+ aria-selected={active}
394
+ aria-disabled={refused ? true : undefined}
395
+ aria-label={mode.label}
396
+ title={refused || `${mode.label} (${mode.key})`}
397
+ onClick={() => selectWorkflowMode(mode.id)}
398
+ >
399
+ <RailIcon name={mode.id} />
400
+ <span className="vp-key-digit">{mode.key}</span>
401
+ <span className="vp-mode-name">{mode.label}</span>
402
+ </button>
403
+ );
404
+ })}
405
+ </div>
406
+ </div>
407
+
408
+ <div className="vp-overlay-right">
409
+ <div className="vp-pill vp-view-pill">
410
+ <div className="vp-menu-wrap" data-vp-menu="camera-view">
411
+ <button
412
+ type="button"
413
+ className="vp-pill-segment"
414
+ data-testid="view-camera-trigger"
415
+ aria-haspopup="menu"
416
+ aria-expanded={menu.open === "camera-view"}
417
+ title={ko("Views and shot cameras", "보기와 샷 카메라")}
418
+ onClick={() => menu.toggle("camera-view")}
419
+ >
420
+ <RailIcon name="view" />
421
+ <span className="vp-label">{ko("Perspective", "원근")}</span>
422
+ </button>
423
+ {menu.open === "camera-view" && (
424
+ <div className="vp-menu" role="menu" aria-label={ko("Views and shot cameras", "보기와 샷 카메라")}>
425
+ <span className="vp-menu-label">{ko("View", "보기")}</span>
426
+ <MenuItem checked onClick={menu.close}>{ko("Perspective", "원근")}</MenuItem>
427
+ <MenuItem
428
+ role="menuitemcheckbox"
429
+ checked={!workspaceLayout.insetCollapsed}
430
+ title={ko("Show the Top-View inset", "탑뷰 인셋 표시")}
431
+ onClick={() => runStudioAction("view.setInset", { collapsed: !workspaceLayout.insetCollapsed })}
432
+ >
433
+ {ko("Top", "탑")}
434
+ </MenuItem>
435
+ {shots.length > 0 && <span className="vp-menu-label">{ko("Shot cameras", "샷 카메라")}</span>}
436
+ {shots.map((entry) => (
437
+ <MenuItem
438
+ key={entry.id}
439
+ checked={activeShot?.id === entry.id}
440
+ title={ko("Show this shot's camera in the preview", "이 샷의 카메라를 미리보기에 표시")}
441
+ onClick={() => { runStudioAction("view.select", { shotId: entry.id }); menu.close(); }}
442
+ >
443
+ {entry.name}
444
+ </MenuItem>
445
+ ))}
446
+ <span className="vp-menu-label">{ko("Shot framing", "샷 구도")}</span>
447
+ <label className="viewport-toolbar-field shot-field">
448
+ <span>{ko("Shot", "샷")}</span>
449
+ <select
450
+ aria-label={ko("Shot preset", "샷 프리셋")}
451
+ value={preset}
452
+ onChange={(event) => applyPreset(event.target.value)}
453
+ >
454
+ {Object.entries(PRESETS).map(([key, value]) => (
455
+ <option key={key} value={key}>{value.label}</option>
456
+ ))}
457
+ </select>
458
+ </label>
459
+ <label className="viewport-toolbar-field ratio-field">
460
+ <span>{ko("Cam", "카메라")}</span>
461
+ <select
462
+ aria-label={ko("Camera preset", "카메라 프리셋")}
463
+ value={cameraPresetId ?? ""}
464
+ disabled={i2vMotionCameraLocked}
465
+ onChange={(event) => {
466
+ const id = event.target.value;
467
+ if (!id) { runStudioAction("stage.setFilmback", { cameraPresetId: null }); return; }
468
+ runStudioAction("shot.frame", { preset: id });
469
+ }}
470
+ >
471
+ <option value="">{ko("Free", "자유")}</option>
472
+ {Object.values(CAMERA_PRESETS).map((value) => (
473
+ <option key={value.id} value={value.id}>{value.label}</option>
474
+ ))}
475
+ </select>
476
+ </label>
477
+ <button
478
+ type="button"
479
+ role="menuitem"
480
+ className="vp-menu-item"
481
+ title={ko("Recenter on subject", "피사체 다시 맞추기")}
482
+ aria-label={ko("Recenter on subject", "피사체 다시 맞추기")}
483
+ onClick={() => { setNonce((n) => n + 1); menu.close(); }}
484
+ >
485
+ <span className="vp-menu-mark" aria-hidden="true">◎</span>
486
+ {ko("Recenter on subject", "피사체 다시 맞추기")}
487
+ </button>
488
+ </div>
489
+ )}
490
+ </div>
491
+ <span className="vp-pill-divider" aria-hidden="true" />
492
+ <div className="vp-menu-wrap" data-vp-menu="shading">
493
+ <button
494
+ type="button"
495
+ className="vp-pill-segment"
496
+ data-testid="shading-trigger"
497
+ aria-haspopup="menu"
498
+ aria-expanded={menu.open === "shading"}
499
+ title={ko("Shading", "셰이딩")}
500
+ onClick={() => menu.toggle("shading")}
501
+ >
502
+ <RailIcon name="shading" />
503
+ <span className="vp-label">{autoColor ? ko("Auto Color", "자동 색") : ko("Clay Lit", "클레이 조명")}</span>
504
+ </button>
505
+ {menu.open === "shading" && (
506
+ <div className="vp-menu" role="menu" aria-label={ko("Shading", "셰이딩")}>
507
+ <MenuItem checked={!autoColor} onClick={() => { setAutoColorOn(false); menu.close(); }}>
508
+ {ko("Clay Lit", "클레이 조명")}
509
+ </MenuItem>
510
+ <MenuItem
511
+ checked={autoColor}
512
+ title={ko("Flat, distinct colour per object — captures include them while on", "오브젝트별 평면 구분 색 — 켜둔 동안 캡처에도 포함됩니다")}
513
+ onClick={() => { setAutoColorOn(true); menu.close(); }}
514
+ >
515
+ {ko("Auto Color", "자동 색")}
516
+ </MenuItem>
517
+ </div>
518
+ )}
519
+ </div>
520
+ <span className="vp-pill-divider" aria-hidden="true" />
521
+ {/* G8 "Show" = the View menu: every what-is-on-screen toggle, in
522
+ every mode. Items keep the menu open: these are toggles you
523
+ compare, not commands you fire. */}
524
+ <div className="view-menu-wrap">
525
+ <button
526
+ type="button"
527
+ className="vp-pill-segment view-menu-trigger"
528
+ data-testid="view-menu-trigger"
529
+ ref={viewMenuTriggerRef}
530
+ aria-haspopup="menu"
531
+ aria-expanded={viewMenuOpen}
532
+ title={ko("Viewport display toggles", "뷰포트 표시 토글")}
533
+ onClick={() => setViewMenuOpen((open) => !open)}
534
+ >
535
+ <RailIcon name="show" />
536
+ <span className="vp-label">{ko("Show", "표시")}</span>
537
+ {viewLooksActive && <span className="view-menu-dot" data-testid="view-menu-dot" aria-hidden="true" />}
538
+ </button>
539
+ {viewMenuOpen && (
540
+ <div
541
+ className="vp-menu view-menu"
542
+ role="menu"
543
+ aria-label={ko("Viewport display", "뷰포트 표시")}
544
+ >
545
+ {/* aria-pressed rides along with aria-checked: the toggles
546
+ published that state contract in their old homes and QA
547
+ still reads it, so the move keeps the signpost (R9). */}
548
+ <button
549
+ type="button"
550
+ role="menuitemcheckbox"
551
+ className={"view-menu-item grid-view-switch" + (gridView ? " active" : "")}
552
+ aria-checked={gridView}
553
+ aria-pressed={gridView}
554
+ title={ko("Blender-style viewport — dark void with a reference grid instead of the deck", "Blender식 뷰포트 — 데크 대신 어두운 배경과 기준 그리드")}
555
+ onClick={() => setGridView((v) => !v)}
556
+ >
557
+ <span className="view-menu-mark" aria-hidden="true">{gridView ? "✓" : ""}</span>
558
+ {ko("Reference grid", "기준 그리드")}
559
+ </button>
560
+ <button
561
+ type="button"
562
+ role="menuitemcheckbox"
563
+ className={"view-menu-item auto-color-toggle" + (autoColor ? " active" : "")}
564
+ aria-checked={autoColor}
565
+ aria-pressed={autoColor}
566
+ title={ko(
567
+ "Distinct display colors per object — captures include them while on",
568
+ "오브젝트별 구분 색 — 켜둔 동안 캡처에도 포함됩니다",
569
+ )}
570
+ onClick={() => setAutoColorOn(!autoColor)}
571
+ >
572
+ <span className="view-menu-mark" aria-hidden="true">{autoColor ? "✓" : ""}</span>
573
+ {ko("Auto Color", "자동 색")}
574
+ </button>
575
+ <button
576
+ type="button"
577
+ role="menuitemcheckbox"
578
+ className={"view-menu-item trails-toggle" + (showTrails ? " active" : "")}
579
+ aria-checked={showTrails}
580
+ aria-pressed={showTrails}
581
+ title={ko("Motion trails while posing", "포즈 편집 중 궤적선")}
582
+ onClick={() => {
583
+ if (showTrails && ikEditTool === "trail") setIkEditTool("ik");
584
+ setShowTrails(!showTrails);
585
+ }}
586
+ >
587
+ <span className="view-menu-mark" aria-hidden="true">{showTrails ? "✓" : ""}</span>
588
+ {ko("Trails", "궤적선")}
589
+ </button>
590
+ {/* Part colours repaint a BODY, so the section only exists
591
+ while a character is selected (R2). */}
592
+ {isCharacterSelection && (
593
+ <div className="view-menu-group" role="group" aria-label={ko("Body part colours", "부위 색상")}>
594
+ <span className="view-menu-label" aria-hidden="true">{ko("Body part colours", "부위 색상")}</span>
595
+ {[
596
+ { value: "off", label: ko("Off", "끕") },
597
+ { value: "shaded", label: ko("Shaded", "음영") },
598
+ { value: "flat", label: ko("Flat", "평면") },
599
+ ].map((option) => {
600
+ const checked = option.value === partColoursChoice;
601
+ return (
602
+ <button
603
+ type="button"
604
+ key={option.value}
605
+ role="menuitemradio"
606
+ className={"view-menu-item part-colour-option" + (checked ? " active" : "")}
607
+ data-part-colours={option.value}
608
+ aria-checked={checked}
609
+ onClick={() => runStudioAction("view.setPartColours", { mode: option.value })}
610
+ >
611
+ <span className="view-menu-mark" aria-hidden="true">{checked ? "✓" : ""}</span>
612
+ {option.label}
613
+ </button>
614
+ );
615
+ })}
616
+ </div>
617
+ )}
618
+ {/* Panel visibility belongs to the same menu (R4): the
619
+ agent column is something you show, not a mode, so it
620
+ gets a checkmark here instead of a topbar button. */}
621
+ {!embedMode && (
622
+ <div className="view-menu-group" role="group" aria-label={ko("Panels", "패널")}>
623
+ <span className="view-menu-label" aria-hidden="true">{ko("Panels", "패널")}</span>
624
+ <button
625
+ type="button"
626
+ role="menuitemcheckbox"
627
+ className={"view-menu-item agent-panel-toggle" + (agentCollapsed ? "" : " active")}
628
+ aria-checked={!agentCollapsed}
629
+ aria-pressed={!agentCollapsed}
630
+ title={ko("Show the agent chat column (Cmd/Ctrl+B)", "에이전트 채팅 열 표시 (Cmd/Ctrl+B)")}
631
+ onClick={() => window.dispatchEvent(new CustomEvent("cozyclay:agent-panel-toggle"))}
632
+ >
633
+ <span className="view-menu-mark" aria-hidden="true">{agentCollapsed ? "" : "✓"}</span>
634
+ {ko("Agent panel", "에이전트 패널")}
635
+ </button>
636
+ </div>
637
+ )}
638
+ </div>
639
+ )}
640
+ </div>
641
+ </div>
642
+ <span className="vp-rail-divider" aria-hidden="true" />
643
+ <span
644
+ className="vp-pill vp-speed"
645
+ data-testid="fly-speed"
646
+ title={ko("Fly speed — scroll while right-dragging to change it", "비행 속도 — 오른쪽 드래그 중 스크롤로 조절")}
647
+ >
648
+ <RailIcon name="speed" size={14} />
649
+ <span className="vp-speed-value">{trimNumber(flySpeed, 1)}×</span>
650
+ </span>
651
+ </div>
652
+
653
+ <ShotPreviewHeader name={activeShot?.name ?? ko("Shot", "샷")} lens={`${shot.focalMm}mm`} />
654
+ </div>
655
+ );
656
+ }