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.
- package/LICENSE +674 -0
- package/README.md +109 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/cozyclay.mjs +194 -0
- package/dist/ardy/cskel27-rest.json +557 -0
- package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
- package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
- package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
- package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
- package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
- package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
- package/dist/assets/index-BW_S1YRy.js +4395 -0
- package/dist/assets/index-nRmmJgap.css +1 -0
- package/dist/demo/walk-then-stop.npz +0 -0
- package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
- package/dist/fonts/instrument-serif-latin.woff2 +0 -0
- package/dist/fonts/inter-latin.woff2 +0 -0
- package/dist/index.html +13 -0
- package/dist/models/x-bot-tpose.fbx +0 -0
- package/dist/models/y-bot-tpose.fbx +0 -0
- package/package.json +80 -0
- package/src/App.jsx +3840 -0
- package/src/ardy/client.js +140 -0
- package/src/ardy/convert.js +313 -0
- package/src/ardy/cskel27-neutral.js +39 -0
- package/src/ardy/cskel27.js +68 -0
- package/src/ardy/export.js +157 -0
- package/src/ardy/ik.js +610 -0
- package/src/ardy/npz.js +520 -0
- package/src/ardy/playback.js +414 -0
- package/src/ardy/prompt-clips.js +16 -0
- package/src/ardy/timeline-coordinates.js +17 -0
- package/src/ardy/timeline-resize.js +11 -0
- package/src/ardy/timeline.jsx +723 -0
- package/src/ardy/to-cskel27.js +205 -0
- package/src/ardy/waypoints.js +364 -0
- package/src/camera-follow.js +366 -0
- package/src/camera-move.js +286 -0
- package/src/controls.jsx +245 -0
- package/src/dualview.jsx +305 -0
- package/src/hierarchy-model.js +77 -0
- package/src/hierarchy-panel.jsx +365 -0
- package/src/history.js +90 -0
- package/src/main.jsx +10 -0
- package/src/object-catalog.jsx +90 -0
- package/src/object-gizmo.jsx +755 -0
- package/src/planview.jsx +653 -0
- package/src/poses.js +424 -0
- package/src/posestudio.jsx +811 -0
- package/src/props.jsx +302 -0
- package/src/room.jsx +64 -0
- package/src/scene-history.js +125 -0
- package/src/scene-objects.js +420 -0
- package/src/shot-authoring.js +113 -0
- package/src/shot.js +239 -0
- package/src/styles.css +5639 -0
- package/src/ui.jsx +391 -0
- package/src/use-render-activity.js +96 -0
- package/tools/ardy/BRIDGE.md +255 -0
- package/tools/ardy/README.md +136 -0
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +1427 -0
- package/tools/ardy/cclay_motion_edit.py +445 -0
- package/tools/ardy/cclay_sequence_generate.py +595 -0
- package/tools/ardy/dump-npz.py +205 -0
- package/tools/ardy/extract-rest.mjs +299 -0
- package/tools/ardy/npz.mjs +335 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/ardy/pose-to-npz.mjs +106 -0
- package/tools/ardy/run-edit-on-box.sh +73 -0
- package/tools/ardy/run-on-box.sh +568 -0
- package/tools/ardy/run-sequence-on-box.sh +162 -0
- package/tools/ardy/visual-qa.mjs +188 -0
- package/tools/ardy/vq-car.mjs +52 -0
- package/tools/dev-full.mjs +29 -0
- package/tools/process-supervisor.mjs +63 -0
- package/tools/qa-browser.mjs +84 -0
- package/tools/qa-crop3.mjs +22 -0
- package/tools/qa-playview.mjs +49 -0
- package/tools/qa-screenshot.mjs +24 -0
- 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
|
+
}
|