cozyclay 2.0.2 → 2.1.1

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 (262) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +211 -36
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +4 -4
  19. package/bin/live/cli.mjs +23 -8
  20. package/bin/live/client.mjs +1 -1
  21. package/bin/live/studio.mjs +5 -2
  22. package/bin/mcp-runtime.mjs +2 -2
  23. package/bin/telemetry-state.mjs +3 -3
  24. package/dist/app/index.html +6 -6
  25. package/dist/assets/analytics-C9pMpW7s.js +1 -0
  26. package/dist/assets/app-B_2XB9rw.css +1 -0
  27. package/dist/assets/app-DAuvKPP8.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-DKkpjKBV.js +19 -0
  32. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  33. package/dist/assets/{ticket-BcmsnHn5.js → ticket-BPk2Ni95.js} +1 -1
  34. package/dist/assets/workflow-jJRBR94Z.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/llms.txt +14 -0
  38. package/dist/sitemap.xml +1 -1
  39. package/dist/workflow/index.html +4 -4
  40. package/mcp/LIVE-PROTOCOL.md +23 -2
  41. package/mcp/README.md +37 -2
  42. package/mcp/ardy-prompts.mjs +1 -1
  43. package/mcp/image-file.mjs +132 -0
  44. package/mcp/live-hub.mjs +2 -2
  45. package/mcp/package.json +1 -0
  46. package/mcp/runtime/package-lock.json +7 -0
  47. package/mcp/runtime/package.json +1 -0
  48. package/mcp/server.mjs +0 -6
  49. package/mcp/tool-handlers.mjs +289 -87
  50. package/package.json +11 -2
  51. package/src/App.jsx +813 -278
  52. package/src/agent-image-client.js +94 -0
  53. package/src/analytics.js +47 -3
  54. package/src/app-stage.jsx +129 -2745
  55. package/src/ardy/auto-physics.js +6 -16
  56. package/src/ardy/collision-blockers.js +3 -7
  57. package/src/ardy/fix-collisions.js +5 -35
  58. package/src/ardy/ik-drag.js +86 -5
  59. package/src/ardy/ik-key-json.js +3 -1
  60. package/src/ardy/ik.js +15 -13
  61. package/src/ardy/motion-calibration.js +1 -1
  62. package/src/ardy/npz.js +0 -8
  63. package/src/ardy/physics-panel.jsx +3 -2
  64. package/src/ardy/physics-review.js +3 -2
  65. package/src/ardy/physics-support.js +1 -1
  66. package/src/ardy/physics-surface.js +1 -1
  67. package/src/ardy/platform-fit-panel.jsx +2 -1
  68. package/src/ardy/playback.js +1 -5
  69. package/src/ardy/range-pin.js +2 -2
  70. package/src/ardy/root-drop.js +0 -1
  71. package/src/ardy/timeline.css +184 -6
  72. package/src/ardy/timeline.jsx +121 -36
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +0 -3
  75. package/src/ardy-bridge-contract.js +281 -0
  76. package/src/asset-pane.jsx +19 -9
  77. package/src/auto-color.js +1 -1
  78. package/src/burn-in.js +1 -1
  79. package/src/camera-block.js +1 -1
  80. package/src/camera-follow.js +75 -32
  81. package/src/camera-move.js +0 -6
  82. package/src/camera-tutorial.jsx +6 -7
  83. package/src/character-kind.js +79 -0
  84. package/src/command-bus.js +76 -17
  85. package/src/commands/cast.js +31 -7
  86. package/src/commands/elements/object.js +1 -1
  87. package/src/commands/elements/shot.js +1 -1
  88. package/src/commands/elements.js +1 -1
  89. package/src/commands/export.js +3 -3
  90. package/src/commands/motion.js +2 -2
  91. package/src/commands/objects.js +105 -10
  92. package/src/commands/project.js +15 -2
  93. package/src/commands/sculpt-images.js +53 -0
  94. package/src/commands/shot.js +88 -10
  95. package/src/commands/view.js +10 -4
  96. package/src/coplanar-depth.js +216 -0
  97. package/src/cuts.js +7 -6
  98. package/src/domains/cast.js +60 -43
  99. package/src/domains/motion/motion-intent-codec.js +50 -0
  100. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  101. package/src/domains/motion/motion-video-capture.js +664 -0
  102. package/src/domains/motion.js +253 -742
  103. package/src/domains/objects.js +106 -5
  104. package/src/domains/scenes.js +79 -28
  105. package/src/domains/shots.js +33 -7
  106. package/src/error-boundary.jsx +1 -1
  107. package/src/execution-telemetry.js +5 -5
  108. package/src/facing-marks.js +14 -1
  109. package/src/first-success-guide.jsx +35 -4
  110. package/src/gizmo-claim.js +3 -1
  111. package/src/grid-floor.jsx +1 -4
  112. package/src/hierarchy-model.js +48 -35
  113. package/src/hierarchy-panel.css +1 -0
  114. package/src/hierarchy-panel.jsx +54 -102
  115. package/src/history.js +0 -2
  116. package/src/i2v-motion-client.js +1 -4
  117. package/src/ime.js +6 -0
  118. package/src/keyframe-pack.js +15 -6
  119. package/src/line-edit.js +4 -4
  120. package/src/live-control.js +6 -6
  121. package/src/matte-editor.js +2 -8
  122. package/src/matte.js +0 -19
  123. package/src/mesh-graph-clone.js +16 -2
  124. package/src/mesh-sniff.js +0 -1
  125. package/src/monotone-cubic.js +40 -0
  126. package/src/motion-store.js +4 -4
  127. package/src/motion-trail.js +5 -11
  128. package/src/multimodel-ingest.js +1 -3
  129. package/src/object-catalog.jsx +7 -5
  130. package/src/object-gizmo.jsx +45 -31
  131. package/src/object-path.js +310 -67
  132. package/src/object-travel.js +224 -0
  133. package/src/otio.js +8 -3
  134. package/src/panels/CharacterTransformPanel.jsx +24 -6
  135. package/src/panels/ObjectTransformPanel.jsx +4 -3
  136. package/src/panels/PoseImagePanel.jsx +205 -0
  137. package/src/panels/PromptBlocksPanel.jsx +3 -1
  138. package/src/panels/ProxyFigurePanel.jsx +56 -0
  139. package/src/panels/ReferenceImageField.jsx +1 -1
  140. package/src/panels/RigControlPanel.jsx +24 -3
  141. package/src/panels/RigPanel.jsx +2 -1
  142. package/src/panels/SculptPanel.jsx +405 -0
  143. package/src/panels/SubjectBox.jsx +1 -1
  144. package/src/panels/SubjectsPanel.jsx +0 -2
  145. package/src/panels/VideoCapturePanel.jsx +2 -1
  146. package/src/panels/pose-image.css +42 -0
  147. package/src/panels/pose.css +3 -0
  148. package/src/panels/sculpt.css +162 -0
  149. package/src/plan-diagram.js +184 -0
  150. package/src/planview.jsx +127 -80
  151. package/src/playground.js +3 -5
  152. package/src/pose-fade.js +77 -0
  153. package/src/pose-gizmo.css +23 -0
  154. package/src/pose-gizmo.js +96 -0
  155. package/src/posestudio.jsx +134 -83
  156. package/src/project-browser.css +53 -0
  157. package/src/project-browser.jsx +49 -4
  158. package/src/project.js +55 -24
  159. package/src/props.jsx +177 -36
  160. package/src/proxy-figure.jsx +69 -0
  161. package/src/range-pin-panel.jsx +0 -1
  162. package/src/receipt-findings.js +20 -0
  163. package/src/render-passes.js +0 -1
  164. package/src/resource-status.jsx +1 -1
  165. package/src/root-path.js +48 -0
  166. package/src/route-owner.js +52 -0
  167. package/src/scene-asset-cache.js +1 -2
  168. package/src/scene-assets.js +12 -4
  169. package/src/scene-fbx.js +1 -1
  170. package/src/scene-mesh-cache.js +0 -1
  171. package/src/scene-objects.js +319 -19
  172. package/src/scenes.js +58 -9
  173. package/src/sculpt-blueprint.js +65 -0
  174. package/src/sculpt-compare.js +384 -0
  175. package/src/sculpt-geometry.js +141 -0
  176. package/src/sculpt-mode.js +168 -0
  177. package/src/sculpt-recipe.js +385 -0
  178. package/src/sculpt-run.js +351 -0
  179. package/src/sculpt-stage.jsx +221 -0
  180. package/src/settings-menu.jsx +1 -1
  181. package/src/shell/BoardSlot.jsx +341 -0
  182. package/src/shell/BottomDock.jsx +60 -16
  183. package/src/shell/CompositionGuideButton.jsx +20 -0
  184. package/src/shell/DetailsSlot.jsx +26 -6
  185. package/src/shell/MenuBar.jsx +78 -6
  186. package/src/shell/OutlinerSlot.jsx +3 -1
  187. package/src/shell/ShotPreviewHeader.jsx +17 -0
  188. package/src/shell/TopBar.jsx +18 -3
  189. package/src/shell/ViewportToolbar.jsx +36 -31
  190. package/src/shell/dock.css +239 -0
  191. package/src/shell/glass-regions.css +3 -2
  192. package/src/shell/log-store.js +4 -4
  193. package/src/shell/mode.css +20 -0
  194. package/src/shell/studio-shell-context.js +4 -1
  195. package/src/shell/viewport.css +12 -0
  196. package/src/shot-authoring.js +69 -6
  197. package/src/stable-items.js +1 -1
  198. package/src/stage-attach-frames.js +169 -0
  199. package/src/stage-canvas-rigs.jsx +402 -0
  200. package/src/stage-capture.jsx +216 -0
  201. package/src/stage-characters.jsx +302 -0
  202. package/src/stage-editor-gizmos.jsx +1229 -0
  203. package/src/stage-labels-ko.js +204 -0
  204. package/src/stage-startup-defaults.js +182 -0
  205. package/src/starter-scenes.js +1 -1
  206. package/src/storyboard.js +24 -5
  207. package/src/studio-actions.js +42 -9
  208. package/src/studio-agent-commands.js +213 -23
  209. package/src/studio-agent-context.js +11 -9
  210. package/src/studio-agent-motion.js +2 -1
  211. package/src/studio-agent-protocol.js +60 -24
  212. package/src/studio-app-binding.js +56 -23
  213. package/src/studio-contact-sheet.js +3 -3
  214. package/src/studio-elements.js +11 -2
  215. package/src/studio-geometry-facts.js +114 -0
  216. package/src/styles.css +92 -9
  217. package/src/ui-scrub.js +4 -4
  218. package/src/ui.jsx +6 -4
  219. package/src/use-asset-shelf.js +60 -0
  220. package/src/use-camera-tutorial.js +50 -0
  221. package/src/use-embed-bridge.js +12 -0
  222. package/src/use-first-success-prompt.js +7 -0
  223. package/src/use-i2v-capture-modal.js +15 -0
  224. package/src/use-sculpt-mode.js +95 -0
  225. package/src/workflow/AgentPanel.jsx +106 -18
  226. package/src/workflow/WorkflowBuilder.jsx +1 -1
  227. package/src/workflow/agent-client.js +113 -45
  228. package/src/workflow/agent-panel.css +35 -0
  229. package/src/workflow/attachment-image.js +1 -1
  230. package/src/workflow/canvas-commands.js +3 -3
  231. package/src/workflow/cozy-scene-node.js +3 -3
  232. package/src/workflow/local-workflow.js +2 -2
  233. package/src/workflow/motion-input.js +3 -3
  234. package/src/workflow/node-schema.js +1 -1
  235. package/src/workflow/scene-asset-sync.js +2 -9
  236. package/src/workflow/shot-prompt-node.js +1 -1
  237. package/src/workflow/video-contract.js +1 -1
  238. package/src/workflow/workflow-resources.js +0 -8
  239. package/src/workflow-send.js +87 -0
  240. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  241. package/tools/check-eslint.mjs +57 -0
  242. package/tools/check-knip.mjs +60 -0
  243. package/tools/check-loc.mjs +54 -0
  244. package/tools/demo-worker/api-client.mjs +7 -13
  245. package/tools/demo-worker/generate.mjs +2 -4
  246. package/tools/eslint-baseline.json +23 -0
  247. package/tools/gt-render/browser.mjs +23 -0
  248. package/tools/headless-studio.mjs +85 -0
  249. package/tools/kimodo/effector-constraints.mjs +4 -4
  250. package/tools/kimodo/generate.mjs +1 -1
  251. package/tools/knip-baseline.json +331 -0
  252. package/tools/loc-baseline.json +126 -0
  253. package/tools/process-supervisor.mjs +1 -1
  254. package/tools/projflow/replay.mjs +4 -4
  255. package/tools/projflow/runner.mjs +3 -3
  256. package/tools/qa/studio-control-count.mjs +2 -0
  257. package/tools/run-tests.mjs +95 -7
  258. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  259. package/dist/assets/app-BxBLC6GT.js +0 -4876
  260. package/dist/assets/app-CJdxvnrl.css +0 -1
  261. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  262. package/dist/assets/workflow-B5XZwVfe.js +0 -179
@@ -0,0 +1,402 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
+ import * as THREE from "three";
4
+ import { aimAt } from "./controls.jsx";
5
+ import { SUBJECT_HEIGHT_M } from "./shot.js";
6
+ import { shotAtFrame } from "./cuts.js";
7
+ import { sampleAt } from "./sample-at.js";
8
+ import { RENDER_ACTIVITY_EVENT } from "./use-render-activity.js";
9
+ import { PRESETS, editorFrameForSubject } from "./stage-startup-defaults.js";
10
+
11
+ /** Selection marker for the picked cast member: a Unity-style XYZ tripod
12
+ * plus a ground ring at the feet. X/Z arrows also drag the character on the
13
+ * floor (Y is display-only — characters stand on the deck). */
14
+ export function ShotRig({ preset, nonce, fovDeg, charA, charB, showB, probeX, probeZ, camRef, look, onMetrics, appliedPresetRef, lastPosRef }) {
15
+ useEffect(() => {
16
+ const cam = camRef.current;
17
+ if (!cam) return;
18
+ // A preset is applied once per (preset, nonce), not once per mount. The
19
+ // Canvas has no Suspense boundary of its own, so a cast member whose FBX
20
+ // is still downloading suspends the whole scene graph; when it resolves,
21
+ // React remounts the siblings — this rig included, on a fresh camera
22
+ // object — and a per-mount seed would snap the shot back to the preset,
23
+ // discarding a frame_shot or a manual move that landed in the meantime
24
+ // (#86 on slow CI runners). Both refs live in App so they survive the
25
+ // remount: the stamp says whether this preset was already applied, the
26
+ // position ref (with look.current) says where the camera last was.
27
+ const stamp = `${preset}:${nonce}`;
28
+ if (appliedPresetRef && appliedPresetRef.current === stamp && lastPosRef?.current) {
29
+ const last = lastPosRef.current;
30
+ cam.position.set(last.x, last.y, last.z);
31
+ cam.rotation.order = "YXZ";
32
+ cam.rotation.set(look.current.pitch, look.current.yaw, 0);
33
+ return;
34
+ }
35
+ if (appliedPresetRef) appliedPresetRef.current = stamp;
36
+ const p = PRESETS[preset];
37
+ const az = (p.azimuth * Math.PI) / 180;
38
+ const el = (p.elevation * Math.PI) / 180;
39
+ const aim =
40
+ p.aimMid && showB ? { x: (charA.x + charB.x) / 2, z: (charA.z + charB.z) / 2 } : { x: charA.x, z: charA.z };
41
+ const horizontal = p.distance * Math.cos(el);
42
+ cam.position.set(
43
+ aim.x + horizontal * Math.sin(az),
44
+ p.height ?? Math.max(p.targetY + p.distance * Math.sin(el), 0.15),
45
+ aim.z + horizontal * Math.cos(az),
46
+ );
47
+ const angles = aimAt(cam.position, { x: aim.x, y: p.targetY, z: aim.z });
48
+ look.current.yaw = angles.yaw;
49
+ look.current.pitch = angles.pitch;
50
+ // eslint-disable-next-line react-hooks/exhaustive-deps
51
+ }, [preset, nonce]);
52
+
53
+
54
+ useEffect(() => {
55
+ const cam = camRef.current;
56
+ if (!cam) return;
57
+ cam.fov = fovDeg;
58
+ cam.updateProjectionMatrix();
59
+ }, [fovDeg, camRef]);
60
+
61
+ // A blocking tool must not claim a framing it is not holding, so the subject's
62
+ // feet and eyes are projected into the frame every tick.
63
+ const probe = useRef(new THREE.Vector3());
64
+ const lastMetrics = useRef({ x: NaN, y: NaN, z: NaN, visible: null });
65
+ useFrame(() => {
66
+ const cam = camRef.current;
67
+ if (!cam) return;
68
+ let visible = false;
69
+ for (const y of [0.1, SUBJECT_HEIGHT_M * 0.94]) {
70
+ // During motion playback the probe follows the played root, so the
71
+ // "subject out of frame" caption stays honest.
72
+ probe.current.set(probeX ?? charA.x, y, probeZ ?? charA.z).project(cam);
73
+ if (probe.current.z < 1 && Math.abs(probe.current.x) <= 1 && Math.abs(probe.current.y) <= 1) visible = true;
74
+ }
75
+ const previous = lastMetrics.current;
76
+ if (
77
+ Math.abs(previous.x - cam.position.x) +
78
+ Math.abs(previous.y - cam.position.y) +
79
+ Math.abs(previous.z - cam.position.z) >
80
+ 1e-4 ||
81
+ previous.visible !== visible
82
+ ) {
83
+ lastMetrics.current = {
84
+ x: cam.position.x,
85
+ y: cam.position.y,
86
+ z: cam.position.z,
87
+ visible,
88
+ };
89
+ onMetrics(cam.position, visible);
90
+ }
91
+ });
92
+
93
+ return null;
94
+ }
95
+
96
+ /** Plays an authored A→B camera move by driving the shot camera exactly the
97
+ way a fly-drag does — position on the camera, orientation through the look
98
+ ref — so ShotRig's metrics, the slate, and the inset all stay honest for
99
+ free. A right-drag interrupts the dolly: the user always outranks it.
100
+
101
+ Two frame sources can drive the move. Standalone preview advances authored
102
+ frames at production cadence; follow mode reads the timeline playhead, so the camera and
103
+ the character motion are two views of the same time axis — playing or
104
+ scrubbing frame 72 at 24 fps puts the camera exactly 3 s into its move.
105
+
106
+ frameRef is the imperative playhead routed props and riders already read in
107
+ their own useFrame. The followFrame prop reaches this Canvas subtree one
108
+ commit later, so alone it would pose the camera a paint behind them. */
109
+ export function MoveRig({ playing, following, followFrame, frameRef, fps, keys, shots, scene, camRef, look, isInterrupted, onDone }) {
110
+ const invalidate = useThree((state) => state.invalidate);
111
+ const preview = useRef({ frame: 0, finished: false, notified: false });
112
+ const handlers = useRef({ isInterrupted, onDone });
113
+ handlers.current = { isInterrupted, onDone };
114
+ // The follow branch only re-applies when its time changes; in demand mode
115
+ // each apply needs one more frame so ShotRig's metrics see the new pose.
116
+ const appliedFrame = useRef(null);
117
+ useEffect(() => {
118
+ // Arming or reshaping the move must schedule a frame by hand: this
119
+ // component owns no three objects, so in demand mode nothing else will.
120
+ appliedFrame.current = null;
121
+ if (following) invalidate();
122
+ }, [keys, shots, scene, fps, following, invalidate]);
123
+ useEffect(() => {
124
+ // A paused scrub changes the playhead without starting the render loop.
125
+ if (following && !playing) invalidate();
126
+ }, [followFrame, following, playing, invalidate]);
127
+ useEffect(() => {
128
+ if (!playing || keys.length < 1) return undefined;
129
+ const firstFrame = keys[0].frame;
130
+ const lastFrame = keys[keys.length - 1].frame;
131
+ const spanFrames = Math.max(lastFrame - firstFrame, 1);
132
+ const spanSeconds = Math.max(spanFrames / Math.max(fps, 1), 0.1);
133
+ const tickMs = (spanSeconds * 1000) / spanFrames;
134
+ let tick = 0;
135
+ preview.current = { frame: firstFrame, finished: false, notified: false };
136
+ invalidate();
137
+ const timer = window.setInterval(() => {
138
+ if (preview.current.finished) return;
139
+ if (handlers.current.isInterrupted?.()) {
140
+ preview.current.finished = true;
141
+ invalidate();
142
+ return;
143
+ }
144
+ tick += 1;
145
+ preview.current = {
146
+ frame: firstFrame + (lastFrame - firstFrame) * Math.min(tick / spanFrames, 1),
147
+ finished: tick >= spanFrames,
148
+ notified: false,
149
+ };
150
+ invalidate();
151
+ }, tickMs);
152
+ return () => window.clearInterval(timer);
153
+ }, [playing, keys, fps, invalidate]);
154
+ useFrame(() => {
155
+ if ((playing && keys.length < 1) || (!playing && following && !shots.some((shot) => shot.cameraKeys.length))) return;
156
+ const cam = camRef.current;
157
+ if (!cam) return;
158
+ const apply = (frame) => {
159
+ // Timeline/PlayView sampling follows the editorial strips. Standalone
160
+ // preview stays scoped to the selected strip's keys. Entering a Shot
161
+ // applies its authored camera immediately (a hard cut). Leaving into a
162
+ // gap returns null and deliberately applies nothing: the last physical
163
+ // camera pose stays put and FlyControls regain ownership, so we do not
164
+ // invent a second cut to an arbitrary "free camera" preset.
165
+ const timelineShot = following ? shotAtFrame(shots, frame) : null;
166
+ const sampledShot = timelineShot
167
+ ? { ...timelineShot, camera: { mode: "keys" } }
168
+ : following ? null : { camera: { mode: "keys" }, cameraKeys: keys };
169
+ const f = sampleAt(scene, sampledShot, frame).camera;
170
+ if (!f) return null;
171
+ cam.position.set(f.pos.x, f.pos.y, f.pos.z);
172
+ look.current.yaw = f.yaw;
173
+ look.current.pitch = f.pitch;
174
+ if (Math.abs(cam.fov - f.fovDeg) > 1e-3) {
175
+ cam.fov = f.fovDeg;
176
+ cam.updateProjectionMatrix();
177
+ }
178
+ return f;
179
+ };
180
+ if (playing) {
181
+ const f = apply(preview.current.frame);
182
+ if (preview.current.finished && !preview.current.notified) {
183
+ preview.current.notified = true;
184
+ handlers.current.onDone(f ? f.fovDeg : cam.fov);
185
+ }
186
+ return;
187
+ }
188
+ if (!following) return;
189
+ if (isInterrupted?.()) return; // manual viewport framing owns the paused camera
190
+ const frame = frameRef?.current ?? followFrame;
191
+ if (appliedFrame.current === frame) return;
192
+ appliedFrame.current = frame;
193
+ apply(frame);
194
+ invalidate();
195
+ });
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * Applies the precomputed follow-camera track to the shot camera. The track
201
+ * is derived offline from the subject trajectory (camera-follow.js), so this
202
+ * rig only samples it at the playhead — scrub, play, PlayView and Record all
203
+ * replay the identical deterministic move. Null-rendering, so every apply
204
+ * must invalidate() by hand in demand mode, like MoveRig. Like MoveRig it
205
+ * prefers frameRef over the frame prop so the camera lands in the same paint
206
+ * as the routed props and riders it follows.
207
+ */
208
+ export function FollowCamRig({ enabled, frame, frameRef, scene, shot, camRef, look, isInterrupted }) {
209
+ const invalidate = useThree((state) => state.invalidate);
210
+ const applied = useRef(null);
211
+ useEffect(() => {
212
+ applied.current = null;
213
+ if (enabled) invalidate();
214
+ }, [scene, shot, enabled, invalidate]);
215
+ useEffect(() => {
216
+ if (enabled) invalidate();
217
+ }, [frame, enabled, invalidate]);
218
+ useFrame(() => {
219
+ if (!enabled) return;
220
+ if (isInterrupted?.()) return; // the user is flying; yield until released
221
+ const cam = camRef.current;
222
+ if (!cam) return;
223
+ const playhead = frameRef?.current ?? frame;
224
+ const sample = sampleAt(scene, shot, playhead).camera;
225
+ if (!sample || applied.current === playhead) return;
226
+ applied.current = playhead;
227
+ cam.position.set(sample.pos.x, sample.pos.y, sample.pos.z);
228
+ look.current.yaw = sample.yaw;
229
+ look.current.pitch = sample.pitch;
230
+ invalidate();
231
+ });
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * Applies the shot camera's yaw/pitch from its look ref every frame. Before
237
+ * the camera split, FlyControls owned the shot camera and performed this
238
+ * apply as a side effect of flying; with the fly controls bound to the
239
+ * editor camera, the recording camera still needs its aim (ShotRig, follow,
240
+ * frame-selection) reflected in its actual rotation — the preview and the
241
+ * capture read the camera object, not the ref.
242
+ */
243
+ export function ShotLookApplier({ camRef, look }) {
244
+ useFrame(() => {
245
+ const cam = camRef.current;
246
+ if (!cam) return;
247
+ if (cam.rotation.order !== "YXZ") cam.rotation.order = "YXZ";
248
+ if (cam.rotation.x !== look.current.pitch || cam.rotation.y !== look.current.yaw || cam.rotation.z !== 0) {
249
+ cam.rotation.set(look.current.pitch, look.current.yaw, 0);
250
+ }
251
+ });
252
+ return null;
253
+ }
254
+
255
+ /**
256
+ * Seeds the editor camera once, inside the Canvas root — App-level effects
257
+ * run before R3F mounts its children, so the ref is still null there. The
258
+ * seed frames both the subject and the shot camera: the first editor frame
259
+ * must read as "the set with the camera in it".
260
+ */
261
+ /**
262
+ * A short editor-camera glide: 260 ms ease toward a target orientation (and
263
+ * optionally a position), instead of the teleport that made "look at the
264
+ * light" and F-framing feel like a cut. Any user press cancels it — the
265
+ * operator always outranks the tour guide.
266
+ */
267
+ export function CameraGlide({ glide, camRef, lookRef, onDone }) {
268
+ const { gl } = useThree();
269
+ const invalidate = useThree((state) => state.invalidate);
270
+ const animRef = useRef(null);
271
+ useEffect(() => {
272
+ if (!glide) {
273
+ animRef.current = null;
274
+ return undefined;
275
+ }
276
+ const cam = camRef.current;
277
+ if (!cam) return undefined;
278
+ const fromPos = cam.position.clone();
279
+ const toPos = glide.position ? new THREE.Vector3(glide.position.x, glide.position.y, glide.position.z) : fromPos.clone();
280
+ const angles = aimAt(toPos, glide.target);
281
+ const from = { yaw: lookRef.current.yaw, pitch: lookRef.current.pitch };
282
+ let dYaw = angles.yaw - from.yaw;
283
+ while (dYaw > Math.PI) dYaw -= 2 * Math.PI;
284
+ while (dYaw < -Math.PI) dYaw += 2 * Math.PI;
285
+ // elapsed accumulates render-loop deltas: the glide needs no wall clock
286
+ animRef.current = { fromPos, toPos, from, dYaw, dPitch: angles.pitch - from.pitch, elapsed: 0 };
287
+ const cancel = () => {
288
+ animRef.current = null;
289
+ onDone?.();
290
+ };
291
+ gl.domElement.addEventListener("pointerdown", cancel, true);
292
+ invalidate();
293
+ return () => gl.domElement.removeEventListener("pointerdown", cancel, true);
294
+ // camRef/lookRef are stable refs; onDone identity is per-glide noise
295
+ // eslint-disable-next-line react-hooks/exhaustive-deps
296
+ }, [glide, gl, invalidate]);
297
+ useFrame((_, delta) => {
298
+ const anim = animRef.current;
299
+ const cam = camRef.current;
300
+ if (!anim || !cam) return;
301
+ anim.elapsed += Math.min(delta, 0.1) * 1000;
302
+ const t = Math.min(1, anim.elapsed / 260);
303
+ const eased = 1 - (1 - t) ** 3;
304
+ cam.position.lerpVectors(anim.fromPos, anim.toPos, eased);
305
+ const yaw = anim.from.yaw + anim.dYaw * eased;
306
+ const pitch = anim.from.pitch + anim.dPitch * eased;
307
+ lookRef.current = { yaw, pitch };
308
+ cam.rotation.order = "YXZ";
309
+ cam.rotation.set(pitch, yaw, 0);
310
+ if (t >= 1) {
311
+ animRef.current = null;
312
+ onDone?.();
313
+ } else {
314
+ invalidate();
315
+ }
316
+ });
317
+ return null;
318
+ }
319
+
320
+ export function EditorCamSeed({ camRef, lookRef, shotCamRef, subject }) {
321
+ const seeded = useRef(false);
322
+ const invalidate = useThree((state) => state.invalidate);
323
+ useEffect(() => {
324
+ if (seeded.current) return;
325
+ const cam = camRef.current;
326
+ if (!cam) return;
327
+ seeded.current = true;
328
+ // The selected character owns the first read. The shot camera remains a
329
+ // useful reference in the inset; mixing its position into this seed pushed
330
+ // the editor eye several metres back and made the mannequin look tiny.
331
+ const frame = editorFrameForSubject(subject);
332
+ const { target, position } = frame;
333
+ cam.position.set(position.x, position.y, position.z);
334
+ const angles = aimAt(cam.position, target);
335
+ lookRef.current = { yaw: angles.yaw, pitch: angles.pitch };
336
+ cam.rotation.order = "YXZ";
337
+ cam.rotation.set(angles.pitch, angles.yaw, 0);
338
+ invalidate();
339
+ });
340
+ return null;
341
+ }
342
+
343
+ export function RenderLoopController({ stageRef }) {
344
+ const frameloop = useThree((state) => state.frameloop);
345
+ const setFrameloop = useThree((state) => state.setFrameloop);
346
+ const invalidate = useThree((state) => state.invalidate);
347
+ useEffect(() => {
348
+ const apply = (next) => {
349
+ setFrameloop(next ? "always" : "demand");
350
+ if (next) invalidate();
351
+ };
352
+ const onActivity = (event) => apply(event.detail);
353
+ window.addEventListener(RENDER_ACTIVITY_EVENT, onActivity);
354
+ return () => window.removeEventListener(RENDER_ACTIVITY_EVENT, onActivity);
355
+ }, [invalidate, setFrameloop]);
356
+ useEffect(() => {
357
+ if (stageRef.current) stageRef.current.dataset.actualRenderLoop = frameloop;
358
+ }, [frameloop, stageRef]);
359
+ return null;
360
+ }
361
+
362
+ /** GPU resets — sleep/wake, a driver restart, VRAM pressure from an export —
363
+ * kill the WebGL context, and without listeners the stage goes black and
364
+ * stays black with no message. preventDefault on webglcontextlost is the
365
+ * documented opt-in for browser-driven restoration; on restore, kick the
366
+ * demand loop so the first frame actually paints. */
367
+ export function ContextLossGuard({ onLostChange }) {
368
+ const gl = useThree((state) => state.gl);
369
+ const invalidate = useThree((state) => state.invalidate);
370
+ useEffect(() => {
371
+ const canvas = gl.domElement;
372
+ const onLost = (event) => {
373
+ event.preventDefault();
374
+ onLostChange(true);
375
+ };
376
+ const onRestored = () => {
377
+ onLostChange(false);
378
+ invalidate();
379
+ };
380
+ canvas.addEventListener("webglcontextlost", onLost);
381
+ canvas.addEventListener("webglcontextrestored", onRestored);
382
+ return () => {
383
+ canvas.removeEventListener("webglcontextlost", onLost);
384
+ canvas.removeEventListener("webglcontextrestored", onRestored);
385
+ };
386
+ }, [gl, invalidate, onLostChange]);
387
+ return null;
388
+ }
389
+
390
+ export function ViewportLayoutInvalidator({ insetX, insetY, insetWidth, insetHeight, hierarchyWidth, sidebarWidth, timelineHeight, planZoom }) {
391
+ const invalidate = useThree((state) => state.invalidate);
392
+ useEffect(() => {
393
+ // The pane frame is regular DOM while its picture is a scissored WebGL
394
+ // viewport. In demand mode a drag can commit its final DOM position just
395
+ // after the continuous loop stops, leaving the picture at the previous
396
+ // coordinates. Redraw now and once more after layout has settled.
397
+ invalidate();
398
+ const frame = requestAnimationFrame(invalidate);
399
+ return () => cancelAnimationFrame(frame);
400
+ }, [invalidate, insetX, insetY, insetWidth, insetHeight, hierarchyWidth, sidebarWidth, timelineHeight, planZoom]);
401
+ return null;
402
+ }
@@ -0,0 +1,216 @@
1
+ import { useEffect } from "react";
2
+ import { useThree } from "@react-three/fiber";
3
+ import * as THREE from "three";
4
+ import { GIZMO_LAYER } from "./dualview.jsx";
5
+
6
+ export const CAPTURE_W = 1920;
7
+ export const CAPTURE_H = 1080;
8
+ export const MCP_CAPTURE_W = 640;
9
+ export const MCP_CAPTURE_H = 360;
10
+ export const MCP_CAPTURE_SAMPLE_STEP = 32;
11
+ // How far the deck runs in an EXPORTED frame. The viewport fades it out at
12
+ // 18..54 m to keep the working view clean; a blocking frame needs the far edge
13
+ // to survive, so the falloff starts later and ends further away, letting the
14
+ // floor resolve into a horizon instead of dissolving into the background.
15
+ //
16
+ // Both values must stay inside the shot camera's far plane (100 m) — geometry
17
+ // past it is clipped outright, so a fog range that ended beyond it would cut
18
+ // the deck off mid-fade and take the horizon with it.
19
+ export const CAPTURE_FOG_NEAR = 55;
20
+ export const CAPTURE_FOG_FAR = 95;
21
+
22
+ /** Offscreen aspect-aware read-back, always from the shot camera. */
23
+ export function CaptureRig({ apiRef, camRef, width = CAPTURE_W, height = CAPTURE_H }) {
24
+ const { gl, scene } = useThree();
25
+ useEffect(() => {
26
+ // Each attempt owns its target independently of the live output size.
27
+ // Resizing the editor cannot dispose an in-flight export's resources.
28
+ function createExportCapture({ width, height }) {
29
+ const target = new THREE.WebGLRenderTarget(width, height, {
30
+ colorSpace: THREE.SRGBColorSpace,
31
+ samples: 4,
32
+ });
33
+ const buffer = new Uint8Array(width * height * 4);
34
+ return {
35
+ scene,
36
+ dispose: () => target.dispose(),
37
+ render() {
38
+ const source = camRef.current;
39
+ if (!source) return null;
40
+ const cam = source.clone();
41
+ // the transform gizmo is UI: it never reaches an exported frame
42
+ cam.layers.disable(GIZMO_LAYER);
43
+ // QA hook: the layer mask the export camera actually renders
44
+ // with — the browser suite asserts GIZMO_LAYER (the gizmo AND
45
+ // the selection cage) is never in it.
46
+ window.__captureCameraMask = cam.layers.mask;
47
+ // A view offset (a crop of a wider frustum) already fixes the aspect.
48
+ if (!cam.view?.enabled) cam.aspect = width / height;
49
+ cam.updateProjectionMatrix();
50
+ const previous = gl.getRenderTarget();
51
+ // The viewport's fog dissolves the deck into the background by ~54 m
52
+ // so the working view has no horizon to distract from blocking. An
53
+ // exported frame wants the opposite: the horizon IS the vanishing
54
+ // point, and without it the floor has no far edge to read depth
55
+ // against. Push the falloff back for this draw only, then restore
56
+ // it so the viewport is untouched.
57
+ const fog = scene.fog;
58
+ const fogNear = fog?.near;
59
+ const fogFar = fog?.far;
60
+ if (fog) {
61
+ fog.near = CAPTURE_FOG_NEAR;
62
+ fog.far = CAPTURE_FOG_FAR;
63
+ }
64
+ try {
65
+ gl.setRenderTarget(target);
66
+ gl.render(scene, cam);
67
+ gl.readRenderTargetPixels(target, 0, 0, width, height, buffer);
68
+ } finally {
69
+ gl.setRenderTarget(previous);
70
+ if (fog) {
71
+ fog.near = fogNear;
72
+ fog.far = fogFar;
73
+ }
74
+ }
75
+ return buffer;
76
+ },
77
+ };
78
+ }
79
+ const api = { ...createExportCapture({ width, height }), createExportCapture };
80
+ apiRef.current = api;
81
+ if (width === MCP_CAPTURE_W && height === MCP_CAPTURE_H) {
82
+ window.__cozyclayMcpCaptureReady = true;
83
+ window.dispatchEvent(new Event("cozyclay:mcp-capture-ready"));
84
+ }
85
+ // QA hook: run one real export render and report what the capture
86
+ // camera saw — the layer mask plus an amber scan of the output
87
+ // frame (the selection cage's warm tone). Lets the suite prove the
88
+ // deliverable frame stays free of editor furniture without driving
89
+ // the whole generation form.
90
+ window.__captureFrame = () => {
91
+ const buffer = apiRef.current?.render() ?? null;
92
+ if (!buffer) return null;
93
+ let amber = 0;
94
+ for (let i = 0; i < buffer.length; i += 4) {
95
+ const r = buffer[i];
96
+ const g = buffer[i + 1];
97
+ const b = buffer[i + 2];
98
+ if (r >= 180 && g >= 165 && g - b >= 35) amber++;
99
+ }
100
+ return { layersMask: window.__captureCameraMask ?? 0, amber, pixels: buffer.length / 4 };
101
+ };
102
+ return () => {
103
+ if (apiRef.current === api) apiRef.current = null;
104
+ if (width === MCP_CAPTURE_W && height === MCP_CAPTURE_H) window.__cozyclayMcpCaptureReady = false;
105
+ api.dispose();
106
+ };
107
+ }, [gl, scene, camRef, apiRef, width, height]);
108
+ return null;
109
+ }
110
+
111
+ export async function captureMcpFrame({ capture, camera, characters, activeCharacterId, objects, rigs, readAuthoredState, partColours = null }) {
112
+ if (!capture || !camera) throw new Error("No renderable shot camera is available for capture_frame.");
113
+ const authoredStateBefore = JSON.stringify(readAuthoredState());
114
+ const buffer = capture.render();
115
+ if (!buffer) throw new Error("No renderable shot camera is available for capture_frame.");
116
+ let nonBlackPixels = 0;
117
+ for (let offset = 0; offset < buffer.length; offset += 4) {
118
+ if (buffer[offset] > 5 || buffer[offset + 1] > 5 || buffer[offset + 2] > 5) nonBlackPixels += 1;
119
+ }
120
+ if (nonBlackPixels === 0) throw new Error("capture_frame rejected a black rendered frame.");
121
+ camera.updateMatrixWorld();
122
+ const raycaster = new THREE.Raycaster();
123
+ const roots = [];
124
+ capture.scene.traverse((node) => {
125
+ const id = node.userData?.sceneObjectId;
126
+ if (typeof id === "string" && objects.some((object) => object.id === id)) roots.push({ id, root: node });
127
+ });
128
+ const assertions = characters.map((character) => {
129
+ const rig = rigs[character.id];
130
+ if (!rig || character.hidden) return { id: character.id, renderable: false, behindCameraPlane: null, fartherAlongCameraForward: null, distanceToFloor: null, occludedBy: null, visiblePixelCount: 0 };
131
+ rig.updateWorldMatrix(true, true);
132
+ const bounds = new THREE.Box3().setFromObject(rig);
133
+ const center = bounds.getCenter(new THREE.Vector3());
134
+ const cameraSpace = camera.worldToLocal(center.clone());
135
+ const behindCameraPlane = cameraSpace.z >= 0;
136
+ const distanceToFloor = Math.max(0, bounds.min.y);
137
+ if (behindCameraPlane || bounds.isEmpty()) {
138
+ return { id: character.id, renderable: true, behindCameraPlane, fartherAlongCameraForward: -cameraSpace.z, distanceToFloor, occludedBy: null, visiblePixelCount: 0 };
139
+ }
140
+ const corners = [
141
+ new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z), new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
142
+ new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z), new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
143
+ new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z), new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
144
+ new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z), new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
145
+ ].map((corner) => corner.project(camera));
146
+ const minX = Math.max(0, Math.floor(((Math.min(...corners.map((corner) => corner.x)) + 1) * 0.5) * MCP_CAPTURE_W));
147
+ const maxX = Math.min(MCP_CAPTURE_W - 1, Math.ceil(((Math.max(...corners.map((corner) => corner.x)) + 1) * 0.5) * MCP_CAPTURE_W));
148
+ const minY = Math.max(0, Math.floor(((1 - Math.max(...corners.map((corner) => corner.y))) * 0.5) * MCP_CAPTURE_H));
149
+ const maxY = Math.min(MCP_CAPTURE_H - 1, Math.ceil(((1 - Math.min(...corners.map((corner) => corner.y))) * 0.5) * MCP_CAPTURE_H));
150
+ const occluders = new Map();
151
+ let visiblePixelCount = 0;
152
+ for (let y = minY; y <= maxY; y += MCP_CAPTURE_SAMPLE_STEP) {
153
+ for (let x = minX; x <= maxX; x += MCP_CAPTURE_SAMPLE_STEP) {
154
+ raycaster.setFromCamera({ x: (x / MCP_CAPTURE_W) * 2 - 1, y: 1 - (y / MCP_CAPTURE_H) * 2 }, camera);
155
+ const characterHit = raycaster.intersectObject(rig, true)[0];
156
+ if (!characterHit) continue;
157
+ const objectHit = raycaster.intersectObjects(roots.map((entry) => entry.root), true)[0];
158
+ const pixels = MCP_CAPTURE_SAMPLE_STEP * MCP_CAPTURE_SAMPLE_STEP;
159
+ if (!objectHit || objectHit.distance >= characterHit.distance - 1e-4) {
160
+ visiblePixelCount += pixels;
161
+ continue;
162
+ }
163
+ const occluder = roots.find((entry) => {
164
+ let node = objectHit.object;
165
+ while (node) {
166
+ if (node === entry.root) return true;
167
+ node = node.parent;
168
+ }
169
+ return false;
170
+ });
171
+ if (occluder) occluders.set(occluder.id, (occluders.get(occluder.id) ?? 0) + pixels);
172
+ }
173
+ }
174
+ const occludedBy = [...occluders.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null;
175
+ return { id: character.id, renderable: true, behindCameraPlane, fartherAlongCameraForward: -cameraSpace.z, distanceToFloor, occludedBy, visiblePixelCount };
176
+ });
177
+ const active = assertions.find((entry) => entry.id === activeCharacterId) ?? assertions[0];
178
+ const authoredStateAfter = JSON.stringify(readAuthoredState());
179
+ const canvas = document.createElement("canvas");
180
+ canvas.width = MCP_CAPTURE_W;
181
+ canvas.height = MCP_CAPTURE_H;
182
+ const context = canvas.getContext("2d");
183
+ if (!context) throw new Error("capture_frame could not encode the rendered image.");
184
+ const image = context.createImageData(MCP_CAPTURE_W, MCP_CAPTURE_H);
185
+ for (let row = 0; row < MCP_CAPTURE_H; row += 1) {
186
+ image.data.set(buffer.subarray((MCP_CAPTURE_H - 1 - row) * MCP_CAPTURE_W * 4, (MCP_CAPTURE_H - row) * MCP_CAPTURE_W * 4), row * MCP_CAPTURE_W * 4);
187
+ }
188
+ context.putImageData(image, 0, 0);
189
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
190
+ if (!blob) throw new Error("capture_frame could not compress the rendered image.");
191
+ const bytes = new Uint8Array(await blob.arrayBuffer());
192
+ let binary = "";
193
+ for (const byte of bytes) binary += String.fromCharCode(byte);
194
+ return {
195
+ width: MCP_CAPTURE_W,
196
+ height: MCP_CAPTURE_H,
197
+ mimeType: "image/png",
198
+ encoding: "base64",
199
+ byteSize: bytes.length,
200
+ data: btoa(binary),
201
+ partColours,
202
+ authoredStateBefore,
203
+ authoredStateAfter,
204
+ assertions: {
205
+ renderable: true,
206
+ blackFrame: false,
207
+ nonBlackPixels,
208
+ behindCameraPlane: active?.behindCameraPlane ?? null,
209
+ fartherAlongCameraForward: active?.fartherAlongCameraForward ?? null,
210
+ distanceToFloor: active?.distanceToFloor ?? null,
211
+ occludedBy: active?.occludedBy ?? null,
212
+ visiblePixelCount: active?.visiblePixelCount ?? 0,
213
+ characters: assertions,
214
+ },
215
+ };
216
+ }