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,4 +1,5 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import packageInfo from "../package.json";
2
3
  import { ko } from "./locale.js";
3
4
  import {
4
5
  hasDirectoryPicker,
@@ -12,11 +13,54 @@ import {
12
13
  requestHandlePermission,
13
14
  storeProjectsDirectory,
14
15
  } from "./project.js";
16
+ import "./project-browser.css";
17
+
18
+ const APP_VERSION = packageInfo.version;
19
+ const CATEGORIES = ["All", "Film", "Dialogue", "Action", "Vehicle"];
20
+ const BLANK_TEMPLATE = Object.freeze({
21
+ id: "blank-stage",
22
+ name: "Blank Stage",
23
+ category: "All",
24
+ description: "An empty stage for blocking a new shot.",
25
+ kind: "blank",
26
+ code: "NEW",
27
+ });
28
+
29
+ // App.jsx keeps the project mutation behind its named requestNewProject/newProject
30
+ // path. The v2 chooser already has the name, so this short-lived handoff seeds
31
+ // the existing ProjectNameDialog and lets its normal submit path commit it.
32
+ let pendingProjectName = null;
33
+
34
+ function formatDate(value) {
35
+ const date = new Date(value);
36
+ if (!Number.isFinite(date.getTime())) return ko("Recently", "최근");
37
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
38
+ }
39
+
40
+ function sampleTemplate(scene) {
41
+ return {
42
+ id: `sample-${scene.id}`,
43
+ starterId: scene.id,
44
+ name: scene.name,
45
+ category: "Action",
46
+ description: scene.blurb,
47
+ kind: "sample",
48
+ code: "SAMPLE",
49
+ };
50
+ }
15
51
 
16
52
  export function ProjectNameDialog({ open, initialName = "My Project", onCancel, onSubmit }) {
17
53
  const [value, setValue] = useState(initialName);
18
54
  useEffect(() => {
19
- if (open) setValue(initialName);
55
+ if (!open) return;
56
+ if (pendingProjectName !== null) {
57
+ const name = pendingProjectName;
58
+ pendingProjectName = null;
59
+ setValue(name);
60
+ onSubmit(name);
61
+ return;
62
+ }
63
+ setValue(initialName);
20
64
  }, [open, initialName]);
21
65
  if (!open) return null;
22
66
  const submit = (event) => {
@@ -25,16 +69,16 @@ export function ProjectNameDialog({ open, initialName = "My Project", onCancel,
25
69
  onSubmit(name);
26
70
  };
27
71
  return (
28
- <div className="project-name-dialog-backdrop" role="presentation">
29
- <form className="project-name-dialog" role="dialog" aria-modal="true" aria-labelledby="project-name-dialog-title" onSubmit={submit}>
72
+ <div className="v2-project-name-dialog-backdrop project-name-dialog-backdrop" role="presentation">
73
+ <form className="v2-project-name-dialog project-name-dialog" role="dialog" aria-modal="true" aria-labelledby="project-name-dialog-title" onSubmit={submit}>
30
74
  <strong id="project-name-dialog-title">{ko("Project name", "프로젝트 이름")}</strong>
31
75
  <label>
32
76
  <span>{ko("Name", "이름")}</span>
33
77
  <input autoFocus value={value} onChange={(event) => setValue(event.target.value)} aria-label={ko("Project name", "프로젝트 이름")} />
34
78
  </label>
35
- <div className="project-name-dialog-actions">
36
- <button type="button" className="btn ghost" onClick={onCancel}>{ko("Cancel", "취소")}</button>
37
- <button type="submit" className="btn primary">{ko("Create", "생성")}</button>
79
+ <div className="v2-project-name-dialog-actions project-name-dialog-actions">
80
+ <button type="button" className="v2-dialog-secondary btn ghost" onClick={onCancel}>{ko("Cancel", "취소")}</button>
81
+ <button type="submit" className="v2-dialog-primary btn primary">{ko("Create", "생성")}</button>
38
82
  </div>
39
83
  </form>
40
84
  </div>
@@ -42,27 +86,38 @@ export function ProjectNameDialog({ open, initialName = "My Project", onCancel,
42
86
  }
43
87
 
44
88
  /**
45
- * Game-engine style project picker. Two sources of truth:
46
- * - Recents: everything the operator opened or saved before (always works).
47
- * - A projects folder: when granted, the folder is enumerated so files the
48
- * operator never opened in THIS browser show up too.
49
- * Fallback for browsers without the File System Access API: recents plus a
50
- * plain "open file" button (the folder section is hidden).
89
+ * v2 start / project screen. Recent projects still come from the browser-local
90
+ * project store, while today's starter scenes become the template cards.
51
91
  */
52
92
  export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew, onClose, startup = false, starters = [], onStarter }) {
93
+ const starterTemplates = useMemo(() => starters.map(sampleTemplate), [starters]);
94
+ const templates = useMemo(
95
+ () => [BLANK_TEMPLATE, ...starterTemplates],
96
+ [starterTemplates],
97
+ );
98
+ const defaultTemplate = starterTemplates[0] ?? BLANK_TEMPLATE;
99
+ const [activeNav, setActiveNav] = useState(startup ? "new" : "recent");
100
+ const [category, setCategory] = useState("All");
101
+ const [selectedTemplateId, setSelectedTemplateId] = useState(defaultTemplate.id);
102
+ const [name, setName] = useState(currentName || defaultTemplate.name || "My Project");
103
+ const [frameRate, setFrameRate] = useState("24");
104
+ const [units, setUnits] = useState("Meters");
53
105
  const [recents, setRecents] = useState([]);
54
106
  const [folder, setFolder] = useState(null);
55
107
  const [folderProjects, setFolderProjects] = useState([]);
56
108
  const [folderDenied, setFolderDenied] = useState(false);
57
- // A stored folder handle Chromium demoted to "prompt": one click on the
58
- // re-allow button re-grants it without re-picking the folder (#51).
59
109
  const [lostFolder, setLostFolder] = useState(null);
110
+ const nameInputRef = useRef(null);
60
111
 
61
112
  useEffect(() => {
62
- loadRecentProjects().then(setRecents);
113
+ let active = true;
114
+ loadRecentProjects().then((entries) => {
115
+ if (active) setRecents(Array.isArray(entries) ? entries : []);
116
+ });
63
117
  loadProjectsDirectory().then(async (handle) => {
64
- if (!handle) return;
118
+ if (!active || !handle) return;
65
119
  const permission = await queryHandlePermission(handle);
120
+ if (!active) return;
66
121
  if (permission !== "granted") {
67
122
  setFolderDenied(true);
68
123
  if (permission === "prompt") setLostFolder(handle);
@@ -70,9 +125,34 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
70
125
  }
71
126
  setFolder(handle);
72
127
  setFolderProjects(await listProjectsInDirectory(handle));
128
+ }).catch(() => {
129
+ if (active) setFolderDenied(true);
73
130
  });
131
+ return () => {
132
+ active = false;
133
+ };
74
134
  }, []);
75
135
 
136
+ useEffect(() => {
137
+ if (startup) nameInputRef.current?.focus();
138
+ }, [startup]);
139
+
140
+ // File › New reaches this screen with unsaved work, so Create can be refused
141
+ // by the discard confirmation. A name left behind then must not seed a later
142
+ // Save's name dialog once the screen closes.
143
+ useEffect(() => () => {
144
+ pendingProjectName = null;
145
+ }, []);
146
+
147
+ const selectedTemplate = templates.find((template) => template.id === selectedTemplateId) ?? templates[0];
148
+ const visibleTemplates = category === "All"
149
+ ? templates
150
+ : templates.filter((template) => template.category === "All" || template.category === category);
151
+ const canCreate = name.trim().length > 0;
152
+ const createReason = ko("Enter a project name to create a project", "프로젝트를 만들려면 이름을 입력하세요");
153
+ const locationLabel = folder?.name || ko("Browser project store", "브라우저 프로젝트 저장소");
154
+ const canChooseFolder = hasFileSystemAccess() && hasDirectoryPicker();
155
+
76
156
  const reauthorizeFolder = async () => {
77
157
  if (!lostFolder) return;
78
158
  if ((await requestHandlePermission(lostFolder)) !== "granted") return;
@@ -84,6 +164,7 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
84
164
  };
85
165
 
86
166
  const chooseFolder = async () => {
167
+ if (!canChooseFolder) return;
87
168
  try {
88
169
  const handle = await pickProjectsDirectory();
89
170
  await storeProjectsDirectory(handle);
@@ -91,8 +172,8 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
91
172
  setFolderDenied(false);
92
173
  setLostFolder(null);
93
174
  setFolderProjects(await listProjectsInDirectory(handle));
94
- } catch (err) {
95
- if (err?.name !== "AbortError") setFolderDenied(true);
175
+ } catch (error) {
176
+ if (error?.name !== "AbortError") setFolderDenied(true);
96
177
  }
97
178
  };
98
179
 
@@ -100,128 +181,208 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
100
181
  if (folder) setFolderProjects(await listProjectsInDirectory(folder));
101
182
  };
102
183
 
184
+ const selectTemplate = (template) => {
185
+ setSelectedTemplateId(template.id);
186
+ setName((current) => current.trim() ? current : template.name);
187
+ };
188
+
189
+ const createProject = (event) => {
190
+ event.preventDefault();
191
+ const projectName = name.trim();
192
+ if (!projectName) return;
193
+ pendingProjectName = projectName;
194
+ onNew(projectName);
195
+ };
196
+
197
+ const recentEntries = [
198
+ ...recents.map((entry) => ({ ...entry, key: `recent-${entry.name}`, removable: true, date: entry.openedAt })),
199
+ ...folderProjects.map((entry) => ({ ...entry, key: `folder-${entry.name}`, removable: false, date: entry.lastModified })),
200
+ ];
201
+
202
+ const navItems = [
203
+ { id: "recent", label: ko("Recent", "최근") },
204
+ { id: "new", label: ko("New Project", "새 프로젝트") },
205
+ { id: "samples", label: ko("Samples", "샘플") },
206
+ { id: "learn", label: ko("Learn", "배우기") },
207
+ ];
208
+
103
209
  return (
104
- <div className={`project-browser-backdrop${startup ? " startup" : ""}`} onPointerDown={(e) => { if (!startup && e.target === e.currentTarget) onClose(); }}>
105
- <div className={`project-browser${startup ? " startup" : ""}`} role="dialog" aria-label={ko("Choose project", "프로젝트 선택")}>
106
- <div className="project-browser-head">
107
- <strong>{startup ? ko("Create your first shot", "첫 샷 만들기") : ko("Projects", "프로젝트")}</strong>
108
- {!startup && <button type="button" className="x" onClick={onClose} aria-label={ko("Close", "닫기")}>✕</button>}
109
- </div>
110
- {startup && <p className="project-browser-intro">{ko(
111
- "Start with a named project, then place a character and frame the shot. Your work is saved in that project.",
112
- "프로젝트 이름을 정하고 인물을 배치한 다음 샷 구도를 잡아 보세요. 작업 내용은 이 프로젝트에 저장됩니다.",
113
- )}</p>}
114
- {startup && (
115
- <section className="beginner-start" aria-label={ko("First shot steps", "첫 샷 단계")}>
116
- <div className="beginner-start-actions">
117
- <button type="button" className="btn primary beginner-create" onClick={onNew}>
118
- {ko("Create a project", "프로젝트 만들기")}
119
- </button>
120
- <button type="button" className="btn ghost beginner-open" onClick={onOpenFile}>
121
- {ko("Open a project", "프로젝트 열기")}
210
+ <div className={`v2-start-screen project-browser-backdrop${startup ? " startup" : ""}`} onPointerDown={(event) => { if (!startup && event.target === event.currentTarget) onClose?.(); }}>
211
+ <div className={`v2-start-window project-browser${startup ? " startup" : ""}`} role="dialog" aria-modal="true" aria-label={ko("Choose project", "프로젝트 선택")}>
212
+ <nav className="v2-start-nav" aria-label={ko("Project navigation", "프로젝트 탐색")}>
213
+ <div className="v2-start-brand">
214
+ <span className="v2-start-brand-mark" aria-hidden="true" />
215
+ <strong>CozyClay</strong>
216
+ </div>
217
+ <div className="v2-start-nav-list">
218
+ {navItems.map((item) => (
219
+ <button
220
+ type="button"
221
+ key={item.id}
222
+ className={`v2-start-nav-item${activeNav === item.id ? " active" : ""}`}
223
+ aria-current={activeNav === item.id ? "page" : undefined}
224
+ onClick={() => setActiveNav(item.id)}
225
+ >
226
+ {item.label}
122
227
  </button>
123
- </div>
124
- <div className="beginner-step-grid">
125
- <div className="beginner-step"><b>1</b><strong>{ko("Place", "배치")}</strong><span>{ko("Choose a character and move it on the stage.", "인물을 고르고 무대에서 움직여요.")}</span></div>
126
- <div className="beginner-step"><b>2</b><strong>{ko("Frame", "구도")}</strong><span>{ko("Set the camera view for your shot.", "샷의 카메라 구도를 정해요.")}</span></div>
127
- <div className="beginner-step"><b>3</b><strong>{ko("Play", "재생")}</strong><span>{ko("Press play to see the scene come alive.", "재생을 눌러 장면을 확인해요.")}</span></div>
128
- </div>
129
- </section>
130
- )}
228
+ ))}
229
+ </div>
230
+ <div className="v2-start-version">v{APP_VERSION}</div>
231
+ </nav>
131
232
 
132
- {starters.length > 0 && (
133
- <section>
134
- <div className="project-browser-section-head">
135
- <span>{ko("Start from a scene", "장면에서 시작")}</span>
233
+ <main className="v2-start-main">
234
+ <div className="v2-start-main-head">
235
+ <div className="v2-start-title-row">
236
+ <h1>{activeNav === "recent" ? ko("Recent", "최근") : activeNav === "samples" ? ko("Samples", "샘플") : activeNav === "learn" ? ko("Learn", "배우기") : ko("New Project", "새 프로젝트")}</h1>
237
+ {activeNav !== "new" && <button type="button" className="v2-start-close x" onClick={onClose} aria-label={ko("Close", "닫기")}>×</button>}
136
238
  </div>
137
- <ul className="project-browser-list starter-list">
138
- {starters.map((scene) => (
139
- <li key={scene.id}>
140
- <button type="button" onClick={() => onStarter?.(scene.id)}>
141
- <strong>{scene.name}</strong>
142
- <span>{scene.blurb}</span>
239
+ {(activeNav === "new" || activeNav === "samples") && (
240
+ <div className="v2-start-tabs" role="tablist" aria-label={ko("Template categories", "템플릿 카테고리")}>
241
+ {CATEGORIES.map((entry) => (
242
+ <button
243
+ type="button"
244
+ key={entry}
245
+ role="tab"
246
+ aria-selected={category === entry}
247
+ className={`v2-start-tab${category === entry ? " active" : ""}`}
248
+ onClick={() => setCategory(entry)}
249
+ >
250
+ {ko(entry, entry === "All" ? "전체" : entry === "Film" ? "영화" : entry === "Dialogue" ? "대화" : entry === "Action" ? "액션" : "차량")}
143
251
  </button>
144
- </li>
145
- ))}
146
- </ul>
147
- </section>
148
- )}
149
-
150
- <section>
151
- <div className="project-browser-section-head">
152
- <span>{ko("Recent projects", "최근 프로젝트")}</span>
252
+ ))}
253
+ </div>
254
+ )}
153
255
  </div>
154
- {recents.length === 0 ? (
155
- <p className="project-browser-empty">{ko("No projects yet — save one and it shows up here.", "아직 프로젝트가 없어요. 저장하면 여기에 나타납니다.")}</p>
256
+
257
+ {activeNav === "learn" ? (
258
+ <div className="v2-start-learn">
259
+ <strong>{ko("Block a shot in three moves", "세 단계로 샷을 블로킹하세요")}</strong>
260
+ <p>{ko("Choose a template, name the project, then place a character and frame the camera in the editor.", "템플릿을 고르고 프로젝트 이름을 정한 다음 편집기에서 인물을 배치하고 카메라를 잡아 보세요.")}</p>
261
+ <button type="button" className="v2-start-learn-action" onClick={() => setActiveNav("new")}>{ko("Start a new project", "새 프로젝트 시작")}</button>
262
+ </div>
263
+ ) : activeNav === "recent" ? (
264
+ <div className="v2-start-recent-page">
265
+ <div className="v2-start-recent-heading">
266
+ <span>{ko("Recent projects", "최근 프로젝트")}</span>
267
+ <div className="v2-start-recent-actions">
268
+ {folder && <button type="button" onClick={refreshFolder}>{ko("Refresh", "새로고침")}</button>}
269
+ <button type="button" onClick={onOpenFile}>{ko("Open project file…", "프로젝트 파일 열기…")}</button>
270
+ </div>
271
+ </div>
272
+ <RecentList entries={recentEntries} currentName={currentName} onOpen={onOpen} onRemove={async (entry) => {
273
+ await removeRecentProject(entry.name);
274
+ setRecents(await loadRecentProjects());
275
+ }} />
276
+ </div>
156
277
  ) : (
157
- <ul className="project-browser-list">
158
- {recents.map((entry) => (
159
- <li key={entry.name} className={entry.name === currentName ? "active" : ""}>
160
- <button type="button" onClick={() => onOpen(entry)}>
161
- <strong>{entry.name}</strong>
162
- <span>{new Date(entry.openedAt).toLocaleString()}</span>
163
- </button>
278
+ <>
279
+ <div className="v2-start-template-grid" role="list" aria-label={ko("Project templates", "프로젝트 템플릿")}>
280
+ {visibleTemplates.map((template) => (
164
281
  <button
165
282
  type="button"
166
- className="del"
167
- title={ko("Remove from list", "목록에서 제거")}
168
- onClick={async () => {
169
- await removeRecentProject(entry.name);
170
- setRecents(await loadRecentProjects());
171
- }}
283
+ role="listitem"
284
+ key={template.id}
285
+ className={`v2-start-template${selectedTemplate?.id === template.id ? " selected" : ""}`}
286
+ data-template-id={template.id}
287
+ onClick={() => selectTemplate(template)}
288
+ onDoubleClick={() => template.starterId && onStarter?.(template.starterId)}
172
289
  >
173
- ✕
290
+ <span className={`v2-start-template-art ${template.kind}`} aria-hidden="true">
291
+ <span className="v2-start-template-code">{template.code}</span>
292
+ </span>
293
+ <span className="v2-start-template-copy">
294
+ <strong>{template.name}</strong>
295
+ <span>{template.description}</span>
296
+ </span>
174
297
  </button>
175
- </li>
176
- ))}
177
- </ul>
178
- )}
179
- </section>
180
-
181
- {hasFileSystemAccess() && hasDirectoryPicker() && (
182
- <section>
183
- <div className="project-browser-section-head">
184
- <span>{ko("Projects folder", "프로젝트 폴더")}</span>
185
- <div className="row-actions">
186
- {folder && <button type="button" onClick={refreshFolder}>{ko("Refresh", "새로고침")}</button>}
187
- <button type="button" onClick={chooseFolder}>
188
- {folder ? ko("Change folder…", "폴더 변경…") : ko("Choose folder…", "폴더 지정…")}
189
- </button>
298
+ ))}
190
299
  </div>
300
+
301
+ <div className="v2-start-recent-block">
302
+ <div className="v2-start-recent-heading"><span>{ko("Recent", "최근")}</span></div>
303
+ <RecentList entries={recents.map((entry) => ({ ...entry, key: `recent-${entry.name}`, removable: true, date: entry.openedAt }))} currentName={currentName} onOpen={onOpen} onRemove={async (entry) => {
304
+ await removeRecentProject(entry.name);
305
+ setRecents(await loadRecentProjects());
306
+ }} compact />
307
+ </div>
308
+ </>
309
+ )}
310
+ {activeNav !== "recent" && activeNav !== "learn" && (
311
+ <div className="v2-start-file-actions">
312
+ <button type="button" className="v2-start-file-button" onClick={onOpenFile}>{ko("Open project file…", "프로젝트 파일 열기…")}</button>
191
313
  </div>
314
+ )}
315
+ </main>
316
+
317
+ <aside className="v2-start-preview" data-testid="start-project-preview" aria-label={ko("Project preview", "프로젝트 미리보기")}>
318
+ <div className={`v2-start-preview-art ${selectedTemplate?.kind || "blank"}`} aria-hidden="true">
319
+ <span>{ko("template preview", "템플릿 미리보기")}</span>
320
+ </div>
321
+ <div className="v2-start-preview-copy">
322
+ <strong>{selectedTemplate?.name}</strong>
323
+ <span>{selectedTemplate?.description}</span>
324
+ </div>
325
+ <form className="v2-start-project-form" onSubmit={createProject}>
326
+ <label className="v2-start-field">
327
+ <span>{ko("Name", "이름")}</span>
328
+ <input ref={nameInputRef} data-testid="start-project-name" aria-label={ko("Name", "이름")} value={name} onChange={(event) => setName(event.target.value)} />
329
+ </label>
330
+ <label className="v2-start-field">
331
+ <span>{ko("Location", "위치")}</span>
332
+ <button type="button" className="v2-start-location" onClick={chooseFolder} disabled={!canChooseFolder} title={canChooseFolder ? ko("Choose the project storage folder", "프로젝트 저장 폴더 선택") : ko("Projects are stored in this browser", "프로젝트는 이 브라우저에 저장됩니다")}>
333
+ <span>{locationLabel}</span><span aria-hidden="true">⌄</span>
334
+ </button>
335
+ </label>
336
+ <label className="v2-start-field">
337
+ <span>{ko("Frame rate", "프레임 레이트")}</span>
338
+ <span className="v2-start-select-wrap">
339
+ <select aria-label={ko("Frame rate", "프레임 레이트")} value={frameRate} onChange={(event) => setFrameRate(event.target.value)}>
340
+ <option value="24">24 fps</option>
341
+ <option value="30">30 fps</option>
342
+ </select>
343
+ </span>
344
+ </label>
345
+ <label className="v2-start-field">
346
+ <span>{ko("Units", "단위")}</span>
347
+ <span className="v2-start-select-wrap">
348
+ <select aria-label={ko("Units", "단위")} value={units} onChange={(event) => setUnits(event.target.value)}>
349
+ <option value="Meters">Meters</option>
350
+ <option value="Feet">Feet</option>
351
+ </select>
352
+ </span>
353
+ </label>
192
354
  {folderDenied && (
193
- <p className="project-browser-empty">
194
- {lostFolder
195
- ? ko("Folder access needs to be re-allowed.", "폴더 접근을 다시 허용해야 해요.")
196
- : ko("Folder access is not granted — choose it again.", "폴더 접근 권한이 없어요. 다시 지정해 주세요.")}
197
- {lostFolder && (
198
- <button type="button" onClick={reauthorizeFolder}>{ko("Re-allow", "다시 허용")}</button>
199
- )}
355
+ <p className="v2-start-location-status" role="status">
356
+ {lostFolder ? ko("Folder access needs to be re-allowed.", "폴더 접근을 다시 허용해야 해요.") : ko("Using the browser project store.", "브라우저 프로젝트 저장소를 사용합니다.")}
357
+ {lostFolder && <button type="button" onClick={reauthorizeFolder}>{ko("Re-allow", "다시 허용")}</button>}
200
358
  </p>
201
359
  )}
202
- {folder && folderProjects.length === 0 && !folderDenied && (
203
- <p className="project-browser-empty">{ko("No .cclayproject files in this folder.", "이 폴더에 .cclayproject 파일이 없어요.")}</p>
204
- )}
205
- {folderProjects.length > 0 && (
206
- <ul className="project-browser-list">
207
- {folderProjects.map((entry) => (
208
- <li key={entry.name} className={entry.name === currentName ? "active" : ""}>
209
- <button type="button" onClick={() => onOpen(entry)}>
210
- <strong>{entry.name}</strong>
211
- <span>{new Date(entry.lastModified).toLocaleString()}</span>
212
- </button>
213
- </li>
214
- ))}
215
- </ul>
216
- )}
217
- </section>
218
- )}
360
+ <div className="v2-start-preview-actions">
361
+ <button type="button" className="v2-start-cancel" onClick={onClose}>{ko("Cancel", "취소")}</button>
362
+ <button type="submit" className="v2-start-create btn primary" data-testid="start-create" disabled={!canCreate} title={canCreate ? ko("Create project", "프로젝트 만들기") : createReason}>{ko("Create", "생성")}</button>
363
+ </div>
364
+ </form>
365
+ </aside>
366
+ </div>
367
+ </div>
368
+ );
369
+ }
219
370
 
220
- <div className="project-browser-foot">
221
- <button type="button" className="btn ghost" onClick={onOpenFile}>{ko("Open file…", "파일로 열기…")}</button>
222
- <button type="button" className="btn primary" onClick={onNew}>{ko("New Project", "새 프로젝트")}</button>
371
+ function RecentList({ entries, currentName, onOpen, onRemove, compact = false }) {
372
+ return entries.length === 0 ? (
373
+ <p className={`v2-start-empty${compact ? " compact" : ""}`}>{ko("No recent projects yet.", "아직 최근 프로젝트가 없습니다.")}</p>
374
+ ) : (
375
+ <div className={`v2-start-recent-list${compact ? " compact" : ""}`} role="list">
376
+ {entries.map((entry) => (
377
+ <div className={`v2-start-recent-row${entry.name === currentName ? " active" : ""}`} role="listitem" key={entry.key}>
378
+ <button type="button" className="v2-start-recent-open" onClick={() => onOpen(entry)}>
379
+ <strong>{entry.name}</strong>
380
+ <span>{formatDate(entry.date)}</span>
381
+ <small>{entry.shots ?? "—"}{ko(" shots", " 샷")}</small>
382
+ </button>
383
+ {entry.removable && <button type="button" className="v2-start-recent-remove" aria-label={ko(`Remove ${entry.name} from recent projects`, `최근 프로젝트에서 ${entry.name} 제거`)} title={ko("Remove from list", "목록에서 제거")} onClick={() => onRemove(entry)}>×</button>}
223
384
  </div>
224
- </div>
385
+ ))}
225
386
  </div>
226
387
  );
227
388
  }
@@ -0,0 +1,19 @@
1
+ import * as THREE from "three";
2
+ import { objectTransformAt } from "./object-path.js";
3
+
4
+ /** The local matrix SetProps composes before an optional attachment frame.
5
+ * Travel-path samples replace x/y/z/rot for the requested frame while the
6
+ * authored pitch/roll and per-axis scale remain part of the prop transform. */
7
+ export function rangePinObjectMatrixAt(object, frame, take = {}, out = new THREE.Matrix4()) {
8
+ const at = objectTransformAt(object, frame, take);
9
+ const source = at ? { ...object, ...at } : object;
10
+ return out.compose(
11
+ new THREE.Vector3(source.x ?? 0, source.y ?? 0, source.z ?? 0),
12
+ new THREE.Quaternion().setFromEuler(new THREE.Euler(
13
+ (source.rotX ?? 0) * THREE.MathUtils.DEG2RAD,
14
+ (source.rot ?? 0) * THREE.MathUtils.DEG2RAD,
15
+ (source.rotZ ?? 0) * THREE.MathUtils.DEG2RAD,
16
+ )),
17
+ new THREE.Vector3(source.scaleX ?? 1, source.scaleY ?? 1, source.scaleZ ?? 1),
18
+ );
19
+ }