cozyclay 1.10.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/bin/agent/agent-routes.mjs +82 -72
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/codex-auth.mjs +2 -1
  10. package/bin/cozyclay.mjs +38 -11
  11. package/bin/live/cli.mjs +22 -3
  12. package/bin/mcp-runtime.mjs +45 -20
  13. package/bin/telemetry-state.mjs +5 -2
  14. package/bin/update-check.mjs +15 -0
  15. package/dist/app/index.html +6 -6
  16. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  17. package/dist/assets/app-BxBLC6GT.js +4876 -0
  18. package/dist/assets/app-CJdxvnrl.css +1 -0
  19. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  20. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  21. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  22. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  23. package/dist/assets/shot-prompt-hN0j_O-p.js +17 -0
  24. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  25. package/dist/assets/workflow-B5XZwVfe.js +179 -0
  26. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  27. package/dist/cozyclay-package.json +1 -1
  28. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  29. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  30. package/dist/fonts/README.md +13 -9
  31. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  33. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  35. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  36. package/dist/index.html +7 -7
  37. package/dist/privacy/index.html +13 -8
  38. package/dist/sitemap.xml +2 -2
  39. package/dist/workflow/index.html +5 -5
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +219 -376
  44. package/package.json +2 -1
  45. package/src/App.jsx +1275 -9086
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/fix-collisions.js +10 -66
  51. package/src/ardy/ik-drag.js +101 -0
  52. package/src/ardy/ik-key-json.js +43 -0
  53. package/src/ardy/ik.js +97 -67
  54. package/src/ardy/physics-panel.css +2 -0
  55. package/src/ardy/physics-panel.jsx +17 -5
  56. package/src/ardy/platform-fit-panel.css +64 -0
  57. package/src/ardy/platform-fit-panel.jsx +40 -0
  58. package/src/ardy/platform-fit.js +326 -0
  59. package/src/ardy/playback.js +166 -20
  60. package/src/ardy/range-pin.js +299 -0
  61. package/src/ardy/timeline-coordinates.js +26 -0
  62. package/src/ardy/timeline.css +1214 -0
  63. package/src/ardy/timeline.jsx +152 -41
  64. package/src/ardy/waypoints.js +3 -3
  65. package/src/asset-pane.css +462 -0
  66. package/src/asset-pane.jsx +461 -232
  67. package/src/camera-tutorial.jsx +38 -18
  68. package/src/command-bus.js +349 -0
  69. package/src/commands/ai.js +31 -0
  70. package/src/commands/cast.js +140 -0
  71. package/src/commands/elements/character.js +13 -0
  72. package/src/commands/elements/motion.js +14 -0
  73. package/src/commands/elements/object.js +22 -0
  74. package/src/commands/elements/scene.js +16 -0
  75. package/src/commands/elements/shot.js +9 -0
  76. package/src/commands/elements/stage.js +11 -0
  77. package/src/commands/elements.js +155 -0
  78. package/src/commands/export.js +20 -0
  79. package/src/commands/index.js +40 -0
  80. package/src/commands/motion.js +231 -0
  81. package/src/commands/objects.js +174 -0
  82. package/src/commands/project.js +53 -0
  83. package/src/commands/scene.js +81 -0
  84. package/src/commands/shared.js +19 -0
  85. package/src/commands/shot.js +103 -0
  86. package/src/commands/stage.js +17 -0
  87. package/src/commands/view.js +42 -0
  88. package/src/document-store.js +144 -0
  89. package/src/domains/cast.js +1007 -0
  90. package/src/domains/motion.js +3219 -0
  91. package/src/domains/objects.js +932 -0
  92. package/src/domains/scenes.js +823 -0
  93. package/src/domains/shots.js +404 -0
  94. package/src/domains/stage.js +93 -0
  95. package/src/dualview.jsx +113 -57
  96. package/src/facing-marks.js +3 -0
  97. package/src/first-success-guide.jsx +14 -14
  98. package/src/grid-view.js +18 -5
  99. package/src/hierarchy-panel.css +386 -0
  100. package/src/hierarchy-panel.jsx +88 -19
  101. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  102. package/src/i2v-motion-studio.jsx +180 -0
  103. package/src/ik-camera.js +3 -0
  104. package/src/main.jsx +6 -0
  105. package/src/motion/generation.js +105 -0
  106. package/src/motion-readiness-ui.jsx +4 -4
  107. package/src/motion-readiness.js +11 -0
  108. package/src/motion-trail.js +366 -9
  109. package/src/object-gizmo.jsx +7 -1
  110. package/src/panels/CameraPanel.jsx +60 -0
  111. package/src/panels/CharacterTransformPanel.jsx +48 -0
  112. package/src/panels/EnvironmentPanel.jsx +40 -0
  113. package/src/panels/Foldout.jsx +32 -0
  114. package/src/panels/LightPanel.jsx +24 -0
  115. package/src/panels/ObjectTransformPanel.jsx +554 -0
  116. package/src/panels/PosePanel.jsx +97 -0
  117. package/src/panels/ProjectPanel.jsx +33 -0
  118. package/src/panels/PromptBlocksPanel.jsx +442 -0
  119. package/src/panels/PropsPanel.jsx +70 -0
  120. package/src/panels/ReferenceImageField.jsx +91 -0
  121. package/src/panels/RigControlPanel.jsx +78 -0
  122. package/src/panels/RigPanel.jsx +37 -0
  123. package/src/panels/SubjectBox.jsx +47 -0
  124. package/src/panels/SubjectsPanel.jsx +39 -0
  125. package/src/panels/VideoCapturePanel.jsx +180 -0
  126. package/src/panels/details.css +1171 -0
  127. package/src/panels/motion.css +197 -0
  128. package/src/panels/pose.css +39 -0
  129. package/src/planview.jsx +60 -31
  130. package/src/posestudio.jsx +59 -4
  131. package/src/project-browser.css +1073 -0
  132. package/src/project-browser.jsx +288 -123
  133. package/src/range-pin-object-transform.js +19 -0
  134. package/src/range-pin-panel.css +518 -0
  135. package/src/range-pin-panel.jsx +344 -0
  136. package/src/result-modal.jsx +5 -5
  137. package/src/room.jsx +79 -51
  138. package/src/scenes.js +9 -8
  139. package/src/semantic-edit.js +2 -0
  140. package/src/settings-menu.jsx +39 -105
  141. package/src/shell/BottomDock.jsx +450 -0
  142. package/src/shell/DetailsSlot.jsx +513 -0
  143. package/src/shell/LibrarySlot.jsx +86 -0
  144. package/src/shell/MenuBar.jsx +601 -0
  145. package/src/shell/OutlinerSlot.jsx +50 -0
  146. package/src/shell/PreferencesDialog.jsx +538 -0
  147. package/src/shell/PreferencesSlot.jsx +40 -0
  148. package/src/shell/StatusBar.jsx +125 -0
  149. package/src/shell/StudioShell.jsx +92 -0
  150. package/src/shell/TopBar.jsx +129 -0
  151. package/src/shell/ViewportToolbar.jsx +656 -0
  152. package/src/shell/agent-glass.css +296 -0
  153. package/src/shell/dock.css +93 -0
  154. package/src/shell/glass-regions.css +985 -0
  155. package/src/shell/glass.css +687 -0
  156. package/src/shell/log-store.js +52 -0
  157. package/src/shell/mode.css +9 -0
  158. package/src/shell/preferences.css +651 -0
  159. package/src/shell/shell.css +282 -0
  160. package/src/shell/studio-shell-context.js +19 -0
  161. package/src/shell/topbar.css +435 -0
  162. package/src/shell/viewport.css +451 -0
  163. package/src/store/authored-intent.js +22 -0
  164. package/src/store/runtime-adapters.js +92 -0
  165. package/src/store/scene-stage.js +21 -0
  166. package/src/store/use-document-store.js +14 -0
  167. package/src/studio-actions.js +210 -0
  168. package/src/studio-agent-commands.js +38 -268
  169. package/src/studio-agent-context.js +38 -9
  170. package/src/studio-agent-motion.js +122 -378
  171. package/src/studio-agent-protocol.js +66 -30
  172. package/src/studio-app-binding.js +379 -0
  173. package/src/studio-contact-sheet.js +73 -0
  174. package/src/studio-elements.js +58 -51
  175. package/src/styles/themes.css +200 -0
  176. package/src/styles/tokens.css +69 -0
  177. package/src/styles.css +456 -1748
  178. package/src/theme.js +44 -0
  179. package/src/timeline-extent.js +16 -0
  180. package/src/timeline-fps.js +13 -0
  181. package/src/trail-key-conflicts.js +39 -0
  182. package/src/trail-pick.js +59 -0
  183. package/src/ui.jsx +8 -8
  184. package/src/use-case-question.jsx +66 -0
  185. package/src/workflow/AgentPanel.jsx +86 -71
  186. package/src/workflow/agent-client.js +12 -2
  187. package/src/workflow/agent-panel.css +34 -32
  188. package/src/workflow/cozy-scene-node.css +2 -0
  189. package/src/workflow/workflow.css +2 -0
  190. package/tools/ardy/bridge.mjs +164 -146
  191. package/tools/ardy/visual-qa.mjs +5 -5
  192. package/tools/bench/EXP3.md +61 -0
  193. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  194. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  195. package/tools/bench/cclay_bench_runner.py +42 -0
  196. package/tools/bench/cube-contact.mjs +162 -0
  197. package/tools/bench/exp3.mjs +152 -0
  198. package/tools/bench/extract-bench-lib.mjs +216 -0
  199. package/tools/bench/extract-bench.mjs +302 -0
  200. package/tools/bench/fal-generate.mjs +88 -0
  201. package/tools/bench/fit/README.md +189 -0
  202. package/tools/bench/fit/camera.mjs +59 -0
  203. package/tools/bench/fit/contact.mjs +419 -0
  204. package/tools/bench/fit/footlock.mjs +234 -0
  205. package/tools/bench/fit/motion.mjs +85 -0
  206. package/tools/bench/fit/pin.mjs +27 -0
  207. package/tools/bench/fit/remote.mjs +105 -0
  208. package/tools/bench/fit-bench.mjs +126 -0
  209. package/tools/bench/fit-sanity.mjs +45 -0
  210. package/tools/bench/metrics.mjs +313 -0
  211. package/tools/bench/obs/depth.mjs +258 -0
  212. package/tools/bench/obs/extrinsics.mjs +175 -0
  213. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  214. package/tools/bench/obs/ground.mjs +294 -0
  215. package/tools/bench/obs/heading.mjs +151 -0
  216. package/tools/bench/obs/ladder.mjs +555 -0
  217. package/tools/bench/obs/mannequin-betas.json +124 -0
  218. package/tools/bench/obs/remote.mjs +83 -0
  219. package/tools/bench/obs/rest_joints.py +52 -0
  220. package/tools/bench/obs/ybot-targets.mjs +49 -0
  221. package/tools/bench/obs-bench.mjs +520 -0
  222. package/tools/bench/score.mjs +343 -0
  223. package/tools/bench/summarize.mjs +86 -0
  224. package/tools/dev/pages/privacy.html +13 -8
  225. package/tools/gt-render/browser.mjs +159 -0
  226. package/tools/gt-render/camera-math.mjs +276 -0
  227. package/tools/gt-render/page.mjs +267 -0
  228. package/tools/gt-render/render.mjs +514 -0
  229. package/tools/gt-render/scene-box.mjs +31 -0
  230. package/tools/gt-render/take-transform.mjs +87 -0
  231. package/tools/qa/css-rule-usage.mjs +283 -0
  232. package/tools/qa/studio-control-count.mjs +70 -32
  233. package/tools/run-tests.mjs +60 -3
  234. package/tools/track/DESIGN.md +513 -0
  235. package/tools/track/backfill-provenance.mjs +116 -0
  236. package/tools/track/budget.mjs +8 -0
  237. package/tools/track/check-rig.mjs +209 -0
  238. package/tools/track/diagnostics.schema.json +60 -0
  239. package/tools/track/export-rig.mjs +256 -0
  240. package/tools/track/fallback.mjs +7 -0
  241. package/tools/track/fk-parity-fixture.mjs +161 -0
  242. package/tools/track/gate.mjs +349 -0
  243. package/tools/track/masks.mjs +318 -0
  244. package/tools/track/metrics.mjs +201 -0
  245. package/tools/track/publish-obs.mjs +108 -0
  246. package/tools/track/py/check_env.py +61 -0
  247. package/tools/track/py/eval_lr.py +229 -0
  248. package/tools/track/py/lr_viterbi.py +294 -0
  249. package/tools/track/py/masks.py +405 -0
  250. package/tools/track/py/objective.py +382 -0
  251. package/tools/track/py/rig.py +508 -0
  252. package/tools/track/py/scene.py +326 -0
  253. package/tools/track/py/test_joint_indices.py +123 -0
  254. package/tools/track/py/test_lr_viterbi.py +195 -0
  255. package/tools/track/py/test_masks.py +229 -0
  256. package/tools/track/py/test_rig.py +288 -0
  257. package/tools/track/py/test_scene.py +354 -0
  258. package/tools/track/py/test_track.py +328 -0
  259. package/tools/track/py/track.py +411 -0
  260. package/tools/track/remote.mjs +140 -0
  261. package/tools/track/rig-dump.mjs +227 -0
  262. package/tools/track/run-box-tests.mjs +14 -0
  263. package/tools/track/run-box.mjs +168 -0
  264. package/tools/track/setup-box.sh +59 -0
  265. package/tools/track/study-2d.mjs +728 -0
  266. package/dist/assets/analytics-B1hnH66c.js +0 -1
  267. package/dist/assets/app-Cu1cexZR.css +0 -1
  268. package/dist/assets/app-Dxdh8kds.js +0 -4861
  269. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  270. package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
  271. package/dist/assets/workflow-CJd-PDtV.js +0 -179
  272. package/dist/assets/workflow-DsudxKHF.css +0 -1
  273. package/src/fal-motion-studio.jsx +0 -180
  274. package/src/scene-history.js +0 -129
  275. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
@@ -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,212 @@ 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
+ if (selectedTemplate?.starterId) {
194
+ onStarter?.(selectedTemplate.starterId, projectName);
195
+ return;
196
+ }
197
+ pendingProjectName = projectName;
198
+ onNew(projectName);
199
+ };
200
+
201
+ const recentEntries = [
202
+ ...recents.map((entry) => ({ ...entry, key: `recent-${entry.name}`, removable: true, date: entry.openedAt })),
203
+ ...folderProjects.map((entry) => ({ ...entry, key: `folder-${entry.name}`, removable: false, date: entry.lastModified })),
204
+ ];
205
+
206
+ const navItems = [
207
+ { id: "recent", label: ko("Recent", "최근") },
208
+ { id: "new", label: ko("New Project", "새 프로젝트") },
209
+ { id: "samples", label: ko("Samples", "샘플") },
210
+ { id: "learn", label: ko("Learn", "배우기") },
211
+ ];
212
+
103
213
  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", "프로젝트 열기")}
214
+ <div className={`v2-start-screen project-browser-backdrop${startup ? " startup" : ""}`} onPointerDown={(event) => { if (!startup && event.target === event.currentTarget) onClose?.(); }}>
215
+ <div className={`v2-start-window project-browser${startup ? " startup" : ""}`} role="dialog" aria-modal="true" aria-label={ko("Choose project", "프로젝트 선택")}>
216
+ <nav className="v2-start-nav" aria-label={ko("Project navigation", "프로젝트 탐색")}>
217
+ <div className="v2-start-brand">
218
+ <span className="v2-start-brand-mark" aria-hidden="true" />
219
+ <strong>CozyClay</strong>
220
+ </div>
221
+ <div className="v2-start-nav-list">
222
+ {navItems.map((item) => (
223
+ <button
224
+ type="button"
225
+ key={item.id}
226
+ className={`v2-start-nav-item${activeNav === item.id ? " active" : ""}`}
227
+ aria-current={activeNav === item.id ? "page" : undefined}
228
+ onClick={() => setActiveNav(item.id)}
229
+ >
230
+ {item.label}
122
231
  </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
- )}
232
+ ))}
233
+ </div>
234
+ <div className="v2-start-version">v{APP_VERSION}</div>
235
+ </nav>
131
236
 
132
- {starters.length > 0 && (
133
- <section>
134
- <div className="project-browser-section-head">
135
- <span>{ko("Start from a scene", "장면에서 시작")}</span>
237
+ <main className="v2-start-main">
238
+ <div className="v2-start-main-head">
239
+ <div className="v2-start-title-row">
240
+ <h1>{activeNav === "recent" ? ko("Recent", "최근") : activeNav === "samples" ? ko("Samples", "샘플") : activeNav === "learn" ? ko("Learn", "배우기") : ko("New Project", "새 프로젝트")}</h1>
241
+ {activeNav !== "new" && <button type="button" className="v2-start-close x" onClick={onClose} aria-label={ko("Close", "닫기")}>×</button>}
136
242
  </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>
243
+ {(activeNav === "new" || activeNav === "samples") && (
244
+ <div className="v2-start-tabs" role="tablist" aria-label={ko("Template categories", "템플릿 카테고리")}>
245
+ {CATEGORIES.map((entry) => (
246
+ <button
247
+ type="button"
248
+ key={entry}
249
+ role="tab"
250
+ aria-selected={category === entry}
251
+ className={`v2-start-tab${category === entry ? " active" : ""}`}
252
+ onClick={() => setCategory(entry)}
253
+ >
254
+ {ko(entry, entry === "All" ? "전체" : entry === "Film" ? "영화" : entry === "Dialogue" ? "대화" : entry === "Action" ? "액션" : "차량")}
143
255
  </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>
256
+ ))}
257
+ </div>
258
+ )}
153
259
  </div>
154
- {recents.length === 0 ? (
155
- <p className="project-browser-empty">{ko("No projects yet — save one and it shows up here.", "아직 프로젝트가 없어요. 저장하면 여기에 나타납니다.")}</p>
260
+
261
+ {activeNav === "learn" ? (
262
+ <div className="v2-start-learn">
263
+ <strong>{ko("Block a shot in three moves", "세 단계로 샷을 블로킹하세요")}</strong>
264
+ <p>{ko("Choose a template, name the project, then place a character and frame the camera in the editor.", "템플릿을 고르고 프로젝트 이름을 정한 다음 편집기에서 인물을 배치하고 카메라를 잡아 보세요.")}</p>
265
+ <button type="button" className="v2-start-learn-action" onClick={() => setActiveNav("new")}>{ko("Start a new project", "새 프로젝트 시작")}</button>
266
+ </div>
267
+ ) : activeNav === "recent" ? (
268
+ <div className="v2-start-recent-page">
269
+ <div className="v2-start-recent-heading">
270
+ <span>{ko("Recent projects", "최근 프로젝트")}</span>
271
+ <div className="v2-start-recent-actions">
272
+ {folder && <button type="button" onClick={refreshFolder}>{ko("Refresh", "새로고침")}</button>}
273
+ <button type="button" onClick={onOpenFile}>{ko("Open project file…", "프로젝트 파일 열기…")}</button>
274
+ </div>
275
+ </div>
276
+ <RecentList entries={recentEntries} currentName={currentName} onOpen={onOpen} onRemove={async (entry) => {
277
+ await removeRecentProject(entry.name);
278
+ setRecents(await loadRecentProjects());
279
+ }} />
280
+ </div>
156
281
  ) : (
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>
282
+ <>
283
+ <div className="v2-start-template-grid" role="list" aria-label={ko("Project templates", "프로젝트 템플릿")}>
284
+ {visibleTemplates.map((template) => (
164
285
  <button
165
286
  type="button"
166
- className="del"
167
- title={ko("Remove from list", "목록에서 제거")}
168
- onClick={async () => {
169
- await removeRecentProject(entry.name);
170
- setRecents(await loadRecentProjects());
171
- }}
287
+ role="listitem"
288
+ key={template.id}
289
+ className={`v2-start-template${selectedTemplate?.id === template.id ? " selected" : ""}`}
290
+ data-template-id={template.id}
291
+ onClick={() => selectTemplate(template)}
292
+ onDoubleClick={() => template.starterId && onStarter?.(template.starterId)}
172
293
  >
173
- ✕
294
+ <span className={`v2-start-template-art ${template.kind}`} aria-hidden="true">
295
+ <span className="v2-start-template-code">{template.code}</span>
296
+ </span>
297
+ <span className="v2-start-template-copy">
298
+ <strong>{template.name}</strong>
299
+ <span>{template.description}</span>
300
+ </span>
174
301
  </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>
302
+ ))}
190
303
  </div>
304
+
305
+ <div className="v2-start-recent-block">
306
+ <div className="v2-start-recent-heading"><span>{ko("Recent", "최근")}</span></div>
307
+ <RecentList entries={recents.map((entry) => ({ ...entry, key: `recent-${entry.name}`, removable: true, date: entry.openedAt }))} currentName={currentName} onOpen={onOpen} onRemove={async (entry) => {
308
+ await removeRecentProject(entry.name);
309
+ setRecents(await loadRecentProjects());
310
+ }} compact />
311
+ </div>
312
+ </>
313
+ )}
314
+ {activeNav !== "recent" && activeNav !== "learn" && (
315
+ <div className="v2-start-file-actions">
316
+ <button type="button" className="v2-start-file-button" onClick={onOpenFile}>{ko("Open project file…", "프로젝트 파일 열기…")}</button>
191
317
  </div>
318
+ )}
319
+ </main>
320
+
321
+ <aside className="v2-start-preview" data-testid="start-project-preview" aria-label={ko("Project preview", "프로젝트 미리보기")}>
322
+ <div className={`v2-start-preview-art ${selectedTemplate?.kind || "blank"}`} aria-hidden="true">
323
+ <span>{ko("template preview", "템플릿 미리보기")}</span>
324
+ </div>
325
+ <div className="v2-start-preview-copy">
326
+ <strong>{selectedTemplate?.name}</strong>
327
+ <span>{selectedTemplate?.description}</span>
328
+ </div>
329
+ <form className="v2-start-project-form" onSubmit={createProject}>
330
+ <label className="v2-start-field">
331
+ <span>{ko("Name", "이름")}</span>
332
+ <input ref={nameInputRef} data-testid="start-project-name" aria-label={ko("Name", "이름")} value={name} onChange={(event) => setName(event.target.value)} />
333
+ </label>
334
+ <label className="v2-start-field">
335
+ <span>{ko("Location", "위치")}</span>
336
+ <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", "프로젝트는 이 브라우저에 저장됩니다")}>
337
+ <span>{locationLabel}</span><span aria-hidden="true">⌄</span>
338
+ </button>
339
+ </label>
340
+ <label className="v2-start-field">
341
+ <span>{ko("Frame rate", "프레임 레이트")}</span>
342
+ <span className="v2-start-select-wrap">
343
+ <select aria-label={ko("Frame rate", "프레임 레이트")} value={frameRate} onChange={(event) => setFrameRate(event.target.value)}>
344
+ <option value="24">24 fps</option>
345
+ <option value="30">30 fps</option>
346
+ </select>
347
+ </span>
348
+ </label>
349
+ <label className="v2-start-field">
350
+ <span>{ko("Units", "단위")}</span>
351
+ <span className="v2-start-select-wrap">
352
+ <select aria-label={ko("Units", "단위")} value={units} onChange={(event) => setUnits(event.target.value)}>
353
+ <option value="Meters">Meters</option>
354
+ <option value="Feet">Feet</option>
355
+ </select>
356
+ </span>
357
+ </label>
192
358
  {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
- )}
359
+ <p className="v2-start-location-status" role="status">
360
+ {lostFolder ? ko("Folder access needs to be re-allowed.", "폴더 접근을 다시 허용해야 해요.") : ko("Using the browser project store.", "브라우저 프로젝트 저장소를 사용합니다.")}
361
+ {lostFolder && <button type="button" onClick={reauthorizeFolder}>{ko("Re-allow", "다시 허용")}</button>}
200
362
  </p>
201
363
  )}
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
- )}
364
+ <div className="v2-start-preview-actions">
365
+ <button type="button" className="v2-start-cancel" onClick={onClose}>{ko("Cancel", "취소")}</button>
366
+ <button type="submit" className="v2-start-create btn primary" data-testid="start-create" disabled={!canCreate} title={canCreate ? ko("Create project", "프로젝트 만들기") : createReason}>{ko("Create", "생성")}</button>
367
+ </div>
368
+ </form>
369
+ </aside>
370
+ </div>
371
+ </div>
372
+ );
373
+ }
219
374
 
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>
375
+ function RecentList({ entries, currentName, onOpen, onRemove, compact = false }) {
376
+ return entries.length === 0 ? (
377
+ <p className={`v2-start-empty${compact ? " compact" : ""}`}>{ko("No recent projects yet.", "아직 최근 프로젝트가 없습니다.")}</p>
378
+ ) : (
379
+ <div className={`v2-start-recent-list${compact ? " compact" : ""}`} role="list">
380
+ {entries.map((entry) => (
381
+ <div className={`v2-start-recent-row${entry.name === currentName ? " active" : ""}`} role="listitem" key={entry.key}>
382
+ <button type="button" className="v2-start-recent-open" onClick={() => onOpen(entry)}>
383
+ <strong>{entry.name}</strong>
384
+ <span>{formatDate(entry.date)}</span>
385
+ <small>{entry.shots ?? "—"}{ko(" shots", " 샷")}</small>
386
+ </button>
387
+ {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
388
  </div>
224
- </div>
389
+ ))}
225
390
  </div>
226
391
  );
227
392
  }
@@ -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
+ }