cozyclay 1.10.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 (272) hide show
  1. package/CHANGELOG.md +137 -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-hub.mjs +35 -93
  40. package/mcp/mesh-file.mjs +48 -0
  41. package/mcp/server.mjs +6 -77
  42. package/mcp/tool-handlers.mjs +219 -376
  43. package/package.json +2 -1
  44. package/src/App.jsx +1249 -9076
  45. package/src/analytics.js +393 -9
  46. package/src/app-context.js +211 -0
  47. package/src/app-stage.jsx +25 -24
  48. package/src/ardy/auto-fix-panel.css +108 -0
  49. package/src/ardy/fix-collisions.js +10 -66
  50. package/src/ardy/ik-drag.js +101 -0
  51. package/src/ardy/ik-key-json.js +43 -0
  52. package/src/ardy/ik.js +97 -67
  53. package/src/ardy/physics-panel.css +2 -0
  54. package/src/ardy/physics-panel.jsx +17 -5
  55. package/src/ardy/platform-fit-panel.css +64 -0
  56. package/src/ardy/platform-fit-panel.jsx +40 -0
  57. package/src/ardy/platform-fit.js +326 -0
  58. package/src/ardy/playback.js +166 -20
  59. package/src/ardy/range-pin.js +299 -0
  60. package/src/ardy/timeline-coordinates.js +26 -0
  61. package/src/ardy/timeline.css +1199 -0
  62. package/src/ardy/timeline.jsx +149 -41
  63. package/src/ardy/waypoints.js +2 -2
  64. package/src/asset-pane.css +462 -0
  65. package/src/asset-pane.jsx +461 -232
  66. package/src/command-bus.js +349 -0
  67. package/src/commands/ai.js +31 -0
  68. package/src/commands/cast.js +140 -0
  69. package/src/commands/elements/character.js +13 -0
  70. package/src/commands/elements/motion.js +14 -0
  71. package/src/commands/elements/object.js +22 -0
  72. package/src/commands/elements/scene.js +16 -0
  73. package/src/commands/elements/shot.js +9 -0
  74. package/src/commands/elements/stage.js +11 -0
  75. package/src/commands/elements.js +155 -0
  76. package/src/commands/export.js +20 -0
  77. package/src/commands/index.js +40 -0
  78. package/src/commands/motion.js +231 -0
  79. package/src/commands/objects.js +174 -0
  80. package/src/commands/project.js +53 -0
  81. package/src/commands/scene.js +81 -0
  82. package/src/commands/shared.js +19 -0
  83. package/src/commands/shot.js +103 -0
  84. package/src/commands/stage.js +17 -0
  85. package/src/commands/view.js +42 -0
  86. package/src/document-store.js +144 -0
  87. package/src/domains/cast.js +1007 -0
  88. package/src/domains/motion.js +3219 -0
  89. package/src/domains/objects.js +932 -0
  90. package/src/domains/scenes.js +817 -0
  91. package/src/domains/shots.js +404 -0
  92. package/src/domains/stage.js +93 -0
  93. package/src/dualview.jsx +113 -57
  94. package/src/facing-marks.js +3 -0
  95. package/src/first-success-guide.jsx +14 -14
  96. package/src/grid-view.js +18 -5
  97. package/src/hierarchy-panel.css +386 -0
  98. package/src/hierarchy-panel.jsx +88 -19
  99. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  100. package/src/i2v-motion-studio.jsx +180 -0
  101. package/src/ik-camera.js +3 -0
  102. package/src/main.jsx +6 -0
  103. package/src/motion/generation.js +105 -0
  104. package/src/motion-readiness-ui.jsx +4 -4
  105. package/src/motion-readiness.js +11 -0
  106. package/src/motion-trail.js +366 -9
  107. package/src/object-gizmo.jsx +7 -1
  108. package/src/panels/CameraPanel.jsx +60 -0
  109. package/src/panels/CharacterTransformPanel.jsx +48 -0
  110. package/src/panels/EnvironmentPanel.jsx +40 -0
  111. package/src/panels/Foldout.jsx +32 -0
  112. package/src/panels/LightPanel.jsx +24 -0
  113. package/src/panels/ObjectTransformPanel.jsx +554 -0
  114. package/src/panels/PosePanel.jsx +97 -0
  115. package/src/panels/ProjectPanel.jsx +33 -0
  116. package/src/panels/PromptBlocksPanel.jsx +442 -0
  117. package/src/panels/PropsPanel.jsx +70 -0
  118. package/src/panels/ReferenceImageField.jsx +91 -0
  119. package/src/panels/RigControlPanel.jsx +78 -0
  120. package/src/panels/RigPanel.jsx +37 -0
  121. package/src/panels/SubjectBox.jsx +47 -0
  122. package/src/panels/SubjectsPanel.jsx +39 -0
  123. package/src/panels/VideoCapturePanel.jsx +180 -0
  124. package/src/panels/details.css +1171 -0
  125. package/src/panels/motion.css +197 -0
  126. package/src/panels/pose.css +39 -0
  127. package/src/planview.jsx +60 -31
  128. package/src/posestudio.jsx +59 -4
  129. package/src/project-browser.css +1073 -0
  130. package/src/project-browser.jsx +284 -123
  131. package/src/range-pin-object-transform.js +19 -0
  132. package/src/range-pin-panel.css +518 -0
  133. package/src/range-pin-panel.jsx +344 -0
  134. package/src/result-modal.jsx +5 -5
  135. package/src/room.jsx +79 -51
  136. package/src/scenes.js +5 -0
  137. package/src/semantic-edit.js +2 -0
  138. package/src/settings-menu.jsx +39 -105
  139. package/src/shell/BottomDock.jsx +450 -0
  140. package/src/shell/DetailsSlot.jsx +513 -0
  141. package/src/shell/LibrarySlot.jsx +86 -0
  142. package/src/shell/MenuBar.jsx +601 -0
  143. package/src/shell/OutlinerSlot.jsx +50 -0
  144. package/src/shell/PreferencesDialog.jsx +538 -0
  145. package/src/shell/PreferencesSlot.jsx +40 -0
  146. package/src/shell/StatusBar.jsx +125 -0
  147. package/src/shell/StudioShell.jsx +92 -0
  148. package/src/shell/TopBar.jsx +129 -0
  149. package/src/shell/ViewportToolbar.jsx +656 -0
  150. package/src/shell/agent-glass.css +296 -0
  151. package/src/shell/dock.css +93 -0
  152. package/src/shell/glass-regions.css +985 -0
  153. package/src/shell/glass.css +687 -0
  154. package/src/shell/log-store.js +52 -0
  155. package/src/shell/mode.css +9 -0
  156. package/src/shell/preferences.css +651 -0
  157. package/src/shell/shell.css +282 -0
  158. package/src/shell/studio-shell-context.js +19 -0
  159. package/src/shell/topbar.css +435 -0
  160. package/src/shell/viewport.css +451 -0
  161. package/src/store/authored-intent.js +22 -0
  162. package/src/store/runtime-adapters.js +92 -0
  163. package/src/store/scene-stage.js +21 -0
  164. package/src/store/use-document-store.js +14 -0
  165. package/src/studio-actions.js +210 -0
  166. package/src/studio-agent-commands.js +38 -268
  167. package/src/studio-agent-context.js +38 -9
  168. package/src/studio-agent-motion.js +122 -378
  169. package/src/studio-agent-protocol.js +66 -30
  170. package/src/studio-app-binding.js +379 -0
  171. package/src/studio-contact-sheet.js +73 -0
  172. package/src/studio-elements.js +58 -51
  173. package/src/styles/themes.css +200 -0
  174. package/src/styles/tokens.css +69 -0
  175. package/src/styles.css +443 -1753
  176. package/src/theme.js +44 -0
  177. package/src/timeline-extent.js +16 -0
  178. package/src/trail-key-conflicts.js +39 -0
  179. package/src/trail-pick.js +59 -0
  180. package/src/ui.jsx +8 -8
  181. package/src/use-case-question.jsx +66 -0
  182. package/src/workflow/AgentPanel.jsx +82 -69
  183. package/src/workflow/agent-client.js +12 -2
  184. package/src/workflow/agent-panel.css +34 -32
  185. package/src/workflow/cozy-scene-node.css +2 -0
  186. package/src/workflow/workflow.css +2 -0
  187. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  188. package/tools/ardy/bridge.mjs +164 -146
  189. package/tools/ardy/visual-qa.mjs +5 -5
  190. package/tools/bench/EXP3.md +61 -0
  191. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  192. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  193. package/tools/bench/cclay_bench_runner.py +42 -0
  194. package/tools/bench/cube-contact.mjs +162 -0
  195. package/tools/bench/exp3.mjs +152 -0
  196. package/tools/bench/extract-bench-lib.mjs +216 -0
  197. package/tools/bench/extract-bench.mjs +302 -0
  198. package/tools/bench/fal-generate.mjs +88 -0
  199. package/tools/bench/fit/README.md +189 -0
  200. package/tools/bench/fit/camera.mjs +59 -0
  201. package/tools/bench/fit/contact.mjs +419 -0
  202. package/tools/bench/fit/footlock.mjs +234 -0
  203. package/tools/bench/fit/motion.mjs +85 -0
  204. package/tools/bench/fit/pin.mjs +27 -0
  205. package/tools/bench/fit/remote.mjs +105 -0
  206. package/tools/bench/fit-bench.mjs +126 -0
  207. package/tools/bench/fit-sanity.mjs +45 -0
  208. package/tools/bench/metrics.mjs +313 -0
  209. package/tools/bench/obs/depth.mjs +258 -0
  210. package/tools/bench/obs/extrinsics.mjs +175 -0
  211. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  212. package/tools/bench/obs/ground.mjs +294 -0
  213. package/tools/bench/obs/heading.mjs +151 -0
  214. package/tools/bench/obs/ladder.mjs +555 -0
  215. package/tools/bench/obs/mannequin-betas.json +124 -0
  216. package/tools/bench/obs/remote.mjs +83 -0
  217. package/tools/bench/obs/rest_joints.py +52 -0
  218. package/tools/bench/obs/ybot-targets.mjs +49 -0
  219. package/tools/bench/obs-bench.mjs +520 -0
  220. package/tools/bench/score.mjs +343 -0
  221. package/tools/bench/summarize.mjs +86 -0
  222. package/tools/dev/pages/privacy.html +13 -8
  223. package/tools/gt-render/browser.mjs +159 -0
  224. package/tools/gt-render/camera-math.mjs +276 -0
  225. package/tools/gt-render/page.mjs +267 -0
  226. package/tools/gt-render/render.mjs +514 -0
  227. package/tools/gt-render/scene-box.mjs +31 -0
  228. package/tools/gt-render/take-transform.mjs +87 -0
  229. package/tools/qa/css-rule-usage.mjs +283 -0
  230. package/tools/qa/studio-control-count.mjs +70 -32
  231. package/tools/run-tests.mjs +58 -3
  232. package/tools/track/DESIGN.md +513 -0
  233. package/tools/track/backfill-provenance.mjs +116 -0
  234. package/tools/track/budget.mjs +8 -0
  235. package/tools/track/check-rig.mjs +209 -0
  236. package/tools/track/diagnostics.schema.json +60 -0
  237. package/tools/track/export-rig.mjs +256 -0
  238. package/tools/track/fallback.mjs +7 -0
  239. package/tools/track/fk-parity-fixture.mjs +161 -0
  240. package/tools/track/gate.mjs +349 -0
  241. package/tools/track/masks.mjs +318 -0
  242. package/tools/track/metrics.mjs +201 -0
  243. package/tools/track/publish-obs.mjs +108 -0
  244. package/tools/track/py/check_env.py +61 -0
  245. package/tools/track/py/eval_lr.py +229 -0
  246. package/tools/track/py/lr_viterbi.py +294 -0
  247. package/tools/track/py/masks.py +405 -0
  248. package/tools/track/py/objective.py +382 -0
  249. package/tools/track/py/rig.py +508 -0
  250. package/tools/track/py/scene.py +326 -0
  251. package/tools/track/py/test_joint_indices.py +123 -0
  252. package/tools/track/py/test_lr_viterbi.py +195 -0
  253. package/tools/track/py/test_masks.py +229 -0
  254. package/tools/track/py/test_rig.py +288 -0
  255. package/tools/track/py/test_scene.py +354 -0
  256. package/tools/track/py/test_track.py +328 -0
  257. package/tools/track/py/track.py +411 -0
  258. package/tools/track/remote.mjs +140 -0
  259. package/tools/track/rig-dump.mjs +227 -0
  260. package/tools/track/run-box-tests.mjs +14 -0
  261. package/tools/track/run-box.mjs +168 -0
  262. package/tools/track/setup-box.sh +59 -0
  263. package/tools/track/study-2d.mjs +728 -0
  264. package/dist/assets/analytics-B1hnH66c.js +0 -1
  265. package/dist/assets/app-Cu1cexZR.css +0 -1
  266. package/dist/assets/app-Dxdh8kds.js +0 -4861
  267. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  268. package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
  269. package/dist/assets/workflow-CJd-PDtV.js +0 -179
  270. package/dist/assets/workflow-DsudxKHF.css +0 -1
  271. package/src/fal-motion-studio.jsx +0 -180
  272. package/src/scene-history.js +0 -129
@@ -1,15 +1,14 @@
1
- import { useEffect, useRef, useState } from "react";
2
- import { getAnalyticsOptOut, setAnalyticsOptOut } from "./analytics.js";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
3
2
  import { LOCALE, ko, localeChosen, setLocale } from "./locale.js";
3
+ import PreferencesDialog from "./shell/PreferencesDialog.jsx";
4
4
 
5
5
  // App settings — language and analytics — behind one labelled topbar trigger
6
6
  // (#193, docs/studio-ui-ia.md R4). Neither item edits the document, so they do
7
7
  // not belong on the Save/Export row as bare toggles.
8
8
  //
9
- // The panel is a group of toggle buttons rather than role="menu": aria-pressed
10
- // is the state contract the analytics opt-out already shipped with, and only
11
- // role="button" supports it. Tab order is DOM order, Escape returns focus to
12
- // the trigger.
9
+ // The trigger now opens the v2 Preferences dialog. The old language strings stay
10
+ // here because the locale contract and the camera-tutorial source contract both
11
+ // treat this file as the Settings entry point.
13
12
  const LANGUAGES = [
14
13
  { id: "en", label: "English", action: "Switch to English" },
15
14
  { id: "ko", label: "한국어", action: "한국어로 전환" },
@@ -17,43 +16,35 @@ const LANGUAGES = [
17
16
 
18
17
  export default function SettingsMenu({ motionSetup, motionSetupReveal = 0 }) {
19
18
  const [open, setOpen] = useState(false);
20
- const [setupOpen, setSetupOpen] = useState(false);
21
- const [optedOut, setOptedOut] = useState(getAnalyticsOptOut);
22
19
  const triggerRef = useRef(null);
23
- const setupRef = useRef(null);
20
+ const close = useCallback(() => {
21
+ setOpen(false);
22
+ triggerRef.current?.focus();
23
+ }, []);
24
+ const openCameraTutorial = useCallback(() => {
25
+ window.dispatchEvent(new CustomEvent("cozyclay:camera-tutorial", { detail: { open: true } }));
26
+ setOpen(false);
27
+ }, []);
28
+ const cameraTutorial = (
29
+ <>
30
+ <h4>{ko("Help", "도움말")}</h4>
31
+ <button
32
+ type="button"
33
+ data-testid="settings-camera-tutorial"
34
+ title={ko("Learn the camera in seven steps", "일곱 단계로 카메라 익히기")}
35
+ onClick={openCameraTutorial}
36
+ >
37
+ {ko("Camera tutorial", "카메라 튜토리얼")}
38
+ </button>
39
+ </>
40
+ );
24
41
 
25
- // Generation setup is an app setting, never a document edit. The same
26
- // popover is revealed by the contextual action beside Generate.
42
+ // A contextual readiness action can reveal Preferences directly on the
43
+ // motion connection page without changing the topbar or adding a second
44
+ // settings surface.
27
45
  useEffect(() => {
28
- if (!motionSetupReveal) return;
29
- setOpen(true);
30
- setSetupOpen(true);
46
+ if (motionSetupReveal) setOpen(true);
31
47
  }, [motionSetupReveal]);
32
- useEffect(() => {
33
- if (open && setupOpen) setupRef.current?.focus();
34
- }, [open, setupOpen, motionSetupReveal]);
35
-
36
- // Dismissal mirrors the project menu: listen only while open, ignore
37
- // presses inside the wrap so the trigger keeps toggling, close on Escape
38
- // and hand focus back to the control the operator came from.
39
- useEffect(() => {
40
- if (!open) return undefined;
41
- const onPointerDown = (event) => {
42
- if (event.target instanceof Element && event.target.closest(".settings-menu-wrap")) return;
43
- setOpen(false);
44
- };
45
- const onKeyDown = (event) => {
46
- if (event.key !== "Escape") return;
47
- setOpen(false);
48
- triggerRef.current?.focus();
49
- };
50
- document.addEventListener("pointerdown", onPointerDown);
51
- window.addEventListener("keydown", onKeyDown);
52
- return () => {
53
- document.removeEventListener("pointerdown", onPointerDown);
54
- window.removeEventListener("keydown", onKeyDown);
55
- };
56
- }, [open]);
57
48
 
58
49
  // First-run cue: the studio starts in English even on a Korean browser, so
59
50
  // the only visible hint that Korean exists used to be the old locale button.
@@ -68,78 +59,21 @@ export default function SettingsMenu({ motionSetup, motionSetupReveal = 0 }) {
68
59
  className="topbar-action settings-menu-trigger"
69
60
  data-testid="settings-menu-trigger"
70
61
  aria-expanded={open}
71
- aria-haspopup="true"
62
+ aria-haspopup="dialog"
72
63
  title={ko("Language and analytics", "언어 및 사용 통계")}
73
64
  ref={triggerRef}
74
- onClick={() => setOpen((value) => !value)}
65
+ onClick={() => (open ? close() : setOpen(true))}
75
66
  >
76
67
  {label}
77
68
  <span className="caret">▾</span>
78
69
  </button>
79
- {open && (
80
- <div className="project-menu settings-menu" role="group" aria-label={ko("Settings", "설정")}>
81
- {/* Motion readiness setup (#277): separate from privacy controls. */}
82
- {motionSetup && (
83
- <>
84
- <button
85
- type="button"
86
- data-testid="settings-motion-setup"
87
- aria-expanded={setupOpen}
88
- ref={setupRef}
89
- onClick={() => setSetupOpen((value) => !value)}
90
- >
91
- {ko("Motion generation", "모션 생성")}
92
- </button>
93
- {setupOpen && motionSetup}
94
- </>
95
- )}
96
- <h4>{ko("Language", "언어")}</h4>
97
- {LANGUAGES.map((language) => (
98
- <button
99
- key={language.id}
100
- type="button"
101
- data-testid={`settings-locale-${language.id}`}
102
- aria-pressed={LOCALE === language.id}
103
- title={language.action}
104
- onClick={() => setLocale(language.id)}
105
- >
106
- {language.label}
107
- <span className="mark" aria-hidden="true">{LOCALE === language.id ? "✓" : ""}</span>
108
- </button>
109
- ))}
110
- <h4>{ko("Privacy", "개인정보")}</h4>
111
- <button
112
- type="button"
113
- data-testid="settings-analytics"
114
- aria-pressed={!optedOut}
115
- title={optedOut
116
- ? ko("Turn anonymous analytics on", "익명 사용 통계 켜기")
117
- : ko("Turn anonymous analytics off", "익명 사용 통계 끄기")}
118
- onClick={async () => {
119
- const actual = await setAnalyticsOptOut(!optedOut);
120
- setOptedOut(actual);
121
- }}
122
- >
123
- {ko("Anonymous analytics", "익명 사용 통계")}
124
- <span className="mark">{optedOut ? ko("off", "끔") : ko("on", "켬")}</span>
125
- </button>
126
- {/* Learning the camera is not a document edit and not a topbar
127
- button (R4): the tutorial opens from this closed popover, so the
128
- mode budgets in docs/studio-ui-ia.md §1 are untouched. */}
129
- <h4>{ko("Help", "도움말")}</h4>
130
- <button
131
- type="button"
132
- data-testid="settings-camera-tutorial"
133
- title={ko("Learn the camera in seven steps", "일곱 단계로 카메라 익히기")}
134
- onClick={() => {
135
- window.dispatchEvent(new CustomEvent("cozyclay:camera-tutorial", { detail: { open: true } }));
136
- setOpen(false);
137
- }}
138
- >
139
- {ko("Camera tutorial", "카메라 튜토리얼")}
140
- </button>
141
- </div>
142
- )}
70
+ <PreferencesDialog
71
+ open={open}
72
+ onClose={close}
73
+ motionSetup={motionSetup}
74
+ initialSection={motionSetupReveal ? "ARDY Connection" : "Appearance"}
75
+ cameraTutorial={cameraTutorial}
76
+ />
143
77
  </div>
144
78
  );
145
79
  }
@@ -0,0 +1,450 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { useStudioShell } from "./studio-shell-context.js";
3
+ import { logStore } from "./log-store.js";
4
+ import "./dock.css";
5
+ import LibrarySlot from "./LibrarySlot.jsx";
6
+ import { ko } from "../locale.js";
7
+ import Timeline from "../ardy/timeline.jsx";
8
+ import { DEFAULT_PLAYBACK_SPEED, SHOT_ASPECT_PRESETS, sceneObjectNameDisplayKo } from "../app-stage.jsx";
9
+ import { motionEditLayout, createMotionEdit } from "../ardy/motion-edit.js";
10
+ import { trackFeature } from "../analytics.js";
11
+ import { defaultRailRange } from "../camera-rail-schedule.js";
12
+
13
+ // G11: the dock resizes within [220, 480] px, and never so far that the 3D
14
+ // viewport drops under 480 px (top bar 44 + status bar 24 + three 1 px gaps).
15
+ // Never below the sequencer's own grid (shell.css --shell-dock-default, 276
16
+ // px): a shorter dock would have to scroll its lanes.
17
+ export const DOCK_MIN_HEIGHT = 276;
18
+ export const DOCK_MAX_HEIGHT = 480;
19
+ const VIEWPORT_MIN_HEIGHT = 480;
20
+ const SHELL_FIXED_ROWS = 44 + 24 + 3;
21
+ // v2: v1 heights were clamped to a 324px floor that left an empty band under
22
+ // the lanes; they are dropped so the dock opens at its real grid height.
23
+ const DOCK_HEIGHT_KEY = "cozyclay.dock.height.v2";
24
+
25
+ function clampDockHeight(height) {
26
+ const roomy = Math.min(DOCK_MAX_HEIGHT, window.innerHeight - SHELL_FIXED_ROWS - VIEWPORT_MIN_HEIGHT);
27
+ return Math.round(Math.min(Math.max(DOCK_MIN_HEIGHT, roomy), Math.max(DOCK_MIN_HEIGHT, height)));
28
+ }
29
+
30
+ function readStored(key) {
31
+ try {
32
+ return globalThis.localStorage?.getItem(key) ?? null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function writeStored(key, value) {
39
+ try {
40
+ globalThis.localStorage?.setItem(key, value);
41
+ } catch (error) {
42
+ console.warn(`[cozyclay] could not store ${key}`, error);
43
+ }
44
+ }
45
+
46
+ function readDockHeight() {
47
+ const stored = Number(readStored(DOCK_HEIGHT_KEY));
48
+ return Number.isFinite(stored) && stored > 0 ? stored : null;
49
+ }
50
+
51
+ const DOCK_TABS = [
52
+ { key: "animation", label: () => ko("Animation", "애니메이션") },
53
+ { key: "assets", label: () => ko("Assets", "에셋") },
54
+ ];
55
+
56
+ /** Animation | Assets: both stay mounted, so switching keeps the Sequencer's
57
+ * scroll and zoom and the Library's folder and search. */
58
+ export default function BottomDock({ tab = "animation", onTabChange, embedded = false }) {
59
+ const {
60
+ tlFrame, motion, waypointMode, craneSelectedIndex,
61
+ isCameraSelection, addActiveCranePoint, deleteSelectedCranePoint, setCraneSelectedIndex, tlFrameCount,
62
+ tlFps, characters, activeCharIndex, ghostLayers, pathSpeed,
63
+ tlPlaying, workflowMode, waypoints, pendingWaypointFrame, promptClips,
64
+ selectedPromptId, stateBadge, ikMode, ikChains, applyMotionTrim,
65
+ resetMotionTrim, cutMotionAtPlayhead, changeMotionSegmentSpeed, removeMotionSegmentById, ikFrames,
66
+ rangePins, rangePinSelection, ikEditTool, rangePinPreview, footSnap,
67
+ bodyContact, shots, shotAspectKey, activeShotIdx, railDraw,
68
+ pathDraw, selectedSceneObject, setPathDraw, setRailDraw, setWorkspaceLayout,
69
+ changeSceneObject, timingTokenRef, beginSceneTransaction, endSceneTransaction, railCurve,
70
+ posing, ikAddKeyframe, ikDeleteKeyframe, setRangePinSelection, setIkEditTool,
71
+ setBodyContact, setToast, setFootSnap, setTlFrame, advanceFrame,
72
+ stepFrame, cameraPreviewEndRef, manualCameraOverrideRef, setTlPlaying, toggleWaypointMode,
73
+ setWaypointMode, selectActiveCharacterInHierarchy, setActiveWaypointId, setPendingWaypointFrame, removeWaypoint,
74
+ queueRootWaypointFrame, addPromptClip, revealPromptBlocks, setSelectedPromptId, setArdyPrompt,
75
+ changePromptClip, resizePromptClip, movePromptClip, removePromptClip, setSelectedHierarchyId,
76
+ selectWorkflowMode, addCameraKeyframe, moveCameraKeyframe, removeCameraKeyframe, syncActiveCameraFraming,
77
+ activeCamera, activeShotDuration, changeActiveCamera, cameraRail, previewCameraShot,
78
+ toggleCameraRailDraw, deleteCameraRail, selectTimelineShot, shotsDomain, runStudioAction,
79
+ clearMotion, subscribeToasts, exportStatus, exportPhaseLabel, ardyRunning, ardyStatus,
80
+ ardyOutcome,
81
+ } = useStudioShell();
82
+ const dockRef = useRef(null);
83
+ const [dockHeight, setDockHeight] = useState(readDockHeight);
84
+ const dockHeightRef = useRef(dockHeight);
85
+ dockHeightRef.current = dockHeight;
86
+
87
+ // The dock height is the shell row's --shell-dock-height. null keeps the
88
+ // shell's responsive default; a stored size is re-clamped on every window
89
+ // resize so the viewport never drops under its minimum height.
90
+ useLayoutEffect(() => {
91
+ const app = dockRef.current?.closest(".app");
92
+ if (!app) return undefined;
93
+ const apply = () => {
94
+ if (dockHeightRef.current === null) app.style.removeProperty("--shell-dock-height");
95
+ else app.style.setProperty("--shell-dock-height", `${clampDockHeight(dockHeightRef.current)}px`);
96
+ };
97
+ apply();
98
+ window.addEventListener("resize", apply);
99
+ return () => window.removeEventListener("resize", apply);
100
+ }, [dockHeight]);
101
+
102
+ function commitDockHeight(next) {
103
+ const height = clampDockHeight(next);
104
+ setDockHeight(height);
105
+ writeStored(DOCK_HEIGHT_KEY, String(height));
106
+ }
107
+
108
+ function beginDockResize(event) {
109
+ if (event.button !== 0) return;
110
+ event.preventDefault();
111
+ event.stopPropagation();
112
+ const startY = event.clientY;
113
+ const startHeight = dockRef.current.getBoundingClientRect().height;
114
+ let latest = startHeight;
115
+ const onMove = (move) => {
116
+ latest = clampDockHeight(startHeight - (move.clientY - startY));
117
+ setDockHeight(latest);
118
+ };
119
+ const onUp = () => {
120
+ document.body.classList.remove("is-resizing", "resize-timeline");
121
+ window.removeEventListener("pointermove", onMove);
122
+ window.removeEventListener("pointerup", onUp);
123
+ window.removeEventListener("pointercancel", onUp);
124
+ commitDockHeight(latest);
125
+ };
126
+ document.body.classList.add("is-resizing", "resize-timeline");
127
+ window.addEventListener("pointermove", onMove);
128
+ window.addEventListener("pointerup", onUp);
129
+ window.addEventListener("pointercancel", onUp);
130
+ }
131
+
132
+ function onDockResizeKey(event) {
133
+ const step = event.shiftKey ? 48 : 16;
134
+ const current = dockRef.current.getBoundingClientRect().height;
135
+ if (event.key === "ArrowUp") commitDockHeight(current + step);
136
+ else if (event.key === "ArrowDown") commitDockHeight(current - step);
137
+ else if (event.key === "Home") commitDockHeight(DOCK_MIN_HEIGHT);
138
+ else if (event.key === "End") commitDockHeight(DOCK_MAX_HEIGHT);
139
+ else return;
140
+ event.preventDefault();
141
+ }
142
+
143
+ // Session Log: every toast (App fans each one out to subscribeToasts),
144
+ // generation jobs and export phases. The toast sink set lives for the
145
+ // whole session, so one subscription on mount is enough.
146
+ useEffect(() => subscribeToasts((toast) => logStore.push({ kind: "toast", text: toast.uiMessage })),
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ []);
149
+ const generationWasRunning = useRef(false);
150
+ useEffect(() => {
151
+ if (ardyRunning && !generationWasRunning.current) logStore.push({ kind: "generation", text: ko("Motion generation started", "모션 생성을 시작했어요") });
152
+ generationWasRunning.current = ardyRunning;
153
+ }, [ardyRunning]);
154
+ useEffect(() => {
155
+ if (ardyRunning && ardyStatus) logStore.push({ kind: "generation", key: "generation:status", text: ardyStatus });
156
+ }, [ardyRunning, ardyStatus]);
157
+ useEffect(() => {
158
+ if (!ardyOutcome) return;
159
+ logStore.push({ kind: "generation", text: ardyOutcome.ok
160
+ ? ko("Motion generation finished", "모션 생성을 마쳤어요")
161
+ : ko(`Motion generation failed — ${ardyOutcome.message}`, `모션 생성 실패 — ${ardyOutcome.message}`) });
162
+ }, [ardyOutcome]);
163
+ const exportPhase = exportStatus?.phase, exportLabel = exportStatus?.label, exportMessage = exportStatus?.message;
164
+ const exportDone = exportStatus?.completedFrames, exportTotal = exportStatus?.frameCount;
165
+ useEffect(() => {
166
+ if (!exportPhase) return;
167
+ const progress = exportPhase === "encoding" && exportTotal ? ` ${exportDone ?? 0}/${exportTotal}` : "";
168
+ const detail = ["completed", "failed", "cancelled"].includes(exportPhase) && exportMessage ? ` — ${exportMessage}` : "";
169
+ logStore.push({
170
+ kind: "export",
171
+ key: `export:${exportLabel}:${exportPhase}`,
172
+ text: `${ko("Export", "내보내기")} ${exportPhaseLabel(exportPhase)}${exportLabel ? ` · ${exportLabel}` : ""}${progress}${detail}`,
173
+ });
174
+ // eslint-disable-next-line react-hooks/exhaustive-deps
175
+ }, [exportPhase, exportLabel, exportMessage, exportDone, exportTotal]);
176
+
177
+ return (
178
+ <div className="bottom-window v2-dock" ref={dockRef}>
179
+ <div
180
+ className="v2-dock-resize"
181
+ data-testid="dock-resize-handle"
182
+ role="separator"
183
+ tabIndex={0}
184
+ aria-orientation="horizontal"
185
+ aria-label={ko("Resize bottom dock", "하단 도크 크기 조절")}
186
+ aria-valuemin={DOCK_MIN_HEIGHT}
187
+ aria-valuemax={DOCK_MAX_HEIGHT}
188
+ aria-valuenow={dockHeight ?? undefined}
189
+ onPointerDown={beginDockResize}
190
+ onKeyDown={onDockResizeKey}
191
+ />
192
+ {!embedded && <div className="dock-tabs" role="tablist" aria-label={ko("Dock", "도크")}>
193
+ {DOCK_TABS.map((entry) => (
194
+ <button
195
+ type="button"
196
+ role="tab"
197
+ key={entry.key}
198
+ data-testid={`dock-tab-${entry.key}`}
199
+ aria-selected={tab === entry.key}
200
+ onClick={() => onTabChange?.(entry.key)}
201
+ >
202
+ {entry.label()}
203
+ </button>
204
+ ))}
205
+ </div>}
206
+ {!embedded && <LibrarySlot active={tab === "assets"} />}
207
+ <div className="bottom-timeline" hidden={tab !== "animation"}>
208
+ <Timeline
209
+ frame={tlFrame}
210
+ craneSelectedIndex={craneSelectedIndex}
211
+ cameraSelected={isCameraSelection}
212
+ onCranePointAdd={addActiveCranePoint}
213
+ onCranePointDelete={deleteSelectedCranePoint}
214
+ onCranePointSelect={setCraneSelectedIndex}
215
+ frameCount={tlFrameCount}
216
+ fps={tlFps}
217
+ playbackSpeed={DEFAULT_PLAYBACK_SPEED}
218
+ trackOwner={characters.length > 1 ? `S${activeCharIndex + 1}` : null}
219
+ ghostLayers={ghostLayers}
220
+ pathSpeed={pathSpeed}
221
+ playing={tlPlaying}
222
+ workflowMode={workflowMode === "pose" ? "motion" : workflowMode}
223
+ waypointMode={waypointMode}
224
+ waypoints={waypoints}
225
+ pathSpeed={pathSpeed}
226
+ pendingWaypointFrame={pendingWaypointFrame}
227
+ promptClips={promptClips}
228
+ selectedPromptId={selectedPromptId}
229
+ badge={stateBadge}
230
+ ikMode={ikMode}
231
+ ikDisabled={!ikChains}
232
+ motion={motion ? {
233
+ frames: motion.frames,
234
+ label: motion.prompt || ko("Loaded take", "불러온 테이크"),
235
+ segments: motionEditLayout(motion.editSegments ?? createMotionEdit(motion.frames)),
236
+ } : null}
237
+ onMotionTrim={applyMotionTrim}
238
+ onMotionTrimReset={resetMotionTrim}
239
+ onMotionCut={cutMotionAtPlayhead}
240
+ onMotionSpeedChange={changeMotionSegmentSpeed}
241
+ onMotionSegmentRemove={removeMotionSegmentById}
242
+ ikFrames={ikFrames}
243
+ rangePins={rangePins}
244
+ selectedPinId={rangePinSelection}
245
+ pendingPinRange={ikMode && ikEditTool === "pin" ? rangePinPreview?.draft ?? null : null}
246
+ footSnap={footSnap}
247
+ bodyContact={bodyContact}
248
+ shots={shots}
249
+ shotAspect={shotAspectKey}
250
+ activeShotIdx={activeShotIdx}
251
+ railDraw={railDraw}
252
+ pathDraw={pathDraw}
253
+ pathObject={selectedSceneObject ? { id: selectedSceneObject.id, name: sceneObjectNameDisplayKo(selectedSceneObject.name), path: selectedSceneObject.path } : null}
254
+ onObjectPathDrawToggle={() => {
255
+ setPathDraw((current) => !current);
256
+ if (!pathDraw) setRailDraw(false);
257
+ setWorkspaceLayout((current) => ({ ...current, insetCollapsed: false }));
258
+ }}
259
+ onObjectPathChange={(path) => {
260
+ if (selectedSceneObject) changeSceneObject(selectedSceneObject.id, { path }, timingTokenRef.current ?? undefined);
261
+ }}
262
+ onObjectPathClear={() => {
263
+ if (!selectedSceneObject) return;
264
+ const token = beginSceneTransaction({ owner: "object-path", cancel: () => {} });
265
+ changeSceneObject(selectedSceneObject.id, { path: null }, token);
266
+ endSceneTransaction(token, { commit: true });
267
+ }}
268
+ onObjectTimingGestureStart={() => {
269
+ timingTokenRef.current = beginSceneTransaction({ owner: "object-timing", cancel: () => { timingTokenRef.current = null; } });
270
+ }}
271
+ onObjectTimingGestureEnd={() => {
272
+ if (timingTokenRef.current != null) endSceneTransaction(timingTokenRef.current, { commit: true });
273
+ timingTokenRef.current = null;
274
+ }}
275
+ cameraRailLength={railCurve?.length ?? null}
276
+ shotCutDisabled={!!posing || ikMode || waypointMode}
277
+ onIkKeyframeAdd={ikAddKeyframe}
278
+ onIkKeyframeRemove={ikDeleteKeyframe}
279
+ onPinSelect={(id) => { setRangePinSelection(id); setIkEditTool("pin"); }}
280
+ onBodyContactToggle={() => {
281
+ setBodyContact((v) => {
282
+ setToast(v ? ko("Body contact off — floor constraints are disabled", "바닥 접촉 꺼짐 — 바닥 제약이 비활성화됩니다") : ko("Body contact on — body markers stay above the floor", "바닥 접촉 켜짐 — 신체 접촉점이 바닥 아래로 내려가지 않습니다"));
283
+ return !v;
284
+ });
285
+ }}
286
+ onFootSnapToggle={() => {
287
+ setFootSnap((v) => {
288
+ setToast(v ? ko("Foot snap off — the feet follow the body", "발 스냅 꺼짐 — 발이 몸을 따라갑니다") : ko("Foot snap on — the feet stay planted while the body moves", "발 스냅 켜짐 — 몸이 움직여도 발은 바닥에 고정됩니다"));
289
+ return !v;
290
+ });
291
+ }}
292
+ onScrub={(frame) => { trackFeature("timeline_scrub"); setTlFrame(frame); }}
293
+ onAdvance={advanceFrame}
294
+ onStep={stepFrame}
295
+ onPlayToggle={() => {
296
+ cameraPreviewEndRef.current = null;
297
+ manualCameraOverrideRef.current = false;
298
+ setTlPlaying((v) => !v);
299
+ }}
300
+ onWaypointToggle={toggleWaypointMode}
301
+ onMarkerSelect={(id) => {
302
+ const waypoint = waypoints.find((entry) => entry.id === id);
303
+ if (!waypoint) throw new Error(`Unknown waypoints ID: ${id}`);
304
+ setTlFrame(Math.min(waypoint.frame, tlFrameCount - 1));
305
+ setWaypointMode(true);
306
+ selectActiveCharacterInHierarchy();
307
+ setActiveWaypointId(id);
308
+ setPendingWaypointFrame(null);
309
+ }}
310
+ onMarkerRemove={removeWaypoint}
311
+ onRootKeyframeAdd={queueRootWaypointFrame}
312
+ onPromptAdd={(frame) => {
313
+ addPromptClip(frame);
314
+ selectActiveCharacterInHierarchy();
315
+ revealPromptBlocks();
316
+ }}
317
+ onPromptSelect={(id) => {
318
+ setSelectedPromptId(id);
319
+ setArdyPrompt(promptClips.find((clip) => clip.id === id)?.text ?? "");
320
+ selectActiveCharacterInHierarchy();
321
+ revealPromptBlocks();
322
+ }}
323
+ onPromptChange={changePromptClip}
324
+ onPromptResize={resizePromptClip}
325
+ onPromptMove={movePromptClip}
326
+ onPromptRemove={removePromptClip}
327
+ onCameraMoveSelect={() => {
328
+ setSelectedHierarchyId("camera");
329
+ if (workflowMode !== "camera") selectWorkflowMode("camera");
330
+ }}
331
+ onCameraKeyframeAdd={addCameraKeyframe}
332
+ onCameraKeyframeMove={moveCameraKeyframe}
333
+ onCameraKeyframeRemove={removeCameraKeyframe}
334
+ onCameraBlockSelect={(shotId) => {
335
+ const selected = shots.find((entry) => entry.id === shotId);
336
+ if (!selected) throw new Error(`Unknown shots ID: ${shotId}`);
337
+ setTlFrame(selected.startFrame);
338
+ setSelectedHierarchyId("camera");
339
+ if (workflowMode !== "camera") selectWorkflowMode("camera");
340
+ }}
341
+ onCameraBlockChange={(patch, shotId) => {
342
+ if (patch.mode === "follow") syncActiveCameraFraming();
343
+ const nextPatch = patch.mode === "rail" && activeCamera.railFollow?.mode === "off"
344
+ ? { ...patch, railFollow: defaultRailRange(activeShotDuration) }
345
+ : patch;
346
+ // The embedded dolly graph edits the shot it sits in; the
347
+ // camera bar above edits the selected one.
348
+ changeActiveCamera(nextPatch, shotId);
349
+ if (patch.mode === "follow" && !motion) {
350
+ setToast(ko(
351
+ "Follow rides the subject's motion — without a loaded motion the camera composes a static frame",
352
+ "팔로우 카메라는 인물 모션을 따라 움직입니다 — 모션이 없으면 카메라는 정지 구도를 유지합니다",
353
+ ));
354
+ }
355
+ if (patch.mode === "rail" && !cameraRail) {
356
+ setRailDraw(true);
357
+ setWorkspaceLayout((current) => ({ ...current, insetCollapsed: false }));
358
+ setToast(ko("Draw this Camera Block's rail in the Top-View", "탑뷰에서 이 카메라 블록의 레일을 그리세요"));
359
+ }
360
+ }}
361
+ onCameraPreview={previewCameraShot}
362
+ onCameraRailDrawToggle={toggleCameraRailDraw}
363
+ onCameraRailDelete={deleteCameraRail}
364
+ onShotSelect={selectTimelineShot}
365
+ onShotBoundaryMove={shotsDomain.resizeTimelineShot}
366
+ onShotRename={shotsDomain.renameTimelineShot}
367
+ onShotRemove={(shotId) => runStudioAction("shot.remove", { shotId })}
368
+ onShotDuplicate={(shotId) => runStudioAction("shot.duplicate", { shotId })}
369
+ onShotCut={() => runStudioAction("shot.create")}
370
+ onShotSplit={(shotId) => runStudioAction("shot.split", { shotId })}
371
+ onShotMove={(shotId, targetFrame) => runStudioAction("shot.reorder", { shotId, startFrame: Math.max(0, Math.round(targetFrame)) })}
372
+ onClearMotion={motion ? clearMotion : null}
373
+ />
374
+ </div>
375
+ {tab === "animation" && <ShotCard />}
376
+ </div>
377
+ );
378
+ }
379
+
380
+ // #570: the ratios offered on the card; the rest stay in Camera mode's menu.
381
+ const CARD_RATIOS = [
382
+ { key: "16:9", label: "16:9" },
383
+ { key: "2.39:1", label: "2.39" },
384
+ { key: "4:3", label: "4:3" },
385
+ { key: "1:1", label: "1:1" },
386
+ { key: "9:16", label: "9:16" },
387
+ ];
388
+
389
+ /** The shot camera's card at the dock's right end. The camera itself is drawn
390
+ * into the stage canvas under `.vp-shot-preview`, which sits over the card's
391
+ * frame slot (see glass.css); the card holds its title, ratio and look-through. */
392
+ function ShotCard() {
393
+ const { activeShot, shot, shotAspectKey, runStudioAction, enterShotLook, exitShotLook, lookThroughShot, tlFps, embedMode } = useStudioShell();
394
+ if (embedMode) return null;
395
+ const ratio = SHOT_ASPECT_PRESETS[shotAspectKey]?.label?.replace(/:1$/, "") ?? shotAspectKey;
396
+ const frames = activeShot && Number.isFinite(activeShot.startFrame) && Number.isFinite(activeShot.endFrame)
397
+ ? `${activeShot.startFrame}–${activeShot.endFrame} · ${((activeShot.endFrame - activeShot.startFrame + 1) / (tlFps || 24)).toFixed(1)}s`
398
+ : null;
399
+ return (
400
+ <section className="dock-shot-card" aria-label={ko("Shot Camera", "샷 카메라")}>
401
+ <header className="dock-shot-head">
402
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="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" /></svg>
403
+ <span className="dock-shot-title">{ko("Shot Camera", "샷 카메라")}</span>
404
+ <span className="dock-shot-meta">{shot.focalMm} mm · {ratio}</span>
405
+ </header>
406
+ {/* While the main view looks through this camera the card has nothing
407
+ of its own to show (the preview pass is off): it says where the
408
+ picture went instead of leaving a hole onto the stage. */}
409
+ <div className="dock-shot-frame" aria-hidden={!lookThroughShot || undefined}>
410
+ {lookThroughShot && (
411
+ <span className="dock-shot-live">
412
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="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" /></svg>
413
+ {ko("Viewing in the main view", "메인 뷰에서 보는 중")}
414
+ </span>
415
+ )}
416
+ </div>
417
+ <div className="dock-shot-ratios" role="radiogroup" aria-label={ko("Shot aspect ratio", "샷 화면 비율")}>
418
+ {CARD_RATIOS.map((entry) => (
419
+ <button
420
+ type="button"
421
+ role="radio"
422
+ key={entry.key}
423
+ data-aspect={entry.key}
424
+ aria-checked={shotAspectKey === entry.key}
425
+ onClick={() => runStudioAction("stage.setFilmback", { shotAspect: entry.key })}
426
+ >
427
+ {entry.label}
428
+ </button>
429
+ ))}
430
+ </div>
431
+ <footer className="dock-shot-foot">
432
+ <span className="dock-shot-name">
433
+ {activeShot?.name ?? ko("No shot yet", "샷 없음")}
434
+ {frames && <span className="dock-shot-range"> · {frames}</span>}
435
+ </span>
436
+ {lookThroughShot ? (
437
+ <button type="button" className="dock-shot-look" data-active="true" aria-pressed="true" onClick={exitShotLook} title={ko("Back to the editor view (Esc)", "편집 시점으로 돌아가기 (Esc)")}>
438
+ {ko("Exit", "나가기")}
439
+ <kbd>Esc</kbd>
440
+ </button>
441
+ ) : (
442
+ <button type="button" className="dock-shot-look" aria-pressed="false" onClick={enterShotLook} title={ko("Look through the shot camera (Esc returns)", "샷 카메라 시점으로 보기 (Esc로 복귀)")}>
443
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="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" /></svg>
444
+ {ko("Look through", "샷 시점")}
445
+ </button>
446
+ )}
447
+ </footer>
448
+ </section>
449
+ );
450
+ }