cozyclay 1.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 (85) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/bin/cozyclay.mjs +194 -0
  5. package/dist/ardy/cskel27-rest.json +557 -0
  6. package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
  8. package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
  9. package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
  10. package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
  11. package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
  12. package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
  13. package/dist/assets/index-BW_S1YRy.js +4395 -0
  14. package/dist/assets/index-nRmmJgap.css +1 -0
  15. package/dist/demo/walk-then-stop.npz +0 -0
  16. package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
  17. package/dist/fonts/instrument-serif-latin.woff2 +0 -0
  18. package/dist/fonts/inter-latin.woff2 +0 -0
  19. package/dist/index.html +13 -0
  20. package/dist/models/x-bot-tpose.fbx +0 -0
  21. package/dist/models/y-bot-tpose.fbx +0 -0
  22. package/package.json +80 -0
  23. package/src/App.jsx +3840 -0
  24. package/src/ardy/client.js +140 -0
  25. package/src/ardy/convert.js +313 -0
  26. package/src/ardy/cskel27-neutral.js +39 -0
  27. package/src/ardy/cskel27.js +68 -0
  28. package/src/ardy/export.js +157 -0
  29. package/src/ardy/ik.js +610 -0
  30. package/src/ardy/npz.js +520 -0
  31. package/src/ardy/playback.js +414 -0
  32. package/src/ardy/prompt-clips.js +16 -0
  33. package/src/ardy/timeline-coordinates.js +17 -0
  34. package/src/ardy/timeline-resize.js +11 -0
  35. package/src/ardy/timeline.jsx +723 -0
  36. package/src/ardy/to-cskel27.js +205 -0
  37. package/src/ardy/waypoints.js +364 -0
  38. package/src/camera-follow.js +366 -0
  39. package/src/camera-move.js +286 -0
  40. package/src/controls.jsx +245 -0
  41. package/src/dualview.jsx +305 -0
  42. package/src/hierarchy-model.js +77 -0
  43. package/src/hierarchy-panel.jsx +365 -0
  44. package/src/history.js +90 -0
  45. package/src/main.jsx +10 -0
  46. package/src/object-catalog.jsx +90 -0
  47. package/src/object-gizmo.jsx +755 -0
  48. package/src/planview.jsx +653 -0
  49. package/src/poses.js +424 -0
  50. package/src/posestudio.jsx +811 -0
  51. package/src/props.jsx +302 -0
  52. package/src/room.jsx +64 -0
  53. package/src/scene-history.js +125 -0
  54. package/src/scene-objects.js +420 -0
  55. package/src/shot-authoring.js +113 -0
  56. package/src/shot.js +239 -0
  57. package/src/styles.css +5639 -0
  58. package/src/ui.jsx +391 -0
  59. package/src/use-render-activity.js +96 -0
  60. package/tools/ardy/BRIDGE.md +255 -0
  61. package/tools/ardy/README.md +136 -0
  62. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  63. package/tools/ardy/bridge.mjs +1427 -0
  64. package/tools/ardy/cclay_motion_edit.py +445 -0
  65. package/tools/ardy/cclay_sequence_generate.py +595 -0
  66. package/tools/ardy/dump-npz.py +205 -0
  67. package/tools/ardy/extract-rest.mjs +299 -0
  68. package/tools/ardy/npz.mjs +335 -0
  69. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  70. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  71. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  72. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  73. package/tools/ardy/pose-to-npz.mjs +106 -0
  74. package/tools/ardy/run-edit-on-box.sh +73 -0
  75. package/tools/ardy/run-on-box.sh +568 -0
  76. package/tools/ardy/run-sequence-on-box.sh +162 -0
  77. package/tools/ardy/visual-qa.mjs +188 -0
  78. package/tools/ardy/vq-car.mjs +52 -0
  79. package/tools/dev-full.mjs +29 -0
  80. package/tools/process-supervisor.mjs +63 -0
  81. package/tools/qa-browser.mjs +84 -0
  82. package/tools/qa-crop3.mjs +22 -0
  83. package/tools/qa-playview.mjs +49 -0
  84. package/tools/qa-screenshot.mjs +24 -0
  85. package/tools/qa-visual.mjs +51 -0
package/src/App.jsx ADDED
@@ -0,0 +1,3840 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Canvas, useFrame, useThree } from "@react-three/fiber";
3
+ import { OrthographicCamera, PerspectiveCamera, Text, useFBX } from "@react-three/drei";
4
+ import * as THREE from "three";
5
+ import { SkeletonUtils } from "three/examples/jsm/Addons.js";
6
+ import { buildArdyPose } from "./ardy/export.js";
7
+ import { checkBridge, generate as ardyGenerate } from "./ardy/client.js";
8
+ import { loadMotionFromUrl } from "./ardy/npz.js";
9
+ import { applyMotionFrame, captureArdyRoot, restorePlaybackBones, snapshotPlaybackBones } from "./ardy/playback.js";
10
+ import { movePromptClipFrames } from "./ardy/prompt-clips.js";
11
+ import Timeline from "./ardy/timeline.jsx";
12
+ import { alignArdyPath, judgeAuthoredPath, judgeNextWaypoint, toSceneRootOffset, PATH_LIMITS } from "./ardy/waypoints.js";
13
+ import { FlyControls, aimAt, forwardFrom } from "./controls.jsx";
14
+ import HierarchyPanel from "./hierarchy-panel.jsx";
15
+ import { PlanBoard } from "./planview.jsx";
16
+ import { DualRender, GIZMO_LAYER, SHOT_ASPECT, fitAspect } from "./dualview.jsx";
17
+ import { Room, StageLights } from "./room.jsx";
18
+ import { SHOT_AUTHORING_KEY, loadShotAuthoring, serializeShotAuthoring } from "./shot-authoring.js";
19
+ import { FOLLOW_DEFAULTS, buildFollowTrack, buildRail, buildRailFollowTrack, simplifyStroke } from "./camera-follow.js";
20
+ import { SetProps } from "./props.jsx";
21
+ import {
22
+ DEFAULT_SCENE_OBJECTS,
23
+ OBJECT_COLORS,
24
+ SCENE_QUARANTINE_KEY,
25
+ SCENE_STORAGE_KEY,
26
+ createSceneObject,
27
+ dropToSurfacePatch,
28
+ loadScene,
29
+ objectSize,
30
+ placementInFront,
31
+ removeSceneObject,
32
+ sceneObjectIdFromHierarchy,
33
+ serializeScene,
34
+ updateSceneObject,
35
+ } from "./scene-objects.js";
36
+ import { createSceneHistoryStore } from "./scene-history.js";
37
+ import ObjectGizmo from "./object-gizmo.jsx";
38
+ import AddObjectMenu from "./object-catalog.jsx";
39
+ import {
40
+ BUILT_IN_POSES,
41
+ POSE_BONES,
42
+ applyPose,
43
+ primeBindPose,
44
+ capturePose,
45
+ deleteCustomPose,
46
+ loadCustomPoses,
47
+ saveCustomPoses,
48
+ } from "./poses.js";
49
+ import { IkHandles, PoseHandles, PoseStudioPanel } from "./posestudio.jsx";
50
+ import {
51
+ MID_TRACKS,
52
+ createIkState,
53
+ ikBakeKeyframe,
54
+ ikEvaluate,
55
+ ikKeyframes,
56
+ ikRemoveKeyframe,
57
+ ikSeedTargets,
58
+ ikTouch,
59
+ resolveIkRig,
60
+ solveIk,
61
+ solveMidJoint,
62
+ solveSwingAngle,
63
+ solveEffectorSwing,
64
+ solveHipsTranslate,
65
+ ikPlantFeet,
66
+ ikSolvePlantedFeet,
67
+ } from "./ardy/ik.js";
68
+ import { Dropdown, Field, Slider, Toast, Vector3Row } from "./ui.jsx";
69
+ import { RENDER_ACTIVITY_EVENT, useRenderActivity } from "./use-render-activity.js";
70
+ import {
71
+ CAMERA_MOVES,
72
+ CUSTOM_MOVE,
73
+ IMAGE_MODELS,
74
+ SUBJECT_HEIGHT_M,
75
+ VIDEO_MODELS,
76
+ composePrompt,
77
+ deriveShot,
78
+ slateLine,
79
+ } from "./shot.js";
80
+ import { captureFraming, classifyMove, cameraMoveAt, moveSequenceSlate, moveSequencePhrase } from "./camera-move.js";
81
+
82
+ // Stated the way a crew states a setup: how far back, which side, how high the
83
+ // lens rides, and what glass is on it. Order matters — Medium is the setup a
84
+ // director reaches for first, so it leads.
85
+ const PRESETS = {
86
+ medium: { label: "Medium", distance: 2.6, azimuth: 22, elevation: 6, fov: 45, targetY: 1.35, two: false },
87
+ wide: { label: "Wide", distance: 7, azimuth: 25, elevation: 4, fov: 38, targetY: 1.2, two: false },
88
+ closeup: { label: "Close-Up", distance: 1.3, azimuth: 16, elevation: 2, fov: 45, targetY: 1.55, two: false },
89
+ low: { label: "Low Angle", distance: 3.5, azimuth: 20, elevation: -14, fov: 50, targetY: 1.1, two: false },
90
+ high: { label: "High Angle", distance: 4.5, azimuth: 20, elevation: 16, fov: 45, targetY: 1.1, two: false },
91
+ ots: {
92
+ label: "Over Shoulder",
93
+ distance: 2.8,
94
+ azimuth: 200,
95
+ elevation: 8,
96
+ fov: 40,
97
+ targetY: 1.5,
98
+ two: true,
99
+ // the foreground shoulder must sit between the lens and subject 1
100
+ charB: { x: 0.62, z: 1.05, rot: 196 },
101
+ },
102
+ two: {
103
+ label: "Two Shot",
104
+ distance: 6,
105
+ azimuth: 18,
106
+ elevation: 5,
107
+ fov: 42,
108
+ targetY: 1.2,
109
+ two: true,
110
+ charB: { x: 1.15, z: 0.1, rot: -14 },
111
+ aimMid: true,
112
+ },
113
+ };
114
+
115
+ const RIG_HIERARCHY_FOCUS = {
116
+ "rig.hips": "hips",
117
+ "rig.spine": "spine",
118
+ "rig.leftArm": "leftHand",
119
+ "rig.rightArm": "rightHand",
120
+ "rig.leftLeg": "leftFoot",
121
+ "rig.rightLeg": "rightFoot",
122
+ };
123
+
124
+ const HIERARCHY_INSPECTOR_TITLES = {
125
+ shot: "Shot settings",
126
+ camera: "Camera",
127
+ characters: "Characters",
128
+ characterA: "Character 1",
129
+ "characterA.character": "Character 1",
130
+ "characterA.motion": "Motion",
131
+ "characterA.baseMotion": "Base Motion",
132
+ "characterA.promptBlocks": "Prompt Blocks",
133
+ "characterA.ik": "IK Corrections",
134
+ "characterA.rig": "Rig",
135
+ characterB: "Character 2",
136
+ "characterB.character": "Character 2",
137
+ rootPath: "Root Path",
138
+ environment: "Environment",
139
+ props: "Props",
140
+ "rig.hips": "Root / Hips",
141
+ "rig.spine": "Spine",
142
+ "rig.leftArm": "Left Arm",
143
+ "rig.rightArm": "Right Arm",
144
+ "rig.leftLeg": "Left Leg",
145
+ "rig.rightLeg": "Right Leg",
146
+ };
147
+
148
+ function hierarchyIdForIkFocus(focus) {
149
+ if (!focus) return null;
150
+ if (focus === "hips") return "rig.hips";
151
+ if (["spine", "chest", "neck", "head"].includes(focus)) return "rig.spine";
152
+ if (focus.startsWith("left") && ["Hand", "Elbow", "Shoulder"].some((part) => focus.endsWith(part))) return "rig.leftArm";
153
+ if (focus.startsWith("right") && ["Hand", "Elbow", "Shoulder"].some((part) => focus.endsWith(part))) return "rig.rightArm";
154
+ if (focus === "leftFoot" || focus === "leftKnee") return "rig.leftLeg";
155
+ if (focus === "rightFoot" || focus === "rightKnee") return "rig.rightLeg";
156
+ return "characterA.rig";
157
+ }
158
+
159
+ const CAPTURE_W = 1920;
160
+ const CAPTURE_H = 1080;
161
+ // Pre-generated clip shipped with the build so a bridge-less session (a hosted
162
+ // static demo, or `npm run dev:ui`) still shows real generated motion.
163
+ // Relative on purpose: it has to resolve under a project sub-path too.
164
+ // Same reason as DEMO_MOTION_URL: a leading slash breaks the app the moment
165
+ // the build is served from a project sub-path (a GitHub Pages project site).
166
+ const CHARACTER_MODEL_URL = "models/y-bot-tpose.fbx";
167
+ const DEMO_MOTION_URL = "demo/walk-then-stop.npz";
168
+ const DEMO_MOTION_PROMPT = "a person walking then a person stops";
169
+ const CLAY = "#f2eee6";
170
+ const CLAY_B = "#ddd6ca";
171
+ const DEFAULT_POSE = BUILT_IN_POSES.find((p) => p.id === "relaxed") ?? BUILT_IN_POSES[0];
172
+ const REST_BONES = Object.fromEntries(POSE_BONES.map((b) => [b.id, [0, 0, 0]]));
173
+ const DEFAULT_DURATION_S = 15; // pre-motion timeline duration; shown as duration × 20 frames
174
+ const DEFAULT_PLAYBACK_SPEED = 1;
175
+ const ARDY_PROMPT_HORIZON_FRAMES = 40; // core model horizon: 2 seconds at 20 fps
176
+ const MAX_WAYPOINTS = 32; // ARDY bridge contract: a root path holds 2..32 distinct waypoint frames
177
+ const ARDY_PROMPT_MAX = 500; // bridge contract: prompt must be non-empty, capped at 500 chars
178
+ const ARDY_DURATION_MIN = 1; // the UI works in whole seconds; the bridge floor is 0.15 s
179
+ const ARDY_DURATION_MAX = 1200; // bridge contract: duration capped at 1200 s
180
+ const ARDY_SEED_MAX = 2 ** 31 - 1; // bridge contract: optional seed, integer in 0..2**31-1
181
+ const DEFAULT_PROMPT_CLIPS = [];
182
+
183
+ /* ------------------------------------------------------------------ 3D --- */
184
+
185
+ function Character({ url, position, rot, tint, pose, onRig, pickId }) {
186
+ const fbx = useFBX(url);
187
+ const model = useMemo(() => {
188
+ const clone = SkeletonUtils.clone(fbx);
189
+ clone.scale.setScalar(0.01); // Mixamo exports centimetres
190
+ clone.traverse((child) => {
191
+ if (child.isMesh) {
192
+ // warm clay reads as a maquette; cold grey reads as a broken render
193
+ child.material = new THREE.MeshStandardMaterial({
194
+ color: tint,
195
+ roughness: 0.82,
196
+ metalness: 0,
197
+ });
198
+ child.frustumCulled = false;
199
+ }
200
+ });
201
+ // Stamp the bind pose while the rig is still untouched: the pose effect
202
+ // below runs immediately after and would otherwise be baked into "rest".
203
+ primeBindPose(clone);
204
+ return clone;
205
+ }, [fbx, tint]);
206
+
207
+ useEffect(() => {
208
+ // During playback the pose prop is null and the motion drives the rig;
209
+ // this effect can flush AFTER the playback effect in a later commit
210
+ // (measured: playback at t, pose reset at t+1.8ms), and resetting to
211
+ // REST then would clobber the animation back to the bind pose. So a
212
+ // null pose means "playback owns the rig" — do not touch it.
213
+ if (!pose) return;
214
+ // every joint is reset first, otherwise switching presets leaves stale limbs
215
+ applyPose(model, { ...REST_BONES, ...pose.bones });
216
+ }, [model, pose]);
217
+
218
+ useEffect(() => {
219
+ onRig?.(model);
220
+ }, [model, onRig]);
221
+
222
+ return (
223
+ // characterPick makes the body a first-class click target: the Scene
224
+ // picker walks up from any hit mesh, finds the tag, and routes the
225
+ // selection to the hierarchy so the Inspector owns the controls.
226
+ <group position={position} rotation={[0, (rot * Math.PI) / 180, 0]} userData={pickId ? { characterPick: pickId } : undefined}>
227
+ <primitive object={model} />
228
+ </group>
229
+ );
230
+ }
231
+
232
+ /** Applies a preset to the free-flying camera, then reports live metrics. */
233
+ function ShotRig({ preset, nonce, fovDeg, charA, charB, showB, probeX, probeZ, camRef, look, onMetrics }) {
234
+ useEffect(() => {
235
+ const cam = camRef.current;
236
+ if (!cam) return;
237
+ const p = PRESETS[preset];
238
+ const az = (p.azimuth * Math.PI) / 180;
239
+ const el = (p.elevation * Math.PI) / 180;
240
+ const aim =
241
+ p.aimMid && showB ? { x: (charA.x + charB.x) / 2, z: (charA.z + charB.z) / 2 } : { x: charA.x, z: charA.z };
242
+ const horizontal = p.distance * Math.cos(el);
243
+ cam.position.set(
244
+ aim.x + horizontal * Math.sin(az),
245
+ Math.max(p.targetY + p.distance * Math.sin(el), 0.15),
246
+ aim.z + horizontal * Math.cos(az),
247
+ );
248
+ const angles = aimAt(cam.position, { x: aim.x, y: p.targetY, z: aim.z });
249
+ look.current.yaw = angles.yaw;
250
+ look.current.pitch = angles.pitch;
251
+ // eslint-disable-next-line react-hooks/exhaustive-deps
252
+ }, [preset, nonce]);
253
+
254
+
255
+ useEffect(() => {
256
+ const cam = camRef.current;
257
+ if (!cam) return;
258
+ cam.fov = fovDeg;
259
+ cam.updateProjectionMatrix();
260
+ }, [fovDeg, camRef]);
261
+
262
+ // A blocking tool must not claim a framing it is not holding, so the subject's
263
+ // feet and eyes are projected into the frame every tick.
264
+ const probe = useRef(new THREE.Vector3());
265
+ const lastMetrics = useRef({ x: NaN, y: NaN, z: NaN, visible: null });
266
+ useFrame(() => {
267
+ const cam = camRef.current;
268
+ if (!cam) return;
269
+ let visible = false;
270
+ for (const y of [0.1, SUBJECT_HEIGHT_M * 0.94]) {
271
+ // During motion playback the probe follows the played root, so the
272
+ // "subject out of frame" caption stays honest.
273
+ probe.current.set(probeX ?? charA.x, y, probeZ ?? charA.z).project(cam);
274
+ if (probe.current.z < 1 && Math.abs(probe.current.x) <= 1 && Math.abs(probe.current.y) <= 1) visible = true;
275
+ }
276
+ const previous = lastMetrics.current;
277
+ if (
278
+ Math.abs(previous.x - cam.position.x) +
279
+ Math.abs(previous.y - cam.position.y) +
280
+ Math.abs(previous.z - cam.position.z) >
281
+ 1e-4 ||
282
+ previous.visible !== visible
283
+ ) {
284
+ lastMetrics.current = {
285
+ x: cam.position.x,
286
+ y: cam.position.y,
287
+ z: cam.position.z,
288
+ visible,
289
+ };
290
+ onMetrics(cam.position, visible);
291
+ }
292
+ });
293
+
294
+ return null;
295
+ }
296
+
297
+ /** Plays an authored A→B camera move by driving the shot camera exactly the
298
+ way a fly-drag does — position on the camera, orientation through the look
299
+ ref — so ShotRig's metrics, the slate, and the inset all stay honest for
300
+ free. A right-drag interrupts the dolly: the user always outranks it.
301
+
302
+ Two clocks can drive the move. Standalone preview runs on its own clock;
303
+ follow mode slaves the move to the timeline playhead, so the camera and
304
+ the character motion are two views of the same time axis — playing or
305
+ scrubbing frame 60 at 20 fps puts the camera exactly 3 s into its move. */
306
+ function MoveRig({ playing, following, followFrame, fps, keys, anchor, camRef, look, isInterrupted, onDone }) {
307
+ const clock = useRef(0);
308
+ const invalidate = useThree((state) => state.invalidate);
309
+ // The follow branch only re-applies when its time changes; in demand mode
310
+ // each apply needs one more frame so ShotRig's metrics see the new pose.
311
+ const appliedFrame = useRef(null);
312
+ useEffect(() => {
313
+ // Arming or reshaping the move must schedule a frame by hand: this
314
+ // component owns no three objects, so in demand mode nothing else will.
315
+ appliedFrame.current = null;
316
+ if (following) invalidate();
317
+ }, [keys, anchor, fps, following, invalidate]);
318
+ useEffect(() => {
319
+ // A paused scrub changes the playhead without starting the render loop.
320
+ if (following && !playing) invalidate();
321
+ }, [followFrame, following, playing, invalidate]);
322
+ useEffect(() => {
323
+ if (playing) clock.current = 0;
324
+ }, [playing]);
325
+ useFrame((_, delta) => {
326
+ if (keys.length < 1) return;
327
+ const cam = camRef.current;
328
+ if (!cam) return;
329
+ const firstFrame = keys[0].frame;
330
+ const spanFrames = Math.max(keys[keys.length - 1].frame - firstFrame, 1);
331
+ const apply = (frame) => {
332
+ const f = cameraMoveAt(keys, anchor, frame);
333
+ if (!f) return null;
334
+ cam.position.set(f.pos.x, f.pos.y, f.pos.z);
335
+ look.current.yaw = f.yaw;
336
+ look.current.pitch = f.pitch;
337
+ if (Math.abs(cam.fov - f.fovDeg) > 1e-3) {
338
+ cam.fov = f.fovDeg;
339
+ cam.updateProjectionMatrix();
340
+ }
341
+ return f;
342
+ };
343
+ if (playing) {
344
+ if (isInterrupted?.()) {
345
+ onDone(cam.fov);
346
+ return;
347
+ }
348
+ // The camera-only preview runs on its own clock across the key span:
349
+ // N keys play through every segment back to back.
350
+ const span = Math.max(spanFrames / Math.max(fps, 1), 0.1);
351
+ clock.current = Math.min(clock.current + delta, span);
352
+ const t = clock.current / span;
353
+ const f = apply(firstFrame + t * spanFrames);
354
+ if (t >= 1) onDone(f ? f.fovDeg : cam.fov);
355
+ return;
356
+ }
357
+ if (!following) return;
358
+ if (isInterrupted?.()) return; // the user is flying; yield until released
359
+ if (appliedFrame.current === followFrame) return;
360
+ appliedFrame.current = followFrame;
361
+ apply(followFrame);
362
+ invalidate();
363
+ });
364
+ return null;
365
+ }
366
+
367
+ /**
368
+ * Applies the precomputed follow-camera track to the shot camera. The track
369
+ * is derived offline from the subject trajectory (camera-follow.js), so this
370
+ * rig only samples it at the playhead — scrub, play, PlayView and Record all
371
+ * replay the identical deterministic move. Null-rendering, so every apply
372
+ * must invalidate() by hand in demand mode, like MoveRig.
373
+ */
374
+ function FollowCamRig({ enabled, frame, track, camRef, look, isInterrupted }) {
375
+ const invalidate = useThree((state) => state.invalidate);
376
+ const applied = useRef(null);
377
+ useEffect(() => {
378
+ applied.current = null;
379
+ if (enabled) invalidate();
380
+ }, [track, enabled, invalidate]);
381
+ useEffect(() => {
382
+ if (enabled) invalidate();
383
+ }, [frame, enabled, invalidate]);
384
+ useFrame(() => {
385
+ if (!enabled || !track || track.length === 0) return;
386
+ if (isInterrupted?.()) return; // the user is flying; yield until released
387
+ const cam = camRef.current;
388
+ if (!cam) return;
389
+ const sample = track[Math.max(0, Math.min(frame, track.length - 1))];
390
+ if (applied.current === sample) return;
391
+ applied.current = sample;
392
+ cam.position.set(sample.pos.x, sample.pos.y, sample.pos.z);
393
+ look.current.yaw = sample.yaw;
394
+ look.current.pitch = sample.pitch;
395
+ invalidate();
396
+ });
397
+ return null;
398
+ }
399
+
400
+ function RenderLoopController({ stageRef }) {
401
+ const frameloop = useThree((state) => state.frameloop);
402
+ const setFrameloop = useThree((state) => state.setFrameloop);
403
+ const invalidate = useThree((state) => state.invalidate);
404
+ useEffect(() => {
405
+ const apply = (next) => {
406
+ setFrameloop(next ? "always" : "demand");
407
+ if (next) invalidate();
408
+ };
409
+ const onActivity = (event) => apply(event.detail);
410
+ window.addEventListener(RENDER_ACTIVITY_EVENT, onActivity);
411
+ return () => window.removeEventListener(RENDER_ACTIVITY_EVENT, onActivity);
412
+ }, [invalidate, setFrameloop]);
413
+ useEffect(() => {
414
+ if (stageRef.current) stageRef.current.dataset.actualRenderLoop = frameloop;
415
+ }, [frameloop, stageRef]);
416
+ return null;
417
+ }
418
+
419
+ function ViewportLayoutInvalidator({ insetX, insetY, insetWidth, insetHeight, hierarchyWidth, sidebarWidth, timelineHeight, planZoom }) {
420
+ const invalidate = useThree((state) => state.invalidate);
421
+ useEffect(() => {
422
+ // The pane frame is regular DOM while its picture is a scissored WebGL
423
+ // viewport. In demand mode a drag can commit its final DOM position just
424
+ // after the continuous loop stops, leaving the picture at the previous
425
+ // coordinates. Redraw now and once more after layout has settled.
426
+ invalidate();
427
+ const frame = requestAnimationFrame(invalidate);
428
+ return () => cancelAnimationFrame(frame);
429
+ }, [invalidate, insetX, insetY, insetWidth, insetHeight, hierarchyWidth, sidebarWidth, timelineHeight, planZoom]);
430
+ return null;
431
+ }
432
+
433
+ /** The authored root path on the set floor while path editing is on: numbered
434
+ pins connected in walk order. Lives on the gizmo layer, so the shot camera
435
+ shows it but exports never do (the bird's-eye board draws its own copy). */
436
+ /**
437
+ * The drawn camera rail in the 3D Scene view: editor furniture on the gizmo
438
+ * layer, so PlayView, the ink prepass and exports never see it.
439
+ */
440
+ function CameraRailScenePreview({ points }) {
441
+ const rootRef = useRef(null);
442
+ const line = useMemo(() => {
443
+ if (!points || points.length < 2) return null;
444
+ const positions = new Float32Array(points.length * 3);
445
+ points.forEach((point, i) => {
446
+ positions[i * 3] = point.x;
447
+ positions[i * 3 + 1] = 0.03;
448
+ positions[i * 3 + 2] = point.z;
449
+ });
450
+ const geometry = new THREE.BufferGeometry();
451
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
452
+ return geometry;
453
+ }, [points]);
454
+ useEffect(() => () => line?.dispose(), [line]);
455
+ useEffect(() => {
456
+ rootRef.current?.traverse((node) => node.layers.set(GIZMO_LAYER));
457
+ });
458
+ if (!line) return null;
459
+ return (
460
+ <group ref={rootRef}>
461
+ <line geometry={line}>
462
+ <lineBasicMaterial color="#a78bfa" transparent opacity={0.8} depthWrite={false} />
463
+ </line>
464
+ </group>
465
+ );
466
+ }
467
+
468
+ function ShotPathPreview({ waypoints, start, activeWaypointFrame }) {
469
+ const rootRef = useRef(null);
470
+ const line = useMemo(() => {
471
+ if (!waypoints.length) return null;
472
+ const points = [{ x: start.x, z: start.z }, ...waypoints];
473
+ const positions = new Float32Array(points.length * 3);
474
+ points.forEach((point, i) => {
475
+ positions[i * 3] = point.x;
476
+ positions[i * 3 + 1] = 0.03;
477
+ positions[i * 3 + 2] = point.z;
478
+ });
479
+ const geometry = new THREE.BufferGeometry();
480
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
481
+ return geometry;
482
+ }, [start.x, start.z, waypoints]);
483
+ useEffect(() => {
484
+ // Every render: drei's Text meshes appear asynchronously, and each new
485
+ // node must land on the gizmo layer before the next capture.
486
+ rootRef.current?.traverse((node) => node.layers.set(GIZMO_LAYER));
487
+ });
488
+ if (!waypoints.length) return null;
489
+ return (
490
+ <group ref={rootRef}>
491
+ {line && (
492
+ <line geometry={line}>
493
+ <lineBasicMaterial color="#4e9fb3" transparent opacity={0.8} depthWrite={false} />
494
+ </line>
495
+ )}
496
+ {waypoints.map((waypoint, i) => {
497
+ const active = waypoint.frame === activeWaypointFrame;
498
+ const color = active ? "#ffd76c" : "#4e9fb3";
499
+ return (
500
+ <group key={waypoint.frame} position={[waypoint.x, 0.03, waypoint.z]}>
501
+ <mesh rotation={[-Math.PI / 2, 0, 0]}>
502
+ <ringGeometry args={[0.13, 0.19, 28]} />
503
+ <meshBasicMaterial color={color} depthWrite={false} />
504
+ </mesh>
505
+ <Text
506
+ position={[0, 0.02, 0.34]}
507
+ rotation={[-Math.PI / 2, 0, 0]}
508
+ fontSize={0.24}
509
+ color={color}
510
+ anchorX="center"
511
+ anchorY="middle"
512
+ outlineWidth={0.03}
513
+ outlineColor="#1c1a17"
514
+ outlineOpacity={0.85}
515
+ >
516
+ {String(i + 1)}
517
+ </Text>
518
+ </group>
519
+ );
520
+ })}
521
+ </group>
522
+ );
523
+ }
524
+
525
+ /** Unity-style scene grid: 1 m cells on the set floor, editor chrome only.
526
+ It rides the gizmo layer so exports and the plan never pick it up, sits a
527
+ hair above the deck to dodge z-fighting, and never swallows a raycast —
528
+ floor clicks pass straight through to the ground plane. Warm greys tuned
529
+ to the clay floor (#e7e1d7). */
530
+ function SceneGrid() {
531
+ const grid = useMemo(() => {
532
+ const helper = new THREE.GridHelper(24, 24, 0x8f8474, 0xa99f8e);
533
+ helper.material.transparent = true;
534
+ helper.material.opacity = 0.62;
535
+ helper.material.depthWrite = false;
536
+ helper.position.y = 0.002;
537
+ helper.layers.set(GIZMO_LAYER);
538
+ helper.raycast = () => null;
539
+ return helper;
540
+ }, []);
541
+ return <primitive object={grid} />;
542
+ }
543
+
544
+ /** Offscreen 1920x1080 read-back, always from the shot camera. */
545
+ function CaptureRig({ apiRef, camRef }) {
546
+ const { gl, scene } = useThree();
547
+ useEffect(() => {
548
+ apiRef.current = {
549
+ render() {
550
+ const source = camRef.current;
551
+ if (!source) return null;
552
+ const target = new THREE.WebGLRenderTarget(CAPTURE_W, CAPTURE_H, {
553
+ colorSpace: THREE.SRGBColorSpace,
554
+ samples: 4,
555
+ });
556
+ const cam = source.clone();
557
+ // the transform gizmo is UI: it never reaches an exported frame
558
+ cam.layers.disable(GIZMO_LAYER);
559
+ // QA hook: the layer mask the export camera actually renders
560
+ // with — the browser suite asserts GIZMO_LAYER (the gizmo AND
561
+ // the selection cage) is never in it.
562
+ window.__captureCameraMask = cam.layers.mask;
563
+ cam.aspect = CAPTURE_W / CAPTURE_H;
564
+ cam.updateProjectionMatrix();
565
+ const previous = gl.getRenderTarget();
566
+ gl.setRenderTarget(target);
567
+ gl.render(scene, cam);
568
+ const buffer = new Uint8Array(CAPTURE_W * CAPTURE_H * 4);
569
+ gl.readRenderTargetPixels(target, 0, 0, CAPTURE_W, CAPTURE_H, buffer);
570
+ gl.setRenderTarget(previous);
571
+ target.dispose();
572
+ return buffer;
573
+ },
574
+ };
575
+ // QA hook: run one real export render and report what the capture
576
+ // camera saw — the layer mask plus an amber scan of the 1920x1080
577
+ // frame (the selection cage's warm tone). Lets the suite prove the
578
+ // deliverable frame stays free of editor furniture without driving
579
+ // the whole generation form.
580
+ window.__captureFrame = () => {
581
+ const buffer = apiRef.current?.render() ?? null;
582
+ if (!buffer) return null;
583
+ let amber = 0;
584
+ for (let i = 0; i < buffer.length; i += 4) {
585
+ const r = buffer[i];
586
+ const g = buffer[i + 1];
587
+ const b = buffer[i + 2];
588
+ if (r >= 180 && g >= 165 && g - b >= 35) amber++;
589
+ }
590
+ return { layersMask: window.__captureCameraMask ?? 0, amber, pixels: buffer.length / 4 };
591
+ };
592
+ }, [gl, scene, camRef, apiRef]);
593
+ return null;
594
+ }
595
+
596
+ /* ----------------------------------------------------------------- app --- */
597
+
598
+ // Unity's tool keys. They are free because camera movement now lives behind a
599
+ // held right button. (docs/unity-reference.md §9.2)
600
+ const GIZMO_HOTKEYS = { KeyW: "move", KeyE: "rotate", KeyR: "scale" };
601
+
602
+ const WORKSPACE_LAYOUT_KEY = "cozyclay.workspace-layout.v1";
603
+ const DEFAULT_WORKSPACE_LAYOUT = Object.freeze({
604
+ hierarchyWidth: 300,
605
+ sidebarWidth: 380,
606
+ timelineHeight: 224,
607
+ insetWidth: 310,
608
+ insetHeight: 310,
609
+ insetCollapsed: false,
610
+ planZoom: 1,
611
+ });
612
+
613
+ function loadWorkspaceLayout() {
614
+ try {
615
+ const saved = JSON.parse(localStorage.getItem(WORKSPACE_LAYOUT_KEY) || "null");
616
+ return saved ? { ...DEFAULT_WORKSPACE_LAYOUT, ...saved } : { ...DEFAULT_WORKSPACE_LAYOUT };
617
+ } catch {
618
+ return { ...DEFAULT_WORKSPACE_LAYOUT };
619
+ }
620
+ }
621
+
622
+ /**
623
+ * The startup scene load (plan §8.3): a tagged result from loadScene plus the
624
+ * session's durability posture. Runs once inside a lazy initializer, so the
625
+ * quarantine write happens before the first render and the toast/error ride
626
+ * along as initial UI state. A throwing getItem counts as absent — private
627
+ * browsing must never crash the studio.
628
+ */
629
+ function loadSceneStartup() {
630
+ let raw = null;
631
+ try {
632
+ raw = localStorage.getItem(SCENE_STORAGE_KEY);
633
+ } catch {
634
+ raw = null;
635
+ }
636
+ // DEFAULT_SCENE_OBJECTS stays the fallback, never a seed over storage
637
+ // (plan §8.5); the clone keeps the record footprint copies independent.
638
+ const defaults = () => DEFAULT_SCENE_OBJECTS.map((object) => ({ ...object, footprint: { ...object.footprint } }));
639
+ const result = loadScene(raw);
640
+ if (result.status === "valid") {
641
+ return {
642
+ objects: result.objects,
643
+ saveBlocked: false,
644
+ error: null,
645
+ toast: result.dropped > 0 ? `${result.dropped} saved object(s) could not be restored` : null,
646
+ };
647
+ }
648
+ if (result.status === "future") {
649
+ // A newer build wrote this scene. Overwriting it with our older schema
650
+ // would destroy newer data, so this session never writes the primary key.
651
+ return {
652
+ objects: defaults(),
653
+ saveBlocked: true,
654
+ error: null,
655
+ toast: "Saved scene was written by a newer CozyClay — it has been left untouched and this session will not save",
656
+ };
657
+ }
658
+ if (result.status === "corrupt") {
659
+ try {
660
+ // Keep the unreadable bytes under the quarantine key before the
661
+ // first save overwrites the primary.
662
+ localStorage.setItem(SCENE_QUARANTINE_KEY, raw);
663
+ return {
664
+ objects: defaults(),
665
+ saveBlocked: false,
666
+ error: null,
667
+ toast: "Saved scene was unreadable — starting empty; the old data is kept under cozyclay.scene.v1.quarantine",
668
+ };
669
+ } catch {
670
+ // If the backup write fails, overwriting the corrupt primary would
671
+ // lose the only copy of the data — saving stays blocked instead.
672
+ return {
673
+ objects: defaults(),
674
+ saveBlocked: true,
675
+ error: "Saved scene was unreadable and could not be backed up — this session will not save",
676
+ toast: null,
677
+ };
678
+ }
679
+ }
680
+ // absent: nothing saved, or a read that threw — either way a fresh scene.
681
+ return { objects: defaults(), saveBlocked: false, error: null, toast: null };
682
+ }
683
+
684
+ export default function App() {
685
+ const [workspaceLayout, setWorkspaceLayout] = useState(loadWorkspaceLayout);
686
+ const [preset, setPreset] = useState("medium");
687
+ const [fovDeg, setFovDeg] = useState(PRESETS.medium.fov);
688
+ const [nonce, setNonce] = useState(0);
689
+ // The Top-View is always the inset: the old double-click swap that let the
690
+ // plan own the big pane is gone, so there is no view mode to toggle.
691
+ const planIsMain = false;
692
+ // Unity Scene/Game tabs: PlayView is the framed output only — no editing
693
+ // chrome (gizmo, inset, fly navigation) reaches it.
694
+ const [centerTab, setCenterTab] = useState("scene");
695
+ globalThis.playMode = centerTab === "play";
696
+ // PlayView is the player for the finished motion: entering starts playback,
697
+ // leaving pauses it. Scene stays the manipulation surface.
698
+ const [tlPlaying, setTlPlaying] = useState(false);
699
+ useEffect(() => {
700
+ // The player always starts the finished piece from frame 0; auto-play
701
+ // only exists once there is a motion to play.
702
+ if (centerTab === "play") setTlFrame(0);
703
+ if (centerTab === "play" && motion) setTlPlaying(true);
704
+ if (centerTab === "scene") setTlPlaying(false);
705
+ // eslint-disable-next-line react-hooks/exhaustive-deps
706
+ }, [centerTab]);
707
+ const stageRef = useRef();
708
+ const mainPaneRef = useRef();
709
+ const insetPaneRef = useRef();
710
+ // User-dragged inset position (px, stage-relative). null = the CSS default
711
+ // (top-right); double-clicking the tag snaps back to it.
712
+ const [insetPos, setInsetPos] = useState(null);
713
+ // Timestamp of the last fold toggle that came from the tag strip (click or
714
+ // caret). A double-click started on the tag lands its dblclick event on
715
+ // the pane body afterwards — the body listener skips those, or every tag
716
+ // double-click would toggle twice.
717
+ const insetToggledAtRef = useRef(0);
718
+ const planCamRef = useRef();
719
+ const planHostRef = planIsMain ? mainPaneRef : insetPaneRef;
720
+
721
+ useEffect(() => {
722
+ localStorage.setItem(WORKSPACE_LAYOUT_KEY, JSON.stringify(workspaceLayout));
723
+ }, [workspaceLayout]);
724
+
725
+ // Wheel over the inset zooms the Top-View plan: scroll up closes in on
726
+ // the pucks (camera lower), scroll down widens out (camera higher) — the
727
+ // ortho extent is divided by planZoom in DualRender. React's onWheel is
728
+ // passive and could never keep the page still, so a real listener.
729
+ useEffect(() => {
730
+ const pane = insetPaneRef.current;
731
+ if (!pane) return;
732
+ const onWheel = (e) => {
733
+ e.preventDefault();
734
+ setWorkspaceLayout((current) => {
735
+ const next = Math.max(0.25, Math.min(4, current.planZoom * Math.pow(1.0015, -e.deltaY)));
736
+ return next === current.planZoom ? current : { ...current, planZoom: Math.round(next * 100) / 100 };
737
+ });
738
+ };
739
+ pane.addEventListener("wheel", onWheel, { passive: false });
740
+ return () => pane.removeEventListener("wheel", onWheel);
741
+ }, []);
742
+
743
+ const workspaceStyle = {
744
+ "--hierarchy-width": `${workspaceLayout.hierarchyWidth}px`,
745
+ "--sidebar-width": `${workspaceLayout.sidebarWidth}px`,
746
+ "--timeline-height": `${workspaceLayout.timelineHeight}px`,
747
+ "--inset-width": `${workspaceLayout.insetWidth}px`,
748
+ "--inset-height": `${workspaceLayout.insetHeight}px`,
749
+ };
750
+
751
+ function beginWorkspaceResize(kind, e) {
752
+ if (e.button !== 0) return;
753
+ e.preventDefault();
754
+ e.stopPropagation();
755
+ const startX = e.clientX;
756
+ const startY = e.clientY;
757
+ const start = workspaceLayout;
758
+ const onMove = (ev) => {
759
+ const dx = ev.clientX - startX;
760
+ const dy = ev.clientY - startY;
761
+ setWorkspaceLayout((current) => {
762
+ if (kind === "sidebar") {
763
+ return {
764
+ ...current,
765
+ sidebarWidth: Math.max(280, Math.min(window.innerWidth * 0.5, start.sidebarWidth - dx)),
766
+ };
767
+ }
768
+ if (kind === "hierarchy") {
769
+ return {
770
+ ...current,
771
+ hierarchyWidth: Math.max(220, Math.min(window.innerWidth * 0.4, start.hierarchyWidth + dx)),
772
+ };
773
+ }
774
+ return {
775
+ ...current,
776
+ timelineHeight: Math.max(110, Math.min(window.innerHeight * 0.58, start.timelineHeight - dy)),
777
+ };
778
+ });
779
+ };
780
+ const onUp = () => {
781
+ document.body.classList.remove("is-resizing", `resize-${kind}`);
782
+ window.removeEventListener("pointermove", onMove);
783
+ window.removeEventListener("pointerup", onUp);
784
+ window.removeEventListener("pointercancel", onUp);
785
+ };
786
+ document.body.classList.add("is-resizing", `resize-${kind}`);
787
+ window.addEventListener("pointermove", onMove);
788
+ window.addEventListener("pointerup", onUp);
789
+ window.addEventListener("pointercancel", onUp);
790
+ }
791
+
792
+ // Double-clicking the inset body folds it (like a window titlebar). The
793
+ // tag keeps its own double-click (snap back to the corner); the old
794
+ // Scene↔Top-View big-pane swap on double-click is gone — the Top-View is
795
+ // always the inset, folded or not. Pane divs are pointer-events:none off
796
+ // the plan board, so this hit-tests the rect instead of DOM targeting.
797
+ useEffect(() => {
798
+ const onDblClick = (event) => {
799
+ const pane = insetPaneRef.current;
800
+ if (!pane) return;
801
+ if (event.target.closest?.(".vp-inset-tag")) return; // the tag's own gesture
802
+ if (Date.now() - insetToggledAtRef.current < 450) return; // a tag gesture already folded this double-click
803
+ const rect = pane.getBoundingClientRect();
804
+ if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) return;
805
+ setWorkspaceLayout((current) => ({ ...current, insetCollapsed: !current.insetCollapsed }));
806
+ };
807
+ window.addEventListener("dblclick", onDblClick);
808
+ return () => window.removeEventListener("dblclick", onDblClick);
809
+ }, []);
810
+
811
+ // Drag the inset pane by its tag chip. Window-level listeners so a fast
812
+ // drag off the chip keeps moving the pane; bounds clamp to the stage.
813
+ function beginInsetDrag(e) {
814
+ if (e.button !== 0) return;
815
+ const stage = stageRef.current;
816
+ const pane = insetPaneRef.current;
817
+ if (!stage || !pane) return;
818
+ e.preventDefault();
819
+ e.stopPropagation();
820
+ const stageRect = stage.getBoundingClientRect();
821
+ const paneRect = pane.getBoundingClientRect();
822
+ const grabX = e.clientX - paneRect.left;
823
+ const grabY = e.clientY - paneRect.top;
824
+ // Dragging the collapsed pill clamps against the EXPANDED size, so a
825
+ // pill parked at an edge can never expand out from under the sidebar.
826
+ const effW = workspaceLayout.insetCollapsed ? workspaceLayout.insetWidth : paneRect.width;
827
+ const effH = workspaceLayout.insetCollapsed ? workspaceLayout.insetHeight : paneRect.height;
828
+ // Foldout semantics on the tag strip: a click without movement folds,
829
+ // a drag past the threshold moves the pane — same gesture family as the
830
+ // inspector's foldout headers.
831
+ let moved = false;
832
+ const onMove = (ev) => {
833
+ if (!moved && Math.abs(ev.clientX - e.clientX) < 4 && Math.abs(ev.clientY - e.clientY) < 4) return;
834
+ moved = true;
835
+ setInsetPos({
836
+ x: Math.max(8, Math.min(ev.clientX - stageRect.left - grabX, stageRect.width - effW - 8)),
837
+ y: Math.max(8, Math.min(ev.clientY - stageRect.top - grabY, stageRect.height - effH - 8)),
838
+ });
839
+ };
840
+ const onUp = (ev) => {
841
+ window.removeEventListener("pointermove", onMove);
842
+ window.removeEventListener("pointerup", onUp);
843
+ window.removeEventListener("pointercancel", onUp);
844
+ // A double-click is one deliberate fold, not two: the second click's
845
+ // pointerup carries detail=2 and is ignored, so rapid clicking never
846
+ // flicker-toggles the inset.
847
+ if (!moved && ev.detail <= 1) {
848
+ insetToggledAtRef.current = Date.now();
849
+ if (workspaceLayout.insetCollapsed) expandInset();
850
+ else setWorkspaceLayout((current) => ({ ...current, insetCollapsed: true }));
851
+ }
852
+ };
853
+ window.addEventListener("pointermove", onMove);
854
+ window.addEventListener("pointerup", onUp);
855
+ window.addEventListener("pointercancel", onUp);
856
+ }
857
+
858
+ // Expanding reclamps the parked position against the restored size: a
859
+ // pill dragged to the stage edge must expand INTO the stage, not under
860
+ // the inspector sidebar.
861
+ function expandInset() {
862
+ const stage = stageRef.current;
863
+ if (stage) {
864
+ const stageRect = stage.getBoundingClientRect();
865
+ const maxX = Math.max(8, stageRect.width - workspaceLayout.insetWidth - 8);
866
+ const maxY = Math.max(8, stageRect.height - workspaceLayout.insetHeight - 8);
867
+ setInsetPos((pos) => (pos ? { x: Math.min(pos.x, maxX), y: Math.min(pos.y, maxY) } : pos));
868
+ }
869
+ setWorkspaceLayout((current) => ({ ...current, insetCollapsed: false }));
870
+ }
871
+
872
+ function beginInsetResize(e) {
873
+ if (e.button !== 0) return;
874
+ const stage = stageRef.current;
875
+ const pane = insetPaneRef.current;
876
+ if (!stage || !pane) return;
877
+ e.preventDefault();
878
+ e.stopPropagation();
879
+ const stageRect = stage.getBoundingClientRect();
880
+ const paneRect = pane.getBoundingClientRect();
881
+ const startX = e.clientX;
882
+ const startY = e.clientY;
883
+ const originX = paneRect.left - stageRect.left;
884
+ const originY = paneRect.top - stageRect.top;
885
+ if (!insetPos) setInsetPos({ x: originX, y: originY });
886
+ const onMove = (ev) => {
887
+ const width = Math.max(190, Math.min(stageRect.width - originX - 8, paneRect.width + ev.clientX - startX));
888
+ const height = Math.max(150, Math.min(stageRect.height - originY - 8, paneRect.height + ev.clientY - startY));
889
+ setWorkspaceLayout((current) => ({ ...current, insetWidth: width, insetHeight: height }));
890
+ };
891
+ const onUp = () => {
892
+ document.body.classList.remove("is-resizing", "resize-inset");
893
+ window.removeEventListener("pointermove", onMove);
894
+ window.removeEventListener("pointerup", onUp);
895
+ window.removeEventListener("pointercancel", onUp);
896
+ };
897
+ document.body.classList.add("is-resizing", "resize-inset");
898
+ window.addEventListener("pointermove", onMove);
899
+ window.addEventListener("pointerup", onUp);
900
+ window.addEventListener("pointercancel", onUp);
901
+ }
902
+ const [showB, setShowB] = useState(false);
903
+ const [charA, setCharA] = useState({ x: 0, z: 0, rot: 0 });
904
+ const [charB, setCharB] = useState({ x: 1.15, z: 0.1, rot: -14 });
905
+ const [poseA, setPoseA] = useState(DEFAULT_POSE);
906
+ const [poseB, setPoseB] = useState(DEFAULT_POSE);
907
+ const [customPoses, setCustomPoses] = useState(() => loadCustomPoses());
908
+ const [posing, setPosing] = useState(null);
909
+ const [posingClosing, setPosingClosing] = useState(false);
910
+ const [studioPick, setStudioPick] = useState(null);
911
+ const [rigA, setRigA] = useState(null);
912
+ const [rigB, setRigB] = useState(null);
913
+ const [, setPoseTick] = useState(0);
914
+
915
+ /* ------------------------------ IK layer ------------------------------ */
916
+ // IK posing for Subject 1: dragging a wrist/ankle handle FOCUSES that
917
+ // joint and solves its chain backward (two-bone analytic IK) on top of
918
+ // the current pose; joints never dragged stay purely on the FK pose.
919
+ // Keys land on the Full-Body lane as sparse per-chain world targets and
920
+ // evaluate as the playhead moves. State lives in a ref — it changes
921
+ // every drag tick and must not re-render the scene; ikTick re-renders
922
+ // only the timeline markers.
923
+ const [ikMode, setIkMode] = useState(false);
924
+ const [ikChains, setIkChains] = useState(null);
925
+ const [ikFkJoints, setIkFkJoints] = useState(null);
926
+ const [ikFocus, setIkFocus] = useState(null);
927
+ const [selectedHierarchyId, setSelectedHierarchyId] = useState("characterA.motion");
928
+ // Scene persistence (plan §8): the startup load runs once in a lazy
929
+ // initializer so the store below can seed from the restored scene; the
930
+ // quarantine write and the save-block decision happen before the first
931
+ // render, and the toast/error they produce ride along as initial UI state.
932
+ const [startup] = useState(loadSceneStartup);
933
+ const [sceneObjects, setSceneObjects] = useState(startup.objects);
934
+ const [sceneSaveError, setSceneSaveError] = useState(startup.error);
935
+ const saveBlockedRef = useRef(startup.saveBlocked);
936
+ const dirtyRef = useRef(false);
937
+ // One-shot save-failure toast: the persistent line stays for the session,
938
+ // the toast fires once per failure episode (not on every failed tick).
939
+ const saveFailureToastRef = useRef(false);
940
+ // The single mutation owner (plan §5.3): every scene-object edit — gizmo
941
+ // drags, plan-board drags, inspector scrubs, hierarchy atomics — routes
942
+ // through this store so one interaction is exactly one undo entry and an
943
+ // in-flight drag can be cancelled. setSceneObjects is stable, so the
944
+ // store is constructed once, seeded with the initial scene.
945
+ const storeRef = useRef(null);
946
+ if (!storeRef.current) {
947
+ storeRef.current = createSceneHistoryStore(sceneObjects, { onObjects: setSceneObjects });
948
+ }
949
+ const store = storeRef.current;
950
+
951
+ // ---- scene persistence (plan §8.4) ----
952
+ // Every write serialises the store at invocation time — the LIVE read,
953
+ // never a value captured by the caller. A stale closure would flush
954
+ // startup state over the latest edit, so flushScene closes over refs only.
955
+ function flushScene() {
956
+ if (saveBlockedRef.current || !dirtyRef.current) return;
957
+ try {
958
+ localStorage.setItem(SCENE_STORAGE_KEY, serializeScene(storeRef.current.objects));
959
+ dirtyRef.current = false;
960
+ setSceneSaveError(null);
961
+ // The next successful write clears the whole failure surface (plan
962
+ // §8.4) without clobbering an unrelated toast that replaced it.
963
+ if (saveFailureToastRef.current) {
964
+ saveFailureToastRef.current = false;
965
+ setToast("");
966
+ }
967
+ } catch (err) {
968
+ const message = `Scene not saved: ${err?.name || "StorageError"}`;
969
+ setSceneSaveError(message);
970
+ if (!saveFailureToastRef.current) {
971
+ saveFailureToastRef.current = true;
972
+ setToast(message);
973
+ }
974
+ }
975
+ }
976
+ // Debounced write: the cleanup clears the timer only — clearing IS the
977
+ // debounce; flushing there would write on every tick.
978
+ useEffect(() => {
979
+ dirtyRef.current = true;
980
+ const timer = setTimeout(flushScene, 400);
981
+ return () => clearTimeout(timer);
982
+ }, [sceneObjects]);
983
+ // Lifecycle flush: pagehide covers navigation/tab close, visibilitychange
984
+ // covers backgrounding, the effect cleanup covers unmount. `beforeunload`
985
+ // is deliberately not used — it defeats bfcache. The [] deps capture only
986
+ // refs and flushScene, so this closure can never hold a stale scene.
987
+ useEffect(() => {
988
+ const onPageHide = () => flushScene();
989
+ const onVisibility = () => {
990
+ if (document.visibilityState === "hidden") flushScene();
991
+ };
992
+ window.addEventListener("pagehide", onPageHide);
993
+ document.addEventListener("visibilitychange", onVisibility);
994
+ return () => {
995
+ window.removeEventListener("pagehide", onPageHide);
996
+ document.removeEventListener("visibilitychange", onVisibility);
997
+ flushScene(); // unmount: the last chance to persist a dirty scene
998
+ };
999
+ }, []);
1000
+ const selectedSceneObjectId = sceneObjectIdFromHierarchy(selectedHierarchyId);
1001
+ const selectedSceneObject = sceneObjects.find((object) => object.id === selectedSceneObjectId) ?? null;
1002
+ // Foot snap (ground plant): while ON, body (hips) drags keep the feet at
1003
+ // the positions captured when the drag started — the knees bend instead
1004
+ // of the feet sinking through the floor. Toggleable in the timeline.
1005
+ const [footSnap, setFootSnap] = useState(true);
1006
+ const ikBodyDragRef = useRef(false); // true while a body drag is active
1007
+ // How far (in frames) a correction eases back to the underlying motion
1008
+ // outside its keyed range. 6 frames @ 20 fps = 0.3 s — long enough to
1009
+ // hide the seam, short enough that a mid-clip fix stays visibly local.
1010
+ const IK_CORRECTION_BLEND_FRAMES = 6;
1011
+
1012
+ const ikStateRef = useRef(createIkState());
1013
+ const [ikTick, setIkTick] = useState(0);
1014
+ const [committedIkEdits, setCommittedIkEdits] = useState([]);
1015
+ // Sorted full-body key frames for the timeline markers. Derived from the
1016
+ // ref state; ikTick re-derives after every key add/remove.
1017
+ const ikFrames = useMemo(() => ikKeyframes(ikStateRef.current),
1018
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1019
+ [ikTick]);
1020
+
1021
+ function selectHierarchy(id) {
1022
+ // A selection switch is the user starting something else: settle any open
1023
+ // drag so its applied travel becomes one committed entry first (plan §6.3).
1024
+ // This MUST stay inside the handler, never in the render body: a settle
1025
+ // during render runs the producer's cancel teardown, and since the first
1026
+ // applied tick re-renders, every drag would die after exactly one tick.
1027
+ store.settle();
1028
+ setSelectedHierarchyId(id);
1029
+ const focus = RIG_HIERARCHY_FOCUS[id];
1030
+ if (focus && ikMode) setIkFocus(focus);
1031
+ }
1032
+ // Producer drag lifecycle (plan §6.1): begin issues a token the producer
1033
+ // presents on every apply and on close; end commits the drag as one
1034
+ // history entry, or rolls it back when commit is false (Escape).
1035
+ function beginSceneTransaction({ owner, cancel }) {
1036
+ return store.begin(owner, cancel);
1037
+ }
1038
+
1039
+ function endSceneTransaction(token, { commit }) {
1040
+ store.end(token, { commit });
1041
+ }
1042
+
1043
+ function focusIkHandle(focus) {
1044
+ setIkFocus(focus);
1045
+ const hierarchyId = hierarchyIdForIkFocus(focus);
1046
+ if (hierarchyId) setSelectedHierarchyId(hierarchyId);
1047
+ }
1048
+
1049
+ // App's single scene-object mutation entry (plan §6.1). A token means a
1050
+ // producer drag stream: apply inside the open transaction so the change
1051
+ // lands in the live array without its own history entry. No token is an
1052
+ // atomic edit — one entry. updateSceneObject returns the same array when
1053
+ // nothing changed, so a no-op can never create an entry.
1054
+ function changeSceneObject(id, patch, token) {
1055
+ const apply = (objects) => updateSceneObject(objects, id, patch);
1056
+ if (token != null) store.applyIn(token, apply);
1057
+ else store.applyAtomic(apply);
1058
+ }
1059
+
1060
+ function deleteSelectedSceneObject() {
1061
+ deleteSceneObject(selectedSceneObjectId);
1062
+ }
1063
+
1064
+ /** Delete by id — the hierarchy context menu's Delete. Unlike the
1065
+ * selection-based path above, removing a row that is not the selection
1066
+ * must leave the selection alone. */
1067
+ function deleteSceneObject(id) {
1068
+ if (!id) return;
1069
+ const wasSelected = id === selectedSceneObjectId;
1070
+ store.applyAtomic((objects) => removeSceneObject(objects, id));
1071
+ if (wasSelected) {
1072
+ setSelectedHierarchyId("props");
1073
+ }
1074
+ }
1075
+ /** Drop-to-surface (plan §9.2/§9.3): End, no modifier. Strict drop-down —
1076
+ * the selection falls until its base touches the highest support top at or
1077
+ * below it, or the floor. dropToSurfacePatch is pure and returns null when
1078
+ * already resting, so a redundant press never creates a history entry, and
1079
+ * x/z are never written. One applyAtomic = one undo entry. */
1080
+ function dropSelectedSceneObject() {
1081
+ const object = sceneObjects.find((item) => item.id === selectedSceneObjectId) ?? null;
1082
+ if (!object) return;
1083
+ const patch = dropToSurfacePatch(object, sceneObjects.filter((item) => item.id !== object.id));
1084
+ if (patch === null) {
1085
+ setToast("Nothing to drop");
1086
+ return;
1087
+ }
1088
+ changeSceneObject(object.id, patch);
1089
+ setToast(`${object.name} dropped to surface`);
1090
+ }
1091
+
1092
+ const [gizmoMode, setGizmoMode] = useState("move");
1093
+ // Snap is a preference, not a law: with it on the gizmo blocks on the plan
1094
+ // board's grid, and Ctrl/Cmd during a drag gives a free one. Off, it is the
1095
+ // other way round. (docs/unity-reference.md §9.5)
1096
+ const [snapEnabled, setSnapEnabled] = useState(true);
1097
+ // True while the right mouse button is flying the camera. Tool hotkeys stand
1098
+ // down during a flythrough, because W/A/S/D belong to the camera then.
1099
+ const flyingRef = useRef(false);
1100
+
1101
+ function addSceneObject(kind) {
1102
+ const camera = shotCamRef.current;
1103
+ const placement = camera
1104
+ ? placementInFront({ x: camera.position.x, z: camera.position.z }, look.current.yaw)
1105
+ : {};
1106
+ const object = createSceneObject(kind, sceneObjects, placement);
1107
+ if (!object) return;
1108
+ store.applyAtomic((objects) => [...objects, object]);
1109
+ setSelectedHierarchyId(`object:${object.id}`);
1110
+ // Deliberate divergence from Unity's rename-on-create: creating an object
1111
+ // here is followed by placing it, and dropping focus into a text field
1112
+ // swallows the very next W/E/R. Renaming stays on F2/Return and the row's
1113
+ // context menu. (docs/unity-reference.md §9.7)
1114
+ setGizmoMode("move");
1115
+ setToast(`${object.name} added — W move, E rotate, R scale`);
1116
+ }
1117
+
1118
+ function duplicateSelectedSceneObject(id = selectedSceneObjectId) {
1119
+ // Defaults to the selection (Ctrl/Cmd+D); the hierarchy context menu
1120
+ // passes a specific row's id. Same result either way: the copy is
1121
+ // selected, offset one grid step, and toasted.
1122
+ const object = sceneObjects.find((item) => item.id === id) ?? null;
1123
+ if (!object) return;
1124
+ const copy = createSceneObject(object.renderer, sceneObjects, {
1125
+ x: object.x,
1126
+ z: object.z,
1127
+ rot: object.rot,
1128
+ });
1129
+ if (!copy) return;
1130
+ // Unity drops the duplicate exactly on top of the original; for blocking,
1131
+ // one grid step to the side means you can see that it worked.
1132
+ const placed = { ...object, id: copy.id, name: copy.name, x: object.x + 0.5 };
1133
+ store.applyAtomic((objects) => [...objects, placed]);
1134
+ setSelectedHierarchyId(`object:${placed.id}`);
1135
+ setToast(`${placed.name} duplicated`);
1136
+ }
1137
+
1138
+ /** Frame the selection: fly the shot camera to a comfortable distance along
1139
+ * the current view direction, the way Unity's F key does. Defaults to the
1140
+ * selection; the hierarchy context menu passes a specific row's id. */
1141
+ function frameSelection(id = selectedSceneObjectId) {
1142
+ const camera = shotCamRef.current;
1143
+ const object = sceneObjects.find((item) => item.id === id) ?? null;
1144
+ if (!camera || !object) return;
1145
+ const size = objectSize(object);
1146
+ const target = {
1147
+ x: object.x,
1148
+ y: (object.y ?? 0) + size.height / 2,
1149
+ z: object.z,
1150
+ };
1151
+ const reach = Math.max(size.width, size.height, size.depth, 0.5);
1152
+ const distance = reach * 2.4 + 0.6;
1153
+ const back = forwardFrom(look.current.yaw, look.current.pitch).multiplyScalar(-distance);
1154
+ camera.position.set(target.x + back.x, Math.max(target.y + back.y, 0.3), target.z + back.z);
1155
+ const angles = aimAt(camera.position, target);
1156
+ look.current.yaw = angles.yaw;
1157
+ look.current.pitch = angles.pitch;
1158
+ }
1159
+
1160
+ /** In-place rename commit from the hierarchy (F2 / Return / rename on
1161
+ * create). The row label lives in the tree; the object name is shared
1162
+ * state, so this is just the inspector's rename through another door. */
1163
+ function renameSceneObject(id, name) {
1164
+ changeSceneObject(id, { name });
1165
+ }
1166
+ // Undo/redo (plan §6.5). The store settles any open drag first, so a
1167
+ // mid-drag press commits that drag as one entry and then steps past it.
1168
+ // After a step the selection can point at a deleted object — drop it to
1169
+ // props so the inspector cannot show a ghost.
1170
+ function undoScene() {
1171
+ const restored = store.undo();
1172
+ if (restored === null) {
1173
+ setToast("Nothing to undo");
1174
+ return;
1175
+ }
1176
+ if (selectedSceneObjectId && !restored.some((object) => object.id === selectedSceneObjectId)) {
1177
+ setSelectedHierarchyId("props");
1178
+ }
1179
+ setToast("Undone");
1180
+ }
1181
+
1182
+ function redoScene() {
1183
+ const restored = store.redo();
1184
+ if (restored === null) {
1185
+ setToast("Nothing to redo");
1186
+ return;
1187
+ }
1188
+ if (selectedSceneObjectId && !restored.some((object) => object.id === selectedSceneObjectId)) {
1189
+ setSelectedHierarchyId("props");
1190
+ }
1191
+ setToast("Redone");
1192
+ }
1193
+
1194
+ useEffect(() => {
1195
+ const onKeyDown = (event) => {
1196
+ const target = event.target;
1197
+ if (
1198
+ target instanceof HTMLInputElement ||
1199
+ target instanceof HTMLTextAreaElement ||
1200
+ target instanceof HTMLSelectElement ||
1201
+ target?.isContentEditable
1202
+ ) return;
1203
+ // While the right button is flying the camera, W/A/S/D/Q/E are the
1204
+ // camera's; a tool switch mid-flight would be a surprise.
1205
+ if (flyingRef.current) return;
1206
+ // Undo/redo (plan §6.5). The input guard above keeps Ctrl/Cmd+Z in
1207
+ // the Name field or the ARDY prompt as the browser's text undo.
1208
+ // Placed before the selection gate: undo works with nothing
1209
+ // selected, and mid-drag the store's settle commits then steps.
1210
+ if (event.code === "KeyZ" && (event.ctrlKey || event.metaKey)) {
1211
+ event.preventDefault();
1212
+ if (event.shiftKey) redoScene();
1213
+ else undoScene();
1214
+ return;
1215
+ }
1216
+ if (GIZMO_HOTKEYS[event.code]) {
1217
+ event.preventDefault();
1218
+ setGizmoMode(GIZMO_HOTKEYS[event.code]);
1219
+ return;
1220
+ }
1221
+ if (event.code === "KeyF" && selectedSceneObjectId) {
1222
+ event.preventDefault();
1223
+ frameSelection();
1224
+ return;
1225
+ }
1226
+ if (event.code === "KeyD" && (event.ctrlKey || event.metaKey) && selectedSceneObjectId) {
1227
+ event.preventDefault();
1228
+ duplicateSelectedSceneObject();
1229
+ return;
1230
+ }
1231
+ if (event.key === "Escape" && selectedSceneObjectId) {
1232
+ setSelectedHierarchyId("props");
1233
+ return;
1234
+ }
1235
+ if (event.code === "End" && selectedSceneObjectId) {
1236
+ event.preventDefault();
1237
+ dropSelectedSceneObject();
1238
+ return;
1239
+ }
1240
+ if (!selectedSceneObjectId) return;
1241
+ if (event.key !== "Delete" && event.key !== "Backspace") return;
1242
+ event.preventDefault();
1243
+ deleteSelectedSceneObject();
1244
+ };
1245
+ window.addEventListener("keydown", onKeyDown);
1246
+ return () => window.removeEventListener("keydown", onKeyDown);
1247
+ });
1248
+
1249
+ const [mode, setMode] = useState("image");
1250
+ const [imageModel, setImageModel] = useState("gpt_image_2");
1251
+ const [videoModel, setVideoModel] = useState("seedance_2");
1252
+ const [cameraMove, setCameraMove] = useState(CAMERA_MOVES[1]);
1253
+ const [customMove, setCustomMove] = useState("");
1254
+ // Authored shot state (camera keys, waypoints, clip length) restored from
1255
+ // the last session — camera moves must survive a reload like the scene does.
1256
+ const [shotStartup] = useState(() => {
1257
+ try {
1258
+ return loadShotAuthoring(localStorage.getItem(SHOT_AUTHORING_KEY));
1259
+ } catch {
1260
+ return null;
1261
+ }
1262
+ });
1263
+ // The camera move workspace: frame-unique keyframings authored on the
1264
+ // timeline's Camera lane. The move's name is never stored — it is
1265
+ // re-derived from the framings segment by segment.
1266
+ const [cameraKeys, setCameraKeys] = useState(shotStartup?.cameraKeys ?? []); // [{ frame, framing }] sorted by frame
1267
+ const [movePlaying, setMovePlaying] = useState(false);
1268
+ // Follow slaves the move to the timeline playhead so camera and character
1269
+ // motion share one time axis; off frees the camera while both stay set.
1270
+ const [moveFollow, setMoveFollow] = useState(true);
1271
+ // Follow cam: the camera is DERIVED from the subject's trajectory (a
1272
+ // steadicam behind it, or a dolly on a drawn Top-View rail) instead of
1273
+ // interpolated between keys. Parameters and the rail survive reloads.
1274
+ const [followCam, setFollowCam] = useState(() => ({
1275
+ enabled: false,
1276
+ distance: FOLLOW_DEFAULTS.distance,
1277
+ height: FOLLOW_DEFAULTS.height,
1278
+ response: FOLLOW_DEFAULTS.response,
1279
+ lead: FOLLOW_DEFAULTS.lead,
1280
+ ...(shotStartup?.followCam ?? {}),
1281
+ }));
1282
+ const [cameraRail, setCameraRail] = useState(shotStartup?.cameraRail ?? null); // simplified control points [{x,z}]
1283
+ const [railDraw, setRailDraw] = useState(false);
1284
+ const [hasCharSheet, setHasCharSheet] = useState(false);
1285
+ const [hasEnvSheet, setHasEnvSheet] = useState(false);
1286
+ const [subject, setSubject] = useState("a young woman in a tan coat");
1287
+ const [subject2, setSubject2] = useState("a man in a dark coat");
1288
+ const [environment, setEnvironment] = useState("a sunlit modern living room");
1289
+ const [style, setStyle] = useState("moody cinematic lighting, 35mm film look");
1290
+
1291
+ const [cameraPos, setCameraPos] = useState({ x: 0.97, y: 1.62, z: 2.39 });
1292
+ const [subjectVisible, setSubjectVisible] = useState(true);
1293
+ const [result, setResult] = useState(null);
1294
+ const [copied, setCopied] = useState(false);
1295
+ const [toast, setToast] = useState(startup.toast ?? "");
1296
+ const [bridge, setBridge] = useState(null);
1297
+ const [ardyPrompt, setArdyPrompt] = useState("");
1298
+ const [ardyDuration, setArdyDuration] = useState(DEFAULT_DURATION_S);
1299
+ // Optional native-ARDY seed: empty string = omit from the request (the
1300
+ // box picks its own); otherwise a plain integer in 0..2**31-1.
1301
+ const [ardySeed, setArdySeed] = useState("2"); // RT demo parity: its GUI seed defaults to 2, so generation is reproducible out of the box; clear the field for the box's random seed
1302
+ const [ardyRunning, setArdyRunning] = useState(false);
1303
+ const [ardyStatus, setArdyStatus] = useState("");
1304
+ const [consoleLines, setConsoleLines] = useState([]);
1305
+ const [bottomTab, setBottomTab] = useState("timeline");
1306
+ // ARDY status doubles as a Unity-style console line: the inspector keeps
1307
+ // the current line, the bottom Console tab keeps the session history.
1308
+ function reportArdyStatus(line) {
1309
+ setArdyStatus(line);
1310
+ setConsoleLines((current) => [...current.slice(-99), { time: new Date(), line }]);
1311
+ }
1312
+ const [ardyReport, setArdyReport] = useState(null);
1313
+ const [ardyOutcome, setArdyOutcome] = useState(null);
1314
+ const ardyAbortRef = useRef(null);
1315
+ // ARDY Core runs at 20 fps: the destination frame lives in [0, duration*20-1].
1316
+ const maxDst = Math.max(0, Math.round(ardyDuration) * 20 - 1);
1317
+
1318
+ /* --------------------------- motion workspace --------------------------- */
1319
+ // The timeline playhead and the root waypoints are App-owned so the scene
1320
+ // (character rig, plan path, ARDY card) reacts to every scrub/play tick.
1321
+ const [tlFrame, setTlFrame] = useState(0);
1322
+
1323
+ const renderActive = useRenderActivity(tlPlaying || movePlaying);
1324
+ const [tlFrameCount, setTlFrameCount] = useState(shotStartup?.frameCount ?? DEFAULT_DURATION_S * 20); // the generation clip length @ 20 fps
1325
+ const [tlFps, setTlFps] = useState(20);
1326
+ const frameCountRef = useRef(DEFAULT_DURATION_S * 20);
1327
+ frameCountRef.current = tlFrameCount;
1328
+ // Root waypoints {frame, x, z, heading: null}, kept sorted by frame —
1329
+ // the fixed bridge contract rejects out-of-order or duplicate frames.
1330
+ const [waypointMode, setWaypointMode] = useState(false);
1331
+ const [waypoints, setWaypoints] = useState(shotStartup?.waypoints ?? []);
1332
+ const [activeWaypointFrame, setActiveWaypointFrame] = useState(null);
1333
+ useEffect(() => {
1334
+ // Subject 1 is the sole frame-zero root start. Drop any legacy seeded
1335
+ // waypoint so Top-View never renders two start markers.
1336
+ setWaypoints((current) => current.filter((waypoint) => waypoint.frame !== 0));
1337
+ setActiveWaypointFrame((current) => (current === 0 ? null : current));
1338
+ }, []);
1339
+ // Every authoring change lands in storage immediately: reloads (and dev
1340
+ // hot updates) must never cost an authored shot again.
1341
+ useEffect(() => {
1342
+ try {
1343
+ localStorage.setItem(SHOT_AUTHORING_KEY, serializeShotAuthoring({ cameraKeys, waypoints, frameCount: tlFrameCount, followCam, cameraRail }));
1344
+ } catch {
1345
+ // quota or blocked storage: authoring simply won't survive a reload
1346
+ }
1347
+ }, [cameraKeys, waypoints, tlFrameCount, followCam, cameraRail]);
1348
+ const [promptClips, setPromptClips] = useState(() => DEFAULT_PROMPT_CLIPS.map((clip) => ({ ...clip })));
1349
+ const [selectedPromptId, setSelectedPromptId] = useState(null);
1350
+ // Loaded motion: decoded arrays plus the world anchor captured at load.
1351
+ const [motion, setMotion] = useState(null);
1352
+ const [motionBusy, setMotionBusy] = useState(false);
1353
+ const [motionError, setMotionError] = useState("");
1354
+ // Pre-playback bone snapshot; restoring it (after Character's pose effect
1355
+ // has re-applied poseA) puts the rig back exactly where it was.
1356
+ const restoreRef = useRef(null);
1357
+
1358
+ const shotCamRef = useRef(null);
1359
+ const captureRef = useRef(null);
1360
+ const look = useRef({ yaw: 0, pitch: 0 });
1361
+ // The poser camera is the IK-mode working view: orbit/dolly/WASD freely
1362
+ // while posing WITHOUT touching the shot camera, which stays frozen on
1363
+ // the framing and shows in the inset. Two separate screens by design.
1364
+ const poserCamRef = useRef(null);
1365
+ const poserLook = useRef({ yaw: 0, pitch: 0 });
1366
+
1367
+ const shot = useMemo(
1368
+ () => deriveShot(cameraPos, charA, (fovDeg * Math.PI) / 180, SUBJECT_HEIGHT_M),
1369
+ [cameraPos, charA, fovDeg],
1370
+ );
1371
+
1372
+ // The derived move sequence: what the keyframings geometrically prove
1373
+ // segment by segment, not what a dropdown claims. Present from two keys.
1374
+ const moveSequence = useMemo(() => {
1375
+ if (cameraKeys.length < 2) return null;
1376
+ const segs = [];
1377
+ for (let i = 0; i < cameraKeys.length - 1; i++) {
1378
+ segs.push(
1379
+ classifyMove(cameraKeys[i].framing, cameraKeys[i + 1].framing, charA, {
1380
+ durationS: (cameraKeys[i + 1].frame - cameraKeys[i].frame) / tlFps,
1381
+ }),
1382
+ );
1383
+ }
1384
+ return {
1385
+ segs,
1386
+ slate: moveSequenceSlate(segs),
1387
+ phrase: moveSequencePhrase(segs),
1388
+ fromShot: segs[0].from,
1389
+ spanS: Math.round(((cameraKeys[cameraKeys.length - 1].frame - cameraKeys[0].frame) / tlFps) * 10) / 10,
1390
+ };
1391
+ }, [cameraKeys, charA, tlFps]);
1392
+ // With Follow armed, Preview means "watch the shot": it plays the timeline
1393
+ // from frame 0 so character motion and the camera move share one clock.
1394
+ // Follow off keeps the camera-only preview on its own clock.
1395
+ const followPreviewArmed = moveFollow && cameraKeys.length >= 1 && !ikMode && !waypointMode && !posing;
1396
+ const previewActive = movePlaying || (followPreviewArmed && tlPlaying);
1397
+
1398
+ /* --------------------------- shot video export --------------------------- */
1399
+ // Record = play the finished piece in PlayView while mirroring the shot
1400
+ // pane into an offscreen 16:9 canvas that MediaRecorder encodes. The mirror
1401
+ // crops the letterbox bars, so the file is exactly the frame the shot
1402
+ // camera renders — camera move, character motion and the ink pass, none of
1403
+ // the editor chrome. Recording rides the same clock as playback and stops
1404
+ // itself when the playhead wraps at the clip end.
1405
+ const [recState, setRecState] = useState("idle"); // "idle" | "recording"
1406
+ const recRef = useRef(null);
1407
+ const tlFrameRef = useRef(0);
1408
+ tlFrameRef.current = tlFrame;
1409
+ const tlPlayingRef = useRef(false);
1410
+ tlPlayingRef.current = tlPlaying;
1411
+ const playModeRef = useRef(false);
1412
+ playModeRef.current = centerTab === "play";
1413
+
1414
+ function stopShotRecording(reason) {
1415
+ const rec = recRef.current;
1416
+ if (!rec) return;
1417
+ recRef.current = null;
1418
+ cancelAnimationFrame(rec.raf);
1419
+ if (reason === "abort") rec.aborted = true;
1420
+ setTlPlaying(false);
1421
+ setRecState("idle");
1422
+ if (rec.recorder.state !== "inactive") rec.recorder.stop();
1423
+ else if (!rec.armed) setToast("Recording cancelled before any frame was captured");
1424
+ }
1425
+
1426
+ function toggleShotRecording() {
1427
+ if (recRef.current) {
1428
+ stopShotRecording("manual");
1429
+ return;
1430
+ }
1431
+ const stage = stageRef.current;
1432
+ const glCanvas = stage ? stage.querySelector("canvas") : null;
1433
+ if (!glCanvas) return;
1434
+ // mp4 first where the platform encoder offers it (Safari, newer Chrome),
1435
+ // webm as the everywhere-else answer. No support at all = no feature.
1436
+ const MIMES = ["video/mp4;codecs=avc1.640028", "video/mp4", "video/webm;codecs=vp9", "video/webm"];
1437
+ const mime = typeof MediaRecorder !== "undefined" ? MIMES.find((m) => MediaRecorder.isTypeSupported(m)) : null;
1438
+ if (!mime) {
1439
+ setToast("This browser cannot encode video (MediaRecorder unavailable)");
1440
+ return;
1441
+ }
1442
+ const mirror = document.createElement("canvas");
1443
+ mirror.width = 1600;
1444
+ mirror.height = 900;
1445
+ const ctx = mirror.getContext("2d");
1446
+ const recorder = new MediaRecorder(mirror.captureStream(30), { mimeType: mime, videoBitsPerSecond: 12_000_000 });
1447
+ const chunks = [];
1448
+ const slate = (moveSequence?.slate ?? "shot").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "shot";
1449
+ const rec = { recorder, raf: 0, armed: false, started: false, lastFrame: -1, warmup: 2, aborted: false };
1450
+ recorder.ondataavailable = (e) => {
1451
+ if (e.data && e.data.size > 0) chunks.push(e.data);
1452
+ };
1453
+ recorder.onstop = () => {
1454
+ if (rec.aborted || chunks.length === 0) return;
1455
+ const ext = mime.startsWith("video/mp4") ? "mp4" : "webm";
1456
+ const name = `cozyclay-${slate}.${ext}`;
1457
+ const url = URL.createObjectURL(new Blob(chunks, { type: mime }));
1458
+ const anchor = document.createElement("a");
1459
+ anchor.href = url;
1460
+ anchor.download = name;
1461
+ anchor.click();
1462
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
1463
+ setToast(`Saved ${name}`);
1464
+ };
1465
+ recRef.current = rec;
1466
+ setRecState("recording");
1467
+ // PlayView is the finished-output player, so record there: no gizmo, no
1468
+ // inset, and the move rides the playhead regardless of Follow. Playing
1469
+ // is forced even without a loaded motion so a camera-only move records.
1470
+ setCenterTab("play");
1471
+ setTlFrame(0);
1472
+ setTlPlaying(true);
1473
+
1474
+ const tick = () => {
1475
+ if (recRef.current !== rec) return;
1476
+ rec.raf = requestAnimationFrame(tick);
1477
+ const main = mainPaneRef.current;
1478
+ if (!playModeRef.current || !main || !stage) return;
1479
+ const frame = tlFrameRef.current;
1480
+ // clip end: the play clock wraps to 0 after the last frame
1481
+ if (rec.started && frame < rec.lastFrame) {
1482
+ stopShotRecording("end");
1483
+ return;
1484
+ }
1485
+ // user paused from the transport: keep what was captured so far
1486
+ if (rec.started && !tlPlayingRef.current) {
1487
+ stopShotRecording("manual");
1488
+ return;
1489
+ }
1490
+ rec.lastFrame = Math.max(rec.lastFrame, frame);
1491
+ if (frame > 0) rec.started = true;
1492
+ const stageRect = stage.getBoundingClientRect();
1493
+ const mainRect = main.getBoundingClientRect();
1494
+ const pane = { x: mainRect.left - stageRect.left, y: mainRect.top - stageRect.top, w: mainRect.width, h: mainRect.height };
1495
+ if (pane.w < 2 || pane.h < 2) return;
1496
+ // a couple of ticks for the PlayView layout + first playMode draw to
1497
+ // land, so the file never opens on a stale Scene-tab composite
1498
+ if (rec.warmup > 0) {
1499
+ rec.warmup -= 1;
1500
+ return;
1501
+ }
1502
+ const img = fitAspect(pane, SHOT_ASPECT);
1503
+ const scale = glCanvas.width / stageRect.width;
1504
+ ctx.drawImage(
1505
+ glCanvas,
1506
+ img.x * scale,
1507
+ img.y * scale,
1508
+ img.w * scale,
1509
+ img.h * scale,
1510
+ 0,
1511
+ 0,
1512
+ mirror.width,
1513
+ mirror.height,
1514
+ );
1515
+ if (!rec.armed) {
1516
+ rec.armed = true;
1517
+ recorder.start();
1518
+ }
1519
+ };
1520
+ rec.raf = requestAnimationFrame(tick);
1521
+ }
1522
+
1523
+ function captureCurrentFraming() {
1524
+ const cam = shotCamRef.current;
1525
+ const pos = cam ? cam.position : cameraPos;
1526
+ return captureFraming({ pos: { x: pos.x, y: pos.y, z: pos.z }, yaw: look.current.yaw, pitch: look.current.pitch, fovDeg });
1527
+ }
1528
+
1529
+ // Key authoring lives on the timeline's Camera lane: clicking an empty
1530
+ // cell keys the CURRENT framing at that frame. Re-keying a keyed frame
1531
+ // overwrites its framing with wherever the camera is now.
1532
+ function addCameraKeyframe(frame) {
1533
+ const framing = captureCurrentFraming();
1534
+ const target = Math.max(0, Math.min(Math.round(frame), tlFrameCount - 1));
1535
+ setCameraKeys((keys) => keys.filter((k) => k.frame !== target).concat({ frame: target, framing }).sort((a, b) => a.frame - b.frame));
1536
+ setSelectedHierarchyId("camera");
1537
+ }
1538
+
1539
+ // Re-time a key by dragging its dot along the lane. Landing on another
1540
+ // key's frame is rejected — keys stay frame-unique.
1541
+ function moveCameraKeyframe(from, to) {
1542
+ const target = Math.max(0, Math.min(Math.round(to), tlFrameCount - 1));
1543
+ if (target === from) return;
1544
+ setCameraKeys((keys) => {
1545
+ if (keys.some((k) => k.frame === target)) return keys;
1546
+ return keys.map((k) => (k.frame === from ? { ...k, frame: target } : k)).sort((a, b) => a.frame - b.frame);
1547
+ });
1548
+ }
1549
+
1550
+ function removeCameraKeyframe(frame) {
1551
+ setCameraKeys((keys) => keys.filter((k) => k.frame !== frame));
1552
+ }
1553
+
1554
+ function clearMove() {
1555
+ setMovePlaying(false);
1556
+ setCameraKeys([]);
1557
+ }
1558
+
1559
+ const allPoses = useMemo(() => [...BUILT_IN_POSES, ...customPoses], [customPoses]);
1560
+ const posedRig = () => (posing === "B" ? rigB : rigA);
1561
+ const setPosed = posing === "B" ? setPoseB : setPoseA;
1562
+
1563
+ /* ------------------------- waypoint workspace --------------------------- */
1564
+
1565
+ // A walking pace turns clicked distance into clip time, so pins land at
1566
+ // frames the character can actually reach without ice-skating.
1567
+ const WALK_SPEED_MPS = 1.4;
1568
+
1569
+ /** ARDY-demo style authoring: each empty-floor press in the Shot view drops
1570
+ the next waypoint where it was clicked; the frame gap comes from walking
1571
+ distance. The bird's-eye board only displays the result. */
1572
+ function addFloorWaypoint(point) {
1573
+ const clampToRoom = (value) => Math.max(-11, Math.min(11, value));
1574
+ const x = clampToRoom(point.x);
1575
+ const z = clampToRoom(point.z);
1576
+ const ordered = [...waypoints].sort((a, b) => a.frame - b.frame);
1577
+ const last = ordered[ordered.length - 1] ?? { frame: 0, x: charA.x, z: charA.z };
1578
+ if (waypoints.length + 1 > MAX_WAYPOINTS) {
1579
+ setToast(`The root path is capped at ${MAX_WAYPOINTS} waypoints`);
1580
+ return;
1581
+ }
1582
+ // A scrubbed playhead is an explicit statement of time: a click lands on
1583
+ // that exact frame. An untouched playhead (it snaps to the last pin
1584
+ // after every placement) falls back to walking-distance pacing.
1585
+ const playhead = Math.round(tlFrame);
1586
+ const pinned = playhead > last.frame;
1587
+ const walkGap = Math.max(8, Math.round((Math.hypot(x - last.x, z - last.z) / WALK_SPEED_MPS) * tlFps));
1588
+ const frame = pinned ? Math.min(playhead, tlFrameCount - 1) : last.frame + walkGap;
1589
+ if (frame > tlFrameCount - 1) {
1590
+ setToast("The path already fills the clip — extend the duration or clear a waypoint");
1591
+ return;
1592
+ }
1593
+ // The generator cannot refuse an impossible pin, so the click is the
1594
+ // last moment a human can: block out-of-band legs with the fix named.
1595
+ const before = ordered[ordered.length - 2] ?? (ordered.length === 1 ? { frame: 0, x: charA.x, z: charA.z } : null);
1596
+ const verdict = judgeNextWaypoint(last, { frame, x, z }, tlFps, before);
1597
+ if (!verdict.ok) {
1598
+ setToast(`Not placed — ${verdict.error}`);
1599
+ return;
1600
+ }
1601
+ setWaypoints((prev) => [...prev, { frame, x, z, heading: null }].sort((a, b) => a.frame - b.frame));
1602
+ setTlFrame(frame);
1603
+ setActiveWaypointFrame(frame);
1604
+ const placed = `Waypoint ${ordered.length + 1} — frame ${frame} ${pinned ? "(at the playhead)" : `(~${(frame / tlFps).toFixed(1)}s at a walk)`}`;
1605
+ setToast(verdict.warnings.length ? `${placed} · ⚠ ${verdict.warnings[0]}` : placed);
1606
+ }
1607
+
1608
+ function removeWaypoint(frame) {
1609
+ setWaypoints((prev) => prev.filter((w) => w.frame !== frame));
1610
+ setActiveWaypointFrame((current) => (current === frame ? null : current));
1611
+ }
1612
+
1613
+ function toggleWaypointMode() {
1614
+ const next = !waypointMode;
1615
+ setWaypointMode(next);
1616
+ if (!next) {
1617
+ setToast("2D Root path constraints off");
1618
+ return;
1619
+ }
1620
+
1621
+ setToast("2D Root path on — click the set floor in the Shot view to drop waypoints; Subject 1 is the frame 0 start");
1622
+ }
1623
+
1624
+ function advanceFrame() {
1625
+ setTlFrame((f) => (f >= frameCountRef.current - 1 ? 0 : f + 1));
1626
+ }
1627
+
1628
+ function stepFrame(delta) {
1629
+ setTlFrame((f) => Math.max(0, Math.min(f + delta, frameCountRef.current - 1)));
1630
+ }
1631
+
1632
+ /* --------------------------- motion playback ---------------------------- */
1633
+
1634
+ // Decoded motion + the world anchor: frame 0 always starts at Subject 1.
1635
+ // Authored root destinations are generated by ARDY as sparse constraints,
1636
+ // so playback consumes the returned trajectory without coordinate warping.
1637
+ async function loadMotion(url, prompt, rotationDeg = charA.rot) {
1638
+ setMotionBusy(true);
1639
+ setMotionError("");
1640
+ // Loading a motion drops out of IK EDIT mode (playback is the new
1641
+ // context) — the IK KEYS stay and keep correcting the clip layer-style.
1642
+ setIkMode(false);
1643
+ try {
1644
+ // Re-loading while a motion is active: restore the previous
1645
+ // pre-playback baseline first, so the new snapshot is not taken
1646
+ // mid-animation and clearing always returns to the blocking pose.
1647
+ const previous = restoreRef.current;
1648
+ restoreRef.current = null;
1649
+ if (previous) restorePlaybackBones(previous.rig, previous.bones);
1650
+ const decoded = await loadMotionFromUrl(url);
1651
+ const rig = rigA;
1652
+ if (!rig) throw new Error("Subject 1 rig is not loaded");
1653
+ restoreRef.current = { rig, bones: snapshotPlaybackBones(rig) };
1654
+ setMotion({
1655
+ // Capture the exact prompt this motion was generated from; the
1656
+ // timeline keeps showing it even if the input field is edited
1657
+ // afterwards.
1658
+ prompt: typeof prompt === "string" ? prompt : "",
1659
+ ...decoded,
1660
+ url,
1661
+ anchorX: charA.x,
1662
+ anchorZ: charA.z,
1663
+ anchorFrame: 0,
1664
+ rotationDeg,
1665
+ });
1666
+ setTlFrameCount(decoded.frames);
1667
+ setTlFps(decoded.fps);
1668
+ setTlFrame(0);
1669
+ setTlPlaying(false);
1670
+ setToast(`Motion loaded: ${decoded.frames} frames @ ${decoded.fps} fps`);
1671
+ } catch (err) {
1672
+ setMotion(null);
1673
+ setMotionError(err?.message || String(err));
1674
+ } finally {
1675
+ setMotionBusy(false);
1676
+ }
1677
+ }
1678
+
1679
+ // Hosted-demo seed. A build served as static files has no ARDY sidecar, so
1680
+ // a first-time visitor would otherwise land on a character standing still
1681
+ // with no way to see generated motion. The clip below ships with the build
1682
+ // and is loaded once, only when the bridge is absent and nothing has been
1683
+ // loaded or generated yet. A local session with the bridge running is
1684
+ // untouched.
1685
+ const demoSeeded = useRef(false);
1686
+ useEffect(() => {
1687
+ if (demoSeeded.current) return;
1688
+ if (!bridge || bridge.ok) return;
1689
+ if (!rigA || motion || motionBusy) return;
1690
+ demoSeeded.current = true;
1691
+ // Loaded, not played: the clip walks the subject out of the default
1692
+ // framing, so autoplay would greet a first-time visitor with an empty
1693
+ // room. Frame 0 is composed; PLAYBACK is one click away.
1694
+ loadMotion(DEMO_MOTION_URL, DEMO_MOTION_PROMPT).catch(() => {
1695
+ /* the seed is a nicety, never a failure the visitor must act on */
1696
+ });
1697
+ }, [bridge, rigA, motion, motionBusy]);
1698
+
1699
+ function clearMotion() {
1700
+ setMotion(null);
1701
+ setMotionError("");
1702
+ // Back to the pre-generation timeline: the current duration at 20 fps.
1703
+ setTlFrameCount(maxDst + 1);
1704
+ setTlFps(20);
1705
+ setTlFrame((f) => Math.min(f, maxDst));
1706
+ setTlPlaying(false);
1707
+ }
1708
+
1709
+ // Drive Subject 1's rig from the loaded clip whenever the playhead moves.
1710
+ // During playback the pose prop is null so Character's effect never
1711
+ // overwrites the animation on unrelated re-renders.
1712
+ useEffect(() => {
1713
+ if (!motion || !rigA) return;
1714
+ applyMotionFrame(rigA, motion, tlFrame);
1715
+ }, [motion, rigA, tlFrame]);
1716
+
1717
+ /* ------------------------------ IK logic ------------------------------ */
1718
+
1719
+ // Resolve the IK rig (chains + FK swing joints) whenever Subject 1's rig
1720
+ // (re)loads. A rig missing any bone resolves to null and IK mode stays
1721
+ // unavailable.
1722
+ useEffect(() => {
1723
+ const resolved = resolveIkRig(rigA);
1724
+ const chains = resolved ? resolved.chains : null;
1725
+ setIkChains(chains);
1726
+ setIkFkJoints(resolved ? resolved.fkJoints : null);
1727
+ ikStateRef.current.chains = chains;
1728
+ if (!chains) setIkMode(false);
1729
+ }, [rigA]);
1730
+
1731
+ function toggleIkMode() {
1732
+ const next = !ikMode;
1733
+ if (next) {
1734
+ // With a motion loaded, IK edits ON TOP of it: the motion is the
1735
+ // rough base layer, the IK keys the correction layer. Every frame
1736
+ // applies the clip first and the keyed corrections after, so the
1737
+ // composite is what gets pinned for re-generation. Pause playback
1738
+ // so a running playhead cannot fight the drag.
1739
+ setTlPlaying(false);
1740
+ // Self-heal the ref after a hot-reload with an older state shape:
1741
+ // a missing `tracked` set would throw on the first drag.
1742
+ if (!ikStateRef.current.tracked) ikStateRef.current = createIkState();
1743
+ ikStateRef.current.chains = ikChains;
1744
+ // Handles open exactly on the effectors of the CURRENT pose —
1745
+ // non-destructive entry. (The evaluate effect applies the keyed
1746
+ // pose at this frame right after ikMode flips, so re-seating on
1747
+ // frame changes is handled there.)
1748
+ if (ikChains) ikSeedTargets(ikChains, ikStateRef.current);
1749
+ // The main view switches to the poser camera: start it exactly on
1750
+ // the shot camera's pose so nothing jumps, then navigation moves
1751
+ // the POSER only — the shot camera (inset) stays frozen.
1752
+ const shotCam = shotCamRef.current;
1753
+ const poserCam = poserCamRef.current;
1754
+ if (shotCam && poserCam) {
1755
+ poserCam.position.copy(shotCam.position);
1756
+ poserCam.quaternion.copy(shotCam.quaternion);
1757
+ poserCam.rotation.order = "YXZ";
1758
+ poserLook.current = { yaw: shotCam.rotation.y, pitch: shotCam.rotation.x };
1759
+ }
1760
+ setIkMode(true);
1761
+ setToast(motion
1762
+ ? "IK mode — correct the motion; drag end keys the fix at this frame"
1763
+ : "IK mode — drag handles in the main view; the shot camera stays frozen in the inset");
1764
+ return;
1765
+ }
1766
+ // Exit: the keyed pose stays — the evaluate effect re-applies the
1767
+ // current frame's keyed rotations the moment ikMode flips, so nothing
1768
+ // the user authored is lost by toggling. Untracked/unkeyed parts keep
1769
+ // their current (FK) pose.
1770
+ setIkMode(false);
1771
+ setToast("IK mode off — keyed poses keep playing");
1772
+ }
1773
+
1774
+ // Drag solve, routed by handle kind: chain targets solve the two-bone
1775
+ // chain toward the target; mid joints reposition the elbow/knee with both
1776
+ // ends pinned (the handle snaps to the clamped position); FK joints swing
1777
+ // toward the pointer. Keys are baked on drag END — see ikDragEnd.
1778
+ function ikSolve(kind, trackId, targetWorld) {
1779
+ if (kind === "chain") {
1780
+ const chain = ikStateRef.current.chains?.get(trackId);
1781
+ if (!chain) return;
1782
+ ikTouch(ikStateRef.current, trackId);
1783
+ ikStateRef.current.targets.set(trackId, targetWorld.clone());
1784
+ solveIk(chain, targetWorld);
1785
+ return;
1786
+ }
1787
+ if (kind === "mid") {
1788
+ // Mid tracks reference their parent chain through MID_TRACKS.
1789
+ const midDef = MID_TRACKS.find((t) => t.id === trackId);
1790
+ const chain = midDef ? ikStateRef.current.chains?.get(midDef.chain) : null;
1791
+ if (!chain) return;
1792
+ ikTouch(ikStateRef.current, chain.track.id);
1793
+ solveMidJoint(chain, targetWorld);
1794
+ return;
1795
+ }
1796
+ // Effector swing: the rotation ring on a focused hand/foot. Rotates
1797
+ // only the end bone — the solved limb position is untouched — and the
1798
+ // bake on drag end now stores b2's quaternion with the chain's.
1799
+ if (kind === "swing") {
1800
+ const chain = ikStateRef.current.chains?.get(trackId);
1801
+ if (!chain || !targetWorld?.axis) return;
1802
+ ikTouch(ikStateRef.current, trackId);
1803
+ solveEffectorSwing(chain, targetWorld.axis, targetWorld.angle, targetWorld.startQuat, targetWorld.startParentQuat);
1804
+ return;
1805
+ }
1806
+ // Body root (hips): arrow drags translate ({ worldDelta, startLocalPos
1807
+ // }), the centre sphere swings ({ axis, angle, startQuat, ... }). With
1808
+ // foot snap ON the feet stay at the positions captured when the drag
1809
+ // started — the legs re-solve after every hips transform so the knees
1810
+ // bend instead of the feet sinking through the floor.
1811
+ if (kind === "body") {
1812
+ const joint = ikFkJoints?.get(trackId);
1813
+ if (!joint) return;
1814
+ ikTouch(ikStateRef.current, trackId);
1815
+ if (footSnap && !ikBodyDragRef.current && ikChains) {
1816
+ // Capture the plant points once, BEFORE the first hips move.
1817
+ ikPlantFeet(ikChains, ikStateRef.current);
1818
+ ikBodyDragRef.current = true;
1819
+ }
1820
+ if (targetWorld?.worldDelta && targetWorld?.startLocalPos) solveHipsTranslate(joint, targetWorld.worldDelta, targetWorld.startLocalPos);
1821
+ else if (targetWorld?.axis) solveSwingAngle(joint, targetWorld.axis, targetWorld.angle, targetWorld.startQuat, targetWorld.startParentQuat);
1822
+ if (footSnap && ikChains) {
1823
+ ikSolvePlantedFeet(ikChains, ikStateRef.current);
1824
+ // the planted re-solve wrote the leg bones — key them too
1825
+ ikTouch(ikStateRef.current, "leftFoot");
1826
+ ikTouch(ikStateRef.current, "rightFoot");
1827
+ }
1828
+ return;
1829
+ }
1830
+ // FK swing: targetWorld is the trackball payload { axis, angle,
1831
+ // startQuat, startParentQuat } from the drag layer.
1832
+ const joint = ikFkJoints?.get(trackId);
1833
+ if (!joint || !targetWorld?.axis) return;
1834
+ ikTouch(ikStateRef.current, trackId);
1835
+ solveSwingAngle(joint, targetWorld.axis, targetWorld.angle, targetWorld.startQuat, targetWorld.startParentQuat);
1836
+ }
1837
+
1838
+ // Drag end: key the dragged part's local rotations at the playhead, so a
1839
+ // scrub away and back restores the dragged pose exactly (slerp).
1840
+ function ikDragEnd() {
1841
+ ikBodyDragRef.current = false;
1842
+ if (ikChains) ikBakeKeyframe(ikChains, ikStateRef.current, tlFrame, ikFkJoints);
1843
+ setIkTick((n) => n + 1);
1844
+ }
1845
+
1846
+ // Manual key: bake the current tracked rotations at the playhead.
1847
+ function ikAddKeyframe() {
1848
+ if (!ikChains) return;
1849
+ ikBakeKeyframe(ikChains, ikStateRef.current, tlFrame, ikFkJoints);
1850
+ setIkTick((n) => n + 1);
1851
+ setToast(`Full-body IK key at frame ${tlFrame}`);
1852
+ }
1853
+
1854
+ function ikDeleteKeyframe(frame) {
1855
+ ikRemoveKeyframe(ikStateRef.current, frame);
1856
+ setIkTick((n) => n + 1);
1857
+ }
1858
+
1859
+ // Keyed-pose playback: the IK layer's keyed bone rotations apply at the
1860
+ // current frame whether or not IK edit mode is on — IK-authored keys are
1861
+ // the source of truth (the user designs first/end keys and ARDY in-
1862
+ // betweens them), so scrubbing/playing with IK OFF must still show them.
1863
+ // With a motion loaded the clip is the base layer: this effect runs
1864
+ // AFTER the motion-apply effect above (definition order), so the keys
1865
+ // override the generated pose exactly where corrections were authored —
1866
+ // and ONLY there: the blend window eases each correction back to the
1867
+ // clip outside its keyed range, so keying frame 39 alone no longer
1868
+ // stomps every earlier frame. Skipped while the pose studio is open (FK
1869
+ // edits there would be stomped). With no keys at all and IK off there is
1870
+ // nothing to apply — pure FK posing / pure motion playback stays
1871
+ // untouched.
1872
+ useEffect(() => {
1873
+ if (!ikChains || !rigA || posing) return;
1874
+ if (!ikMode && ikStateRef.current.keys.size === 0) return;
1875
+ ikEvaluate(ikChains, ikStateRef.current, tlFrame, ikFkJoints, motion ? IK_CORRECTION_BLEND_FRAMES : 0);
1876
+ }, [ikMode, ikChains, rigA, motion, posing, tlFrame, ikTick, ikFkJoints]);
1877
+
1878
+ // Re-seat the handles on the keyed pose when the FRAME changes with IK
1879
+ // on — scrubbing to frame 39 shows that frame's interpolated pose AND
1880
+ // places the handles on its effectors, ready to edit into a new key.
1881
+ // Deliberately NOT in the evaluate effect above: a gizmo axis drag can
1882
+ // leave the target offset from the effector on purpose, and re-seeding
1883
+ // after every bake would wipe that relationship mid-workflow.
1884
+ const ikPrevFrameRef = useRef(tlFrame);
1885
+ useEffect(() => {
1886
+ const frameChanged = ikPrevFrameRef.current !== tlFrame;
1887
+ ikPrevFrameRef.current = tlFrame;
1888
+ if (!ikMode || !ikChains || !rigA || posing) return;
1889
+ if (!frameChanged) return; // pure toggle-on: evaluate already ran
1890
+ ikSeedTargets(ikChains, ikStateRef.current);
1891
+ }, [ikMode, ikChains, rigA, motion, posing, tlFrame, ikTick, ikFkJoints]);
1892
+
1893
+ // QA hook: lets headless visual checks read the live rig/motion state
1894
+ // (tools/ardy/visual-qa.mjs). Harmless in normal use.
1895
+ useEffect(() => {
1896
+ window.__cozyclay = {
1897
+ rigA, motion, tlFrame, ikMode, ikChains, ikFocus, ik: ikStateRef.current,
1898
+ committedIkEdits, waypoints,
1899
+ // the camera the main view renders through (poser in IK mode) — QA
1900
+ // projections must use this one, not the frozen shot camera
1901
+ activeCam: ikMode ? poserCamRef.current : shotCamRef.current,
1902
+ shotCam: shotCamRef.current,
1903
+ poserCam: poserCamRef.current,
1904
+ planCam: planCamRef.current,
1905
+ charA,
1906
+ insetPane: insetPaneRef.current,
1907
+ };
1908
+ }, [rigA, motion, tlFrame, ikMode, ikChains, ikFocus, ikTick, charA, committedIkEdits, waypoints]);
1909
+ // QA hook (plan §6.5): exposes history depth and the present === objects
1910
+ // invariant so the browser suite can assert undo entry counts directly.
1911
+ // Reads live store state at call time; re-registered after every render.
1912
+ useEffect(() => {
1913
+ window.__sceneHistory = () => ({ ...store.depths(), settled: store.present() === store.objects });
1914
+ });
1915
+
1916
+ // On clear, restore the exact pre-playback bone rotations. This runs in
1917
+ // the parent AFTER Character's pose effect (children flush first), so even
1918
+ // a pose change made during playback cannot leak into the restored rig.
1919
+ useEffect(() => {
1920
+ if (motion) return;
1921
+ const saved = restoreRef.current;
1922
+ if (!saved) return;
1923
+ restoreRef.current = null;
1924
+ restorePlaybackBones(saved.rig, saved.bones);
1925
+ }, [motion]);
1926
+
1927
+ const motionPos = useMemo(() => {
1928
+ if (!motion) return null;
1929
+ const f = Math.min(tlFrame, motion.frames - 1);
1930
+ const a = Math.min(motion.anchorFrame, motion.frames - 1);
1931
+ const offset = toSceneRootOffset(
1932
+ motion.rootPos[f * 3] - motion.rootPos[a * 3],
1933
+ motion.rootPos[f * 3 + 2] - motion.rootPos[a * 3 + 2],
1934
+ motion.rotationDeg,
1935
+ );
1936
+ return {
1937
+ x: motion.anchorX + offset.x,
1938
+ z: motion.anchorZ + offset.z,
1939
+ };
1940
+ }, [motion, tlFrame]);
1941
+
1942
+ // The subject's full per-frame scene trajectory — what the follow camera
1943
+ // is derived from. Without a loaded motion the subject stands still and
1944
+ // the follow camera simply composes a static frame.
1945
+ const subjectTrack = useMemo(() => {
1946
+ if (!followCam.enabled) return null;
1947
+ const frames = Math.max(tlFrameCount, 1);
1948
+ if (!motion) return Array.from({ length: frames }, () => ({ x: charA.x, z: charA.z }));
1949
+ const a = Math.min(motion.anchorFrame, motion.frames - 1);
1950
+ return Array.from({ length: frames }, (_, f) => {
1951
+ const ff = Math.min(f, motion.frames - 1);
1952
+ const offset = toSceneRootOffset(
1953
+ motion.rootPos[ff * 3] - motion.rootPos[a * 3],
1954
+ motion.rootPos[ff * 3 + 2] - motion.rootPos[a * 3 + 2],
1955
+ motion.rotationDeg,
1956
+ );
1957
+ return { x: motion.anchorX + offset.x, z: motion.anchorZ + offset.z };
1958
+ });
1959
+ }, [followCam.enabled, motion, tlFrameCount, charA.x, charA.z]);
1960
+
1961
+ // The dense rail (spline through the drawn control points) — shared by
1962
+ // the follow controller and the Top-View display.
1963
+ const railCurve = useMemo(() => (cameraRail ? buildRail(cameraRail) : null), [cameraRail]);
1964
+
1965
+ const followTrack = useMemo(() => {
1966
+ if (!followCam.enabled || !subjectTrack) return null;
1967
+ const yaw = (charA.rot * Math.PI) / 180;
1968
+ const params = {
1969
+ distance: followCam.distance,
1970
+ height: followCam.height,
1971
+ response: followCam.response,
1972
+ lead: followCam.lead,
1973
+ initialDir: { x: Math.sin(yaw), z: Math.cos(yaw) },
1974
+ };
1975
+ return railCurve
1976
+ ? buildRailFollowTrack(subjectTrack, tlFps, railCurve, params)
1977
+ : buildFollowTrack(subjectTrack, tlFps, params);
1978
+ }, [followCam, subjectTrack, railCurve, tlFps, charA.rot]);
1979
+
1980
+ // The follow camera owns the shot camera in the same situations key
1981
+ // following would: never while an authoring mode holds the viewport.
1982
+ const followCamActive =
1983
+ followCam.enabled && !!followTrack && (centerTab === "play" || (!ikMode && !waypointMode && !posing));
1984
+
1985
+ // Implied locomotion speed per authored segment (@ 20 fps). Shown in the
1986
+ // timeline hint so a path that forces a crawl or a sprint is visible
1987
+ // before spending a generation on it.
1988
+ const pathSpeed = useMemo(() => {
1989
+ if (waypoints.length < 2) return null;
1990
+ let min = Infinity;
1991
+ let max = 0;
1992
+ for (let i = 1; i < waypoints.length; i += 1) {
1993
+ const a = waypoints[i - 1];
1994
+ const b = waypoints[i];
1995
+ const seconds = (b.frame - a.frame) / 20;
1996
+ if (seconds <= 0) continue;
1997
+ const speed = Math.hypot(b.x - a.x, b.z - a.z) / seconds;
1998
+ min = Math.min(min, speed);
1999
+ max = Math.max(max, speed);
2000
+ }
2001
+ if (!Number.isFinite(min)) return null;
2002
+ return { min, max, warn: min < 0.5 || max > 3 };
2003
+ }, [waypoints]);
2004
+
2005
+ const stateBadge = ardyRunning
2006
+ ? { label: "GENERATING", kind: "generating" }
2007
+ : motion
2008
+ ? { label: "PLAYBACK", kind: "playback" }
2009
+ : waypointMode
2010
+ ? { label: "ROOT PATH", kind: "root" }
2011
+ : null;
2012
+
2013
+ function openStudio(which) {
2014
+ setPosing(which);
2015
+ setPosingClosing(false);
2016
+ setStudioPick((which === "B" ? poseB : poseA)?.id ?? null);
2017
+ }
2018
+
2019
+ function closeStudio() {
2020
+ // let the panel play its exit animation before it leaves the tree
2021
+ setPosingClosing(true);
2022
+ window.setTimeout(() => {
2023
+ setPosing(null);
2024
+ setPosingClosing(false);
2025
+ }, 190);
2026
+ }
2027
+
2028
+ function savePose() {
2029
+ const rig = posedRig();
2030
+ if (!rig) return;
2031
+ const pose = {
2032
+ id: `custom_${Date.now()}`,
2033
+ label: `My Pose ${customPoses.length + 1}`,
2034
+ prompt: "in the exact body pose shown in the blocking frame",
2035
+ bones: capturePose(rig),
2036
+ custom: true,
2037
+ };
2038
+ const next = [...customPoses, pose];
2039
+ setCustomPoses(next);
2040
+ saveCustomPoses(next);
2041
+ setStudioPick(pose.id);
2042
+ setPosed(pose);
2043
+ setToast("Pose saved");
2044
+ }
2045
+
2046
+ function removePose(id) {
2047
+ const next = deleteCustomPose(id, customPoses);
2048
+ setCustomPoses(next);
2049
+ saveCustomPoses(next);
2050
+ if (poseA?.id === id) setPoseA(DEFAULT_POSE);
2051
+ if (poseB?.id === id) setPoseB(DEFAULT_POSE);
2052
+ if (studioPick === id) setStudioPick(DEFAULT_POSE.id);
2053
+ }
2054
+
2055
+ function applyPreset(key) {
2056
+ const p = PRESETS[key];
2057
+ setPreset(key);
2058
+ setFovDeg(p.fov);
2059
+ setShowB(p.two);
2060
+ if (p.charB) setCharB(p.charB);
2061
+ setNonce((n) => n + 1);
2062
+ }
2063
+
2064
+ function bufferToPng(buffer) {
2065
+ const canvas = document.createElement("canvas");
2066
+ canvas.width = CAPTURE_W;
2067
+ canvas.height = CAPTURE_H;
2068
+ const ctx = canvas.getContext("2d");
2069
+ const image = ctx.createImageData(CAPTURE_W, CAPTURE_H);
2070
+ // WebGL reads bottom-up; flip into canvas order.
2071
+ for (let row = 0; row < CAPTURE_H; row += 1) {
2072
+ const from = (CAPTURE_H - 1 - row) * CAPTURE_W * 4;
2073
+ image.data.set(buffer.subarray(from, from + CAPTURE_W * 4), row * CAPTURE_W * 4);
2074
+ }
2075
+ ctx.putImageData(image, 0, 0);
2076
+ return canvas.toDataURL("image/png");
2077
+ }
2078
+
2079
+ /** Park the shot camera on a framing, read back a 1920x1080 PNG, and put
2080
+ everything back before the next paint — the viewport never sees it. */
2081
+ function captureFramingPng(framing) {
2082
+ const cam = shotCamRef.current;
2083
+ if (!cam || !captureRef.current) return null;
2084
+ const prev = { x: cam.position.x, y: cam.position.y, z: cam.position.z, yaw: look.current.yaw, pitch: look.current.pitch, fov: cam.fov };
2085
+ cam.position.set(framing.pos.x, framing.pos.y, framing.pos.z);
2086
+ cam.rotation.order = "YXZ";
2087
+ cam.rotation.set(framing.pitch, framing.yaw, 0);
2088
+ cam.fov = framing.fovDeg;
2089
+ cam.updateProjectionMatrix();
2090
+ const buffer = captureRef.current.render();
2091
+ cam.position.set(prev.x, prev.y, prev.z);
2092
+ cam.rotation.set(prev.pitch, prev.yaw, 0);
2093
+ look.current.yaw = prev.yaw;
2094
+ look.current.pitch = prev.pitch;
2095
+ cam.fov = prev.fov;
2096
+ cam.updateProjectionMatrix();
2097
+ return buffer ? bufferToPng(buffer) : null;
2098
+ }
2099
+
2100
+ function generate() {
2101
+ const models = mode === "video" ? VIDEO_MODELS : IMAGE_MODELS;
2102
+ const model = models.find((m) => m.id === (mode === "video" ? videoModel : imageModel));
2103
+ // An authored keyframe move outranks the dropdown: the phrase is derived
2104
+ // from the framings, and the exported frames become first/last conditioning.
2105
+ const movePlan = mode === "video" ? moveSequence : null;
2106
+ const prompt = composePrompt({
2107
+ mode,
2108
+ model,
2109
+ shot: movePlan ? movePlan.fromShot : shot,
2110
+ subject,
2111
+ subject2: showB ? subject2 : null,
2112
+ posePhrase: poseA?.prompt ?? "",
2113
+ pose2Phrase: showB ? (poseB?.prompt ?? "") : "",
2114
+ environment,
2115
+ style,
2116
+ cameraMove: movePlan ? CUSTOM_MOVE : cameraMove,
2117
+ customMove: movePlan ? movePlan.phrase : customMove,
2118
+ hasCharSheet,
2119
+ hasEnvSheet,
2120
+ });
2121
+ let frame = null;
2122
+ let frameB = null;
2123
+ if (movePlan) {
2124
+ frame = captureFramingPng(cameraKeys[0].framing);
2125
+ frameB = captureFramingPng(cameraKeys[cameraKeys.length - 1].framing);
2126
+ } else {
2127
+ const buffer = captureRef.current?.render();
2128
+ frame = buffer ? bufferToPng(buffer) : null;
2129
+ }
2130
+ setResult({ prompt, frame, frameB, move: movePlan });
2131
+ setCopied(false);
2132
+ navigator.clipboard
2133
+ ?.writeText(prompt)
2134
+ .then(() => {
2135
+ setCopied(true);
2136
+ setToast("Prompt copied to clipboard");
2137
+ })
2138
+ .catch(() => {});
2139
+ }
2140
+
2141
+ function download() {
2142
+ const save = (href, name) => {
2143
+ const a = document.createElement("a");
2144
+ a.href = href;
2145
+ a.download = name;
2146
+ document.body.appendChild(a);
2147
+ a.click();
2148
+ a.remove();
2149
+ };
2150
+ if (result.frameB) {
2151
+ // named for the seat they take in a first/last-frame video request
2152
+ save(result.frame, "blocking-frame-A-start.png");
2153
+ save(result.frameB, "blocking-frame-B-end.png");
2154
+ setToast("Start & end frames downloaded");
2155
+ return;
2156
+ }
2157
+ save(result.frame, "blocking-frame.png");
2158
+ setToast("Frame downloaded");
2159
+ }
2160
+ function downloadArdyPose() {
2161
+ const rig = posedRig();
2162
+ if (!rig) {
2163
+ setToast("Character not loaded yet");
2164
+ return;
2165
+ }
2166
+ const pose = buildArdyPose({
2167
+ rig,
2168
+ camRef: shotCamRef,
2169
+ look,
2170
+ fovDeg,
2171
+ slate: slateLine(shot),
2172
+ rigName: posing === "B" ? "y-bot-tpose" : "x-bot-tpose",
2173
+ root: captureArdyRoot(rig),
2174
+ });
2175
+ const blob = new Blob([JSON.stringify(pose, null, 2)], { type: "application/json" });
2176
+ const url = URL.createObjectURL(blob);
2177
+ const a = document.createElement("a");
2178
+ a.href = url;
2179
+ a.download = "cozyclay-pose.json";
2180
+ document.body.appendChild(a);
2181
+ a.click();
2182
+ a.remove();
2183
+ URL.revokeObjectURL(url);
2184
+ setToast("ARDY pose exported");
2185
+ }
2186
+ // Probe the dev sidecar exactly once. A missing bridge is an expected
2187
+ // state — CozyClay is a static app and the sidecar is an optional dev
2188
+ // companion — so the panel degrades to a hint instead of erroring.
2189
+ useEffect(() => {
2190
+ let alive = true;
2191
+ checkBridge().then((state) => {
2192
+ if (alive) setBridge(state);
2193
+ });
2194
+ return () => {
2195
+ alive = false;
2196
+ };
2197
+ }, []);
2198
+
2199
+ function addPromptClip(frame) {
2200
+ const snapped = Math.max(0, Math.round(frame / ARDY_PROMPT_HORIZON_FRAMES) * ARDY_PROMPT_HORIZON_FRAMES);
2201
+ const startFrame = Math.max(snapped, promptClips.reduce((max, clip) => Math.max(max, clip.endFrame), 0));
2202
+ const clip = { id: `prompt-${Date.now()}`, startFrame, endFrame: startFrame + ARDY_PROMPT_HORIZON_FRAMES, text: "" };
2203
+ setPromptClips((prev) => [...prev, clip]);
2204
+ setSelectedPromptId(clip.id);
2205
+ setTlFrameCount((count) => Math.max(count, clip.endFrame));
2206
+ setArdyDuration(Math.max(ARDY_DURATION_MIN, clip.endFrame / 20));
2207
+ }
2208
+
2209
+ function changePromptClip(id, text) {
2210
+ setPromptClips((prev) => prev.map((clip) => (clip.id === id ? { ...clip, text } : clip)));
2211
+ if (id === selectedPromptId) setArdyPrompt(text);
2212
+ }
2213
+
2214
+ function resizePromptClip(id, edge, rawFrame) {
2215
+ setPromptClips((prev) => {
2216
+ const next = prev.map((clip) => {
2217
+ if (clip.id !== id) return clip;
2218
+ const snapped = Math.max(0, Math.round(rawFrame / ARDY_PROMPT_HORIZON_FRAMES) * ARDY_PROMPT_HORIZON_FRAMES);
2219
+ return edge === "start"
2220
+ ? { ...clip, startFrame: Math.min(snapped, clip.endFrame - ARDY_PROMPT_HORIZON_FRAMES) }
2221
+ : { ...clip, endFrame: Math.max(clip.startFrame + ARDY_PROMPT_HORIZON_FRAMES, snapped) };
2222
+ });
2223
+ const end = next.reduce((max, clip) => Math.max(max, clip.endFrame), ARDY_PROMPT_HORIZON_FRAMES);
2224
+ setTlFrameCount((count) => Math.max(count, end));
2225
+ setArdyDuration(end / 20);
2226
+ return next;
2227
+ });
2228
+ }
2229
+
2230
+ function movePromptClip(id, rawStartFrame) {
2231
+ setPromptClips((prev) => {
2232
+ const next = movePromptClipFrames(prev, id, rawStartFrame, ARDY_PROMPT_HORIZON_FRAMES);
2233
+ if (next === prev) return prev;
2234
+ const end = next.reduce((max, clip) => Math.max(max, clip.endFrame), ARDY_PROMPT_HORIZON_FRAMES);
2235
+ setTlFrameCount((count) => Math.max(count, end));
2236
+ setArdyDuration(end / 20);
2237
+ return next;
2238
+ });
2239
+ }
2240
+
2241
+ function removePromptClip(id) {
2242
+ setPromptClips((prev) => prev.filter((clip) => clip.id !== id));
2243
+ if (selectedPromptId === id) setSelectedPromptId(null);
2244
+ }
2245
+
2246
+ function changeDuration(value) {
2247
+ const duration = Math.max(ARDY_DURATION_MIN, Math.min(Math.round(Number(value)) || ARDY_DURATION_MIN, ARDY_DURATION_MAX));
2248
+ setArdyDuration(duration);
2249
+ // Keep a stale destination frame in range when duration shrinks.
2250
+ // The generation clip is duration × 20 frames; root waypoints beyond
2251
+ // its end would be invalid for a new shorter generation, so prune
2252
+ // them even while a motion is loaded (the frame-0 start survives).
2253
+ const last = Math.max(0, duration * 20 - 1);
2254
+ setWaypoints((prev) => prev.filter((w) => w.frame <= last));
2255
+ // Without a loaded motion the timeline IS the generation clip: resize
2256
+ // it to duration × 20 and clamp the playhead. While a motion is loaded
2257
+ // the timeline keeps the clip's own frame count and playhead until clear.
2258
+ if (!motion) {
2259
+ setTlFrameCount(last + 1);
2260
+ setTlFrame((f) => Math.min(f, last));
2261
+ }
2262
+ }
2263
+
2264
+ // Optional native-ARDY seed. Empty = request omits seed; the raw string
2265
+ // is kept as typed (trimmed only). runArdy validates the bridge contract
2266
+ // (integer in 0..2**31-1) right before the request and toasts on a
2267
+ // violation, so an invalid seed can never reach the bridge silently.
2268
+ function changeArdySeed(value) {
2269
+ setArdySeed(value.trim());
2270
+ }
2271
+
2272
+ function runAllPromptBlocks() {
2273
+ const clips = promptClips
2274
+ .filter((clip) => clip.text.trim())
2275
+ .sort((a, b) => a.startFrame - b.startFrame);
2276
+ if (!clips.length) {
2277
+ setToast("Add at least one Prompt Block before generating");
2278
+ return;
2279
+ }
2280
+ const totalFrames = Math.max(...clips.map((clip) => clip.endFrame));
2281
+ const duration = Math.max(ARDY_DURATION_MIN, Math.ceil(totalFrames / 20));
2282
+ setArdyPrompt(clips[0].text);
2283
+ setArdyDuration(duration);
2284
+ runArdy({
2285
+ promptOverride: clips[0].text,
2286
+ durationOverride: duration,
2287
+ promptClipsOverride: clips,
2288
+ });
2289
+ }
2290
+
2291
+ async function runArdy({
2292
+ promptOverride = ardyPrompt,
2293
+ durationOverride = ardyDuration,
2294
+ promptClipsOverride = [],
2295
+ } = {}) {
2296
+ const rig = posedRig();
2297
+ if (!rig) {
2298
+ setToast("Character not loaded yet");
2299
+ return;
2300
+ }
2301
+ // Root guidance sends only authored sparse keys. ARDY owns every
2302
+ // in-between frame; no dense interpolation or playback warp is applied.
2303
+ // Prompt and duration are bridge-contract inputs too: reject bad
2304
+ // values here, before any pose build or network, with a specific toast.
2305
+ const prompt = promptOverride.trim();
2306
+ if (!prompt) {
2307
+ setToast("Motion prompt is required — describe what the subject should do before generating");
2308
+ return;
2309
+ }
2310
+ if (prompt.length > ARDY_PROMPT_MAX) {
2311
+ setToast(`Motion prompt is capped at ${ARDY_PROMPT_MAX} characters (currently ${prompt.length}) — shorten it before generating`);
2312
+ return;
2313
+ }
2314
+ // Regeneration must keep the loaded clip's exact frame count. The form
2315
+ // may still show an older duration after a motion is loaded; using it
2316
+ // would ask ARDY for (for example) 120 frames against an 80-frame base.
2317
+ const duration = motion && ikFrames.length > 0
2318
+ ? motion.frames / motion.fps
2319
+ : Math.round(Number(durationOverride)) || ARDY_DURATION_MIN;
2320
+ if (duration < ARDY_DURATION_MIN || duration > ARDY_DURATION_MAX) {
2321
+ setToast(`Duration must be between ${ARDY_DURATION_MIN} and ${ARDY_DURATION_MAX} seconds`);
2322
+ return;
2323
+ }
2324
+ const seed = ardySeed === "" ? null : Number(ardySeed);
2325
+ if (seed !== null && (!Number.isInteger(seed) || seed < 0 || seed > ARDY_SEED_MAX)) {
2326
+ setToast(`Seed must be an integer in 0..${ARDY_SEED_MAX} — clear it to let the box pick one`);
2327
+ return;
2328
+ }
2329
+ // Prompt clips are real generation blocks. Gaps inherit the current
2330
+ // prompt so the bridge always receives one contiguous 0..N sequence.
2331
+ // Built BEFORE the root-path judge: whether the rollout is chained
2332
+ // changes which window limit binds the path (per block, not per clip).
2333
+ const clipFrames = duration * 20;
2334
+ const segments = [];
2335
+ let cursor = 0;
2336
+ const sourcePromptClips = promptClipsOverride
2337
+ .filter((clip) => clip.text.trim())
2338
+ .sort((a, b) => a.startFrame - b.startFrame);
2339
+ for (const clip of sourcePromptClips) {
2340
+ const startFrame = Math.max(cursor, Math.min(clipFrames, clip.startFrame));
2341
+ const endFrame = Math.max(startFrame, Math.min(clipFrames, clip.endFrame));
2342
+ if (startFrame > cursor) segments.push({ startFrame: cursor, endFrame: startFrame, prompt });
2343
+ if (endFrame - startFrame >= 3) segments.push({ startFrame, endFrame, prompt: clip.text.trim() || prompt });
2344
+ cursor = Math.max(cursor, endFrame);
2345
+ if (cursor >= clipFrames) break;
2346
+ }
2347
+ if (cursor < clipFrames) segments.push({ startFrame: cursor, endFrame: clipFrames, prompt });
2348
+ if (segments.length === 0) segments.push({ startFrame: 0, endFrame: clipFrames, prompt });
2349
+ const hasPromptSchedule = segments.length > 1;
2350
+ const rootPath = waypointMode
2351
+ ? [{ frame: 0, x: charA.x, z: charA.z, heading: null }, ...waypoints]
2352
+ : [];
2353
+ if (waypointMode) {
2354
+ if (waypoints.length < 1) {
2355
+ setToast("Add at least one root destination before generating");
2356
+ return;
2357
+ }
2358
+ if (rootPath.length > MAX_WAYPOINTS) {
2359
+ setToast(`The root path is capped at ${MAX_WAYPOINTS} sparse waypoints`);
2360
+ return;
2361
+ }
2362
+ if (waypoints.some((waypoint) => waypoint.frame <= 0 || waypoint.frame >= clipFrames)) {
2363
+ setToast(`Root waypoint frames must stay inside 1..${clipFrames - 1}`);
2364
+ return;
2365
+ }
2366
+ // Placement-time checks can be invalidated afterwards (removing a
2367
+ // middle pin merges two legs; the duration field can grow), so the
2368
+ // whole path is re-judged at the door. A prompt schedule chains
2369
+ // the rollout block by block, so the trained window binds each
2370
+ // block instead of the whole clip.
2371
+ const pathVerdict = judgeAuthoredPath(rootPath, 20, clipFrames, { chained: hasPromptSchedule });
2372
+ if (pathVerdict.errors.length > 0) {
2373
+ setToast(`Not generated — ${pathVerdict.errors[0]}`);
2374
+ return;
2375
+ }
2376
+ if (hasPromptSchedule) {
2377
+ const longBlock = segments.find((segment) => segment.endFrame - segment.startFrame > PATH_LIMITS.clipMaxS * 20);
2378
+ if (longBlock) {
2379
+ setToast(`Not generated — with a root path every prompt block must stay within ${PATH_LIMITS.clipMaxS} s (ARDY's trained window per chained call); split the ${((longBlock.endFrame - longBlock.startFrame) / 20).toFixed(1)} s block`);
2380
+ return;
2381
+ }
2382
+ }
2383
+ if (pathVerdict.warnings.length > 0) setToast(`⚠ ${pathVerdict.warnings[0]}`);
2384
+ }
2385
+ // Align + densify, never the raw sparse path: the model forces frame-0
2386
+ // facing to +Z, so the path is rotated until its first travel tangent
2387
+ // is +Z (heading 0) and resampled with path-tangent headings — sparse
2388
+ // heading-less pins that fight the forced facing corrupt the whole
2389
+ // track (see the sign-convention notes in ardy/waypoints.js).
2390
+ const alignedRoot = waypointMode ? alignArdyPath(rootPath, charA.rot, MAX_WAYPOINTS) : null;
2391
+ const ardyWaypoints = alignedRoot ? alignedRoot.waypoints : [];
2392
+
2393
+ // Capture every block boundary plus every authored IK key. Each sample
2394
+ // is the composite base-motion + IK pose at that frame and carries the
2395
+ // live ARDY root recovered from positional skinning.
2396
+ const editedSegments = motion && hasPromptSchedule
2397
+ ? segments.filter((segment) =>
2398
+ ikFrames.some((frame) => frame >= segment.startFrame && frame < segment.endFrame)
2399
+ )
2400
+ : [];
2401
+ const hasBlockEdits = editedSegments.length > 0;
2402
+ const shouldPin = hasBlockEdits || (!hasPromptSchedule && Boolean(motion || ikFrames.length > 0));
2403
+ const constraintFrames = !shouldPin
2404
+ ? []
2405
+ : waypointMode
2406
+ ? [0]
2407
+ : hasBlockEdits
2408
+ ? ikFrames.filter((frame) =>
2409
+ editedSegments.some((segment) => frame >= segment.startFrame && frame < segment.endFrame)
2410
+ )
2411
+ : [...new Set([
2412
+ ...segments.flatMap((segment) => [
2413
+ segment.startFrame,
2414
+ Math.min(segment.endFrame - 1, segment.startFrame + 1),
2415
+ Math.max(segment.startFrame, segment.endFrame - 2),
2416
+ segment.endFrame - 1,
2417
+ ]),
2418
+ ...ikFrames.filter((frame) => frame >= 0 && frame < clipFrames),
2419
+ ])].sort((a, b) => a - b);
2420
+ const currentFrame = tlFrame;
2421
+ const poses = constraintFrames.map((constraintFrame) => {
2422
+ if (motion) applyMotionFrame(rig, motion, constraintFrame);
2423
+ if (ikChains && ikStateRef.current.keys.size > 0) {
2424
+ ikEvaluate(ikChains, ikStateRef.current, constraintFrame, ikFkJoints, motion ? IK_CORRECTION_BLEND_FRAMES : 0);
2425
+ }
2426
+ return {
2427
+ frame: constraintFrame,
2428
+ pose: buildArdyPose({
2429
+ rig,
2430
+ camRef: shotCamRef,
2431
+ look,
2432
+ fovDeg,
2433
+ slate: slateLine(shot),
2434
+ rigName: posing === "B" ? "y-bot-tpose" : "x-bot-tpose",
2435
+ root: captureArdyRoot(rig),
2436
+ }),
2437
+ };
2438
+ });
2439
+ if (motion) applyMotionFrame(rig, motion, currentFrame);
2440
+ if (ikChains && ikStateRef.current.keys.size > 0) {
2441
+ ikEvaluate(ikChains, ikStateRef.current, currentFrame, ikFkJoints, motion ? IK_CORRECTION_BLEND_FRAMES : 0);
2442
+ }
2443
+
2444
+ // ARDY generates in Subject 1's clip-local frame. Frame 0 is therefore
2445
+ // always the origin; scene placement and the total scene->clip rotation
2446
+ // (actor yaw plus the path-alignment fold) are restored only at
2447
+ // playback, without constraining any later generated root frame.
2448
+ const rootRotationDeg = alignedRoot ? alignedRoot.rotationDeg : charA.rot;
2449
+ const controller = new AbortController();
2450
+ ardyAbortRef.current = controller;
2451
+ setArdyRunning(true);
2452
+ reportArdyStatus("connecting…");
2453
+ setArdyReport(null);
2454
+ setArdyOutcome(null);
2455
+ try {
2456
+ const body = { prompt, duration, posePin: shouldPin };
2457
+ if (shouldPin && !hasBlockEdits) body.poses = poses;
2458
+ if (ardySeed !== "") body.seed = Number(ardySeed);
2459
+ if (waypointMode) {
2460
+ body.waypoints = ardyWaypoints;
2461
+ // A root path and a prompt schedule now travel TOGETHER: the
2462
+ // sequence generator threads the Root2D constraint set through
2463
+ // its chained calls (the interactive demo's pattern), so
2464
+ // neither authored surface is silently dropped any more.
2465
+ if (hasPromptSchedule && !hasBlockEdits) body.segments = segments;
2466
+ } else if (hasBlockEdits) {
2467
+ if (!motion?.url) {
2468
+ throw new Error("The current motion has no bridge source; generate the prompt blocks once before regenerating IK edits");
2469
+ }
2470
+ const startFrame = Math.min(...editedSegments.map((segment) => segment.startFrame));
2471
+ const endFrame = Math.max(...editedSegments.map((segment) => segment.endFrame));
2472
+ const posesByFrame = new Map(poses.map((entry) => [entry.frame, entry.pose]));
2473
+ body.motionEdit = {
2474
+ sourceMotion: motion.url,
2475
+ startFrame,
2476
+ endFrame,
2477
+ contextBefore: 40,
2478
+ contextAfter: 20,
2479
+ edits: constraintFrames.map((frame) => ({
2480
+ frame,
2481
+ tracks: [...(ikStateRef.current.keys.get(frame)?.keys() || [])],
2482
+ pose: posesByFrame.get(frame),
2483
+ })),
2484
+ };
2485
+ } else if (hasPromptSchedule) body.segments = segments;
2486
+ let editCommitReport = null;
2487
+ const done = await ardyGenerate(
2488
+ body,
2489
+ (event) => {
2490
+ if (event.event === "status") reportArdyStatus(event.message);
2491
+ else if (event.event === "report") {
2492
+ setArdyReport(event.report);
2493
+ if (hasBlockEdits) editCommitReport = event.report;
2494
+ }
2495
+ },
2496
+ { signal: controller.signal },
2497
+ );
2498
+ if (
2499
+ hasBlockEdits &&
2500
+ (
2501
+ editCommitReport?.commit_verified !== true ||
2502
+ !body.motionEdit.edits.every((entry) =>
2503
+ editCommitReport.committed_keys?.includes(entry.frame)
2504
+ )
2505
+ )
2506
+ ) {
2507
+ throw new Error("ARDY returned motion without verified authored IK keys");
2508
+ }
2509
+ setArdyOutcome({ ok: true, output: done.output, bytes: done.bytes, motionUrl: done.motionUrl, rotationDeg: rootRotationDeg });
2510
+ // Fetch and decode the real npz right away; decode errors are shown
2511
+ // in the card, playback is never faked.
2512
+ if (done.motionUrl) await loadMotion(done.motionUrl, prompt, rootRotationDeg);
2513
+ if (hasBlockEdits) {
2514
+ setCommittedIkEdits((current) => [
2515
+ ...current,
2516
+ ...body.motionEdit.edits.map(({ frame, tracks }) => ({ frame, tracks })),
2517
+ ]);
2518
+ ikStateRef.current.keys.clear();
2519
+ ikStateRef.current.tracked.clear();
2520
+ ikStateRef.current.plants.clear();
2521
+ setIkTick((value) => value + 1);
2522
+ }
2523
+ setToast("ARDY motion generated");
2524
+ } catch (err) {
2525
+ // Cancellation surfaces as an AbortError; everything else is the
2526
+ // bridge or the generator explaining itself.
2527
+ setArdyOutcome({
2528
+ ok: false,
2529
+ message: err?.name === "AbortError" ? "Cancelled" : err?.message || String(err),
2530
+ });
2531
+ } finally {
2532
+ setArdyRunning(false);
2533
+ ardyAbortRef.current = null;
2534
+ }
2535
+ }
2536
+
2537
+ function cancelArdy() {
2538
+ ardyAbortRef.current?.abort();
2539
+ }
2540
+
2541
+ const models = mode === "video" ? VIDEO_MODELS : IMAGE_MODELS;
2542
+
2543
+ return (
2544
+ <div className={"app" + (renderActive ? "" : " render-idle")}>
2545
+ <header className="topbar">
2546
+ <div className="logo">
2547
+ <span className="wordmark">
2548
+ Cozy <span>Clay</span>
2549
+ </span>
2550
+ </div>
2551
+
2552
+ </header>
2553
+
2554
+ <div className="main" style={workspaceStyle}>
2555
+ <div className="workspace">
2556
+ <aside className="panel hierarchy-left" aria-label="Hierarchy">
2557
+ <HierarchyPanel
2558
+ selectedId={selectedHierarchyId}
2559
+ onSelect={selectHierarchy}
2560
+ showB={showB}
2561
+ motionFrames={motion?.frames ?? 0}
2562
+ promptCount={promptClips.length}
2563
+ ikFrames={ikFrames.length}
2564
+ ikMode={ikMode}
2565
+ waypointCount={waypoints.length}
2566
+ sceneObjects={sceneObjects}
2567
+ onAddObject={addSceneObject}
2568
+ onRenameObject={renameSceneObject}
2569
+ onDuplicateObject={duplicateSelectedSceneObject}
2570
+ onDeleteObject={deleteSceneObject}
2571
+ onFrameObject={frameSelection}
2572
+ />
2573
+ </aside>
2574
+ <div
2575
+ className="workspace-splitter workspace-splitter-vertical"
2576
+ role="separator"
2577
+ aria-label="Resize hierarchy panel"
2578
+ onPointerDown={(event) => beginWorkspaceResize("hierarchy", event)}
2579
+ />
2580
+ <div className="viewport">
2581
+ <div className="pane-tabs" role="tablist" aria-label="Center view">
2582
+ <button
2583
+ type="button"
2584
+ role="tab"
2585
+ aria-selected={centerTab === "scene"}
2586
+ className={centerTab === "scene" ? "active" : ""}
2587
+ onClick={() => setCenterTab("scene")}
2588
+ >
2589
+ Scene
2590
+ </button>
2591
+ <button
2592
+ type="button"
2593
+ role="tab"
2594
+ aria-selected={centerTab === "play"}
2595
+ className={centerTab === "play" ? "active" : ""}
2596
+ onClick={() => setCenterTab("play")}
2597
+ >
2598
+ PlayView
2599
+ </button>
2600
+ </div>
2601
+ {centerTab === "scene" && (
2602
+ <div className="editor-toolbar scene-tools" aria-label="Scene tools">
2603
+ <div className="tool-switch" role="group" aria-label="Gizmo tool">
2604
+ <button
2605
+ type="button"
2606
+ className={gizmoMode === "move" ? "active" : ""}
2607
+ title="Move tool (W)"
2608
+ aria-pressed={gizmoMode === "move"}
2609
+ onClick={() => setGizmoMode("move")}
2610
+ >
2611
+ <svg viewBox="0 0 16 16" aria-hidden="true" className="tool-icon"><path d="M8 1v14M1 8h14" stroke="currentColor" strokeWidth="1.4"/><path d="M8 1 6 3h4L8 1zM8 15l-2-2h4l-2 2zM1 8l2-2v4L1 8zM15 8l-2-2v4l2-2z" fill="currentColor"/></svg>
2612
+ Move
2613
+ </button>
2614
+ <button
2615
+ type="button"
2616
+ className={gizmoMode === "rotate" ? "active" : ""}
2617
+ title="Rotate tool (E)"
2618
+ aria-pressed={gizmoMode === "rotate"}
2619
+ onClick={() => setGizmoMode("rotate")}
2620
+ >
2621
+ <svg viewBox="0 0 16 16" aria-hidden="true" className="tool-icon"><circle cx="8" cy="8" r="5.4" fill="none" stroke="currentColor" strokeWidth="1.4"/><path d="M13.4 8l2-2v4l-2 2z" fill="currentColor" transform="rotate(45 13.4 8)"/></svg>
2622
+ Rotate
2623
+ </button>
2624
+ <button
2625
+ type="button"
2626
+ className={gizmoMode === "scale" ? "active" : ""}
2627
+ title="Scale tool (R)"
2628
+ aria-pressed={gizmoMode === "scale"}
2629
+ onClick={() => setGizmoMode("scale")}
2630
+ >
2631
+ <svg viewBox="0 0 16 16" aria-hidden="true" className="tool-icon"><rect x="3" y="3" width="7" height="7" fill="none" stroke="currentColor" strokeWidth="1.4"/><path d="M13 13h-4M13 13V9M13 13l-3.5-3.5" stroke="currentColor" strokeWidth="1.4" fill="none"/></svg>
2632
+ Scale
2633
+ </button>
2634
+ </div>
2635
+ <button
2636
+ type="button"
2637
+ className={"snap-switch" + (snapEnabled ? " active" : "")}
2638
+ title="Grid snapping — hold Ctrl during a drag to invert"
2639
+ aria-pressed={snapEnabled}
2640
+ onClick={() => setSnapEnabled((v) => !v)}
2641
+ >
2642
+ Snap
2643
+ </button>
2644
+ </div>
2645
+ )}
2646
+
2647
+ <div className="stage" id="stage" ref={stageRef} data-render-loop={renderActive ? "always" : "demand"}>
2648
+ <Canvas frameloop={renderActive ? "always" : "demand"} dpr={[1, 2]} gl={{ preserveDrawingBuffer: true, antialias: true }}>
2649
+ <RenderLoopController stageRef={stageRef} />
2650
+ <ViewportLayoutInvalidator
2651
+ insetX={insetPos?.x ?? null}
2652
+ insetY={insetPos?.y ?? null}
2653
+ insetWidth={workspaceLayout.insetWidth}
2654
+ insetHeight={workspaceLayout.insetHeight}
2655
+ hierarchyWidth={workspaceLayout.hierarchyWidth}
2656
+ sidebarWidth={workspaceLayout.sidebarWidth}
2657
+ timelineHeight={workspaceLayout.timelineHeight}
2658
+ planZoom={workspaceLayout.planZoom}
2659
+ />
2660
+ <color attach="background" args={["#eef4f3"]} />
2661
+ <StageLights />
2662
+ <Room />
2663
+ <SetProps objects={sceneObjects} selectedId={selectedSceneObjectId} />
2664
+
2665
+ <PerspectiveCamera
2666
+ ref={shotCamRef}
2667
+ makeDefault={!ikMode}
2668
+ fov={fovDeg}
2669
+ near={0.1}
2670
+ far={100}
2671
+ position={[0.97, 1.62, 2.39]}
2672
+ />
2673
+ {/* the poser camera is the default (event/raycast) camera in
2674
+ IK mode so handle hit-testing matches what you see */}
2675
+ <PerspectiveCamera
2676
+ ref={poserCamRef}
2677
+ makeDefault={ikMode}
2678
+ fov={fovDeg}
2679
+ near={0.1}
2680
+ far={100}
2681
+ position={[0.97, 1.62, 2.39]}
2682
+ />
2683
+ {/* a plan is a plan: orthographic, framed to the working area, so
2684
+ pucks stay the same size wherever they sit */}
2685
+ <OrthographicCamera
2686
+ ref={planCamRef}
2687
+ near={0.1}
2688
+ far={80}
2689
+ position={[0, 24, 0]}
2690
+ rotation={[-Math.PI / 2, 0, 0]}
2691
+ />
2692
+
2693
+ <Character
2694
+ url={CHARACTER_MODEL_URL}
2695
+ position={motion ? [motion.anchorX, 0, motion.anchorZ] : [charA.x, 0, charA.z]}
2696
+ rot={motion ? motion.rotationDeg : charA.rot}
2697
+ tint={CLAY}
2698
+ pose={motion ? null : poseA}
2699
+ onRig={setRigA}
2700
+ pickId="A"
2701
+ />
2702
+ {showB && (
2703
+ <Character
2704
+ url={CHARACTER_MODEL_URL}
2705
+ position={[charB.x, 0, charB.z]}
2706
+ rot={charB.rot}
2707
+ tint={CLAY_B}
2708
+ pose={poseB}
2709
+ onRig={setRigB}
2710
+ pickId="B"
2711
+ />
2712
+ )}
2713
+
2714
+ <ShotRig
2715
+ preset={preset}
2716
+ nonce={nonce}
2717
+ fovDeg={fovDeg}
2718
+ charA={charA}
2719
+ charB={charB}
2720
+ showB={showB}
2721
+ probeX={motionPos ? motionPos.x : charA.x}
2722
+ probeZ={motionPos ? motionPos.z : charA.z}
2723
+ camRef={shotCamRef}
2724
+ look={look}
2725
+ onMetrics={(p, visible) => {
2726
+ setCameraPos((prev) =>
2727
+ Math.abs(prev.x - p.x) + Math.abs(prev.y - p.y) + Math.abs(prev.z - p.z) > 1e-4
2728
+ ? { x: p.x, y: p.y, z: p.z }
2729
+ : prev,
2730
+ );
2731
+ setSubjectVisible((prev) => (prev === visible ? prev : visible));
2732
+ }}
2733
+ />
2734
+ <MoveRig
2735
+ playing={movePlaying}
2736
+ // Authoring modes own the viewport: placing or dragging a root
2737
+ // waypoint scrubs the playhead as a side effect, and follow
2738
+ // must not turn that scrub into a camera lurch. Same for IK
2739
+ // and pose studio, where the shot camera is deliberately frozen.
2740
+ // PlayView is the finished-output player: the move always rides
2741
+ // the playhead there. The Follow toggle and authoring-mode gates
2742
+ // only protect the Scene tab's manipulation surfaces.
2743
+ // A follow cam owns the shot camera outright; keys resume when it is off.
2744
+ following={!followCamActive && cameraKeys.length >= 1 && (centerTab === "play" || (moveFollow && !ikMode && !waypointMode && !posing))}
2745
+ followFrame={tlFrame}
2746
+ fps={tlFps}
2747
+ keys={cameraKeys}
2748
+ anchor={charA}
2749
+ camRef={shotCamRef}
2750
+ look={look}
2751
+ isInterrupted={() => flyingRef.current}
2752
+ onDone={(finalFov) => {
2753
+ setMovePlaying(false);
2754
+ setFovDeg(Math.round(finalFov * 10) / 10);
2755
+ }}
2756
+ />
2757
+ <FollowCamRig
2758
+ enabled={followCamActive && !movePlaying}
2759
+ frame={tlFrame}
2760
+ track={followTrack}
2761
+ camRef={shotCamRef}
2762
+ look={look}
2763
+ isInterrupted={() => flyingRef.current}
2764
+ />
2765
+ {/* Camera stays live in IK mode but drives the POSER camera,
2766
+ never the shot camera: the handle layer only consumes
2767
+ pointerdowns that hit a handle, so empty-space drags orbit
2768
+ and the wheel dollies without wrecking the framing. */}
2769
+ <FlyControls
2770
+ enabled={!posing && !playMode}
2771
+ camRef={ikMode ? poserCamRef : shotCamRef}
2772
+ look={ikMode ? poserLook : look}
2773
+ getPivot={() => {
2774
+ if (!selectedSceneObject) return null;
2775
+ const size = objectSize(selectedSceneObject);
2776
+ return { x: selectedSceneObject.x, y: (selectedSceneObject.y ?? 0) + size.height / 2, z: selectedSceneObject.z };
2777
+ }}
2778
+ onFlyStateChange={(flying) => {
2779
+ flyingRef.current = flying;
2780
+ }}
2781
+ />
2782
+ <PoseHandles
2783
+ root={posing === "B" ? rigB : rigA}
2784
+ enabled={!!posing && !planIsMain && !playMode}
2785
+ onChange={() => setPoseTick((n) => n + 1)}
2786
+ />
2787
+ <IkHandles
2788
+ chains={ikChains}
2789
+ fkJoints={ikFkJoints}
2790
+ ikState={ikStateRef.current}
2791
+ enabled={ikMode && !posing && !playMode}
2792
+ focus={ikFocus}
2793
+ onFocus={focusIkHandle}
2794
+ onSolve={ikSolve}
2795
+ onDragEnd={ikDragEnd}
2796
+ />
2797
+ <PlanBoard
2798
+ hostRef={planHostRef}
2799
+ planCamRef={planCamRef}
2800
+ shotCamRef={shotCamRef}
2801
+ look={look}
2802
+ fovDeg={fovDeg}
2803
+ charA={charA}
2804
+ setCharA={setCharA}
2805
+ charB={charB}
2806
+ setCharB={setCharB}
2807
+ showB={showB}
2808
+ waypoints={waypoints}
2809
+ activeWaypointFrame={activeWaypointFrame}
2810
+ onSelectWaypoint={(frame) => { setActiveWaypointFrame(frame); setTlFrame(Math.min(frame, tlFrameCount - 1)); setWaypointMode(true); }}
2811
+ // The bird's-eye board displays and selects; placing and moving
2812
+ // waypoints live on the Shot-view floor now, so no move handler.
2813
+ // Selection switch first, then the producer begins its
2814
+ // transaction (plan §6.4): the settle here commits any
2815
+ // previously open drag as one entry so the fresh token
2816
+ // issued by onObjectMoveStart cannot leak.
2817
+ onSelectEntity={(id) => {
2818
+ store.settle();
2819
+ setSelectedHierarchyId(id.startsWith("object:") ? id : id === "cam" ? "camera" : id === "b" ? "characterB" : "characterA");
2820
+ }}
2821
+ sceneObjects={sceneObjects}
2822
+ selectedSceneObjectId={selectedSceneObjectId}
2823
+ onMoveSceneObject={changeSceneObject}
2824
+ onObjectMoveStart={beginSceneTransaction}
2825
+ onObjectMoveEnd={endSceneTransaction}
2826
+ cameraRailPoints={railCurve ? railCurve.points : null}
2827
+ railDraw={railDraw}
2828
+ onRailStroke={(stroke) => {
2829
+ const simplified = simplifyStroke(stroke, 0.12);
2830
+ if (simplified.length < 2) return;
2831
+ setCameraRail(simplified);
2832
+ setRailDraw(false);
2833
+ const curve = buildRail(simplified);
2834
+ setToast(`Camera rail drawn — ${curve ? curve.length.toFixed(1) : "?"} m, ${simplified.length} control points`);
2835
+ }}
2836
+ />
2837
+ {/* Object gizmo: the shot pane's direct manipulation. Off while
2838
+ the plan owns the big pane (the pucks are the handles there)
2839
+ and while posing/IK owns the pointer. */}
2840
+ <ObjectGizmo
2841
+ object={selectedSceneObject}
2842
+ objects={sceneObjects}
2843
+ mode={gizmoMode}
2844
+ snap={snapEnabled}
2845
+ enabled={!planIsMain && !posing && !ikMode && !playMode}
2846
+ paneRef={mainPaneRef}
2847
+ camRef={shotCamRef}
2848
+ onChange={changeSceneObject}
2849
+ onDragStart={beginSceneTransaction}
2850
+ onDragEnd={endSceneTransaction}
2851
+ onSelect={(id) =>
2852
+ selectHierarchy(
2853
+ id === "char:A" ? "characterA" : id === "char:B" ? "characterB" : id ? `object:${id}` : "props",
2854
+ )
2855
+ }
2856
+ onGroundClick={waypointMode && !planIsMain ? addFloorWaypoint : undefined}
2857
+ />
2858
+ {/* Authoring chrome: the grid and pins belong to the Scene tab
2859
+ only — PlayView is the finished output and shows none of it. */}
2860
+ {centerTab === "scene" && <SceneGrid />}
2861
+ {centerTab === "scene" && railCurve && <CameraRailScenePreview points={railCurve.points} />}
2862
+ {waypointMode && centerTab === "scene" && (
2863
+ <ShotPathPreview waypoints={waypoints} start={charA} activeWaypointFrame={activeWaypointFrame} />
2864
+ )}
2865
+ <CaptureRig apiRef={captureRef} camRef={shotCamRef} />
2866
+ <DualRender
2867
+ stageRef={stageRef}
2868
+ mainRef={mainPaneRef}
2869
+ insetRef={insetPaneRef}
2870
+ shotCamRef={shotCamRef}
2871
+ planCamRef={planCamRef}
2872
+ poserCamRef={poserCamRef}
2873
+ ikMode={ikMode}
2874
+ planIsMain={planIsMain}
2875
+ playMode={playMode}
2876
+ insetCollapsed={workspaceLayout.insetCollapsed}
2877
+ planZoom={workspaceLayout.planZoom}
2878
+ />
2879
+ </Canvas>
2880
+
2881
+ <div ref={mainPaneRef} className={"vp-pane vp-main" + (planIsMain ? " plan" : "")} />
2882
+ <div
2883
+ ref={insetPaneRef}
2884
+ hidden={playMode}
2885
+ className={"vp-pane vp-inset" + (planIsMain || ikMode ? " shot" : " plan") + (workspaceLayout.insetCollapsed ? " collapsed" : "")}
2886
+ style={insetPos ? { left: insetPos.x, top: insetPos.y, right: "auto" } : undefined}
2887
+ >
2888
+ <span
2889
+ className="vp-inset-tag"
2890
+ title={workspaceLayout.insetCollapsed ? "Click or ▸ to expand · drag to move" : "Click or ▾ to fold · drag to move"}
2891
+ onPointerDown={beginInsetDrag}
2892
+ >
2893
+ <span
2894
+ className="vp-inset-caret"
2895
+ role="button"
2896
+ tabIndex={-1}
2897
+ aria-expanded={!workspaceLayout.insetCollapsed}
2898
+ aria-label={workspaceLayout.insetCollapsed ? "Expand inset view" : "Collapse inset view"}
2899
+ title={workspaceLayout.insetCollapsed ? "Expand inset view" : "Collapse inset view"}
2900
+ onPointerDown={(e) => e.stopPropagation()}
2901
+ // Same one-fold-per-gesture rule as the tag: ignore the
2902
+ // second click of a double-click (detail=2).
2903
+ onClick={(e) => {
2904
+ if (e.detail > 1) return;
2905
+ insetToggledAtRef.current = Date.now();
2906
+ if (workspaceLayout.insetCollapsed) expandInset();
2907
+ else setWorkspaceLayout((current) => ({ ...current, insetCollapsed: true }));
2908
+ }}
2909
+ >
2910
+ {workspaceLayout.insetCollapsed ? "▸" : "▾"}
2911
+ </span>
2912
+ {planIsMain || ikMode ? "Shot view" : "Top-View"}
2913
+ {!planIsMain && !ikMode && workspaceLayout.planZoom !== 1 && !workspaceLayout.insetCollapsed && (
2914
+ <em className="vp-inset-zoom">{workspaceLayout.planZoom.toFixed(2).replace(/\.?0+$/, "")}×</em>
2915
+ )}
2916
+ </span>
2917
+ {!workspaceLayout.insetCollapsed && (
2918
+ <span
2919
+ className="vp-inset-resize"
2920
+ role="separator"
2921
+ aria-label="Resize inset view"
2922
+ onPointerDown={beginInsetResize}
2923
+ />
2924
+ )}
2925
+ </div>
2926
+
2927
+ <div className="film-frame" hidden={playMode}>
2928
+ <span />
2929
+ <span />
2930
+ <span />
2931
+ <span />
2932
+ </div>
2933
+ <div className={"caption" + (subjectVisible ? "" : " off")} hidden={playMode}>
2934
+ {subjectVisible ? slateLine(shot) : "SUBJECT OUT OF FRAME"}
2935
+ </div>
2936
+
2937
+ {playMode && !motion && (
2938
+ <div className="playview-empty" role="status">
2939
+ <strong>No motion yet</strong>
2940
+ <span>Generate motion in the Scene tab — PlayView plays the finished result.</span>
2941
+ </div>
2942
+ )}
2943
+
2944
+ {posing && (
2945
+ <PoseStudioPanel
2946
+ subject={posing === "B" ? 2 : 1}
2947
+ poses={allPoses}
2948
+ selectedId={studioPick}
2949
+ closing={posingClosing}
2950
+ onSelect={setStudioPick}
2951
+ onApply={() => {
2952
+ const pose = allPoses.find((p) => p.id === studioPick);
2953
+ if (pose) setPosed(pose);
2954
+ }}
2955
+ onReset={() => {
2956
+ setStudioPick(DEFAULT_POSE.id);
2957
+ setPosed(DEFAULT_POSE);
2958
+ }}
2959
+ onSave={savePose}
2960
+ onDelete={removePose}
2961
+ onClose={closeStudio}
2962
+ />
2963
+ )}
2964
+ </div>
2965
+ </div>
2966
+
2967
+ <div
2968
+ className="workspace-splitter workspace-splitter-vertical"
2969
+ role="separator"
2970
+ aria-label="Resize hierarchy and inspector panel"
2971
+ onPointerDown={(event) => beginWorkspaceResize("sidebar", event)}
2972
+ />
2973
+ <aside className="panel hierarchy-sidebar inspector-sidebar" data-inspector={selectedHierarchyId}>
2974
+ {/* Save failures live above the tab content, not inside the Props
2975
+ card: that card is hidden whenever any hierarchy node is
2976
+ selected, and saves fire exactly while objects are being
2977
+ edited — the one case where a failure line inside it is
2978
+ invisible. As a sibling of the tab panes this line stays
2979
+ on screen for every selection and every tab until the
2980
+ next successful write clears it (plan §8.4); the one-shot
2981
+ toast still announces each failure episode. */}
2982
+ {sceneSaveError && (
2983
+ <p className="scene-save-error" role="status">
2984
+ {sceneSaveError}
2985
+ </p>
2986
+ )}
2987
+ <section className="inspector-pane">
2988
+ <div className="inspector-heading">
2989
+ <strong>Inspector</strong>
2990
+ <span className="inspector-heading-selection">{selectedSceneObject?.name ?? HIERARCHY_INSPECTOR_TITLES[selectedHierarchyId] ?? "Selection"}</span>
2991
+ </div>
2992
+ <div className="inspector-scroll">
2993
+ <Foldout hidden={selectedHierarchyId !== "shot"} title="Shot type">
2994
+ <div className="presets">
2995
+ {Object.entries(PRESETS).map(([key, p]) => (
2996
+ <button key={key} className={preset === key ? "active" : ""} onClick={() => applyPreset(key)}>
2997
+ {p.label}
2998
+ </button>
2999
+ ))}
3000
+ </div>
3001
+ </Foldout>
3002
+
3003
+ <Foldout hidden={selectedHierarchyId !== "camera"} title="Camera">
3004
+ <Slider label="Lens (FOV)" min={14} max={90} step={1} value={fovDeg} unit="°" onChange={setFovDeg} />
3005
+ <div className="readout">
3006
+ <span title="camera to subject">{shot.distance.toFixed(2)} m</span>
3007
+ <span title="nearest prime on a full-frame sensor">{shot.focalMm} mm</span>
3008
+ <span title="angle relative to the subject's eyes">{shot.elevationDeg.toFixed(0)}°</span>
3009
+ </div>
3010
+ <button className="btn ghost" onClick={() => setNonce((n) => n + 1)}>
3011
+ Recenter on subject
3012
+ </button>
3013
+
3014
+ <h3 className="move-head">Move keys</h3>
3015
+ <div className="move-ab">
3016
+ <button
3017
+ type="button"
3018
+ className="btn ghost"
3019
+ title="Key the current framing at the playhead frame"
3020
+ onClick={() => addCameraKeyframe(tlFrame)}
3021
+ >
3022
+ + Key here
3023
+ </button>
3024
+ <button
3025
+ type="button"
3026
+ className="btn ghost"
3027
+ disabled={cameraKeys.length < 1}
3028
+ title="Play the move. With Follow on it plays the timeline too, so character motion rides along; right-drag interrupts"
3029
+ onClick={() => {
3030
+ if (followPreviewArmed) {
3031
+ if (tlPlaying) {
3032
+ setTlPlaying(false);
3033
+ return;
3034
+ }
3035
+ setTlFrame(0);
3036
+ setTlPlaying(true);
3037
+ return;
3038
+ }
3039
+ setMovePlaying((playing) => !playing);
3040
+ }}
3041
+ >
3042
+ {previewActive ? "Stop" : "Preview"}
3043
+ </button>
3044
+ <button
3045
+ type="button"
3046
+ className="btn ghost"
3047
+ disabled={cameraKeys.length < 1}
3048
+ title="Slave the move to the timeline: play or scrub and the camera rides along. Turn off to fly freely while keys stay set"
3049
+ onClick={() => setMoveFollow((follow) => !follow)}
3050
+ >
3051
+ {moveFollow ? "Follow ✓" : "Follow"}
3052
+ </button>
3053
+ <button type="button" className="btn ghost" disabled={cameraKeys.length < 1} onClick={clearMove}>
3054
+ Clear
3055
+ </button>
3056
+ <button
3057
+ type="button"
3058
+ className={"btn ghost" + (recState === "recording" ? " rec-live" : "")}
3059
+ disabled={cameraKeys.length < 1 && !motion}
3060
+ title="Play the piece in PlayView and save it as a video file — camera move and character motion, no editor chrome"
3061
+ onClick={toggleShotRecording}
3062
+ >
3063
+ {recState === "recording" ? "■ Stop rec" : "● Record"}
3064
+ </button>
3065
+ </div>
3066
+ {moveSequence ? (
3067
+ <div className="move-slate" title="derived from the keyframings, not chosen from a list">
3068
+ {moveSequence.slate} · {moveSequence.spanS}s
3069
+ </div>
3070
+ ) : (
3071
+ <div className="move-slate">
3072
+ {cameraKeys.length === 1
3073
+ ? `locked-off hold from frame ${cameraKeys[0].frame} — click the Camera lane at another frame to add a move`
3074
+ : "click the Camera timeline lane to key the current framing at that frame"}
3075
+ </div>
3076
+ )}
3077
+
3078
+ <h3 className="move-head">Follow cam</h3>
3079
+ <div className="move-ab">
3080
+ <button
3081
+ type="button"
3082
+ className="btn ghost"
3083
+ title="Derive the camera from the subject's trajectory: a steadicam trailing behind, or a dolly on a drawn rail. Keys pause while it owns the camera"
3084
+ onClick={() => setFollowCam((c) => ({ ...c, enabled: !c.enabled }))}
3085
+ >
3086
+ {followCam.enabled ? "Follow ●" : "Follow cam"}
3087
+ </button>
3088
+ <button
3089
+ type="button"
3090
+ className={"btn ghost" + (railDraw ? " rec-live" : "")}
3091
+ disabled={!followCam.enabled}
3092
+ title="Draw the camera rail in the Top-View: drag one stroke across the deck. Esc mid-stroke cancels"
3093
+ onClick={() => {
3094
+ const next = !railDraw;
3095
+ setRailDraw(next);
3096
+ if (next) {
3097
+ setWorkspaceLayout((current) => ({ ...current, insetCollapsed: false }));
3098
+ setToast("Draw the rail in the Top-View — drag one stroke across the deck");
3099
+ }
3100
+ }}
3101
+ >
3102
+ {railDraw ? "Drawing…" : "✏ Rail"}
3103
+ </button>
3104
+ <button type="button" className="btn ghost" disabled={!cameraRail} onClick={() => setCameraRail(null)}>
3105
+ Clear rail
3106
+ </button>
3107
+ </div>
3108
+ {followCam.enabled && (
3109
+ <>
3110
+ <Slider label="Distance" min={1} max={8} step={0.1} value={followCam.distance} unit=" m" onChange={(distance) => setFollowCam((c) => ({ ...c, distance }))} />
3111
+ <Slider label="Height" min={0.4} max={4} step={0.05} value={followCam.height} unit=" m" onChange={(height) => setFollowCam((c) => ({ ...c, height }))} />
3112
+ <Slider label="Response" min={0.2} max={2} step={0.05} value={followCam.response} unit=" s" onChange={(response) => setFollowCam((c) => ({ ...c, response }))} />
3113
+ <Slider label="Lead" min={0} max={0.6} step={0.05} value={followCam.lead} unit=" s" onChange={(lead) => setFollowCam((c) => ({ ...c, lead }))} />
3114
+ <div className="move-slate" title="what the rig is doing, derived from the setup — a rail makes it a dolly, none makes it a steadicam">
3115
+ {railCurve ? `DOLLY ON RAIL · ${railCurve.length.toFixed(1)} m` : "STEADICAM FOLLOW"} · HOLD {followCam.distance.toFixed(1)} m
3116
+ </div>
3117
+ </>
3118
+ )}
3119
+ </Foldout>
3120
+
3121
+ <Foldout hidden={!["characters", "characterA", "characterA.character", "characterB", "characterB.character"].includes(selectedHierarchyId)} title={showB ? "Subjects" : "Subject"}>
3122
+ <div className={"subjects-row" + (showB ? "" : " single")}>
3123
+ <SubjectBox
3124
+ label="Subject 1"
3125
+ value={charA}
3126
+ onChange={setCharA}
3127
+ onPose={() => openStudio("A")}
3128
+ posing={posing === "A"}
3129
+ />
3130
+ {showB && (
3131
+ <SubjectBox
3132
+ label="Subject 2"
3133
+ value={charB}
3134
+ onChange={setCharB}
3135
+ onRemove={() => setShowB(false)}
3136
+ onPose={() => openStudio("B")}
3137
+ posing={posing === "B"}
3138
+ />
3139
+ )}
3140
+ </div>
3141
+ {!showB && (
3142
+ <button type="button" className="add-subject" onClick={() => setShowB(true)}>
3143
+ <span className="as-plus">+</span>
3144
+ <span>Add second subject</span>
3145
+ </button>
3146
+ )}
3147
+ </Foldout>
3148
+
3149
+ <Foldout hidden={!["characters", "characterA", "characterA.character", "characterB", "characterB.character"].includes(selectedHierarchyId)} title="Pose">
3150
+ <Field label={showB ? "Subject 1 pose" : "Pose"}>
3151
+ <Dropdown
3152
+ ariaLabel="Subject 1 pose"
3153
+ value={poseA?.id}
3154
+ options={allPoses.map((p) => ({ value: p.id, label: p.label }))}
3155
+ onChange={(id) => setPoseA(allPoses.find((p) => p.id === id))}
3156
+ />
3157
+ </Field>
3158
+ {showB && (
3159
+ <Field label="Subject 2 pose">
3160
+ <Dropdown
3161
+ ariaLabel="Subject 2 pose"
3162
+ value={poseB?.id}
3163
+ options={allPoses.map((p) => ({ value: p.id, label: p.label }))}
3164
+ onChange={(id) => setPoseB(allPoses.find((p) => p.id === id))}
3165
+ />
3166
+ </Field>
3167
+ )}
3168
+ </Foldout>
3169
+
3170
+ <Foldout hidden={selectedHierarchyId !== "shot"} title="Prompt">
3171
+ <div className="segmented" data-active={mode}>
3172
+ <button className={mode === "image" ? "active" : ""} onClick={() => setMode("image")}>
3173
+ Image
3174
+ </button>
3175
+ <button className={mode === "video" ? "active" : ""} onClick={() => setMode("video")}>
3176
+ Video
3177
+ </button>
3178
+ </div>
3179
+ <Field label="Model">
3180
+ <Dropdown
3181
+ ariaLabel="Model"
3182
+ value={mode === "video" ? videoModel : imageModel}
3183
+ options={models.map((m) => ({ value: m.id, label: m.label }))}
3184
+ onChange={mode === "video" ? setVideoModel : setImageModel}
3185
+ />
3186
+ </Field>
3187
+
3188
+ <div className="sheet-checks">
3189
+ <label className="check">
3190
+ <input type="checkbox" checked={hasCharSheet} onChange={(e) => setHasCharSheet(e.target.checked)} />
3191
+ <span>I have a character sheet</span>
3192
+ </label>
3193
+ <label className="check">
3194
+ <input type="checkbox" checked={hasEnvSheet} onChange={(e) => setHasEnvSheet(e.target.checked)} />
3195
+ <span>I have an environment sheet</span>
3196
+ </label>
3197
+ </div>
3198
+
3199
+ {!hasCharSheet && (
3200
+ <Field label={showB ? "Subject 1" : "Subject"}>
3201
+ <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} />
3202
+ </Field>
3203
+ )}
3204
+ {!hasCharSheet && showB && (
3205
+ <Field label="Subject 2">
3206
+ <input type="text" value={subject2} onChange={(e) => setSubject2(e.target.value)} />
3207
+ </Field>
3208
+ )}
3209
+ {!hasEnvSheet && (
3210
+ <Field label="Environment">
3211
+ <input type="text" value={environment} onChange={(e) => setEnvironment(e.target.value)} />
3212
+ </Field>
3213
+ )}
3214
+ {mode === "video" && !moveSequence && (
3215
+ <Field label="Camera move">
3216
+ <Dropdown
3217
+ ariaLabel="Camera move"
3218
+ value={cameraMove}
3219
+ options={CAMERA_MOVES.map((m) => ({ value: m, label: m }))}
3220
+ onChange={setCameraMove}
3221
+ />
3222
+ </Field>
3223
+ )}
3224
+ {mode === "video" && !moveSequence && cameraMove === CUSTOM_MOVE && (
3225
+ <Field label="Custom camera move">
3226
+ <input
3227
+ type="text"
3228
+ value={customMove}
3229
+ onChange={(e) => setCustomMove(e.target.value)}
3230
+ placeholder="describe the camera move"
3231
+ />
3232
+ </Field>
3233
+ )}
3234
+ {mode === "video" && moveSequence && (
3235
+ <Field label="Camera move">
3236
+ <div
3237
+ className="move-slate inline"
3238
+ title="authored from the timeline keyframings — select Camera in the hierarchy to edit"
3239
+ >
3240
+ {moveSequence.slate} · {moveSequence.spanS}s
3241
+ </div>
3242
+ </Field>
3243
+ )}
3244
+ <Field label="Look / style">
3245
+ <input type="text" value={style} onChange={(e) => setStyle(e.target.value)} />
3246
+ </Field>
3247
+
3248
+ <button
3249
+ className="btn ghost full"
3250
+ title="Export the posed character as an ARDY CozyClayPoseV1 JSON"
3251
+ onClick={downloadArdyPose}
3252
+ >
3253
+ Export ARDY pose
3254
+ </button>
3255
+ <button className="btn primary full generate" onClick={generate}>
3256
+ Generate
3257
+ </button>
3258
+ </Foldout>
3259
+ <Foldout hidden={!["characterA.motion", "characterA.baseMotion"].includes(selectedHierarchyId)} title="ARDY motion">
3260
+ {bridge === null ? (
3261
+ <p className="ardy-hint">Checking for the dev bridge…</p>
3262
+ ) : bridge.ok ? (
3263
+ <>
3264
+ <p className="ardy-meta">
3265
+ {bridge.host ?? "box"} · {bridge.device ?? "device?"} · encoder{" "}
3266
+ {bridge.encoder ?? "?"}
3267
+ </p>
3268
+ <p className="ardy-hint">Neutral skeleton · block boundaries and IK keys are pinned automatically.</p>
3269
+ <Field label="Motion prompt">
3270
+ <input
3271
+ type="text"
3272
+ value={ardyPrompt}
3273
+ onChange={(e) => setArdyPrompt(e.target.value)}
3274
+ placeholder="what the subject should do"
3275
+ maxLength={ARDY_PROMPT_MAX}
3276
+ />
3277
+ <span className="ardy-clamp">max {ARDY_PROMPT_MAX} chars · required</span>
3278
+ </Field>
3279
+ <Field label="Duration (s)">
3280
+ <input
3281
+ type="number"
3282
+ min={ARDY_DURATION_MIN}
3283
+ max={ARDY_DURATION_MAX}
3284
+ step={1}
3285
+ value={ardyDuration}
3286
+ onChange={(e) => changeDuration(e.target.value)}
3287
+ />
3288
+ </Field>
3289
+ <Field label="Seed (optional)">
3290
+ <input
3291
+ type="text"
3292
+ inputMode="numeric"
3293
+ value={ardySeed}
3294
+ onChange={(e) => changeArdySeed(e.target.value)}
3295
+ placeholder="empty = random"
3296
+ />
3297
+ <span className="ardy-clamp">RT default 2 · empty = random · 0..{ARDY_SEED_MAX}</span>
3298
+ </Field>
3299
+ {ardyRunning ? (
3300
+ <button type="button" className="btn ghost full" onClick={cancelArdy}>
3301
+ Cancel run
3302
+ </button>
3303
+ ) : (
3304
+ <button
3305
+ type="button"
3306
+ className="btn primary full generate"
3307
+ onClick={() => runArdy()}
3308
+ >
3309
+ Generate motion
3310
+ </button>
3311
+ )}
3312
+ {ardyStatus && <p className="ardy-status">{ardyStatus}</p>}
3313
+ {ardyReport && (
3314
+ <div className="ardy-report">
3315
+ <div className="ardy-report-grid">
3316
+ <span>shape mean error</span>
3317
+ <b>{fmtMeters(ardyReport.shape_mean_error_m)}</b>
3318
+ <span>shape max error</span>
3319
+ <b>{fmtMeters(ardyReport.shape_max_error_m)}</b>
3320
+ <span>max jump</span>
3321
+ <b>{fmtMeters(ardyReport.continuity?.max_jump_m)}</b>
3322
+ </div>
3323
+ <p className="ardy-caveat">
3324
+ Shape error proves joint-center placement only —{" "}
3325
+ {ardyReport.surface_contact_verified
3326
+ ? "contact verified"
3327
+ : "foot-to-floor contact NOT verified"}{" "}
3328
+ · target_space {ardyReport.target_space ?? "unknown"}
3329
+ </p>
3330
+ </div>
3331
+ )}
3332
+ {ardyOutcome?.ok && (
3333
+ <>
3334
+ <p className="ardy-outcome done">
3335
+ Output <code>{ardyOutcome.output}</code> ({ardyOutcome.bytes} bytes)
3336
+ {ardyOutcome.motionUrl && (
3337
+ <>
3338
+ {" "}
3339
+ · motion <code>{ardyOutcome.motionUrl}</code>
3340
+ </>
3341
+ )}
3342
+ </p>
3343
+ {motionBusy ? (
3344
+ <p className="ardy-hint">Decoding motion…</p>
3345
+ ) : motion ? (
3346
+ <p className="ardy-outcome done">
3347
+ Motion loaded — {motion.frames} frames @ {motion.fps} fps, playing on Subject 1
3348
+ </p>
3349
+ ) : motionError ? (
3350
+ <>
3351
+ <p className="ardy-outcome error">Motion decode failed: {motionError}</p>
3352
+ {ardyOutcome.motionUrl && (
3353
+ <button
3354
+ type="button"
3355
+ className="btn ghost full"
3356
+ onClick={() => loadMotion(ardyOutcome.motionUrl, undefined, ardyOutcome.rotationDeg ?? charA.rot)}
3357
+ >
3358
+ Retry load
3359
+ </button>
3360
+ )}
3361
+ </>
3362
+ ) : null}
3363
+ </>
3364
+ )}
3365
+ {ardyOutcome && !ardyOutcome.ok && (
3366
+ <p className="ardy-outcome error">{ardyOutcome.message}</p>
3367
+ )}
3368
+ </>
3369
+ ) : (
3370
+ <>
3371
+ <p className="ardy-hint">
3372
+ Motion generation runs on your own machine, so it is off here. The clip
3373
+ on the timeline was generated ahead of time; staging, paths, cameras and
3374
+ playback all work without it. To generate your own, clone the repo and
3375
+ start the bridge with <code>node tools/ardy/bridge.mjs</code>.
3376
+ </p>
3377
+ {bridge.reason && <p className="ardy-hint">{bridge.reason}</p>}
3378
+ </>
3379
+ )}
3380
+ </Foldout>
3381
+ <Foldout hidden={selectedHierarchyId !== "characterA.promptBlocks"} title="Prompt Blocks">
3382
+ <p className="inspector-hint">Blocks define what ARDY generates over each frame range. Selecting one also moves editing context to that prompt.</p>
3383
+ <div className="inspector-list">
3384
+ {promptClips.map((clip) => (
3385
+ <button
3386
+ type="button"
3387
+ key={clip.id}
3388
+ className={selectedPromptId === clip.id ? "active" : ""}
3389
+ onClick={() => {
3390
+ setSelectedPromptId(clip.id);
3391
+ setArdyPrompt(clip.text);
3392
+ setTlFrame(Math.min(clip.startFrame, tlFrameCount - 1));
3393
+ }}
3394
+ >
3395
+ <span>{clip.text || "Untitled motion"}</span>
3396
+ <small>{clip.startFrame}–{clip.endFrame}f</small>
3397
+ </button>
3398
+ ))}
3399
+ </div>
3400
+ {selectedPromptId && (
3401
+ <Field label="Selected block prompt">
3402
+ <input
3403
+ type="text"
3404
+ value={promptClips.find((clip) => clip.id === selectedPromptId)?.text ?? ""}
3405
+ onChange={(event) => {
3406
+ changePromptClip(selectedPromptId, event.target.value);
3407
+ setArdyPrompt(event.target.value);
3408
+ }}
3409
+ placeholder="describe this motion block"
3410
+ />
3411
+ </Field>
3412
+ )}
3413
+ <button
3414
+ type="button"
3415
+ className="btn primary full generate prompt-block-generate"
3416
+ disabled={!bridge?.ok || !promptClips.some((clip) => clip.text.trim()) || ardyRunning}
3417
+ onClick={runAllPromptBlocks}
3418
+ >
3419
+ {ardyRunning
3420
+ ? `Generating ${promptClips.length} blocks…`
3421
+ : `Generate all ${promptClips.length} blocks`}
3422
+ </button>
3423
+ {!bridge?.ok && <p className="ardy-hint">Start the ARDY bridge to enable generation.</p>}
3424
+ {ardyStatus && <p className="ardy-status">{ardyStatus}</p>}
3425
+ <button type="button" className="btn ghost full" onClick={() => addPromptClip(tlFrame)}>
3426
+ Add block at frame {tlFrame}
3427
+ </button>
3428
+ </Foldout>
3429
+
3430
+ <Foldout hidden={selectedHierarchyId !== "characterA.ik"} title="IK Corrections">
3431
+ <div className="inspector-status-grid">
3432
+ <span>IK mode</span><b>{ikMode ? "ON" : "OFF"}</b>
3433
+ <span>Current frame</span><b>{tlFrame}</b>
3434
+ <span>Keys</span><b>{ikFrames.length}</b>
3435
+ </div>
3436
+ <button type="button" className={"btn full" + (ikMode ? " primary" : "")} onClick={toggleIkMode} disabled={!ikChains}>
3437
+ {ikMode ? "Exit IK mode" : "Enter IK mode"}
3438
+ </button>
3439
+ <label className="check">
3440
+ <input type="checkbox" checked={footSnap} onChange={() => setFootSnap((value) => !value)} />
3441
+ <span>Keep feet planted during body edits</span>
3442
+ </label>
3443
+ <button type="button" className="btn ghost full" onClick={ikAddKeyframe} disabled={!ikChains}>
3444
+ Add key at frame {tlFrame}
3445
+ </button>
3446
+ <div className="inspector-list compact">
3447
+ {ikFrames.map((frame) => (
3448
+ <button
3449
+ type="button"
3450
+ key={frame}
3451
+ className={tlFrame === frame ? "active" : ""}
3452
+ onClick={() => setTlFrame(frame)}
3453
+ onContextMenu={(event) => {
3454
+ event.preventDefault();
3455
+ ikDeleteKeyframe(frame);
3456
+ }}
3457
+ >
3458
+ <span>Frame {frame}</span>
3459
+ <small>{tlFrame === frame ? "Current" : "right-click removes"}</small>
3460
+ </button>
3461
+ ))}
3462
+ </div>
3463
+ </Foldout>
3464
+
3465
+ <Foldout hidden={!(selectedHierarchyId === "characterA.rig" || selectedHierarchyId.startsWith("rig."))} title="Rig Control">
3466
+ <p className="inspector-hint">
3467
+ {selectedHierarchyId.startsWith("rig.")
3468
+ ? `${HIERARCHY_INSPECTOR_TITLES[selectedHierarchyId]} is the active control group.`
3469
+ : "Choose a body group in the hierarchy, then manipulate its handle in the main view."}
3470
+ </p>
3471
+ <div className="inspector-status-grid">
3472
+ <span>Rig</span><b>{ikChains ? "Ready" : "Unavailable"}</b>
3473
+ <span>Focus</span><b>{ikFocus ?? "None"}</b>
3474
+ <span>Foot lock</span><b>{footSnap ? "ON" : "OFF"}</b>
3475
+ </div>
3476
+ <button type="button" className={"btn full" + (ikMode ? " primary" : "")} onClick={toggleIkMode} disabled={!ikChains}>
3477
+ {ikMode ? "Finish rig editing" : "Edit rig with IK"}
3478
+ </button>
3479
+ </Foldout>
3480
+
3481
+ <Foldout hidden={selectedHierarchyId !== "rootPath"} title="Root Path">
3482
+ <div className="inspector-status-grid">
3483
+ <span>Path mode</span><b>{waypointMode ? "ON" : "OFF"}</b>
3484
+ <span>Waypoints</span><b>{waypoints.length}</b>
3485
+ <span>Current frame</span><b>{tlFrame}</b>
3486
+ </div>
3487
+ <button type="button" className={"btn full" + (waypointMode ? " primary" : "")} onClick={toggleWaypointMode}>
3488
+ {waypointMode ? "Finish path editing" : "Edit root path"}
3489
+ </button>
3490
+ {waypointMode && (
3491
+ <p className="inspector-hint">Click the set floor in the Shot view to drop the next waypoint. Scrub the playhead past the last pin first to choose its exact frame; left alone, time between pins follows walking distance.</p>
3492
+ )}
3493
+ <div className="inspector-list compact">
3494
+ {waypoints.map((waypoint) => (
3495
+ <button
3496
+ type="button"
3497
+ key={waypoint.frame}
3498
+ className={activeWaypointFrame === waypoint.frame ? "active" : ""}
3499
+ onClick={() => {
3500
+ setActiveWaypointFrame(waypoint.frame);
3501
+ setTlFrame(waypoint.frame);
3502
+ setWaypointMode(true);
3503
+ }}
3504
+ onContextMenu={(event) => {
3505
+ event.preventDefault();
3506
+ removeWaypoint(waypoint.frame);
3507
+ }}
3508
+ >
3509
+ <span>Frame {waypoint.frame}</span>
3510
+ <small>{waypoint.x.toFixed(2)}, {waypoint.z.toFixed(2)}</small>
3511
+ </button>
3512
+ ))}
3513
+ </div>
3514
+ </Foldout>
3515
+
3516
+ <Foldout hidden={selectedHierarchyId !== "environment"} title="Environment">
3517
+ <label className="check">
3518
+ <input type="checkbox" checked={hasEnvSheet} onChange={(event) => setHasEnvSheet(event.target.checked)} />
3519
+ <span>I have an environment sheet</span>
3520
+ </label>
3521
+ {!hasEnvSheet && (
3522
+ <Field label="Environment description">
3523
+ <input type="text" value={environment} onChange={(event) => setEnvironment(event.target.value)} />
3524
+ </Field>
3525
+ )}
3526
+ <Field label="Look / style">
3527
+ <input type="text" value={style} onChange={(event) => setStyle(event.target.value)} />
3528
+ </Field>
3529
+ </Foldout>
3530
+
3531
+ <Foldout hidden={selectedHierarchyId !== "props"} title="Props">
3532
+ <p className="inspector-hint">Everything you add to the set lives here. Pick one to edit it, or click it in the shot view.</p>
3533
+ <AddObjectMenu onAdd={addSceneObject} label="Add object to the set" />
3534
+ <div className="inspector-list compact">
3535
+ {sceneObjects.map((object) => (
3536
+ <button
3537
+ type="button"
3538
+ key={object.id}
3539
+ onClick={() => selectHierarchy(`object:${object.id}`)}
3540
+ >
3541
+ <span>{object.name}</span>
3542
+ <small>{object.renderer}</small>
3543
+ </button>
3544
+ ))}
3545
+ </div>
3546
+ </Foldout>
3547
+
3548
+ <Foldout hidden={!selectedSceneObject} title="Object Transform">
3549
+ {selectedSceneObject && (
3550
+ <>
3551
+ <p className="inspector-hint">
3552
+ Type a value and press Enter, or drag an axis letter to scrub.
3553
+ </p>
3554
+ <div className="presets gizmo-modes">
3555
+ <button type="button" className={gizmoMode === "move" ? "active" : ""} onClick={() => setGizmoMode("move")}>
3556
+ Move <kbd>W</kbd>
3557
+ </button>
3558
+ <button type="button" className={gizmoMode === "rotate" ? "active" : ""} onClick={() => setGizmoMode("rotate")}>
3559
+ Rotate <kbd>E</kbd>
3560
+ </button>
3561
+ <button type="button" className={gizmoMode === "scale" ? "active" : ""} onClick={() => setGizmoMode("scale")}>
3562
+ Scale <kbd>R</kbd>
3563
+ </button>
3564
+ </div>
3565
+ <label className="check snap-toggle">
3566
+ <input type="checkbox" checked={snapEnabled} onChange={(event) => setSnapEnabled(event.target.checked)} />
3567
+ <span>Snap to grid — hold <kbd>Ctrl</kbd> to invert</span>
3568
+ </label>
3569
+ <Field label="Name">
3570
+ <input
3571
+ type="text"
3572
+ value={selectedSceneObject.name}
3573
+ onChange={(event) => changeSceneObject(selectedSceneObject.id, { name: event.target.value })}
3574
+ />
3575
+ </Field>
3576
+ <Vector3Row
3577
+ label="Position"
3578
+ fields={[
3579
+ { axis: "X", value: selectedSceneObject.x, step: 0.05, precision: 2, onChange: (x) => changeSceneObject(selectedSceneObject.id, { x }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3580
+ { axis: "Y", value: selectedSceneObject.y ?? 0, step: 0.05, precision: 2, onChange: (y) => changeSceneObject(selectedSceneObject.id, { y }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3581
+ { axis: "Z", value: selectedSceneObject.z, step: 0.05, precision: 2, onChange: (z) => changeSceneObject(selectedSceneObject.id, { z }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3582
+ ]}
3583
+ />
3584
+ <Vector3Row
3585
+ label="Rotation"
3586
+ fields={[
3587
+ { axis: "X", value: selectedSceneObject.rotX ?? 0, step: 1, precision: 1, onChange: (rotX) => changeSceneObject(selectedSceneObject.id, { rotX }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3588
+ { axis: "Y", value: selectedSceneObject.rot, step: 1, precision: 1, onChange: (rot) => changeSceneObject(selectedSceneObject.id, { rot }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3589
+ { axis: "Z", value: selectedSceneObject.rotZ ?? 0, step: 1, precision: 1, onChange: (rotZ) => changeSceneObject(selectedSceneObject.id, { rotZ }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3590
+ ]}
3591
+ />
3592
+ <Vector3Row
3593
+ label="Scale"
3594
+ fields={[
3595
+ { axis: "X", value: selectedSceneObject.scaleX ?? 1, step: 0.05, precision: 2, onChange: (scaleX) => changeSceneObject(selectedSceneObject.id, { scaleX }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3596
+ { axis: "Y", value: selectedSceneObject.scaleY ?? 1, step: 0.05, precision: 2, onChange: (scaleY) => changeSceneObject(selectedSceneObject.id, { scaleY }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3597
+ { axis: "Z", value: selectedSceneObject.scaleZ ?? 1, step: 0.05, precision: 2, onChange: (scaleZ) => changeSceneObject(selectedSceneObject.id, { scaleZ }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3598
+ ]}
3599
+ />
3600
+ <div className="object-colors" role="group" aria-label="Object colour">
3601
+ {OBJECT_COLORS.map((color) => (
3602
+ <button
3603
+ type="button"
3604
+ key={color}
3605
+ className={"object-color" + (selectedSceneObject.color === color ? " active" : "")}
3606
+ style={{ background: color }}
3607
+ aria-label={`Colour ${color}`}
3608
+ aria-pressed={selectedSceneObject.color === color}
3609
+ onClick={() => changeSceneObject(selectedSceneObject.id, { color })}
3610
+ />
3611
+ ))}
3612
+ </div>
3613
+ </>
3614
+ )}
3615
+ </Foldout>
3616
+ </div>
3617
+ {selectedSceneObject && (
3618
+ <div className="inspector-footer">
3619
+ <button
3620
+ type="button"
3621
+ className="btn ghost full"
3622
+ title="You can also press Delete or Backspace"
3623
+ onClick={deleteSelectedSceneObject}
3624
+ >
3625
+ Remove {selectedSceneObject.name}
3626
+ </button>
3627
+ </div>
3628
+ )}
3629
+ </section>
3630
+ </aside>
3631
+ </div>
3632
+
3633
+ <div
3634
+ className="workspace-splitter timeline-splitter"
3635
+ role="separator"
3636
+ aria-label="Resize frame monitor"
3637
+ onPointerDown={(event) => beginWorkspaceResize("timeline", event)}
3638
+ />
3639
+ <div className="bottom-window">
3640
+ <nav className="bottom-window-tabs" aria-label="Bottom window">
3641
+ <button
3642
+ type="button"
3643
+ className={bottomTab === "timeline" ? "active" : ""}
3644
+ aria-pressed={bottomTab === "timeline"}
3645
+ onClick={() => setBottomTab("timeline")}
3646
+ >
3647
+ Animation
3648
+ </button>
3649
+ <button
3650
+ type="button"
3651
+ className={bottomTab === "console" ? "active" : ""}
3652
+ aria-pressed={bottomTab === "console"}
3653
+ onClick={() => setBottomTab("console")}
3654
+ >
3655
+ Console
3656
+ </button>
3657
+ </nav>
3658
+ <div className="console-pane" hidden={bottomTab !== "console"}>
3659
+ {consoleLines.length === 0 ? (
3660
+ <p className="console-empty">No messages yet — ARDY status lines appear here.</p>
3661
+ ) : (
3662
+ consoleLines.map((entry, index) => (
3663
+ <p className="console-line" key={index}>
3664
+ <time>{entry.time.toLocaleTimeString()}</time>
3665
+ <span>{entry.line}</span>
3666
+ </p>
3667
+ ))
3668
+ )}
3669
+ </div>
3670
+ <div className="bottom-timeline" hidden={bottomTab !== "timeline"}>
3671
+ <Timeline
3672
+ frame={tlFrame}
3673
+ frameCount={tlFrameCount}
3674
+ fps={tlFps}
3675
+ playbackSpeed={DEFAULT_PLAYBACK_SPEED}
3676
+ playing={tlPlaying}
3677
+ waypointMode={waypointMode}
3678
+ waypointFrames={waypoints.map((w) => w.frame)}
3679
+ pathSpeed={pathSpeed}
3680
+ pendingWaypointFrame={activeWaypointFrame}
3681
+ promptClips={promptClips}
3682
+ selectedPromptId={selectedPromptId}
3683
+ badge={stateBadge}
3684
+ ikMode={ikMode}
3685
+ ikDisabled={!ikChains}
3686
+ ikFrames={ikFrames}
3687
+ footSnap={footSnap}
3688
+ cameraKeyFrames={cameraKeys.map((k) => k.frame)}
3689
+ onIkToggle={toggleIkMode}
3690
+ onIkKeyframeAdd={ikAddKeyframe}
3691
+ onIkKeyframeRemove={ikDeleteKeyframe}
3692
+ onFootSnapToggle={() => {
3693
+ setFootSnap((v) => {
3694
+ setToast(v ? "Foot snap off — the feet follow the body" : "Foot snap on — the feet stay planted while the body moves");
3695
+ return !v;
3696
+ });
3697
+ }}
3698
+ onScrub={setTlFrame}
3699
+ onAdvance={advanceFrame}
3700
+ onStep={stepFrame}
3701
+ onPlayToggle={() => setTlPlaying((v) => !v)}
3702
+ onWaypointToggle={toggleWaypointMode}
3703
+ onMarkerSelect={(f) => { setTlFrame(Math.min(f, tlFrameCount - 1)); setActiveWaypointFrame(f); setWaypointMode(true); setSelectedHierarchyId("rootPath"); }}
3704
+ onMarkerRemove={removeWaypoint}
3705
+ onRootKeyframeAdd={() => {
3706
+ setWaypointMode(true);
3707
+ setToast("Root path on — click the set floor in the Shot view to drop waypoints");
3708
+ }}
3709
+ onPromptAdd={addPromptClip}
3710
+ onPromptSelect={(id) => {
3711
+ setSelectedPromptId(id);
3712
+ setArdyPrompt(promptClips.find((clip) => clip.id === id)?.text ?? "");
3713
+ setSelectedHierarchyId("characterA.promptBlocks");
3714
+ }}
3715
+ onPromptChange={changePromptClip}
3716
+ onPromptResize={resizePromptClip}
3717
+ onPromptMove={movePromptClip}
3718
+ onPromptRemove={removePromptClip}
3719
+ onCameraMoveSelect={() => setSelectedHierarchyId("camera")}
3720
+ onCameraKeyframeAdd={addCameraKeyframe}
3721
+ onCameraKeyframeMove={moveCameraKeyframe}
3722
+ onCameraKeyframeRemove={removeCameraKeyframe}
3723
+ onClearMotion={motion ? clearMotion : null}
3724
+ />
3725
+ </div>
3726
+ </div>
3727
+ </div>
3728
+
3729
+ <footer className="brandbar">
3730
+ <span className="wordmark">
3731
+ Cozy <span>Clay</span>
3732
+ </span>
3733
+ </footer>
3734
+
3735
+ {result && (
3736
+ <div className="modal-overlay" onClick={() => setResult(null)}>
3737
+ <div className="modal" onClick={(e) => e.stopPropagation()}>
3738
+ <div className="modal-head">
3739
+ <h3>Your shot is ready</h3>
3740
+ <button className="x" onClick={() => setResult(null)}>
3741
+
3742
+ </button>
3743
+ </div>
3744
+ {result.frameB ? (
3745
+ <div className="move-frames">
3746
+ <figure>
3747
+ <img className="preview" src={result.frame} alt="move start frame" />
3748
+ <figcaption>A · start</figcaption>
3749
+ </figure>
3750
+ <figure>
3751
+ <img className="preview" src={result.frameB} alt="move end frame" />
3752
+ <figcaption>B · end</figcaption>
3753
+ </figure>
3754
+ </div>
3755
+ ) : (
3756
+ result.frame && <img className="preview" src={result.frame} alt="framed shot" />
3757
+ )}
3758
+ {result.move && <div className="move-slate">{result.move.slate} · {result.move.spanS}s</div>}
3759
+ <label className="modal-label">Prompt {copied && <em>· copied</em>}</label>
3760
+ <div className="promptbox">{result.prompt}</div>
3761
+ <div className="modal-actions">
3762
+ <button
3763
+ className="btn"
3764
+ onClick={() =>
3765
+ navigator.clipboard?.writeText(result.prompt).then(() => {
3766
+ setCopied(true);
3767
+ setToast("Prompt copied to clipboard");
3768
+ })
3769
+ }
3770
+ >
3771
+ {copied ? "Copied ✓" : "Copy prompt"}
3772
+ </button>
3773
+ {result.frame && (
3774
+ <button className="btn" onClick={download}>
3775
+ {result.frameB ? "Download start & end frames" : "Download frame"}
3776
+ </button>
3777
+ )}
3778
+ </div>
3779
+ </div>
3780
+ </div>
3781
+ )}
3782
+
3783
+ <Toast message={toast} onDone={() => setToast("")} />
3784
+ </div>
3785
+ );
3786
+ }
3787
+ /** ARDY report meters: missing/failed values render as an em dash, never NaN. */
3788
+ function fmtMeters(value) {
3789
+ return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(4)} m` : "—";
3790
+ }
3791
+
3792
+ /** Mid-clip frame of a base motion, the sensible default for the destination. */
3793
+
3794
+ /** Unity Inspector-style foldout: a titled section the user can collapse.
3795
+ * Cards default to open; the fold state is per-title session state. */
3796
+ function Foldout({ title, hidden, children }) {
3797
+ const [open, setOpen] = useState(true);
3798
+ return (
3799
+ <section className={"card foldout" + (open ? " open" : "")} hidden={hidden}>
3800
+ <h3>
3801
+ <button type="button" className="foldout-head" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
3802
+ <span className="foldout-arrow" aria-hidden="true">{open ? "\u25BE" : "\u25B8"}</span>
3803
+ <span className="foldout-title">{title}</span>
3804
+ </button>
3805
+ </h3>
3806
+ {open && <div className="foldout-body">{children}</div>}
3807
+ </section>
3808
+ );
3809
+ }
3810
+
3811
+ function SubjectBox({ label, value, onChange, onRemove, onPose, posing }) {
3812
+ const set = (key) => (v) => onChange((prev) => ({ ...prev, [key]: v }));
3813
+ return (
3814
+ <div className="subject-box">
3815
+ <div className="subject-box-head">
3816
+ <span className="sb-name">{label}</span>
3817
+ <div className="sb-actions">
3818
+ {onPose && (
3819
+ <button
3820
+ type="button"
3821
+ className={"cam-toggle" + (posing ? " active" : "")}
3822
+ title={`Pose ${label}`}
3823
+ onClick={onPose}
3824
+ >
3825
+
3826
+ </button>
3827
+ )}
3828
+ {onRemove && (
3829
+ <button type="button" className="sb-remove" title="Remove subject" onClick={onRemove}>
3830
+
3831
+ </button>
3832
+ )}
3833
+ </div>
3834
+ </div>
3835
+ <Slider compact label="Left / right" min={-4} max={4} step={0.1} value={value.x} onChange={set("x")} />
3836
+ <Slider compact label="Depth" min={-4} max={4} step={0.1} value={value.z} onChange={set("z")} />
3837
+ <Slider compact label="Rotate" min={-180} max={180} step={1} value={value.rot} unit="°" onChange={set("rot")} />
3838
+ </div>
3839
+ );
3840
+ }