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,404 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { aimAt } from '../controls.jsx';
3
+ import { PRESETS, DEFAULT_DURATION_S, TIMELINE_FPS } from '../app-stage.jsx';
4
+ import { CAMERA_MOVES, SUBJECT_HEIGHT_M, focalMmToFov, fovToFocalMm } from '../shot.js';
5
+ import { CAMERA_PRESETS, cameraPresetFraming } from '../camera-move.js';
6
+ import { createDocumentStore } from '../document-store.js';
7
+ import { useDocumentDomain } from '../store/use-document-store.js';
8
+ import { frameDraft } from '../studio-agent-commands.js';
9
+ import { StudioProtocolError } from '../studio-agent-protocol.js';
10
+ import { createShotAuthoringDocument, readShotAuthoring, readShotAuthoringDocument, SHOT_AUTHORING_KEY,
11
+ SHOT_AUTHORING_LEGACY_KEYS, SHOT_AUTHORING_QUARANTINE_KEY } from '../shot-authoring.js';
12
+ import { shotIndexAtFrame } from '../cuts.js';
13
+ import { createCameraBlock, updateCameraBlock } from '../camera-block.js';
14
+ import { followFramingFromCamera, craneHeightAt, buildRail, simplifyStroke } from '../camera-follow.js';
15
+ import { defaultRailRange, clampRailRange } from '../camera-rail-schedule.js';
16
+ import { timelineSpan, timelineContentExtent } from '../timeline-extent.js';
17
+ import { trackFeature } from '../analytics.js';
18
+ import { ko, isKo } from '../locale.js';
19
+
20
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
21
+ function normalizeState(value) {
22
+ const document = createShotAuthoringDocument(value);
23
+ const shots = document.shots.map(shot => {
24
+ const follow = shot.camera.railFollow;
25
+ if (follow?.mode !== 'range') return shot;
26
+ return { ...shot, camera: updateCameraBlock(shot.camera, { railFollow: { mode: 'range', ...clampRailRange(follow, shot.endFrame - shot.startFrame + 1) } }) };
27
+ });
28
+ return { ...value, shots, frameCount: document.frameCount ?? DEFAULT_DURATION_S * TIMELINE_FPS };
29
+ }
30
+
31
+ // The stable handle survives scene loads. Camera pose belongs to the same
32
+ // history entry as the shot it frames; only the persisted shots are projected.
33
+ export function createShotsDomain(appContext, initial = {}) {
34
+ const initialState = value => normalizeState({ shots: [], frameCount: DEFAULT_DURATION_S * TIMELINE_FPS,
35
+ cameraMove: CAMERA_MOVES[1], fovDeg: PRESETS.medium.fov, camera: null, manual: false, ...value });
36
+ let native = createDocumentStore({ owned: { shot: initialState(initial) } });
37
+ const listeners = new Set();
38
+ const notify = () => { for (const listener of listeners) listener(); };
39
+ let release = native.subscribe(notify), loading = false;
40
+ const documentStore = { ...Object.fromEntries(Object.keys(native).map(key => [key, (...args) => native[key](...args)])),
41
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); } };
42
+ const state = () => documentStore.read('shot');
43
+ const read = () => state().shots;
44
+ function writeState(update) {
45
+ return documentStore.write('shot', before => {
46
+ const next = normalizeState(typeof update === 'function' ? update(before) : update);
47
+ return same(before, next) ? before : next;
48
+ });
49
+ }
50
+ const write = update => writeState(before => ({ ...before, shots: typeof update === 'function' ? update(before.shots) : update }));
51
+ function renderCamera(camera, manual, fovDeg) {
52
+ const angles = aimAt(camera.position, camera.lookAt), shared = appContext.shared;
53
+ Object.assign(shared.look.current, angles);
54
+ shared.shotCameraPosRef.current = { ...camera.position };
55
+ const mounted = shared.shotCamRef.current;
56
+ if (mounted) {
57
+ mounted.position.copy(camera.position); mounted.rotation.order = 'YXZ';
58
+ mounted.rotation.set(angles.pitch, angles.yaw, 0); mounted.fov = fovDeg; mounted.updateProjectionMatrix();
59
+ }
60
+ shared.manualCameraOverrideRef.current = manual;
61
+ shared.setCameraPos(camera.position);
62
+ appContext.patchLive({ camera: camera.position, studioCamera: camera });
63
+ }
64
+ let previous = state();
65
+ const publish = () => {
66
+ const next = state(), live = appContext.live.state;
67
+ if (live) {
68
+ appContext.patchLive({ shots: next.shots, fovDeg: next.fovDeg });
69
+ appContext.patchTimeline({ frameCount: next.frameCount });
70
+ if (next.camera && (!same(previous.camera, next.camera) || previous.fovDeg !== next.fovDeg || previous.manual !== next.manual)) renderCamera(next.camera, next.manual, next.fovDeg);
71
+ if (!loading && !same(previous, next)) appContext.shared.markSemanticEdit('shot', previous, next);
72
+ }
73
+ previous = next;
74
+ };
75
+ const unsubscribe = documentStore.subscribe(publish);
76
+ function replaceState(next) {
77
+ release(); native.dispose(); native = createDocumentStore({ owned: { shot: next } }); release = native.subscribe(notify);
78
+ }
79
+ function beginAction() {
80
+ // The camera mounts after this hook. Capture its actual pose before the
81
+ // first authored entry, rather than seeding a second camera at render time.
82
+ if (!state().camera) {
83
+ const live = appContext.ports.read();
84
+ if (live.camera) {
85
+ replaceState({ ...state(), camera: structuredClone(live.camera), manual: live.manual });
86
+ previous = state();
87
+ }
88
+ }
89
+ return documentStore.beginAction('shot');
90
+ }
91
+ function cameraPatch(camera, manual = true) {
92
+ const filmback = appContext.live.state.filmback;
93
+ const fovDeg = focalMmToFov(camera.focalMm, filmback.sensorId, filmback.aspectRatio) * 180 / Math.PI;
94
+ if (fovDeg < 14 || fovDeg > 90) throw new StudioProtocolError('INVALID_ARGUMENT', 'focalMm is outside the editor lens range');
95
+ return { camera, manual, fovDeg };
96
+ }
97
+ function commitDraft(draft) {
98
+ writeState(before => ({ ...before, shots: draft.shotDocument.shots, ...cameraPatch(draft.camera, draft.manual) }));
99
+ }
100
+ function setPresetMetadata(cameraPresetId) {
101
+ const stage = appContext.storeDomain('stage');
102
+ if (!stage || stage.read().cameraPresetId === cameraPresetId) return [];
103
+ appContext.recordAction('stage', () => stage.setCameraPresetId(cameraPresetId), null, true);
104
+ return [appContext.ports.read().host.sceneId];
105
+ }
106
+ function frame(args) {
107
+ const raw = appContext.ports.read(), filmback = raw.filmback, cameraPresetId = args.preset ?? null;
108
+ if (args.preset !== undefined) {
109
+ if (!CAMERA_PRESETS[args.preset]) throw new StudioProtocolError('INVALID_ARGUMENT', 'Unknown camera preset');
110
+ const subject = raw.characters.find(row => row.id === raw.activeCharacterId) ?? raw.characters[0];
111
+ const framing = cameraPresetFraming(args.preset, { x: subject?.x ?? 0, z: subject?.z ?? 0, height: SUBJECT_HEIGHT_M * (subject?.scale ?? 1) }, filmback);
112
+ args = { subjectIds: [subject?.id], framing: { exact: { position: framing.pos,
113
+ lookAt: { x: subject?.x ?? 0, y: SUBJECT_HEIGHT_M * (subject?.scale ?? 1) * 0.52, z: subject?.z ?? 0 }, focalMm: framing.focalMm } } };
114
+ }
115
+ const plan = frameDraft({ name: 'frame_shot', args }, { ...raw, camera: raw.camera ?? state().camera,
116
+ frame: raw.view.frame, shotDocument: { shots: read() } }, { bounds: appContext.ports.bounds });
117
+ commitDraft(plan.draft);
118
+ return { ...plan, affectedIds: [...plan.affectedIds, ...setPresetMetadata(cameraPresetId)] };
119
+ }
120
+ function captureCamera(shotId) {
121
+ const shared = appContext.shared, mounted = shared.shotCamRef.current;
122
+ if (!mounted) return;
123
+ const position = { x: mounted.position.x, y: mounted.position.y, z: mounted.position.z };
124
+ const { yaw, pitch } = shared.look.current;
125
+ const camera = { position, lookAt: { x: position.x - Math.sin(yaw) * Math.cos(pitch), y: position.y + Math.sin(pitch), z: position.z - Math.cos(yaw) * Math.cos(pitch) },
126
+ focalMm: fovToFocalMm(mounted.fov * Math.PI / 180, appContext.live.state.filmback.sensorId, appContext.live.state.filmback.aspectRatio),
127
+ sensorId: appContext.live.state.filmback.sensorId, slate: 'Shot camera' };
128
+ writeState(before => {
129
+ const subject = shared.motionPos ?? shared.charA, subjectYaw = (shared.charA.rot * Math.PI) / 180;
130
+ const shots = before.shots.map(shot => {
131
+ if (shot.id !== shotId) return shot;
132
+ const followCam = createCameraBlock(shot.camera).followCam;
133
+ const measured = followFramingFromCamera(position, pitch, subject, followCam.aimHeight, { x: Math.sin(subjectYaw), z: Math.cos(subjectYaw) });
134
+ return { ...shot, camera: updateCameraBlock(shot.camera, { followCam: { ...followCam, ...measured } }) };
135
+ });
136
+ return { ...before, shots, ...cameraPatch(camera) };
137
+ });
138
+ }
139
+ function setLens(fovDeg) {
140
+ const before = state(), filmback = appContext.live.state.filmback;
141
+ writeState({ ...before, fovDeg, camera: before.camera && { ...before.camera, focalMm: fovToFocalMm(fovDeg * Math.PI / 180, filmback.sensorId, filmback.aspectRatio) } });
142
+ }
143
+ function placeCamera(args) {
144
+ const before = appContext.ports.read().camera ?? state().camera;
145
+ const position = { ...before.position, ...Object.fromEntries(['x', 'y', 'z'].filter(key => args[key] !== undefined).map(key => [key, args[key]])) };
146
+ const aim = ['lookAtX', 'lookAtY', 'lookAtZ'].filter(key => args[key] !== undefined);
147
+ if (aim.length && aim.length !== 3) throw new StudioProtocolError('INVALID_ARGUMENT', 'Supply all three lookAt coordinates.');
148
+ const lookAt = aim.length ? { x: args.lookAtX, y: args.lookAtY, z: args.lookAtZ } : Object.fromEntries(['x', 'y', 'z'].map(key => [key, position[key] + before.lookAt[key] - before.position[key]]));
149
+ writeState(current => ({ ...current, ...cameraPatch({ ...before, position, lookAt, focalMm: args.focalMm ?? before.focalMm }) }));
150
+ setPresetMetadata(null);
151
+ }
152
+ function run(id, args = {}) {
153
+ const result = appContext.bus.run(id, args);
154
+ if (!result.ok) throw new StudioProtocolError(result.code, result.message);
155
+ return result;
156
+ }
157
+ let gesture = null;
158
+ function finishGesture(cancel = false) {
159
+ if (!gesture) return;
160
+ const active = gesture; gesture = null; active.unsubscribe?.(); active.release();
161
+ return active.txId ? run(cancel ? 'run.cancel' : 'run.commit', { txId: active.txId }) : undefined;
162
+ }
163
+ function beginGesture() {
164
+ if (gesture) return;
165
+ const commit = () => finishGesture(), cancel = () => finishGesture(true);
166
+ const key = event => { if (event.key === 'Escape') cancel(); };
167
+ const target = globalThis.window;
168
+ const events = { pointerup: commit, pointercancel: cancel, blur: commit, keydown: key };
169
+ for (const [name, handler] of Object.entries(events)) target?.addEventListener?.(name, handler);
170
+ gesture = { release() { for (const [name, handler] of Object.entries(events)) target?.removeEventListener?.(name, handler); } };
171
+ }
172
+ function edit(id, args) {
173
+ if (!gesture) return run(id, args);
174
+ if (gesture.id && gesture.id !== id) { finishGesture(); beginGesture(); }
175
+ if (!gesture.txId) {
176
+ gesture.id = id; gesture.txId = run('run.begin', { id, args }).txId;
177
+ gesture.unsubscribe = appContext.bus.subscribe(event => {
178
+ if (event.type !== 'transaction.cancelled' || event.txId !== gesture?.txId) return;
179
+ const active = gesture; gesture = null; active.unsubscribe(); active.release();
180
+ });
181
+ }
182
+ return run('run.update', { txId: gesture.txId, args });
183
+ }
184
+ const domain = { documentStore, state, read, write, writeState, beginAction, run, edit, beginGesture, finishGesture,
185
+ bindRender(context) { appContext = context; },
186
+ capture: () => appContext.shared.captureCurrentFraming(), frame, captureCamera, placeCamera, setLens, renderCamera,
187
+ canUndo: id => documentStore.canUndo(id), stepHistory: redo => { finishGesture(); return Boolean((redo ? documentStore.redo : documentStore.undo)()); },
188
+ document: () => ({ shots: read() }), publish: value => writeState(before => ({ ...before, shots: value.shots, ...cameraPatch(value.camera, value.manual) })), commitDraft,
189
+ load(value) { finishGesture(true); loading = true; try { replaceState(initialState(value)); notify(); } finally { loading = false; } },
190
+ dispose() { finishGesture(true); unregister(); unsubscribe(); release(); native.dispose(); listeners.clear(); },
191
+ };
192
+ const unregister = appContext.registerStoreDomain('shot', domain);
193
+ // Startup publication is non-authored and happens before the first bus read.
194
+ loading = true; publish(); loading = false;
195
+ return domain;
196
+ }
197
+
198
+ export function useShots(appContext) {
199
+ const [shotStartup] = useState(() => {
200
+ try {
201
+ let sourceKey = SHOT_AUTHORING_KEY, raw = localStorage.getItem(sourceKey), loaded = readShotAuthoring(raw);
202
+ for (const legacyKey of SHOT_AUTHORING_LEGACY_KEYS) {
203
+ if (loaded.status !== 'absent') break;
204
+ sourceKey = legacyKey; raw = localStorage.getItem(sourceKey); loaded = readShotAuthoring(raw);
205
+ }
206
+ if (loaded.status === 'corrupt') { localStorage.setItem(SHOT_AUTHORING_QUARANTINE_KEY, raw); localStorage.removeItem(sourceKey); return { state: null, saveBlocked: false }; }
207
+ if (loaded.status === 'future') return { state: null, saveBlocked: true };
208
+ return { state: loaded.state, saveBlocked: false };
209
+ } catch { return { state: null, saveBlocked: false }; }
210
+ });
211
+ const startupShotState = readShotAuthoringDocument(appContext.shared.startupScene.shotDocument ?? undefined).state ?? shotStartup.state;
212
+ const [domain] = useState(() => appContext.storeDomain('shot') ?? createShotsDomain(appContext, startupShotState ?? {}));
213
+ domain.bindRender(appContext);
214
+ const { shots, frameCount: tlFrameCount, fovDeg, cameraMove } = useDocumentDomain(domain.documentStore, 'shot');
215
+ const [customMove] = useState('');
216
+ const [movePlaying, setMovePlaying] = useState(false);
217
+ const [moveFollow] = useState(true);
218
+ const [railDraw, setRailDraw] = useState(false);
219
+ const [craneSelectedIndex, setCraneSelectedIndex] = useState(null);
220
+ const [tlFrame, setTlFrame] = useState(0);
221
+ const [tlFps, setTlFps] = useState(TIMELINE_FPS);
222
+ const activeShotIdx = shotIndexAtFrame(shots, tlFrame), activeShot = shots[activeShotIdx] ?? null;
223
+ const cameraKeys = activeShot?.cameraKeys ?? [], activeCamera = createCameraBlock(activeShot?.camera);
224
+ const craneActive = !!activeCamera.craneHeight, cameraRail = activeCamera.cameraRail;
225
+ const activeShotDuration = activeShot ? activeShot.endFrame - activeShot.startFrame + 1 : 0;
226
+ const hasCameraKeys = shots.some(shot => shot.cameraKeys.length > 0);
227
+ const selectedShotId = () => domain.read()[shotIndexAtFrame(domain.read(), appContext.live.state?.timeline.currentFrame ?? tlFrame)]?.id;
228
+ // Arm before the first pointer tick; clicks without a write create no entry.
229
+ useEffect(() => {
230
+ const start = () => domain.beginGesture();
231
+ window.addEventListener('pointerdown', start, true);
232
+ return () => { window.removeEventListener('pointerdown', start, true); domain.finishGesture(true); };
233
+ }, [domain]);
234
+ function setShots(value) { return appContext.storeDomain('shot').write(value); }
235
+ function editShots(value) { const owner = appContext.storeDomain('shot'); return owner.run('shot.replace', { shots: typeof value === 'function' ? value(owner.read()) : value }); }
236
+ function setTlFrameCount(value) {
237
+ const before = domain.state().frameCount, frameCount = Math.max(24, Math.round(typeof value === 'function' ? value(before) : value));
238
+ if (frameCount !== before) return domain.run('shot.setTimeline', { frameCount });
239
+ }
240
+ function setFovDeg(value) { return domain.edit('shot.setLens', { fovDeg: typeof value === 'function' ? value(domain.state().fovDeg) : value }); }
241
+ function changeActiveCamera(patch, shotId = selectedShotId()) {
242
+ if (!shotId) return;
243
+ return appContext.storeDomain('shot').edit('shot.setCamera', { shotId, patch });
244
+ }
245
+ function changeShotTargetModel(targetModel, shotId = selectedShotId()) {
246
+ if (shotId) return domain.run('shot.set', { id: shotId, set: { targetModel: targetModel || null } });
247
+ }
248
+ function syncActiveCameraFraming() {
249
+ if (!appContext.shared.shotCamRef.current || appContext.shared.ikMode || appContext.shared.playMode) return;
250
+ const shotId = selectedShotId();
251
+ return domain.edit('shot.captureCamera', shotId ? { shotId } : {});
252
+ }
253
+ function commitManualCameraFraming() { trackFeature('orbit'); return syncActiveCameraFraming(); }
254
+ function beginCameraFramingGesture() { if (!appContext.shared.ikMode && !appContext.shared.playMode) domain.beginGesture(); }
255
+ function beginTimelineEditGesture(kind, id) {
256
+ if (['camera-key', 'rail', 'shot-boundary'].includes(kind)) return domain.beginGesture();
257
+ if (['prompt-move', 'prompt-resize', 'prompt-text'].includes(kind)) return appContext.storeDomain('cast').beginGesture();
258
+ }
259
+ function setShotCameraRail(shotId, points) { return appContext.storeDomain('shot').run('shot.setCameraRail', { shotId, points }); }
260
+ function clearShotCameraRail(shotId) { return appContext.storeDomain('shot').run('shot.clearCameraRail', { shotId }); }
261
+ function changeCameraRail(points) { const shotId = selectedShotId(); if (shotId) return setShotCameraRail(shotId, points.map(({ x, z }) => ({ x, z }))); }
262
+ function addCameraKeyframe(frame, shotId = selectedShotId()) {
263
+ if (!shotId) return;
264
+ const receipt = domain.run('shot.addKey', { shotId, frame: Math.max(0, Math.round(frame)) });
265
+ if (receipt.authored) appContext.shared.markCraftAction('camera_key');
266
+ appContext.shared.setSelectedHierarchyId('camera'); return receipt;
267
+ }
268
+ function moveCameraKeyframe(shotId, keyId, from, to) { if (from !== to) return domain.edit('shot.moveKey', { shotId, keyId, frame: Math.max(0, Math.round(to)) }); }
269
+ function removeCameraKeyframe(shotId, keyId) { return domain.run('shot.removeKey', { shotId, keyId }); }
270
+ function clearMove() { setMovePlaying(false); const shotId = selectedShotId(); if (shotId) return domain.run('shot.clearKeys', { shotId }); }
271
+ function addTimelineShot() { return appContext.storeDomain('shot').run('shot.create'); }
272
+ function splitTimelineShot(shotId) { return appContext.storeDomain('shot').run('shot.split', { shotId }); }
273
+ function duplicateTimelineShot(shotId) { return appContext.storeDomain('shot').run('shot.duplicate', { shotId }); }
274
+ function removeTimelineShot(shotId) { return appContext.storeDomain('shot').run('shot.remove', { shotId }); }
275
+ function moveTimelineShot(shotId, startFrame) { return appContext.storeDomain('shot').run('shot.reorder', { shotId, startFrame }); }
276
+ function setTimelineShotRange(shotId, startFrame, endFrame) { return appContext.storeDomain('shot').edit('shot.setRange', { shotId, range: { startFrame, endFrameExclusive: endFrame + 1 } }); }
277
+ function resizeTimelineShot(shotId, edge, frame) {
278
+ const shot = domain.read().find(row => row.id === shotId);
279
+ return setTimelineShotRange(shotId, edge === 'start' ? Math.min(frame, shot.endFrame) : shot.startFrame, edge === 'end' ? Math.max(frame, shot.startFrame) : shot.endFrame);
280
+ }
281
+ function renameTimelineShot(shotId, name) { return domain.run('shot.rename', { shotId, name }); }
282
+ function selectTimelineShot(shotId) {
283
+ const selected = domain.read().find(row => row.id === shotId);
284
+ if (!selected) throw new Error(`Unknown shots ID: ${shotId}`);
285
+ appContext.shared.manualCameraOverrideRef.current = false; setTlFrame(selected.startFrame);
286
+ appContext.shared.setSelectedHierarchyId('camera');
287
+ if (appContext.shared.workflowMode !== 'camera') appContext.shared.selectWorkflowMode('camera');
288
+ }
289
+ function previewCameraShot(shotId) {
290
+ const selected = domain.read().find(row => row.id === shotId);
291
+ if (!selected) throw new Error(`Unknown shots ID: ${shotId}`);
292
+ if (appContext.shared.waypointMode) return;
293
+ if (appContext.shared.tlPlaying && appContext.shared.cameraPreviewEndRef.current === selected.endFrame) {
294
+ appContext.shared.cameraPreviewEndRef.current = null; appContext.shared.setTlPlaying(false); return;
295
+ }
296
+ setMovePlaying(false); appContext.shared.manualCameraOverrideRef.current = false;
297
+ appContext.shared.cameraPreviewEndRef.current = selected.endFrame; setTlFrame(selected.startFrame); appContext.shared.setTlPlaying(true);
298
+ }
299
+ function addActiveCranePoint(requestedT = null, shotId = selectedShotId()) {
300
+ const shot = domain.read().find(row => row.id === shotId), camera = createCameraBlock(shot?.camera), points = camera.craneHeight?.points;
301
+ if (!points || points.length >= 8) return;
302
+ let t = Number.isFinite(requestedT) ? Math.max(0.02, Math.min(0.98, requestedT)) : null;
303
+ if (t != null) { const nearby = points.findIndex(point => Math.abs(point.t - t) < 0.02); if (nearby >= 0) { setCraneSelectedIndex(nearby); return; } }
304
+ let gap = 0;
305
+ if (t == null) {
306
+ for (let i = 1; i < points.length - 1; i++) if (points[i + 1].t - points[i].t > points[gap + 1].t - points[gap].t) gap = i;
307
+ t = (points[gap].t + points[gap + 1].t) / 2;
308
+ } else { gap = points.findIndex((point, index) => index < points.length - 1 && t > point.t && t < points[index + 1].t); if (gap < 0) return; }
309
+ changeActiveCamera({ craneHeight: { points: [...points.slice(0, gap + 1), { t, height: craneHeightAt(camera.craneHeight, t) }, ...points.slice(gap + 1)] } }, shotId);
310
+ trackFeature('crane_graph'); setCraneSelectedIndex(gap + 1);
311
+ }
312
+ function deleteSelectedCranePoint() {
313
+ const points = activeCamera.craneHeight?.points;
314
+ if (!points || craneSelectedIndex == null || craneSelectedIndex <= 0 || craneSelectedIndex >= points.length - 1) return;
315
+ changeActiveCamera({ craneHeight: { points: points.filter((_, index) => index !== craneSelectedIndex) } }); setCraneSelectedIndex(null);
316
+ }
317
+ function changeCranePoints(points, options) {
318
+ if (options?.dragging) domain.beginGesture();
319
+ return changeActiveCamera({ craneHeight: { points } });
320
+ }
321
+ function changeCraneRail(points, options) {
322
+ if (options?.dragging) domain.beginGesture();
323
+ return changeActiveCamera({ cameraRail: points });
324
+ }
325
+ function toggleCameraRailDraw() {
326
+ if (!activeShot || appContext.shared.waypointMode) return;
327
+ domain.finishGesture();
328
+ syncActiveCameraFraming();
329
+ if (activeCamera.mode !== 'rail') changeActiveCamera({ mode: 'rail', railFollow: activeCamera.railFollow?.mode === 'off' ? defaultRailRange(activeShotDuration) : activeCamera.railFollow }, activeShot.id);
330
+ const next = !railDraw; trackFeature('dolly_rail'); setRailDraw(next);
331
+ if (next) { appContext.shared.setWorkspaceLayout(current => ({ ...current, insetCollapsed: false })); appContext.notify(ko("Draw the selected Shot's rail in the Top-View", '탑뷰에서 선택한 샷의 레일을 그리세요')); }
332
+ }
333
+ function deleteCameraRail() {
334
+ if (!cameraRail || !activeShot) return;
335
+ setRailDraw(false); clearShotCameraRail(activeShot.id);
336
+ appContext.notify(ko('Camera rail deleted — Follow keeps the current distance', '카메라 레일 삭제됨 — 팔로우가 현재 거리를 유지합니다'));
337
+ }
338
+ function drawCameraRail(stroke) {
339
+ const simplified = simplifyStroke(stroke, 0.12);
340
+ if (simplified.length < 2) return;
341
+ changeCameraRail(simplified); setRailDraw(false);
342
+ const curve = buildRail(simplified);
343
+ if (appContext.shared.playgroundMode && activeShot && curve) {
344
+ const speed = Math.max(0.2, activeCamera.followCam?.maxDollySpeed ?? 4);
345
+ const travel = Math.ceil(curve.length / speed * tlFps) + Math.round(tlFps * 0.5);
346
+ const endFrame = Math.min(tlFrameCount - 1, activeShot.startFrame + Math.max(travel, activeShot.endFrame - activeShot.startFrame));
347
+ setTimelineShotRange(activeShot.id, activeShot.startFrame, endFrame);
348
+ appContext.shared.enterPreview(); setTlFrame(activeShot.startFrame);
349
+ appContext.notify(isKo ? '레일 완성 — 샷 카메라 시점으로 전환했습니다. ▶ 로 재생, Esc 로 복귀' : 'Rail drawn — you are looking through the shot camera. Press ▶ to ride it; Esc goes back to flying.'); return;
350
+ }
351
+ appContext.notify(isKo ? `카메라 레일 완성 — ${curve ? curve.length.toFixed(1) : '?'} m, 제어점 ${simplified.length}개` : `Camera rail drawn — ${curve ? curve.length.toFixed(1) : '?'} m, ${simplified.length} control points`);
352
+ }
353
+ function publishStudioShots(value) { return appContext.storeDomain('shot').publish(value); }
354
+ function commitStudioShots(draft) { return appContext.storeDomain('shot').commitDraft(draft); }
355
+ function publishStudioCamera(camera, manual) { return appContext.storeDomain('shot').writeState(before => ({ ...before, camera, manual })); }
356
+ function applyPreset(key) { appContext.shared.stageDomain.setPreset(key); setFovDeg(PRESETS[key].fov); appContext.shared.setNonce(n => n + 1); }
357
+ function setLiveCamera(args) { return domain.run(args.preset !== undefined ? 'shot.frame' : 'shot.placeCamera', args); }
358
+ function changeLens(value) { return setFovDeg(value); }
359
+ // Range normalization happens inside the authored write, not a later effect.
360
+ function clampShotRailRanges() {}
361
+ const extentInputs = useRef(null);
362
+ function syncTimelineExtent() {
363
+ const inputs = [appContext.shared.characters, appContext.shared.activeChar.id, appContext.shared.motion, appContext.shared.promptClips, appContext.shared.multiModelFootage?.frames];
364
+ // App also re-runs this effect when shot history publishes. Only new
365
+ // content can request a new extent; replaying it after Undo would replace
366
+ // the restored entry and destroy Redo. Shot writes already clamp ranges.
367
+ if (extentInputs.current?.every((value, index) => value === inputs[index])) return;
368
+ extentInputs.current = inputs;
369
+ const extent = timelineContentExtent(...inputs);
370
+ const span = timelineSpan(extent, domain.read(), domain.state().frameCount);
371
+ setTlFrameCount(span); if (extent > 0) setTlFrame(frame => Math.min(frame, span - 1));
372
+ }
373
+ function finishCameraMove(finalFov) { setMovePlaying(false); setFovDeg(Math.round(finalFov * 10) / 10); }
374
+ async function exportShotVideo({ download = true, shotId = null } = {}, commandContext = null) {
375
+ if (appContext.shared.recRef.current) return null;
376
+ const atPlayhead = shotIndexAtFrame(shots, tlFrame);
377
+ const target = shotId ? shots.find(entry => entry.id === shotId) : shots[atPlayhead >= 0 ? atPlayhead : 0] ?? null;
378
+ if (shotId && !target) return null;
379
+ let exportShots = shots;
380
+ if (target && target.cameraKeys.length === 0 && !shotId) {
381
+ appContext.bus.run('shot.addKey', { shotId: target.id, frame: target.startFrame });
382
+ exportShots = appContext.storeDomain('shot').read();
383
+ }
384
+ const range = target && (shotId || !appContext.shared.motion) ? { startFrame: target.startFrame, endFrame: target.endFrame }
385
+ : { startFrame: 0, endFrame: Math.max(0, appContext.shared.currentRecordFrameCount() - 1) };
386
+ return appContext.shared.executeExportRequest(appContext.shared.exportRequest('video', async job => {
387
+ const abort = () => job.controller.abort(commandContext.signal.reason);
388
+ commandContext?.signal.addEventListener('abort', abort, { once: true });
389
+ try { commandContext?.signal.throwIfAborted(); return await appContext.shared.runShotExport({ ...range, download }, job); }
390
+ finally { commandContext?.signal.removeEventListener('abort', abort); }
391
+ }, { exportShots, download }));
392
+ }
393
+ return { ...domain, shots, setShots, editShots, fovDeg, setFovDeg, cameraMove, customMove, startupShotState,
394
+ movePlaying, setMovePlaying, moveFollow, railDraw, setRailDraw, craneSelectedIndex, setCraneSelectedIndex,
395
+ tlFrame, setTlFrame, tlFrameCount, setTlFrameCount, tlFps, setTlFps, activeShotIdx, activeShot, cameraKeys,
396
+ activeCamera, craneActive, cameraRail, activeShotDuration, hasCameraKeys, changeActiveCamera, changeShotTargetModel,
397
+ syncActiveCameraFraming, commitManualCameraFraming, beginCameraFramingGesture, beginTimelineEditGesture,
398
+ setShotCameraRail, clearShotCameraRail, changeCameraRail, addCameraKeyframe, moveCameraKeyframe, removeCameraKeyframe, clearMove,
399
+ addTimelineShot, splitTimelineShot, duplicateTimelineShot, removeTimelineShot, moveTimelineShot, setTimelineShotRange,
400
+ resizeTimelineShot, renameTimelineShot, selectTimelineShot, previewCameraShot, addActiveCranePoint, deleteSelectedCranePoint,
401
+ changeCranePoints, changeCraneRail, toggleCameraRailDraw, deleteCameraRail, drawCameraRail,
402
+ publishStudioShots, commitStudioShots, publishStudioCamera, applyPreset, setLiveCamera, changeLens,
403
+ clampShotRailRanges, syncTimelineExtent, finishCameraMove, exportShotVideo };
404
+ }
@@ -0,0 +1,93 @@
1
+ import { useContext, useEffect, useRef, useState } from 'react';
2
+ import { AppContext } from '../app-context.js';
3
+ import { createSceneStageStore } from '../store/scene-stage.js';
4
+ import { useDocumentDomain } from '../store/use-document-store.js';
5
+ import { createKeyLight } from '../scenes.js';
6
+ import { shotAspectRatio } from '../shot.js';
7
+ import { normalizeStage } from '../commands/stage.js';
8
+
9
+ // The facade composes this store's sessions with the other document owners.
10
+ export function createStageDomain(appContext) {
11
+ const documentStore = createSceneStageStore(normalizeStage(appContext.shared.startupStage));
12
+ const read = () => documentStore.read('stage');
13
+ const publish = () => {
14
+ if (appContext.live.state) appContext.patchLive({ stage: { ...appContext.live.state.stage, ...read() },
15
+ filmback: { sensorId: read().sensorId, aspectRatio: shotAspectRatio(read().shotAspect) } });
16
+ const persisted = appContext.shared.actorStageRef;
17
+ if (persisted?.current) persisted.current = { ...persisted.current, ...read() };
18
+ };
19
+ const release = documentStore.subscribe(publish);
20
+ function beginAction() { return documentStore.beginAction('stage'); }
21
+ function recordAction(fn) {
22
+ const session = beginAction();
23
+ try { const result = session.run(fn); return { result, ...session.commit() }; }
24
+ catch (error) { session.cancel(); throw error; }
25
+ }
26
+ function write(value) {
27
+ return documentStore.write('stage', before => {
28
+ const next = normalizeStage(typeof value === 'function' ? value(before) : value);
29
+ return JSON.stringify(before) === JSON.stringify(next) ? before : next;
30
+ });
31
+ }
32
+ const canUndo = id => documentStore.canUndo(id);
33
+ function stepHistory(redo) { return Boolean((redo ? documentStore.redo : documentStore.undo)()); }
34
+ const setters = Object.fromEntries(Object.entries({ 'setKeyLight': 'keyLight', 'setEnvironmentImage': 'environmentImage', 'setEnvironment': 'environment',
35
+ 'setStyle': 'style', 'setHasEnvSheet': 'hasEnvSheet', 'setShotAspectKey': 'shotAspect', 'setCameraPresetId': 'cameraPresetId', 'setSensorFormat': 'sensorId' })
36
+ .map(([name, key]) => [name, value => write(before => ({ ...before, [key]: typeof value === 'function' ? value(before[key]) : value }))]));
37
+ const document = () => ({ stage: { ...appContext.shared.actorStageRef.current, ...read() } });
38
+ const domain = { documentStore, document, read, write, beginAction, recordAction, canUndo, stepHistory, ...setters,
39
+ publish: state => write(state.stage), commitDraft: write,
40
+ load(stage) { documentStore.load(normalizeStage(stage)); },
41
+ dispose() { unregister(); release(); documentStore.dispose(); },
42
+ };
43
+ const unregister = appContext.registerStoreDomain('stage', domain);
44
+ return domain;
45
+ }
46
+
47
+ export function useStageTransaction() {
48
+ const app = useContext(AppContext), session = useRef(null);
49
+ const run = (...args) => {
50
+ const receipt = app.bus.run(...args);
51
+ if (!receipt.ok) app.notify(receipt.message);
52
+ return receipt;
53
+ };
54
+ function finish(cancel = false) {
55
+ if (!session.current) return;
56
+ const { txId } = session.current;
57
+ session.current = null;
58
+ return run(cancel ? 'run.cancel' : 'run.commit', { txId });
59
+ }
60
+ function begin(id) {
61
+ if (session.current?.id !== id) finish();
62
+ if (!session.current) {
63
+ const receipt = run('run.begin', { id, args: {} });
64
+ if (receipt.ok) session.current = { id, txId: receipt.txId };
65
+ }
66
+ return session.current?.txId;
67
+ }
68
+ useEffect(() => () => { if (session.current) app.bus.run('run.cancel', { txId: session.current.txId }); }, [app]);
69
+ return { run, begin, commit: () => finish(), cancel: () => finish(true) };
70
+ }
71
+
72
+ export function useStage(appContext) {
73
+ const [domain] = useState(() => createStageDomain(appContext));
74
+ const stage = useDocumentDomain(domain.documentStore, 'stage');
75
+ const [preset, setPreset] = useState('medium');
76
+ const gesture = useRef(null);
77
+ function finishGesture() {
78
+ if (!gesture.current) return;
79
+ const txId = gesture.current; gesture.current = null;
80
+ return appContext.bus.run('run.commit', { txId });
81
+ }
82
+ function changeKeyLight(_gesture, patch) {
83
+ const keyLight = domain.read().keyLight;
84
+ if (!gesture.current) gesture.current = appContext.bus.run('run.begin', { id: 'stage.setKeyLight', args: {} }).txId;
85
+ return appContext.bus.run('run.update', { txId: gesture.current, args: { keyLight: typeof patch === 'function' ? patch(keyLight) : patch } });
86
+ }
87
+ function resetKeyLight() { return appContext.bus.run('stage.setKeyLight', { keyLight: createKeyLight(null) }); }
88
+ function changeEnvironmentImage(environmentImage) { return appContext.bus.run('stage.setEnvironment', { environmentImage }); }
89
+ function publishStudioStage(stage) {
90
+ return appContext.storeDomain('stage').write(stage);
91
+ }
92
+ return { ...domain, ...stage, shotAspectKey: stage.shotAspect, preset, setPreset, changeKeyLight, resetKeyLight, changeEnvironmentImage, finishGesture, publishStudioStage };
93
+ }