cozyclay 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/LIVE-PROTOCOL.md +1 -1
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. package/src/scene-history.js +0 -129
@@ -0,0 +1,817 @@
1
+ import { useRef, useState } from "react";
2
+ import { StudioProtocolError } from "../studio-agent-protocol.js";
3
+ import { createDocumentStore } from "../document-store.js";
4
+ import { useDocumentDomain } from "../store/use-document-store.js";
5
+ import {
6
+ SCENES_STORAGE_KEY,
7
+ readSceneDocument,
8
+ serializeSceneDocument,
9
+ SCENES_VERSION,
10
+ migrateStageFrames,
11
+ activeSceneIndex,
12
+ createSceneDocument,
13
+ createSceneStage,
14
+ addScene,
15
+ duplicateScene,
16
+ renameScene,
17
+ removeScene,
18
+ } from "../scenes.js";
19
+ import {
20
+ loadProjectSession,
21
+ loadWorkflowGraph,
22
+ createProjectDocument,
23
+ storeProjectSession,
24
+ verifyEmbeddedAsset,
25
+ hasFileSystemAccess,
26
+ pickProjectFileForSave,
27
+ writeProjectFile,
28
+ rememberRecentProject,
29
+ downloadProjectFallback,
30
+ PROJECT_EXTENSION,
31
+ normalizeWorkflowGraph,
32
+ storeWorkflowGraph,
33
+ pickProjectFileForOpen,
34
+ readProjectFile,
35
+ openProjectFallback,
36
+ readProjectDocument,
37
+ requestHandlePermission,
38
+ createWorkflowGraph,
39
+ clearStoredProjectHandle,
40
+ } from "../project.js";
41
+ import { playgroundSceneUrl, fetchSceneProject } from "../playground.js";
42
+ import { openAssetDb, referencedAssetIds, getAsset, putAsset } from "../scene-assets.js";
43
+ import { internWorkflowOutputs, workflowOutputRefs, resolveWorkflowOutputs } from "../workflow/workflow-resources.js";
44
+ import { encodeMotionResource } from "../motion-resources.js";
45
+ import { openMotionDb, putMotion, sweepMotions } from "../motion-store.js";
46
+ import { resourceManifest } from "../project-resources.js";
47
+ import { isKo, ko } from "../locale.js";
48
+ import { track, bucketCount, bucketProjectAge } from "../analytics.js";
49
+ import { mergeProjectCustomPoses } from "../project-poses.js";
50
+ import { DEFAULT_WORKSPACE_LAYOUT, DEFAULT_DURATION_S, TIMELINE_FPS } from "../app-stage.jsx";
51
+ import { saveCustomPoses } from "../poses.js";
52
+ import { readShotAuthoringDocument } from "../shot-authoring.js";
53
+ import { initialShots } from "../cuts.js";
54
+ import { createIkState } from "../ardy/ik.js";
55
+
56
+ // The scene list and project identity share one history. Dirty is derived from
57
+ // the saved checkpoint, not an authored edit, and has its own non-history slice.
58
+ export function createScenesDomain(appContext, initial, name) {
59
+ const ordered = rows => rows.map((scene, order) => ({ ...scene, order }));
60
+ let native = createDocumentStore({ owned: { scenes: ordered(initial.scenes), project: { name, activeSceneId: initial.activeSceneId } } });
61
+ const listeners = new Set();
62
+ const notify = () => { for (const listener of listeners) listener(); };
63
+ let release = native.subscribe(notify);
64
+ const documentStore = {
65
+ ...Object.fromEntries(Object.keys(native).map(key => [key, (...args) => native[key](...args)])),
66
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
67
+ };
68
+ const dirtyStore = createDocumentStore({ owned: { projectDirty: false } });
69
+ const read = () => documentStore.read("scenes"), metadata = () => documentStore.read("project");
70
+ function write(rows) {
71
+ return documentStore.write("scenes", before => {
72
+ let next = typeof rows === "function" ? rows(before) : rows;
73
+ // Generic collection set preserves array position until the owner
74
+ // consumes an order intent. Semantic reorder uses this same path.
75
+ for (const row of next.filter(row => before.find(item => item.id === row.id)?.order !== row.order)) {
76
+ const index = next.findIndex(item => item.id === row.id);
77
+ next = next.filter(item => item.id !== row.id);
78
+ next.splice(Math.min(next.length, Math.max(0, Math.round(row.order ?? index))), 0, row);
79
+ }
80
+ for (let index = 0; index < next.length; index++) {
81
+ const previous = before.find(row => row.id === next[index].id);
82
+ if (previous && previous.name !== next[index].name) {
83
+ const requested = next[index].name;
84
+ next = next.map((row, i) => i === index ? { ...row, name: previous.name } : row);
85
+ next = renameScene(next, index, requested);
86
+ }
87
+ }
88
+ next = ordered(next);
89
+ return JSON.stringify(next) === JSON.stringify(before) ? before : next;
90
+ });
91
+ }
92
+ const publish = () => {
93
+ appContext.publishScenes(read());
94
+ appContext.shared.activeSceneIdRef.current = metadata().activeSceneId;
95
+ if (appContext.live.state) appContext.patchLive({ scenes: read(), activeSceneId: metadata().activeSceneId });
96
+ storeProjectSession(metadata().name);
97
+ domain.persist?.();
98
+ domain.refreshDirty?.();
99
+ };
100
+ const unsubscribe = documentStore.subscribe(publish);
101
+ const domain = {
102
+ documentStore, dirtyStore, read, write, metadata,
103
+ "setScenes": write,
104
+ beginAction: () => documentStore.beginAction("scenes"),
105
+ canUndo: id => documentStore.canUndo(id),
106
+ stepHistory: redo => Boolean((redo ? documentStore.redo : documentStore.undo)()),
107
+ publish: state => write(state.scenes), commitDraft: write,
108
+ document: () => ({ scenes: domain.snapshot(), project: { ...metadata(), name: metadata().name ?? "Untitled" } }),
109
+ renameProject(name) {
110
+ documentStore.write("project", before => before.name === name.trim() ? before : { ...before, name: name.trim() });
111
+ },
112
+ nameSaved(name) {
113
+ if (metadata().name === name) return;
114
+ const boundary = documentStore.beginAction("project");
115
+ boundary.run(() => domain.renameProject(name));
116
+ boundary.cancel({ restore: false });
117
+ },
118
+ // Session cache/file loads are explicit non-authored boundaries. They
119
+ // retire scene history, just as opening a scene retired native history.
120
+ replaceDocument(scenes, activeSceneId, name = metadata().name) {
121
+ release(); native.dispose();
122
+ native = createDocumentStore({ owned: { scenes: ordered(scenes), project: { name, activeSceneId } } });
123
+ release = native.subscribe(notify); notify();
124
+ },
125
+ setDirty(value) {
126
+ if (dirtyStore.read("projectDirty") === value) return;
127
+ const boundary = dirtyStore.beginAction("projectDirty");
128
+ boundary.run(() => dirtyStore.write("projectDirty", value));
129
+ boundary.cancel({ restore: false });
130
+ },
131
+ dispose() { unregister(); unsubscribe(); release(); native.dispose(); dirtyStore.dispose(); listeners.clear(); },
132
+ };
133
+ const unregister = appContext.registerStoreDomain("scenes", domain);
134
+ return domain;
135
+ }
136
+
137
+ export function useScenes(appContext) {
138
+ const [domain] = useState(() => appContext.storeDomain("scenes") ?? createScenesDomain(appContext, appContext.shared.startup.document, loadProjectSession()?.name ?? null));
139
+ const scenes = useDocumentDomain(domain.documentStore, "scenes");
140
+ const { activeSceneId, name: projectName } = useDocumentDomain(domain.documentStore, "project");
141
+ const projectDirty = useDocumentDomain(domain.dirtyStore, "projectDirty");
142
+ const runtimeValues = useRef(new Map());
143
+ async function runProject(id, args = {}) {
144
+ const receipt = await appContext.bus.run(id, args);
145
+ if (!receipt.ok) appContext.notify(receipt.message);
146
+ return receipt;
147
+ }
148
+ async function runWithValue(id, key, value) {
149
+ const token = crypto.randomUUID(); runtimeValues.current.set(token, value);
150
+ try { return await runProject(id, { [key]: token }); }
151
+ finally { runtimeValues.current.delete(token); }
152
+ }
153
+
154
+ const [sceneSaveError, setSceneSaveError] = useState(appContext.shared.startup.error);
155
+
156
+ function snapshotActiveScene(sourceScenes = appContext.storeDomain("scenes")?.read() ?? appContext.live.scenes) {
157
+ return sourceScenes.map((scene) => scene.id === appContext.shared.activeSceneIdRef.current
158
+ ? { ...scene, objects: appContext.shared.storeRef.current.objects, shotDocument: appContext.shared.shotDocumentRef.current, stage: appContext.shared.actorStageRef.current }
159
+ : scene);
160
+ }
161
+
162
+ function persistScenes(nextScenes, nextActiveSceneId) {
163
+ if (appContext.shared.saveBlockedRef.current) return false;
164
+ try {
165
+ localStorage.setItem(SCENES_STORAGE_KEY, serializeSceneDocument({
166
+ version: SCENES_VERSION,
167
+ activeSceneId: nextActiveSceneId,
168
+ scenes: nextScenes,
169
+ }));
170
+ appContext.shared.dirtyRef.current = false;
171
+ setSceneSaveError(null);
172
+ if (appContext.shared.saveFailureToastRef.current) {
173
+ appContext.shared.saveFailureToastRef.current = false;
174
+ appContext.notify("");
175
+ }
176
+ return true;
177
+ } catch (err) {
178
+ const message = `Scenes not saved: ${err?.name || "StorageError"}`;
179
+ setSceneSaveError(message);
180
+ if (!appContext.shared.saveFailureToastRef.current) {
181
+ appContext.shared.saveFailureToastRef.current = true;
182
+ appContext.notify(message);
183
+ }
184
+ return false;
185
+ }
186
+ }
187
+
188
+ /* ============================ project files ============================
189
+ * Game-engine workflow: the authoring state (scenes + cast + layers,
190
+ * workspace layout, custom poses) round-trips through a real
191
+ * `.cclayproject` file. localStorage stays as the always-on session
192
+ * cache; the file is the portable, user-owned document. */
193
+ const [projectSaveState, setProjectSaveState] = useState("idle");
194
+
195
+ const [projectMenuOpen, setProjectMenuOpen] = useState(false);
196
+
197
+ const [projectBrowserOpen, setProjectBrowserOpen] = useState(false);
198
+
199
+ const [projectNameDialog, setProjectNameDialog] = useState(null);
200
+
201
+ // A first-run author should choose a document (or explicitly start a named
202
+ // local draft). Keep this as a light startup sheet so the studio remains
203
+ // inspectable while the choice is pending; it never traps the topbar.
204
+ // ?tutorial=camera opens the starter scene itself (#209), so the chooser is
205
+ // suppressed the same way a ?scene= launch suppresses it.
206
+ const [projectStartupOpen, setProjectStartupOpen] = useState(() => !appContext.shared.playgroundMode && !appContext.shared.cameraTutorialQuery && !playgroundSceneUrl(globalThis.location?.search) && !loadProjectSession()?.name);
207
+
208
+ const [projectManifest, setProjectManifest] = useState({ items: [], totals: { embedded: 0, external: 0, missing: 0, bytes: 0 }, missing: [] });
209
+
210
+ const [saveBlockedReasons, setSaveBlockedReasons] = useState(null);
211
+
212
+ const [workflowRevision, setWorkflowRevision] = useState(0);
213
+
214
+ function projectDocumentInput(name) {
215
+ return {
216
+ scenesDocument: {
217
+ version: SCENES_VERSION,
218
+ activeSceneId: appContext.shared.activeSceneIdRef.current,
219
+ scenes: snapshotActiveScene(),
220
+ },
221
+ workspaceLayout: appContext.shared.projectStateRef.current.workspaceLayout,
222
+ customPoses: appContext.shared.projectStateRef.current.customPoses,
223
+ workflow: loadWorkflowGraph(),
224
+ name,
225
+ };
226
+ }
227
+
228
+ function collectProjectSnapshot(name) {
229
+ return JSON.stringify(createProjectDocument(projectDocumentInput(name)));
230
+ }
231
+
232
+ async function collectProjectSerialized(name) {
233
+ const input = projectDocumentInput(name);
234
+ const scenesDocument = {
235
+ ...input.scenesDocument,
236
+ scenes: input.scenesDocument.scenes.map((scene) => ({
237
+ ...scene,
238
+ stage: scene.stage
239
+ ? {
240
+ ...scene.stage,
241
+ characters: (scene.stage.characters ?? []).map((character) => ({
242
+ ...character,
243
+ motionRef: character.motionRef ? { ...character.motionRef } : character.motionRef,
244
+ })),
245
+ }
246
+ : scene.stage,
247
+ })),
248
+ };
249
+ const db = await openAssetDb();
250
+ try {
251
+ const ids = [...referencedAssetIds(scenesDocument.scenes)];
252
+ const assets = await Promise.all(ids.map((id) => getAsset(db, id)));
253
+ const workflowResult = await internWorkflowOutputs(input.workflow);
254
+ const referencedMotionIds = new Set(
255
+ scenesDocument.scenes.flatMap((scene) => (scene.stage?.characters ?? [])
256
+ .map((character) => character.motionRef?.motionId?.toLowerCase())
257
+ .filter(Boolean)),
258
+ );
259
+ const motions = [...appContext.shared.projectMotionsRef.current.entries()]
260
+ .filter(([id]) => referencedMotionIds.has(id))
261
+ .map(([, record]) => record);
262
+ const motionCache = new Map();
263
+ for (const record of motions) motionCache.set(record.motionId.toLowerCase(), record);
264
+ for (const scene of scenesDocument.scenes) for (const character of scene.stage?.characters ?? []) {
265
+ const clip = appContext.shared.motionFullRef.current.get(character.id);
266
+ if (!clip?.sourceBytes) continue;
267
+ let record = appContext.shared.motionEncodingCacheRef.current.get(clip.sourceBytes);
268
+ if (!record) {
269
+ record = await encodeMotionResource(clip.sourceBytes, { prompt: character.motionRef?.prompt, sourceUrl: character.motionRef?.url });
270
+ appContext.shared.motionEncodingCacheRef.current.set(clip.sourceBytes, record);
271
+ }
272
+ const cached = motionCache.get(record.motionId) ?? record;
273
+ motionCache.set(record.motionId, cached);
274
+ if (!motions.includes(cached)) motions.push(cached);
275
+ appContext.shared.projectMotionsRef.current.set(record.motionId.toLowerCase(), cached);
276
+ character.motionRef = { ...(character.motionRef || {}), motionId: cached.motionId };
277
+ }
278
+ try { const motionDb = await openMotionDb(); await Promise.all(motions.map((record) => putMotion(motionDb, record))); motionDb.close(); } catch (error) { console.warn("[cozyclay] could not cache motions", error); }
279
+ const allAssets = [...assets.filter(Boolean), ...workflowResult.assets];
280
+ const nextInput = { ...input, scenesDocument, workflow: workflowResult.graph, assets: allAssets, motions };
281
+ const manifest = resourceManifest({ scenesDocument: nextInput.scenesDocument, workflow: nextInput.workflow, poseLibrary: nextInput.customPoses, assets: allAssets, motions, workflowOutputRefs });
282
+ setProjectManifest(manifest);
283
+ if (manifest.missing.length) {
284
+ const error = new Error("Project has missing resources");
285
+ error.code = "missing-resources";
286
+ error.items = manifest.missing;
287
+ throw error;
288
+ }
289
+ return JSON.stringify(createProjectDocument({ ...nextInput, savedAt: Date.now() }), null, 2);
290
+ } finally {
291
+ db.close();
292
+ }
293
+ }
294
+
295
+ function markProjectClean(name, snapshot, previousName) {
296
+ appContext.shared.projectSnapshotRef.current = snapshot;
297
+ if (domain.metadata().name === previousName) domain.nameSaved(name);
298
+ refreshProjectDirty();
299
+ storeProjectSession(domain.metadata().name);
300
+ }
301
+
302
+ function projectProblemsNotice(problems) {
303
+ if (!Array.isArray(problems) || !problems.length) return "";
304
+ const codes = [...new Set(problems.map((problem) => problem?.code).filter(Boolean))].join(", ");
305
+ return isKo
306
+ ? ` · 포함된 자원 ${problems.length}개를 건너뛰었어요${codes ? ` (${codes})` : ""}`
307
+ : ` · skipped ${problems.length} embedded resource${problems.length === 1 ? "" : "s"}${codes ? ` (${codes})` : ""}`;
308
+ }
309
+
310
+ async function rehydrateProjectAssets(project, warnings = []) {
311
+ for (const warning of warnings) console.warn(`[cozyclay] ${warning}`);
312
+ if (!project.assets.length) return;
313
+ try {
314
+ const db = await openAssetDb();
315
+ try {
316
+ const referenced = referencedAssetIds(project.scenesDocument.scenes);
317
+ const results = await Promise.allSettled(project.assets.map(async (asset) => {
318
+ if (!referenced.has(asset.id)) {
319
+ console.warn(`[cozyclay] skipped embedded asset outside the project closure: ${asset.id}`);
320
+ return;
321
+ }
322
+ if (!(await verifyEmbeddedAsset(asset))) {
323
+ console.warn(`[cozyclay] skipped embedded asset with mismatched content address: ${asset.id}`);
324
+ return;
325
+ }
326
+ await putAsset(db, asset);
327
+ }));
328
+ for (const result of results) if (result.status === "rejected") console.warn("[cozyclay] could not restore an embedded asset", result.reason);
329
+ } finally {
330
+ db.close();
331
+ }
332
+ } catch (error) {
333
+ console.warn("[cozyclay] could not open the asset store for project restore", error);
334
+ }
335
+ }
336
+
337
+ /** Save the project; the answer says what happened, for project.save:
338
+ * { saved, name, fileName, downloaded } or { saved: false, naming |
339
+ * cancelled | failure }. Every outcome is also shown to the user here. */
340
+ async function saveProject(saveAs = false, explicitName = null, context = null) {
341
+ if (!context) {
342
+ const receipt = await runProject(saveAs ? "project.saveAs" : "project.save", explicitName === null ? {} : { name: explicitName });
343
+ return receipt.ok ? { saved: true, ...receipt.output } : { saved: false, failure: receipt.code };
344
+ }
345
+ const currentName = domain.metadata().name;
346
+ if (currentName === null && explicitName === null) {
347
+ setProjectNameDialog({ kind: "save", initialName: "My Project" });
348
+ return { saved: false, naming: true };
349
+ }
350
+ setProjectSaveState("saving");
351
+ const name = (explicitName ?? currentName ?? "My Project").trim() || "My Project";
352
+ const checkpoint = collectProjectSnapshot(name);
353
+ let downloaded = false;
354
+ try {
355
+ const serialized = await collectProjectSerialized(name);
356
+ context.check();
357
+ let handle = appContext.shared.projectHandleRef.current;
358
+ if (saveAs || !handle || !hasFileSystemAccess()) {
359
+ if (hasFileSystemAccess()) {
360
+ handle = await pickProjectFileForSave(name);
361
+ appContext.shared.projectHandleRef.current = handle;
362
+ await writeProjectFile(handle, serialized);
363
+ await rememberRecentProject(handle, name);
364
+ } else {
365
+ downloadProjectFallback(serialized, name);
366
+ downloaded = true;
367
+ }
368
+ } else {
369
+ await writeProjectFile(handle, serialized);
370
+ }
371
+ context.check();
372
+ markProjectClean(name, checkpoint, currentName);
373
+ setSaveBlockedReasons(null);
374
+ setProjectSaveState(domain.dirtyStore.read("projectDirty") ? "dirty" : "saved");
375
+ track("project:saved", {
376
+ object_count_bucket: bucketCount(appContext.shared.projectStateRef.current.sceneObjects?.length ?? 0),
377
+ shot_count_bucket: bucketCount(appContext.shared.shots.length),
378
+ });
379
+ appContext.notify((isKo, ko) => isKo ? `프로젝트 저장됨: ${name}${PROJECT_EXTENSION}` : `Project saved: ${name}${PROJECT_EXTENSION}`);
380
+ return { saved: true, name, fileName: downloaded ? `${name}${PROJECT_EXTENSION}` : appContext.shared.projectHandleRef.current?.name ?? `${name}${PROJECT_EXTENSION}`, downloaded };
381
+ } catch (err) {
382
+ if (err?.name === "AbortError") {
383
+ setProjectSaveState(projectDirty ? "dirty" : "saved");
384
+ return { saved: false, cancelled: true }; // user closed the picker
385
+ }
386
+ setProjectSaveState("error");
387
+ if (err?.code === "missing-resources") setSaveBlockedReasons([{ code: err.code, items: err.items }]);
388
+ else if (err?.code === "resources-too-large") setSaveBlockedReasons([err]);
389
+ else appContext.notify(ko("Could not save the project", "프로젝트를 저장하지 못했어요"));
390
+ return { saved: false, failure: err?.code ?? err?.name ?? "error" };
391
+ }
392
+ }
393
+
394
+ function applyProject(project, authorized = false) {
395
+ if (!authorized) return runWithValue("project.open", "projectToken", project);
396
+ const checked = readSceneDocument(JSON.stringify(project.scenesDocument));
397
+ if (!["valid", "migrated"].includes(checked.status)) throw new StudioProtocolError("INVALID_ARGUMENT", "Invalid project scene document.");
398
+ project = { ...project, scenesDocument: checked.document };
399
+ appContext.shared.studioDocumentEpochRef.current = crypto.randomUUID();
400
+ appContext.shared.tutorialProjectEpochRef.current += 1;
401
+ appContext.shared.tutorialSeedEpochRef.current = null;
402
+ appContext.shared.setTutorialSeedPending(false);
403
+ appContext.shared.setCameraTutorialHandoff(null);
404
+ appContext.shared.exportShotIdRef.current = null;
405
+ appContext.shared.projectMotionsRef.current = new Map((project.motions ?? []).map((record) => [record.motionId?.toLowerCase(), record]).filter(([id]) => id));
406
+ const source = project.scenesDocument;
407
+ openMotionDb().then(async (db) => { try { await Promise.all([...appContext.shared.projectMotionsRef.current.values()].map((record) => putMotion(db, record))); const ids = new Set((source?.scenes ?? []).flatMap((scene) => (scene.stage?.characters ?? []).map((character) => character.motionRef?.motionId?.toLowerCase()).filter(Boolean))); await sweepMotions(db, ids); } finally { db.close(); } }).catch(() => {});
408
+ // A project FILE carries its own scene document and never passes the
409
+ // storage reader, so the 20 fps → 24 fps clock migration is applied here
410
+ // too — otherwise an older .cozyclay would open a sixth too fast.
411
+ const doc = Number.isInteger(source.version) && source.version < SCENES_VERSION
412
+ ? { ...source, version: SCENES_VERSION, scenes: source.scenes.map((scene) => ({ ...scene, stage: migrateStageFrames(scene.stage) })) }
413
+ : source;
414
+ const mergedCustomPoses = mergeProjectCustomPoses(appContext.shared.customPoses, project.customPoses);
415
+ domain.replaceDocument(doc.scenes, doc.activeSceneId, project.name);
416
+ if (project.workspaceLayout) appContext.shared.setWorkspaceLayout({ ...DEFAULT_WORKSPACE_LAYOUT, ...project.workspaceLayout });
417
+ appContext.bus.run('cast.setCustomPoses', { poses: mergedCustomPoses });
418
+ const resolvedWorkflow = resolveWorkflowOutputs(normalizeWorkflowGraph(project.workflow), new Map((project.assets ?? []).map((asset) => [asset.id, asset])));
419
+ storeWorkflowGraph(resolvedWorkflow);
420
+ saveCustomPoses(mergedCustomPoses);
421
+ persistScenes(doc.scenes, doc.activeSceneId);
422
+ openScene(doc.scenes[activeSceneIndex(doc.scenes, doc.activeSceneId)], doc.scenes);
423
+ appContext.shared.projectSnapshotRef.current = collectProjectSnapshot(project.name);
424
+ domain.pendingCheckpoint = { clock: appContext.undoClock, name: project.name };
425
+ domain.setDirty(false);
426
+ storeProjectSession(project.name);
427
+ setProjectStartupOpen(false);
428
+ // Whatever document this is, it is no longer the scene the tutorial opened
429
+ // for itself; startCameraTutorial re-arms the flag after its own open.
430
+ appContext.shared.tutorialStarterRef.current = false;
431
+ setProjectManifest(resourceManifest({ scenesDocument: doc, workflow: resolvedWorkflow, poseLibrary: mergedCustomPoses, assets: project.assets ?? [], motions: appContext.shared.projectMotionsRef.current, workflowOutputRefs }));
432
+ track("project:opened", { age_bucket: bucketProjectAge(Date.now() - (project.savedAt ?? Date.now())) });
433
+ }
434
+
435
+ /** Open a bundled starter scene as a fresh, saveable project. Used by the
436
+ * first-run dialog and by `npx cozyclay --scene <id>` (`?scene=`), which is
437
+ * how the landing-page tutorial hands people into the local studio. */
438
+ async function openStarterScene(id, source = "starter", context = null) {
439
+ const before = source === "tutorial" ? collectProjectSnapshot("Tutorial") : null;
440
+ const epoch = appContext.shared.tutorialProjectEpochRef.current;
441
+ const url = playgroundSceneUrl(`?scene=${encodeURIComponent(id)}`);
442
+ const project = url ? await fetchSceneProject(url) : null;
443
+ // A pending tutorial fetch has no authority over work authored/opened
444
+ // while it was loading, including an unnamed project.
445
+ if (source === "tutorial" && (epoch !== appContext.shared.tutorialProjectEpochRef.current || before !== collectProjectSnapshot("Tutorial"))) return false;
446
+ if (!project) {
447
+ appContext.notify(ko("That starter scene is not in this build", "이 빌드에는 그 시작 장면이 없어요"));
448
+ return false;
449
+ }
450
+ context?.check();
451
+ applyProject({ ...project, savedAt: null }, true);
452
+ appContext.shared.projectHandleRef.current = null;
453
+ track("scene:loaded", { scene_source: source });
454
+ return true;
455
+ }
456
+
457
+ async function openProject(context = null) {
458
+ if (!context) return runProject("project.open");
459
+ try {
460
+ let file = null;
461
+ let handle = null;
462
+ if (hasFileSystemAccess()) {
463
+ handle = await pickProjectFileForOpen();
464
+ file = await readProjectFile(handle);
465
+ } else {
466
+ file = await openProjectFallback();
467
+ }
468
+ if (!file) return false;
469
+ const result = readProjectDocument(file.text);
470
+ if (!result.ok) {
471
+ appContext.notify(isKo ? `프로젝트를 열 수 없어요: ${result.reason}` : `Cannot open project: ${result.reason}`);
472
+ return false;
473
+ }
474
+ result.project.savedAt = result.project.savedAt ?? file.savedAt ?? null;
475
+ appContext.shared.projectHandleRef.current = handle;
476
+ if (handle) await rememberRecentProject(handle, result.project.name);
477
+ await rehydrateProjectAssets(result.project, result.warnings);
478
+ context.check();
479
+ applyProject(result.project, true);
480
+ setProjectStartupOpen(false);
481
+ appContext.notify(`${isKo ? `프로젝트 열림: ${result.project.name}` : `Project opened: ${result.project.name}`}${projectProblemsNotice(result.problems)}`);
482
+ return true;
483
+ } catch (err) {
484
+ if (err?.name === "AbortError") return false;
485
+ if (err?.code) throw err;
486
+ console.error("openProject failed", err);
487
+ appContext.notify(ko("Could not open the project", "프로젝트를 열지 못했어요"));
488
+ return false;
489
+ }
490
+ }
491
+
492
+ /** Open a project from the browser dialog: a stored handle from the
493
+ * recents list or a file enumerated in the projects folder. */
494
+ async function openProjectByHandle(handle, context = null) {
495
+ if (!context) return runWithValue("project.open", "handleToken", handle);
496
+ try {
497
+ // A stored handle may have been demoted to "prompt" since the last
498
+ // session (#51); this click is the user gesture that can re-grant it.
499
+ if ((await requestHandlePermission(handle)) !== "granted") {
500
+ appContext.notify(ko("Project access was not granted — allow access and try again.", "프로젝트 접근이 허용되지 않았어요. 접근을 허용하고 다시 시도해 주세요."));
501
+ return false;
502
+ }
503
+ const file = await readProjectFile(handle);
504
+ const result = readProjectDocument(file.text);
505
+ if (!result.ok) {
506
+ appContext.notify(isKo ? `프로젝트를 열 수 없어요: ${result.reason}` : `Cannot open project: ${result.reason}`);
507
+ return false;
508
+ }
509
+ result.project.savedAt = result.project.savedAt ?? file.savedAt ?? null;
510
+ appContext.shared.projectHandleRef.current = handle;
511
+ await rememberRecentProject(handle, result.project.name);
512
+ await rehydrateProjectAssets(result.project, result.warnings);
513
+ context.check();
514
+ applyProject(result.project, true);
515
+ setProjectBrowserOpen(false);
516
+ setProjectStartupOpen(false);
517
+ appContext.notify(`${isKo ? `프로젝트 열림: ${result.project.name}` : `Project opened: ${result.project.name}`}${projectProblemsNotice(result.problems)}`);
518
+ return true;
519
+ } catch (err) {
520
+ if (err?.code) throw err;
521
+ console.error("openProjectByHandle failed", err);
522
+ appContext.notify(ko("Could not open the project", "프로젝트를 열지 못했어요"));
523
+ return false;
524
+ }
525
+ }
526
+
527
+ function requestNewProject(authorized = false) {
528
+ if (!authorized) return runProject("project.new");
529
+ if (domain.dirtyStore.read("projectDirty") && !window.confirm(ko("Discard unsaved changes and start a new project?", "저장되지 않은 변경사항을 버리고 새 프로젝트를 시작할까요?"))) return;
530
+ setProjectNameDialog({ kind: "new", initialName: projectName ?? "My Project" });
531
+ }
532
+
533
+ function newProject(name, authorized = false) {
534
+ if (!authorized) return runProject("project.new", typeof name === "string" ? { name } : {});
535
+ if (typeof name !== "string") return requestNewProject(true);
536
+ name = name.trim() || "My Project";
537
+ setProjectNameDialog(null);
538
+ const fresh = createSceneDocument(ko("SCENE 01", "씬 01"));
539
+ storeWorkflowGraph(createWorkflowGraph());
540
+ domain.replaceDocument(fresh.scenes, fresh.activeSceneId, name);
541
+ persistScenes(fresh.scenes, fresh.activeSceneId);
542
+ openScene(fresh.scenes[0], fresh.scenes);
543
+ appContext.shared.projectHandleRef.current = null;
544
+ clearStoredProjectHandle();
545
+ appContext.shared.projectSnapshotRef.current = collectProjectSnapshot(name);
546
+ domain.setDirty(false);
547
+ storeProjectSession(name);
548
+ setProjectStartupOpen(false);
549
+ appContext.shared.setFirstSuccessGuideOpen(true);
550
+ domain.pendingCheckpoint = { clock: appContext.undoClock, name };
551
+ appContext.notify(ko(`New project: ${name}`, `새 프로젝트: ${name}`));
552
+ return true;
553
+ }
554
+
555
+ const [restoreOffer, setRestoreOffer] = useState(null);
556
+
557
+ async function restoreStoredProject(record, context = null) {
558
+ if (!context) return runWithValue("project.restore", "handleToken", record.handle);
559
+ try {
560
+ const file = await readProjectFile(record.handle);
561
+ const result = readProjectDocument(file.text);
562
+ if (!result.ok) return false;
563
+ result.project.savedAt = result.project.savedAt ?? file.savedAt ?? null;
564
+ appContext.shared.projectHandleRef.current = record.handle;
565
+ await rehydrateProjectAssets(result.project, result.warnings);
566
+ context.check();
567
+ applyProject(result.project, true);
568
+ appContext.notify(`${isKo ? `프로젝트 복원됨: ${result.project.name}` : `Project restored: ${result.project.name}`}${projectProblemsNotice(result.problems)}`);
569
+ return true;
570
+ } catch (error) {
571
+ if (error?.code) throw error;
572
+ console.warn("Could not restore the project; retaining the session cache.", error);
573
+ return false;
574
+ }
575
+ }
576
+
577
+ function flushScenes() {
578
+ if (!appContext.shared.dirtyRef.current) return;
579
+ persistScenes(snapshotActiveScene(), appContext.shared.activeSceneIdRef.current);
580
+ }
581
+
582
+ function restoredShotState(scene) {
583
+ const restored = readShotAuthoringDocument(scene?.shotDocument ?? undefined);
584
+ if (restored.state) return restored.state;
585
+ const frameCount = DEFAULT_DURATION_S * TIMELINE_FPS;
586
+ return { shots: initialShots(frameCount), waypoints: [], frameCount };
587
+ }
588
+
589
+ function openScene(scene, nextScenes) {
590
+ appContext.shared.tutorialProjectEpochRef.current += 1;
591
+ appContext.shared.tutorialSeedEpochRef.current = null;
592
+ appContext.shared.setTutorialSeedPending(false);
593
+ appContext.shared.setCameraTutorial(false);
594
+ appContext.shared.setCameraTutorialHandoff(null);
595
+ appContext.shared.exportShotIdRef.current = null;
596
+ appContext.shared.studioSceneEpochRef.current = crypto.randomUUID();
597
+ const shotState = restoredShotState(scene);
598
+ const stage = createSceneStage(scene.stage);
599
+ const objects = Array.isArray(scene.objects) ? scene.objects : [];
600
+ const loaded = appContext.loadStoreDomains({ ...scene, stage, objects, shot: shotState, cast: stage.characters });
601
+ if (!loaded.has("shot")) {
602
+ appContext.shared.setShots(shotState.shots);
603
+ appContext.shared.setTlFrameCount(shotState.frameCount ?? DEFAULT_DURATION_S * TIMELINE_FPS);
604
+ }
605
+ appContext.shared.setRigMountEpoch((value) => value + 1);
606
+ appContext.shared.setHasCharSheet(stage.hasCharSheet);
607
+ if (!loaded.has("stage")) appContext.shared.stageDomain.load(stage);
608
+ // The motion-layer buffer reloads from the scene's first character.
609
+ const firstLayer = stage.characters[0]?.layer;
610
+ appContext.shared.setWaypoints(firstLayer?.waypoints ?? shotState.waypoints ?? []);
611
+ appContext.shared.setPromptClips(firstLayer?.promptClips?.map((clip) => ({ ...clip })) ?? []);
612
+ // Takes belong to the room being left; restoreMotionRefs re-fetches the
613
+ // incoming scene's, and a stale full take must never survive the switch.
614
+ appContext.shared.motionFullRef.current.clear();
615
+ appContext.shared.setSelectedPromptId(null);
616
+ appContext.shared.ikStatesRef.current.clear();
617
+ appContext.shared.ikStateRef.current = createIkState();
618
+ appContext.shared.loadedLayerCharRef.current = stage.characters[0]?.id ?? null;
619
+ appContext.shared.setActiveCharacterId(stage.characters[0]?.id ?? null);
620
+ appContext.shared.restoreMotionRefs(stage.characters);
621
+ appContext.shared.setTlFrame(0);
622
+ appContext.shared.setMovePlaying(false);
623
+ appContext.shared.manualCameraOverrideRef.current = false;
624
+ appContext.shared.setRailDraw(false);
625
+ appContext.shared.setActiveWaypointId(null);
626
+ appContext.shared.setPendingWaypointFrame(null);
627
+ appContext.shared.setSelectedHierarchyId("shot");
628
+ appContext.publishScenes(nextScenes);
629
+ appContext.shared.activeSceneIdRef.current = scene.id;
630
+ appContext.storeDomain("scenes")?.replaceDocument(nextScenes, scene.id);
631
+ track("scene:loaded", { scene_source: "local" });
632
+ }
633
+
634
+ /** The scene controls' doors (the scene pill, the Hierarchy scene menu)
635
+ * into the shared registry; run_action reaches the same scene actions. */
636
+ function selectSceneDocument(sceneId) { return appContext.shared.runStudioAction("scene.switch", { sceneId }); }
637
+
638
+ function createSceneDocumentFromUi() { return appContext.shared.runStudioAction("scene.create"); }
639
+
640
+ function duplicateSceneDocumentFromUi(sceneId) { return appContext.shared.runStudioAction("scene.duplicate", { sceneId }); }
641
+
642
+ function renameSceneDocumentFromUi(sceneId, name) { return appContext.shared.runStudioAction("scene.rename", { sceneId, name }); }
643
+
644
+ function deleteSceneDocumentFromUi(sceneId) { return appContext.shared.runStudioAction("scene.delete", { sceneId }); }
645
+
646
+ function switchSceneDocument(sceneId) {
647
+ if (sceneId === appContext.shared.activeSceneIdRef.current) return;
648
+ const savedScenes = snapshotActiveScene();
649
+ const target = savedScenes.find((scene) => scene.id === sceneId);
650
+ if (!target) return;
651
+ persistScenes(savedScenes, sceneId);
652
+ openScene(target, savedScenes);
653
+ }
654
+
655
+ function addSceneDocument() {
656
+ const savedScenes = snapshotActiveScene();
657
+ const nextScenes = addScene(savedScenes);
658
+ const target = nextScenes[nextScenes.length - 1];
659
+ persistScenes(nextScenes, target.id);
660
+ openScene(target, nextScenes);
661
+ track("scene:created", { scene_source: "ui" });
662
+ }
663
+
664
+ function duplicateSceneDocument(sceneId) {
665
+ const savedScenes = snapshotActiveScene();
666
+ const index = savedScenes.findIndex((scene) => scene.id === sceneId);
667
+ if (index < 0) return;
668
+ const nextScenes = duplicateScene(savedScenes, index);
669
+ const target = nextScenes[index + 1];
670
+ persistScenes(nextScenes, target.id);
671
+ openScene(target, nextScenes);
672
+ }
673
+
674
+ function renameSceneDocument(sceneId, name) {
675
+ const savedScenes = appContext.storeDomain("scenes")?.read() ?? snapshotActiveScene();
676
+ const index = savedScenes.findIndex((scene) => scene.id === sceneId);
677
+ if (index < 0) return;
678
+ const nextScenes = renameScene(savedScenes, index, name);
679
+ appContext.storeDomain('scenes').write(nextScenes);
680
+ persistScenes(nextScenes, appContext.shared.activeSceneIdRef.current);
681
+ }
682
+
683
+ function deleteSceneDocument(sceneId) {
684
+ const savedScenes = snapshotActiveScene();
685
+ const index = savedScenes.findIndex((scene) => scene.id === sceneId);
686
+ if (index < 0 || savedScenes.length <= 1) return;
687
+ const nextScenes = removeScene(savedScenes, index);
688
+ if (sceneId !== appContext.shared.activeSceneIdRef.current) {
689
+ const owned = appContext.storeDomain("scenes");
690
+ if (owned) owned.replaceDocument(nextScenes, appContext.shared.activeSceneIdRef.current);
691
+ else {
692
+ appContext.publishScenes(nextScenes);
693
+ persistScenes(nextScenes, appContext.shared.activeSceneIdRef.current);
694
+ }
695
+ return;
696
+ }
697
+ const target = nextScenes[Math.min(index, nextScenes.length - 1)];
698
+ persistScenes(nextScenes, target.id);
699
+ openScene(target, nextScenes);
700
+ }
701
+ function applyExternalScene(incoming) {
702
+ if (!incoming || !Array.isArray(incoming.scenes)) return;
703
+ const incomingActiveId = typeof incoming.activeSceneId === "string" ? incoming.activeSceneId : appContext.shared.activeSceneIdRef.current;
704
+ const incomingScenes = incoming.scenes;
705
+ const incomingScene = incomingScenes.find((scene) => scene?.id === incomingActiveId) ?? incomingScenes[0];
706
+ if (!incomingScene?.id) return;
707
+ const currentSnapshot = {
708
+ version: SCENES_VERSION,
709
+ activeSceneId: appContext.shared.activeSceneIdRef.current,
710
+ scenes: snapshotActiveScene(),
711
+ };
712
+ if (JSON.stringify(currentSnapshot) === JSON.stringify({ version: SCENES_VERSION, activeSceneId: incomingActiveId, scenes: incomingScenes })) return;
713
+ const nextScenes = incomingScenes;
714
+ if (incomingScene.id !== appContext.shared.activeSceneIdRef.current) {
715
+ openScene(incomingScene, nextScenes);
716
+ return;
717
+ }
718
+ const currentScene = currentSnapshot.scenes.find((scene) => scene?.id === incomingScene.id);
719
+ if (JSON.stringify(currentScene?.objects ?? []) !== JSON.stringify(incomingScene.objects ?? [])) {
720
+ appContext.shared.objectsDomain.applyExternalObjects(incomingScene.objects);
721
+ }
722
+ const incomingStage = createSceneStage(incomingScene.stage);
723
+ const currentStage = currentScene?.stage;
724
+ if (JSON.stringify(currentStage ?? null) !== JSON.stringify(incomingStage)) {
725
+ appContext.shared.castDomain.applyExternalCharacters(incomingStage.characters);
726
+ }
727
+ domain.replaceDocument(nextScenes, incomingScene.id);
728
+ }
729
+ function loadLiveScenes(args, authorized = false) {
730
+ if (!authorized) {
731
+ const receipt = appContext.bus.run("load_scenes", { document: args.document }, {
732
+ origin: "mcp", host: appContext.ports.read().host, expectedRevision: appContext.ports.revision.current,
733
+ ...(args.confirmationToken ? { confirmationToken: args.confirmationToken } : {}),
734
+ });
735
+ return receipt.ok ? receipt.output : receipt;
736
+ }
737
+ if (!args.document || typeof args.document !== "object" || Array.isArray(args.document)) throw new Error("Invalid scene document");
738
+ const loaded = readSceneDocument(JSON.stringify(args.document));
739
+ if (loaded.status !== "valid" && loaded.status !== "migrated") throw new Error("Invalid scene document");
740
+ const document = loaded.document;
741
+ const target = document.scenes[activeSceneIndex(document.scenes, document.activeSceneId)];
742
+ const live = appContext.live.state;
743
+ live.persistScenes(document.scenes, document.activeSceneId);
744
+ live.openScene(target, document.scenes);
745
+ appContext.patchLive({ scenes: domain.read() });
746
+ appContext.patchLive({ activeSceneId: domain.metadata().activeSceneId });
747
+ appContext.patchLive({ objects: appContext.shared.storeRef.current.objects });
748
+ appContext.patchLive({ characters: createSceneStage(target.stage).characters });
749
+ appContext.publishCharacters(live.characters);
750
+ return {
751
+ sceneName: target.name,
752
+ activeSceneId: document.activeSceneId,
753
+ scenes: document.scenes.map((scene) => ({ id: scene.id, name: scene.name })),
754
+ };
755
+ }
756
+ function refreshProjectDirty(afterRender = true) {
757
+ const name = domain.metadata().name;
758
+ // App publishes the incoming shot/cast/workspace envelopes at render.
759
+ // Checkpoint those, not the outgoing refs read inside the load action.
760
+ // A real authored commit in between must never be declared saved.
761
+ if (afterRender && domain.pendingCheckpoint) {
762
+ const checkpoint = domain.pendingCheckpoint;
763
+ domain.pendingCheckpoint = null;
764
+ if (checkpoint.clock === appContext.undoClock && checkpoint.name === name) appContext.shared.projectSnapshotRef.current = collectProjectSnapshot(name);
765
+ }
766
+ const dirty = name !== null && collectProjectSnapshot(name) !== appContext.shared.projectSnapshotRef.current;
767
+ domain.setDirty(dirty);
768
+ setProjectSaveState((current) => current === "saving" ? current : dirty ? "dirty" : "saved");
769
+ // eslint-disable-next-line react-hooks/exhaustive-deps
770
+ }
771
+ domain.snapshot = snapshotActiveScene;
772
+ domain.fileState = () => ({ name: domain.metadata().name, hasFile: Boolean(appContext.shared.projectHandleRef.current),
773
+ fileAccess: hasFileSystemAccess(), gesture: globalThis.navigator?.userActivation?.isActive === true });
774
+ domain.save = (args, context) => saveProject(args.saveAs, args.name ?? null, context);
775
+ domain.loadScenes = args => loadLiveScenes(args, true);
776
+ domain.projectAction = async (id, args, context) => {
777
+ const runtime = key => {
778
+ if (context.origin !== "ui") throw new StudioProtocolError("CAPABILITY_MISSING", "Runtime project handles belong to the UI.");
779
+ const value = runtimeValues.current.get(args[key]);
780
+ if (!value) throw new StudioProtocolError("STALE_TARGET", "The selected project is no longer available.");
781
+ return value;
782
+ };
783
+ if (id === "project.browse") { setProjectStartupOpen(false); setProjectBrowserOpen(true); return; }
784
+ if (id === "project.new") return newProject(args.name, true);
785
+ if (id === "project.openStarter") return openStarterScene(args.id, args.source, context);
786
+ if (id === "project.restore") return restoreStoredProject({ handle: runtime("handleToken") }, context);
787
+ if (args.handleToken) return openProjectByHandle(runtime("handleToken"), context);
788
+ if (args.projectToken) { applyProject(runtime("projectToken"), true); return true; }
789
+ if (args.serialized !== undefined) {
790
+ const result = readProjectDocument(args.serialized);
791
+ if (!result.ok) throw new StudioProtocolError("INVALID_ARGUMENT", `Cannot open project: ${result.reason}`);
792
+ await rehydrateProjectAssets(result.project, result.warnings);
793
+ context.check();
794
+ applyProject(result.project, true);
795
+ appContext.shared.projectHandleRef.current = null;
796
+ return true;
797
+ }
798
+ return openProject(context);
799
+ };
800
+ domain.persist = () => persistScenes(snapshotActiveScene(), domain.metadata().activeSceneId);
801
+ domain.refreshDirty = () => refreshProjectDirty(false);
802
+ return {
803
+ ...domain, applyExternalScene, loadLiveScenes, refreshProjectDirty,
804
+ scenes, activeSceneId, sceneSaveError, snapshotActiveScene, persistScenes, projectName,
805
+ projectDirty, setProjectDirty: domain.setDirty, projectSaveState, setProjectSaveState, projectMenuOpen,
806
+ setProjectMenuOpen, projectBrowserOpen, setProjectBrowserOpen, projectNameDialog, setProjectNameDialog,
807
+ projectStartupOpen, setProjectStartupOpen, projectManifest, setProjectManifest, saveBlockedReasons,
808
+ setSaveBlockedReasons, workflowRevision, setWorkflowRevision, collectProjectSnapshot,
809
+ collectProjectSerialized, projectProblemsNotice, rehydrateProjectAssets, saveProject, applyProject,
810
+ openStarterScene: async (id, source = "starter") => (await runProject("project.openStarter", { id, source })).output?.opened === true,
811
+ openProject, openProjectByHandle, requestNewProject, newProject, restoreOffer,
812
+ setRestoreOffer, restoreStoredProject, flushScenes, openScene, selectSceneDocument,
813
+ createSceneDocumentFromUi, duplicateSceneDocumentFromUi, renameSceneDocumentFromUi,
814
+ deleteSceneDocumentFromUi, switchSceneDocument, addSceneDocument, duplicateSceneDocument,
815
+ renameSceneDocument, deleteSceneDocument,
816
+ };
817
+ }