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,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
+ }