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
@@ -1,14 +1,16 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
2
2
  import { ko } from "./locale.js";
3
3
  import { CHARACTER_MODEL_IDS } from "./scenes.js";
4
4
  import { OBJECT_LIBRARY } from "./scene-objects.js";
5
- import { displayObjectGroupName, displayObjectLabel } from "./object-catalog.jsx";
5
+ import { displayObjectLabel } from "./object-catalog.jsx";
6
6
  import { assetAspect, isMeshAssetId, isSupportedMeshType } from "./scene-assets.js";
7
7
  import { assetKind, formatAssetBytes } from "./asset-shelf.js";
8
8
  import { assetRecord } from "./scene-asset-cache.js";
9
9
  import ResourceStatus from "./resource-status.jsx";
10
+ import { logStore } from "./shell/log-store.js";
11
+ import "./asset-pane.css";
10
12
 
11
- /** Casting assets offered in the bottom Assets tab. `id` doubles as the FBX
13
+ /** Casting assets offered in the Content browser's Characters folder. `id` doubles as the FBX
12
14
  * file stem and the ARDY wire rig name (see scenes.js). */
13
15
  export const CHARACTER_ASSETS = CHARACTER_MODEL_IDS.map((id) => ({
14
16
  id,
@@ -27,71 +29,29 @@ function CharacterPreview({ model }) {
27
29
  );
28
30
  }
29
31
 
30
- function ObjectPreview({ kind, color }) {
31
- const fill = color || "#b8bec3";
32
- const common = { fill, stroke: "#d7dde0", strokeWidth: 1.2, strokeLinejoin: "round" };
33
- let shape;
34
- switch (kind) {
35
- case "cube":
36
- shape = <>
37
- <path {...common} d="m9 16 15-8 15 8-15 8Z" />
38
- <path {...common} d="m9 16 15 8v16L9 31Z" opacity=".82" />
39
- <path {...common} d="m39 16-15 8v16l15-9Z" opacity=".62" />
40
- </>;
41
- break;
42
- case "sphere":
43
- shape = <>
44
- <circle {...common} cx="24" cy="24" r="16" />
45
- <path className="preview-detail" d="M11 24h26M24 8c8 8 8 24 0 32M24 8c-8 8-8 24 0 32" />
46
- </>;
47
- break;
48
- case "capsule":
49
- shape = <>
50
- <rect {...common} x="14" y="5" width="20" height="38" rx="10" />
51
- <path className="preview-detail" d="M15 17h18M15 31h18" />
52
- </>;
53
- break;
54
- case "cylinder":
55
- shape = <>
56
- <path {...common} d="M11 14c0-4 26-4 26 0v20c0 5-26 5-26 0Z" />
57
- <ellipse cx="24" cy="14" rx="13" ry="5" fill={fill} stroke="#d7dde0" strokeWidth="1.2" />
58
- <path className="preview-detail" d="M11 34c0 5 26 5 26 0" />
59
- </>;
60
- break;
61
- case "cone":
62
- shape = <>
63
- <path {...common} d="m24 6 14 31H10Z" />
64
- <ellipse cx="24" cy="37" rx="14" ry="4" fill={fill} stroke="#d7dde0" strokeWidth="1.2" />
65
- </>;
66
- break;
67
- case "plane":
68
- shape = <>
69
- <path {...common} d="M7 28 29 14l12 7-22 14Z" />
70
- <path className="preview-detail" d="m7 28 12 7 22-14" />
71
- </>;
72
- break;
73
- case "chair":
74
- shape = <>
75
- <path {...common} d="M13 8h18v17H13Z" />
76
- <path {...common} d="M12 25h24v8H12Z" />
77
- <path className="preview-limb" d="M15 33v10m18-10v10M13 25V8" />
78
- </>;
79
- break;
80
- case "car":
81
- shape = <>
82
- <path {...common} d="M7 27h5l5-9h15l7 9h3v9H7Z" />
83
- <path className="preview-detail" d="M17 18h15l4 9H13Z" />
84
- <circle cx="15" cy="36" r="4" fill="#181a1c" stroke="#d7dde0" />
85
- <circle cx="35" cy="36" r="4" fill="#181a1c" stroke="#d7dde0" />
86
- </>;
87
- break;
88
- case "small-plane":
89
- shape = <path {...common} d="M22 5h4l3 15 13 8v4l-13-3-2 12 5 3v2l-8-2-8 2v-2l5-3-2-12-13 3v-4l13-8Z" />;
90
- break;
91
- default:
92
- shape = <rect {...common} x="10" y="10" width="28" height="28" rx="4" />;
93
- }
94
- return <svg className="asset-card-preview" viewBox="0 0 48 48" aria-hidden="true">{shape}</svg>;
32
+ // #570: catalogue objects are drawn as line glyphs on a 24 grid, the same
33
+ // stroke family as the shell's icons; the tile tints them on selection.
34
+ const OBJECT_GLYPHS = {
35
+ cube: "M12 3.5 19.5 7.75v8.5L12 20.5l-7.5-4.25v-8.5z M4.5 7.75 12 12l7.5-4.25 M12 12v8.5",
36
+ sphere: "M12 4a8 8 0 1 1 0 16 8 8 0 1 1 0-16z M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3",
37
+ capsule: "M8 8a4 4 0 0 1 8 0v8a4 4 0 0 1-8 0z M8 9.5c0 1 1.8 1.6 4 1.6s4-.6 4-1.6",
38
+ cylinder: "M5 7c0-1.38 3.13-2.5 7-2.5s7 1.12 7 2.5-3.13 2.5-7 2.5S5 8.38 5 7z M5 7v10c0 1.38 3.13 2.5 7 2.5s7-1.12 7-2.5V7",
39
+ cone: "M12 3.5 5 17 M12 3.5 19 17 M5 17c0 1.38 3.13 2.5 7 2.5s7-1.12 7-2.5",
40
+ plane: "M12 7.5 21 12l-9 4.5L3 12z",
41
+ chair: "M7 3.5h10v8.5H7z M5.5 12h13v3h-13z M7 15v5.5 M17 15v5.5",
42
+ car: "M3 15.5v-3l2.5-.5 2.5-4h8l3 4 2 .5v3z M6 15.5a1.75 1.75 0 1 0 3.5 0 M14.5 15.5a1.75 1.75 0 1 0 3.5 0 M8 12h10",
43
+ "small-plane": "M12 3v18 M3 11.5l9-2 9 2v2l-9-1-9 1z M8.5 19.5 12 18.5l3.5 1",
44
+ };
45
+
46
+ const STORAGE_FOLDER = "__storage";
47
+
48
+ function ObjectPreview({ kind }) {
49
+ const d = OBJECT_GLYPHS[kind] ?? "M5 5h14v14H5z";
50
+ return (
51
+ <svg className="asset-card-preview content-object-glyph" viewBox="0 0 24 24" aria-hidden="true">
52
+ <path d={d} />
53
+ </svg>
54
+ );
95
55
  }
96
56
 
97
57
  /** Generic isometric cube: a GLB has no cheap thumbnail, and inventing one
@@ -171,81 +131,6 @@ function grabProps(onAssetGrab, payload) {
171
131
  };
172
132
  }
173
133
 
174
- /** One imported picture. The card renders immediately as a skeleton and the
175
- * thumbnail lands when the decode does — the grid never waits on a decode. */
176
- function ImageAssetCard({ id, onAssetGrab }) {
177
- // null = decoding (skeleton), undefined = record gone, object = ready. A
178
- // cached thumb still starts null for one microtask; no frame is lost.
179
- const [thumb, setThumb] = useState(null);
180
- useEffect(() => {
181
- let alive = true;
182
- loadThumb(id).then((result) => {
183
- if (alive) setThumb(result ?? undefined);
184
- });
185
- return () => {
186
- alive = false;
187
- };
188
- }, [id]);
189
- // undefined = the record is gone (another tab swept it); show nothing
190
- // rather than a card that spawns a blank quad. null = the record is
191
- // there but its bytes did not decode — the card MUST stay visible, or
192
- // the failure leaves garbage the storage manager cannot even show.
193
- if (thumb === undefined) return null;
194
- const label = thumb?.name?.replace(/\.[^.]+$/, "") || ko("Image", "이미지");
195
- const failed = thumb === null;
196
- return (
197
- <button
198
- type="button"
199
- className={"asset-card" + (failed ? " asset-card-failed" : "")}
200
- title={failed
201
- ? ko(`${label} — could not decode; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`)
202
- : ko(`Drag ${label} into the scene`, `${label}을(를) 씬에 드래그하세요`)}
203
- {...(failed ? {} : grabProps(onAssetGrab, { kind: "image", assetId: id, label, aspect: thumb?.aspect ?? 1, thumb: thumb?.url ?? null }))}
204
- >
205
- {thumb ? (
206
- <img className="asset-card-thumb" src={thumb.url} alt="" draggable={false} />
207
- ) : (
208
- <span className="asset-card-thumb asset-card-thumb-skeleton" aria-hidden="true" />
209
- )}
210
- <span className="asset-card-label">{label}</span>
211
- <span className="asset-card-kind">{failed ? ko("Unreadable", "읽을 수 없음") : ko("Image", "이미지")}</span>
212
- </button>
213
- );
214
- }
215
-
216
- function MeshAssetCard({ id, onAssetGrab }) {
217
- const [thumb, setThumb] = useState(null);
218
- useEffect(() => {
219
- let alive = true;
220
- loadThumb(id).then((result) => {
221
- if (alive) setThumb(result ?? undefined);
222
- });
223
- return () => {
224
- alive = false;
225
- };
226
- }, [id]);
227
- if (thumb === undefined) return null;
228
- const label = thumb?.name?.replace(/\.[^.]+$/, "") || ko("Model", "모델");
229
- const failed = thumb === null;
230
- return (
231
- <button
232
- type="button"
233
- className={"asset-card" + (failed ? " asset-card-failed" : "")}
234
- title={failed
235
- ? ko(`${label} — could not read; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`)
236
- : ko(`Drag ${label} into the scene`, `${label}을(를) 씬에 드래그하세요`)}
237
- {...(failed ? {} : grabProps(onAssetGrab, { kind: "mesh", assetId: id, label }))}
238
- >
239
- {failed ? (
240
- <span className="asset-card-thumb asset-card-thumb-skeleton" aria-hidden="true" />
241
- ) : (
242
- <MeshPreview />
243
- )}
244
- <span className="asset-card-label">{label}</span>
245
- <span className="asset-card-kind">{failed ? ko("Unreadable", "읽을 수 없음") : ko("Model", "모델")}</span>
246
- </button>
247
- );
248
- }
249
134
 
250
135
  function StorageAssetRow({ id, onDelete, deleting, usageCount = 0, graphSignature }) {
251
136
  const [thumb, setThumb] = useState(null);
@@ -349,106 +234,450 @@ function StorageManager({ unusedAssetIds, usedAssetIds, usageCounts, graphSignat
349
234
  );
350
235
  }
351
236
 
237
+
238
+ /** The Content grid's one tile grammar: a square thumbnail, an 11px name and
239
+ * a 10px type line. Click selects (amber ring), a left-button press starts the
240
+ * App-owned drag, double-click places the asset at the origin. */
241
+ function ContentTile({ assetKey, label, type, title, preview, selected, onSelect, grab, onPlace, failed = false }) {
242
+ return (
243
+ <button
244
+ type="button"
245
+ className={"content-tile" + (failed ? " is-failed" : "")}
246
+ data-testid="content-asset"
247
+ data-asset-key={assetKey}
248
+ aria-pressed={selected}
249
+ title={title}
250
+ {...(grab ?? {})}
251
+ onClick={() => onSelect(assetKey)}
252
+ onDoubleClick={onPlace ? () => onPlace() : undefined}
253
+ >
254
+ <span className="content-tile-thumb">{preview}</span>
255
+ <span className="content-tile-name">{label}</span>
256
+ <span className="content-tile-type">{type}</span>
257
+ </button>
258
+ );
259
+ }
260
+
261
+ function useThumb(id) {
262
+ // null = decoding (skeleton), undefined = record gone, object = ready.
263
+ const [thumb, setThumb] = useState(null);
264
+ useEffect(() => {
265
+ let alive = true;
266
+ loadThumb(id).then((result) => {
267
+ if (alive) setThumb(result ?? undefined);
268
+ });
269
+ return () => {
270
+ alive = false;
271
+ };
272
+ }, [id]);
273
+ return thumb;
274
+ }
275
+
276
+ /** One imported picture. The tile renders immediately as a skeleton and the
277
+ * thumbnail lands when the decode does; the grid never waits on a decode. */
278
+ function ImageAssetTile({ id, onAssetGrab, onAssetPlace, selectedKey, onSelect, query }) {
279
+ const thumb = useThumb(id);
280
+ // undefined = another tab swept the record: show nothing. null = the bytes
281
+ // did not decode: the tile MUST stay visible so storage can delete it.
282
+ if (thumb === undefined) return null;
283
+ const label = thumb?.name?.replace(/\.[^.]+$/, "") || ko("Image", "이미지");
284
+ if (!matchesQuery(label, query)) return null;
285
+ const failed = thumb === null;
286
+ const payload = { kind: "image", assetId: id, label, aspect: thumb?.aspect ?? 1, thumb: thumb?.url ?? null };
287
+ const key = `image:${id}`;
288
+ return (
289
+ <ContentTile
290
+ assetKey={key}
291
+ label={label}
292
+ type={failed ? ko("Unreadable", "읽을 수 없음") : ko("Image", "이미지")}
293
+ failed={failed}
294
+ title={failed
295
+ ? ko(`${label} — could not decode; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`)
296
+ : ko(`Drag ${label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
297
+ preview={thumb
298
+ ? <img className="content-tile-img" src={thumb.url} alt="" draggable={false} />
299
+ : <span className="content-tile-skeleton" aria-hidden="true" />}
300
+ selected={selectedKey === key}
301
+ onSelect={onSelect}
302
+ grab={failed ? null : grabProps(onAssetGrab, payload)}
303
+ onPlace={failed ? null : () => onAssetPlace?.(payload)}
304
+ />
305
+ );
306
+ }
307
+
308
+ function MeshAssetTile({ id, onAssetGrab, onAssetPlace, selectedKey, onSelect, query }) {
309
+ const thumb = useThumb(id);
310
+ if (thumb === undefined) return null;
311
+ const label = thumb?.name?.replace(/\.[^.]+$/, "") || ko("Model", "모델");
312
+ if (!matchesQuery(label, query)) return null;
313
+ const failed = thumb === null;
314
+ const payload = { kind: "mesh", assetId: id, label };
315
+ const key = `mesh:${id}`;
316
+ return (
317
+ <ContentTile
318
+ assetKey={key}
319
+ label={label}
320
+ type={failed ? ko("Unreadable", "읽을 수 없음") : ko("Model", "모델")}
321
+ failed={failed}
322
+ title={failed
323
+ ? ko(`${label} — could not read; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`)
324
+ : ko(`Drag ${label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
325
+ preview={failed ? <span className="content-tile-skeleton" aria-hidden="true" /> : <MeshPreview />}
326
+ selected={selectedKey === key}
327
+ onSelect={onSelect}
328
+ grab={failed ? null : grabProps(onAssetGrab, payload)}
329
+ onPlace={failed ? null : () => onAssetPlace?.(payload)}
330
+ />
331
+ );
332
+ }
333
+
334
+ function CameraPreview() {
335
+ return (
336
+ <svg className="asset-card-preview content-glyph is-camera" viewBox="0 0 48 48" aria-hidden="true">
337
+ <rect x="7" y="16" width="24" height="18" rx="3" />
338
+ <path d="m31 22 10-5v16l-10-5Z" />
339
+ <circle cx="14" cy="11" r="4" />
340
+ <circle cx="24" cy="11" r="4" />
341
+ </svg>
342
+ );
343
+ }
344
+
345
+ function MotionPreview() {
346
+ return (
347
+ <svg className="asset-card-preview content-glyph is-motion" viewBox="0 0 48 48" aria-hidden="true">
348
+ <path d="M6 32c6 0 7-14 13-14s7 14 13 14 6-10 10-10" />
349
+ <circle cx="6" cy="32" r="2.5" />
350
+ <circle cx="42" cy="22" r="2.5" />
351
+ </svg>
352
+ );
353
+ }
354
+
355
+ function matchesQuery(label, query) {
356
+ return !query || String(label).toLowerCase().includes(query);
357
+ }
358
+
359
+ /** Folder ids in display order. Basic Shapes and Sets split the object
360
+ * catalogue by its own groups; Props holds what the user imported. */
361
+ const FOLDERS = [
362
+ { id: "basic", label: () => ko("Basic Shapes", "기본 도형") },
363
+ { id: "characters", label: () => ko("Characters", "인물") },
364
+ { id: "sets", label: () => ko("Sets", "세트") },
365
+ { id: "props", label: () => ko("Props", "소품") },
366
+ { id: "cameras", label: () => ko("Cameras", "카메라") },
367
+ { id: "motions", label: () => ko("Motions", "모션") },
368
+ { id: "poses", label: () => ko("Poses", "포즈") },
369
+ ];
370
+
371
+ function catalogueTile(entry, props) {
372
+ const label = displayObjectLabel(entry.label);
373
+ const key = `object:${entry.kind}`;
374
+ const payload = { kind: "object", objectKind: entry.kind, label, color: entry.color };
375
+ return (
376
+ <ContentTile
377
+ key={key}
378
+ assetKey={key}
379
+ label={label}
380
+ type={entry.group === "Primitives" ? ko("Shape", "도형") : ko("Set piece", "세트 소품")}
381
+ title={ko(`Drag ${entry.label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
382
+ preview={<ObjectPreview kind={entry.kind} color={entry.color} />}
383
+ selected={props.selectedKey === key}
384
+ onSelect={props.onSelect}
385
+ grab={grabProps(props.onAssetGrab, payload)}
386
+ onPlace={() => props.onAssetPlace?.(payload)}
387
+ />
388
+ );
389
+ }
390
+
391
+ function EmptyNote({ children }) {
392
+ return <p className="content-empty">{children}</p>;
393
+ }
394
+
395
+ function FolderGrid({ folder, query, ...props }) {
396
+ const { imageAssetIds, meshAssetIds, shots = [], takeVersions = [], poses = [], onShotOpen, onTakeOpen, selectedKey, onSelect } = props;
397
+ let tiles = [];
398
+ let empty = null;
399
+ if (folder === "basic" || folder === "sets") {
400
+ const group = folder === "basic" ? "Primitives" : "Set pieces";
401
+ tiles = OBJECT_LIBRARY
402
+ .filter((entry) => entry.group === group && matchesQuery(displayObjectLabel(entry.label), query))
403
+ .map((entry) => catalogueTile(entry, props));
404
+ } else if (folder === "characters") {
405
+ tiles = CHARACTER_ASSETS.filter((asset) => matchesQuery(asset.label, query)).map((asset) => {
406
+ const key = `character:${asset.id}`;
407
+ const payload = { kind: "character", id: asset.id, label: asset.label };
408
+ return (
409
+ <ContentTile
410
+ key={key}
411
+ assetKey={key}
412
+ label={asset.label}
413
+ type={ko("Character", "인물")}
414
+ title={ko(`Drag ${asset.label} into the scene, or double-click to place it at the origin`, `${asset.label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
415
+ preview={<CharacterPreview model={asset.id} />}
416
+ selected={selectedKey === key}
417
+ onSelect={onSelect}
418
+ grab={grabProps(props.onAssetGrab, payload)}
419
+ onPlace={() => props.onAssetPlace?.(payload)}
420
+ />
421
+ );
422
+ });
423
+ } else if (folder === "props") {
424
+ if (imageAssetIds === null || meshAssetIds === null) {
425
+ tiles = [0, 1, 2].map((n) => (
426
+ <span className="content-tile is-loading" key={`loading-${n}`} aria-hidden="true">
427
+ <span className="content-tile-thumb"><span className="content-tile-skeleton" /></span>
428
+ </span>
429
+ ));
430
+ } else {
431
+ tiles = [
432
+ ...imageAssetIds.map((id) => <ImageAssetTile key={`image:${id}`} id={id} query={query} {...props} />),
433
+ ...meshAssetIds.map((id) => <MeshAssetTile key={`mesh:${id}`} id={id} query={query} {...props} />),
434
+ ];
435
+ if (!tiles.length) empty = ko(
436
+ "No imported props yet. Drop or paste a picture, or drop a .glb, .obj or .fbx into the studio.",
437
+ "아직 가져온 소품이 없어요. 이미지를 드래그하거나 붙여넣고, .glb·.obj·.fbx 파일을 스튜디오에 끌어다 놓으세요.",
438
+ );
439
+ }
440
+ } else if (folder === "cameras") {
441
+ tiles = shots.filter((shot) => matchesQuery(shot.name ?? shot.id, query)).map((shot) => {
442
+ const key = `shot:${shot.id}`;
443
+ const label = shot.name || shot.id;
444
+ return (
445
+ <ContentTile
446
+ key={key}
447
+ assetKey={key}
448
+ label={label}
449
+ type={ko("Camera", "카메라")}
450
+ title={ko(`${label} — double-click to open it in Camera mode`, `${label} — 더블클릭하면 카메라 모드에서 엽니다`)}
451
+ preview={<CameraPreview />}
452
+ selected={selectedKey === key}
453
+ onSelect={onSelect}
454
+ onPlace={onShotOpen ? () => onShotOpen(shot.id) : null}
455
+ />
456
+ );
457
+ });
458
+ if (!shots.length) empty = ko("No shot cameras yet. Use + Add › Camera in the viewport.", "아직 샷 카메라가 없어요. 뷰포트의 + Add › 카메라를 사용하세요.");
459
+ } else if (folder === "motions") {
460
+ tiles = takeVersions.map((entry, index) => ({ entry, index, label: `v${index + 1}${entry.label ? ` · ${entry.label}` : ""}` }))
461
+ .filter(({ label }) => matchesQuery(label, query))
462
+ .map(({ entry, index, label }) => {
463
+ const key = `take:${index}`;
464
+ return (
465
+ <ContentTile
466
+ key={key}
467
+ assetKey={key}
468
+ label={label}
469
+ type={ko("Motion", "모션")}
470
+ title={ko(`${label} — double-click to load this take`, `${label} — 더블클릭하면 이 테이크를 불러옵니다`)}
471
+ preview={<MotionPreview />}
472
+ selected={selectedKey === key}
473
+ onSelect={onSelect}
474
+ onPlace={onTakeOpen ? () => onTakeOpen(entry) : null}
475
+ />
476
+ );
477
+ });
478
+ if (!takeVersions.length) empty = ko("No takes for this character yet. Generate Motion creates one.", "이 인물의 테이크가 아직 없어요. Generate Motion으로 만들 수 있어요.");
479
+ } else if (folder === "poses") {
480
+ tiles = poses.map((pose, index) => ({ pose, index, label: pose?.name || pose?.label || ko(`Pose ${index + 1}`, `포즈 ${index + 1}`) }))
481
+ .filter(({ label }) => matchesQuery(label, query))
482
+ .map(({ pose, index, label }) => {
483
+ const key = `pose:${pose?.id ?? index}`;
484
+ return (
485
+ <ContentTile
486
+ key={key}
487
+ assetKey={key}
488
+ label={label}
489
+ type={ko("Pose", "포즈")}
490
+ title={label}
491
+ preview={<CharacterPreview model="y-bot-tpose" />}
492
+ selected={selectedKey === key}
493
+ onSelect={onSelect}
494
+ />
495
+ );
496
+ });
497
+ if (!poses.length) empty = ko("No saved poses yet. Save one from the pose studio.", "저장된 포즈가 아직 없어요. 포즈 스튜디오에서 저장하세요.");
498
+ }
499
+ if (empty) return <EmptyNote>{empty}</EmptyNote>;
500
+ if (query && folder !== "props" && !tiles.length) return <EmptyNote>{ko("Nothing matches this search.", "검색 결과가 없어요.")}</EmptyNote>;
501
+ return <div className="content-grid" role="list">{tiles}</div>;
502
+ }
503
+
504
+ const LOG_KIND_LABEL = {
505
+ toast: () => ko("Note", "알림"),
506
+ generation: () => ko("Generate", "생성"),
507
+ export: () => ko("Export", "내보내기"),
508
+ info: () => ko("Info", "정보"),
509
+ };
510
+
511
+ function formatLogTime(at) {
512
+ const date = new Date(at);
513
+ return [date.getHours(), date.getMinutes(), date.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
514
+ }
515
+
516
+ /** The Log tab: every session event the log store collected, newest last,
517
+ * pinned to the bottom while new events arrive. */
518
+ function LogView({ query }) {
519
+ const entries = useSyncExternalStore(logStore.subscribe, logStore.getEntries, logStore.getEntries);
520
+ const listRef = useRef(null);
521
+ const visible = query ? entries.filter((entry) => entry.text.toLowerCase().includes(query)) : entries;
522
+ useLayoutEffect(() => {
523
+ const list = listRef.current;
524
+ if (list) list.scrollTop = list.scrollHeight;
525
+ }, [visible.length, visible[visible.length - 1]?.text]);
526
+ if (!entries.length) return <EmptyNote>{ko("No events yet this session. Notices, generation jobs and exports appear here.", "이번 세션의 기록이 아직 없어요. 알림, 생성 작업, 내보내기가 여기에 표시됩니다.")}</EmptyNote>;
527
+ if (!visible.length) return <EmptyNote>{ko("Nothing matches this search.", "검색 결과가 없어요.")}</EmptyNote>;
528
+ return (
529
+ <ol className="content-log" ref={listRef} data-testid="content-log" aria-live="polite">
530
+ {visible.map((entry) => (
531
+ <li className="content-log-row" key={entry.id} data-kind={entry.kind} data-testid="content-log-entry">
532
+ <time className="content-log-time" dateTime={new Date(entry.at).toISOString()}>{formatLogTime(entry.at)}</time>
533
+ <span className="content-log-kind">{(LOG_KIND_LABEL[entry.kind] ?? LOG_KIND_LABEL.info)()}</span>
534
+ <span className="content-log-text" title={entry.text}>{entry.text}</span>
535
+ </li>
536
+ ))}
537
+ </ol>
538
+ );
539
+ }
540
+
541
+ function Chevron({ open }) {
542
+ return (
543
+ <svg className="content-chevron" viewBox="0 0 12 12" aria-hidden="true" data-open={open || undefined}>
544
+ <path d="m4 2.5 3.5 3.5L4 9.5" />
545
+ </svg>
546
+ );
547
+ }
548
+
352
549
  /**
353
- * Bottom-window asset shelf: everything placeable, one grid — the cast, the
354
- * object catalogue and the user's imported pictures, each under its own
355
- * heading. The drag itself is owned by App (ghost overlay + ground raycast on
356
- * drop); the pane only reports the grab with a discriminated payload.
550
+ * The bottom dock's Content | Log pane (v2 2a, G10/G11).
551
+ *
552
+ * Content lists everything placeable under seven folders; the drag itself is
553
+ * owned by App (ghost overlay + ground raycast on drop), the pane only reports
554
+ * the grab with a discriminated payload, and double-click hands the same
555
+ * payload to `onAssetPlace` for an origin placement.
357
556
  *
358
557
  * `imageAssetIds` / `meshAssetIds` are null while App's asset scan is in
359
- * flight, then the SOURCE ids (see asset-shelf.js) — derived mattes and cut
360
- * renders never reach this component. Mesh ids are the imported GLBs.
558
+ * flight, then the SOURCE ids (see asset-shelf.js). `onShelfVisibleChange`
559
+ * tells App when the imported-asset folder is on screen so it scans only then.
361
560
  */
362
- export default function AssetPane({ onAssetGrab, imageAssetIds, meshAssetIds = null, manageStorage, onManageStorageToggle, unusedAssetIds, usedAssetIds, usageCounts, graphSignature, trashCount, onDeleteUnusedAsset, onUndoDelete, deletingAssetId, resourceManifest }) {
561
+ export default function AssetPane({
562
+ onAssetGrab, onAssetPlace, imageAssetIds, meshAssetIds = null, manageStorage, onManageStorageToggle,
563
+ unusedAssetIds, usedAssetIds, usageCounts, graphSignature, trashCount, onDeleteUnusedAsset, onUndoDelete,
564
+ deletingAssetId, resourceManifest, shots, takeVersions, poses, onShotOpen, onTakeOpen,
565
+ collapsed, onCollapsedChange, onShelfVisibleChange,
566
+ }) {
567
+ const [tab, setTab] = useState("content");
568
+ const [folder, setFolder] = useState("basic");
569
+ const [search, setSearch] = useState("");
570
+ const [selectedKey, setSelectedKey] = useState(null);
571
+ const query = search.trim().toLowerCase();
572
+ const shelfVisible = !collapsed && tab === "content" && (folder === "props" || manageStorage);
573
+ useEffect(() => {
574
+ onShelfVisibleChange?.(shelfVisible);
575
+ }, [shelfVisible, onShelfVisibleChange]);
576
+ const folderLabel = manageStorage ? ko("Manage storage", "저장 공간 관리") : FOLDERS.find((entry) => entry.id === folder).label();
577
+ const openFolder = (id) => {
578
+ setFolder(id);
579
+ setSelectedKey(null);
580
+ if (manageStorage) onManageStorageToggle();
581
+ };
363
582
  return (
364
- <div className="assets-shelf">
365
- {resourceManifest ? <ResourceStatus manifest={resourceManifest} compact /> : null}
366
- <div className="assets-shelf-toolbar">
367
- <button type="button" className="assets-manage-toggle" aria-pressed={manageStorage} onClick={onManageStorageToggle}>
368
- {manageStorage ? ko("Back to assets", "에셋으로 돌아가기") : ko("Manage storage", "저장 공간 관리")}
583
+ <section className="content-browser" data-testid="content-browser" data-collapsed={collapsed || undefined} aria-label={ko("Content", "콘텐츠")}>
584
+ <header className="content-head">
585
+ <button
586
+ type="button"
587
+ className="content-collapse"
588
+ data-testid="content-collapse"
589
+ aria-expanded={!collapsed}
590
+ aria-label={collapsed ? ko("Expand content", "콘텐츠 펼치기") : ko("Collapse content", "콘텐츠 접기")}
591
+ title={collapsed ? ko("Expand Content", "콘텐츠 펼치기") : ko("Collapse Content — the Sequencer takes the full width", "콘텐츠 접기 — 시퀀서가 전체 너비를 씁니다")}
592
+ onClick={() => onCollapsedChange(!collapsed)}
593
+ >
594
+ <Chevron open={!collapsed} />
369
595
  </button>
370
- </div>
371
- {manageStorage ? (
372
- <StorageManager unusedAssetIds={unusedAssetIds} usedAssetIds={usedAssetIds} usageCounts={usageCounts} graphSignature={graphSignature} trashCount={trashCount} onDelete={onDeleteUnusedAsset} onUndo={onUndoDelete} deletingAssetId={deletingAssetId} />
373
- ) : <>
374
- <section className="assets-section">
375
- <h3 className="assets-section-title">{ko("Characters", "인물")}</h3>
376
- <div className="assets-grid">
377
- {CHARACTER_ASSETS.map((asset) => (
378
- <button
379
- type="button"
380
- className="asset-card"
381
- key={asset.id}
382
- title={ko(`Drag ${asset.label} into the scene`, `${asset.label}을(를) 씬에 드래그하세요`)}
383
- {...grabProps(onAssetGrab, { kind: "character", id: asset.id, label: asset.label })}
384
- >
385
- <CharacterPreview model={asset.id} />
386
- <span className="asset-card-label">{asset.label}</span>
387
- <span className="asset-card-kind">{ko("Character", "인물")}</span>
388
- </button>
389
- ))}
390
- </div>
391
- </section>
392
- <section className="assets-section">
393
- <h3 className="assets-section-title">{ko("Objects", "오브젝트")}</h3>
394
- <div className="assets-grid">
395
- {OBJECT_LIBRARY.map((entry) => (
596
+ <div className="content-tabs" role="tablist" aria-label={ko("Content and Log", "콘텐츠와 로그")}>
597
+ <button type="button" role="tab" data-testid="content-tab-content" aria-selected={tab === "content"} onClick={() => { setTab("content"); if (collapsed) onCollapsedChange(false); }}>{ko("Content", "콘텐츠")}</button>
598
+ <button type="button" role="tab" data-testid="content-tab-log" aria-selected={tab === "log"} onClick={() => { setTab("log"); if (collapsed) onCollapsedChange(false); }}>{ko("Log", "로그")}</button>
599
+ </div>
600
+ {tab === "content" && !collapsed && (
601
+ <label className="content-folder-picker" title={ko("Folder", "폴더")}>
602
+ <select
603
+ data-testid="content-folder-picker"
604
+ aria-label={ko("Folder", "폴더")}
605
+ value={manageStorage ? STORAGE_FOLDER : folder}
606
+ onChange={(event) => {
607
+ if (event.target.value === STORAGE_FOLDER) { if (!manageStorage) onManageStorageToggle(); }
608
+ else openFolder(event.target.value);
609
+ }}
610
+ >
611
+ {FOLDERS.map((entry) => <option key={entry.id} value={entry.id}>{entry.label()}</option>)}
612
+ <option value={STORAGE_FOLDER}>{ko("Manage storage", "저장 공간 관리")}</option>
613
+ </select>
614
+ <svg viewBox="0 0 10 10" aria-hidden="true"><path d="m2.5 4 2.5 2.5L7.5 4" /></svg>
615
+ </label>
616
+ )}
617
+ <nav className="content-breadcrumb" aria-label={ko("Location", "위치")}>
618
+ {tab === "content" ? <>
619
+ <span>{ko("Content", "콘텐츠")}</span><span aria-hidden="true">/</span><span className="is-leaf">{folderLabel}</span>
620
+ </> : <>
621
+ <span>{ko("Log", "로그")}</span><span aria-hidden="true">/</span><span className="is-leaf">{ko("This session", "이번 세션")}</span>
622
+ </>}
623
+ </nav>
624
+ <input
625
+ type="search"
626
+ className="content-search"
627
+ data-testid="content-search"
628
+ value={search}
629
+ placeholder={tab === "content" ? ko("Search objects", "오브젝트 검색") : ko("Search", "검색")}
630
+ aria-label={tab === "content" ? ko(`Search ${folderLabel}`, `${folderLabel} 검색`) : ko("Search the log", "로그 검색")}
631
+ onChange={(event) => setSearch(event.target.value)}
632
+ />
633
+ </header>
634
+ {collapsed ? null : tab === "log" ? (
635
+ <div className="content-body is-log"><LogView query={query} /></div>
636
+ ) : (
637
+ <div className="content-body">
638
+ <nav className="content-folders" aria-label={ko("Folders", "폴더")}>
639
+ {FOLDERS.map((entry) => (
396
640
  <button
397
641
  type="button"
398
- className="asset-card"
399
- key={entry.kind}
400
- title={ko(
401
- `Drag ${entry.label} into the scene`,
402
- `${displayObjectLabel(entry.label)}을(를) 씬에 드래그하세요`,
403
- )}
404
- {...grabProps(onAssetGrab, { kind: "object", objectKind: entry.kind, label: displayObjectLabel(entry.label), color: entry.color })}
642
+ key={entry.id}
643
+ className="content-folder"
644
+ data-testid={`content-folder-${entry.id}`}
645
+ aria-current={!manageStorage && folder === entry.id ? "true" : undefined}
646
+ onClick={() => openFolder(entry.id)}
405
647
  >
406
- <ObjectPreview kind={entry.kind} color={entry.color} />
407
- <span className="asset-card-label">{displayObjectLabel(entry.label)}</span>
408
- <span className="asset-card-kind">{displayObjectGroupName(entry.group)}</span>
648
+ {entry.label()}
409
649
  </button>
410
650
  ))}
651
+ <button type="button" className="content-folder is-utility" aria-pressed={manageStorage} onClick={onManageStorageToggle}>
652
+ {ko("Manage storage", "저장 공간 관리")}
653
+ </button>
654
+ </nav>
655
+ <div className="content-main">
656
+ {/* Only a project that carries resources has totals worth a line. */}
657
+ {resourceManifest?.items?.length ? <ResourceStatus manifest={resourceManifest} compact /> : null}
658
+ {manageStorage ? (
659
+ <StorageManager unusedAssetIds={unusedAssetIds} usedAssetIds={usedAssetIds} usageCounts={usageCounts} graphSignature={graphSignature} trashCount={trashCount} onDelete={onDeleteUnusedAsset} onUndo={onUndoDelete} deletingAssetId={deletingAssetId} />
660
+ ) : (
661
+ <FolderGrid
662
+ folder={folder}
663
+ query={query}
664
+ onAssetGrab={onAssetGrab}
665
+ onAssetPlace={onAssetPlace}
666
+ imageAssetIds={imageAssetIds}
667
+ meshAssetIds={meshAssetIds}
668
+ shots={shots}
669
+ takeVersions={takeVersions}
670
+ poses={poses}
671
+ onShotOpen={onShotOpen}
672
+ onTakeOpen={onTakeOpen}
673
+ selectedKey={selectedKey}
674
+ onSelect={setSelectedKey}
675
+ />
676
+ )}
411
677
  </div>
412
- </section>
413
- <section className="assets-section">
414
- <h3 className="assets-section-title">{ko("My images", "내 이미지")}</h3>
415
- {imageAssetIds === null ? (
416
- <div className="assets-grid" aria-busy="true">
417
- {[0, 1, 2].map((n) => <span className="asset-card asset-card-skeleton" key={n} aria-hidden="true" />)}
418
- </div>
419
- ) : imageAssetIds.length === 0 ? (
420
- <p className="assets-empty">
421
- {ko(
422
- "No imported images yet. Use \u201cImport image as cutout\u201d in the Props inspector, or drop or paste a picture into the studio.",
423
- "아직 가져온 이미지가 없어요. 소품 인스펙터의 \u201c이미지를 컷아웃으로 가져오기\u201d를 사용하거나, 이미지를 스튜디오에 드래그하거나 붙여넣으세요.",
424
- )}
425
- </p>
426
- ) : (
427
- <div className="assets-grid">
428
- {imageAssetIds.map((id) => <ImageAssetCard key={id} id={id} onAssetGrab={onAssetGrab} />)}
429
- </div>
430
- )}
431
- </section>
432
- <section className="assets-section">
433
- <h3 className="assets-section-title">{ko("My models", "내 모델")}</h3>
434
- {meshAssetIds === null ? (
435
- <div className="assets-grid" aria-busy="true">
436
- {[0, 1, 2].map((n) => <span className="asset-card asset-card-skeleton" key={n} aria-hidden="true" />)}
437
- </div>
438
- ) : meshAssetIds.length === 0 ? (
439
- <p className="assets-empty">
440
- {ko(
441
- "No imported models yet. Use \u201cImport 3D object\u201d in the Props inspector, or drop a .glb, .obj or .fbx into the studio.",
442
- "아직 가져온 모델이 없어요. 소품 인스펙터의 \u201c3D 오브젝트 가져오기\u201d를 사용하거나, .glb, .obj 또는 .fbx 파일을 스튜디오에 끌어다 놓으세요.",
443
- )}
444
- </p>
445
- ) : (
446
- <div className="assets-grid">
447
- {meshAssetIds.map((id) => <MeshAssetCard key={id} id={id} onAssetGrab={onAssetGrab} />)}
448
- </div>
449
- )}
450
- </section>
451
- </>}
452
- </div>
678
+ {!manageStorage && <p className="content-hint">{ko("Drag into the scene to place", "장면으로 끌어 놓으세요")}</p>}
679
+ </div>
680
+ )}
681
+ </section>
453
682
  );
454
683
  }