cozyclay 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/bin/cozyclay.mjs +194 -0
  5. package/dist/ardy/cskel27-rest.json +557 -0
  6. package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
  8. package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
  9. package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
  10. package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
  11. package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
  12. package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
  13. package/dist/assets/index-BW_S1YRy.js +4395 -0
  14. package/dist/assets/index-nRmmJgap.css +1 -0
  15. package/dist/demo/walk-then-stop.npz +0 -0
  16. package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
  17. package/dist/fonts/instrument-serif-latin.woff2 +0 -0
  18. package/dist/fonts/inter-latin.woff2 +0 -0
  19. package/dist/index.html +13 -0
  20. package/dist/models/x-bot-tpose.fbx +0 -0
  21. package/dist/models/y-bot-tpose.fbx +0 -0
  22. package/package.json +80 -0
  23. package/src/App.jsx +3840 -0
  24. package/src/ardy/client.js +140 -0
  25. package/src/ardy/convert.js +313 -0
  26. package/src/ardy/cskel27-neutral.js +39 -0
  27. package/src/ardy/cskel27.js +68 -0
  28. package/src/ardy/export.js +157 -0
  29. package/src/ardy/ik.js +610 -0
  30. package/src/ardy/npz.js +520 -0
  31. package/src/ardy/playback.js +414 -0
  32. package/src/ardy/prompt-clips.js +16 -0
  33. package/src/ardy/timeline-coordinates.js +17 -0
  34. package/src/ardy/timeline-resize.js +11 -0
  35. package/src/ardy/timeline.jsx +723 -0
  36. package/src/ardy/to-cskel27.js +205 -0
  37. package/src/ardy/waypoints.js +364 -0
  38. package/src/camera-follow.js +366 -0
  39. package/src/camera-move.js +286 -0
  40. package/src/controls.jsx +245 -0
  41. package/src/dualview.jsx +305 -0
  42. package/src/hierarchy-model.js +77 -0
  43. package/src/hierarchy-panel.jsx +365 -0
  44. package/src/history.js +90 -0
  45. package/src/main.jsx +10 -0
  46. package/src/object-catalog.jsx +90 -0
  47. package/src/object-gizmo.jsx +755 -0
  48. package/src/planview.jsx +653 -0
  49. package/src/poses.js +424 -0
  50. package/src/posestudio.jsx +811 -0
  51. package/src/props.jsx +302 -0
  52. package/src/room.jsx +64 -0
  53. package/src/scene-history.js +125 -0
  54. package/src/scene-objects.js +420 -0
  55. package/src/shot-authoring.js +113 -0
  56. package/src/shot.js +239 -0
  57. package/src/styles.css +5639 -0
  58. package/src/ui.jsx +391 -0
  59. package/src/use-render-activity.js +96 -0
  60. package/tools/ardy/BRIDGE.md +255 -0
  61. package/tools/ardy/README.md +136 -0
  62. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  63. package/tools/ardy/bridge.mjs +1427 -0
  64. package/tools/ardy/cclay_motion_edit.py +445 -0
  65. package/tools/ardy/cclay_sequence_generate.py +595 -0
  66. package/tools/ardy/dump-npz.py +205 -0
  67. package/tools/ardy/extract-rest.mjs +299 -0
  68. package/tools/ardy/npz.mjs +335 -0
  69. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  70. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  71. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  72. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  73. package/tools/ardy/pose-to-npz.mjs +106 -0
  74. package/tools/ardy/run-edit-on-box.sh +73 -0
  75. package/tools/ardy/run-on-box.sh +568 -0
  76. package/tools/ardy/run-sequence-on-box.sh +162 -0
  77. package/tools/ardy/visual-qa.mjs +188 -0
  78. package/tools/ardy/vq-car.mjs +52 -0
  79. package/tools/dev-full.mjs +29 -0
  80. package/tools/process-supervisor.mjs +63 -0
  81. package/tools/qa-browser.mjs +84 -0
  82. package/tools/qa-crop3.mjs +22 -0
  83. package/tools/qa-playview.mjs +49 -0
  84. package/tools/qa-screenshot.mjs +24 -0
  85. package/tools/qa-visual.mjs +51 -0
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Follow camera: a real crew in three pieces of math.
3
+ *
4
+ * A tracking shot on set is three jobs — the RAIL constrains where the camera
5
+ * may be, the DOLLY GRIP pushes along it to hold distance to the subject, and
6
+ * the OPERATOR pans to keep the framing. Keyframe interpolation can't play
7
+ * that scene: between keys it never looks at what the subject actually does,
8
+ * which is exactly why corners feel wrong. Here the camera is DERIVED from
9
+ * the subject's per-frame trajectory instead.
10
+ *
11
+ * Everything is integrated offline over the whole clip at a fixed dt, so
12
+ * scrubbing, playing backwards, PlayView and Record all see the same
13
+ * deterministic track — no wall-clock springs, no simulation state to
14
+ * desync. Same trajectory + same params = the same track, always.
15
+ *
16
+ * Conventions match camera-move.js / controls.jsx: Y-up metres, YXZ look
17
+ * angles with yaw = atan2(-dx, -dz).
18
+ */
19
+
20
+ /** critically damped response: reaches ~98% of a step in `response` seconds */
21
+ function omegaFor(response) {
22
+ return 4.6 / Math.max(response, 0.05);
23
+ }
24
+
25
+ /** one semi-implicit Euler step of a critically damped spring (scalar) */
26
+ function springStep(pos, vel, target, omega, dt) {
27
+ const acc = omega * omega * (target - pos) - 2 * omega * vel;
28
+ const nextVel = vel + acc * dt;
29
+ return [pos + nextVel * dt, nextVel];
30
+ }
31
+
32
+ function aimAngles(position, target) {
33
+ const dx = target.x - position.x;
34
+ const dy = target.y - position.y;
35
+ const dz = target.z - position.z;
36
+ return {
37
+ yaw: Math.atan2(-dx, -dz),
38
+ pitch: Math.atan2(dy, Math.max(Math.hypot(dx, dz), 1e-6)),
39
+ };
40
+ }
41
+
42
+ export const FOLLOW_DEFAULTS = {
43
+ /** metres the grip tries to hold between camera and subject */
44
+ distance: 3,
45
+ /** camera height in metres (a rail is 2D; height is the operator's) */
46
+ height: 1.6,
47
+ /** dolly response in seconds — larger = heavier, lazier camera */
48
+ response: 0.7,
49
+ /** operator aim response; a head pans faster than a dolly moves */
50
+ aimResponse: 0.35,
51
+ /** seconds of subject velocity the operator leads the frame by */
52
+ lead: 0.25,
53
+ /** where on the body the operator holds frame (chest, metres) */
54
+ aimHeight: 1.35,
55
+ /** hard cap on camera translation (m/s) — a crew has legs, not thrusters */
56
+ maxSpeed: 2.8,
57
+ /** EMA weight for the BEHIND direction; slow on purpose, so a corner
58
+ * sweeps the trailing position gradually and the camera cuts the corner
59
+ * the way a steadicam op does instead of whipping around the subject.
60
+ * Tuned with maxSpeed against a 90° corner walk: together they hold the
61
+ * worst pan under ~60°/s (3°/frame @20), a brisk but human pan. */
62
+ dirBlend: 0.05,
63
+ };
64
+
65
+ /**
66
+ * Subject travel directions, one unit XZ vector per frame. Velocity is
67
+ * EMA-smoothed so a foot-plant wobble doesn't wag the whole camera, and the
68
+ * last real direction is held through stops — a grip doesn't forget which
69
+ * way the actor was walking just because they paused.
70
+ */
71
+ export function travelDirections(subject, fps, initialDir = null, blend = 0.25) {
72
+ const dirs = [];
73
+ let dir = initialDir && Math.hypot(initialDir.x, initialDir.z) > 1e-6
74
+ ? normalize(initialDir)
75
+ : null;
76
+ let vx = 0;
77
+ let vz = 0;
78
+ for (let f = 0; f < subject.length; f += 1) {
79
+ if (f > 0) {
80
+ vx += ((subject[f].x - subject[f - 1].x) * fps - vx) * blend;
81
+ vz += ((subject[f].z - subject[f - 1].z) * fps - vz) * blend;
82
+ }
83
+ if (Math.hypot(vx, vz) > 0.15) dir = normalize({ x: vx, z: vz });
84
+ if (!dir) {
85
+ // nothing has moved yet: probe the first future step so the camera
86
+ // starts behind the walk instead of snapping when it begins
87
+ for (let probe = f + 1; probe < subject.length; probe += 1) {
88
+ const dx = subject[probe].x - subject[f].x;
89
+ const dz = subject[probe].z - subject[f].z;
90
+ if (Math.hypot(dx, dz) > 0.05) {
91
+ dir = normalize({ x: dx, z: dz });
92
+ break;
93
+ }
94
+ }
95
+ if (!dir) dir = { x: 0, z: 1 };
96
+ }
97
+ dirs.push(dir);
98
+ }
99
+ return dirs;
100
+ }
101
+
102
+ function normalize(v) {
103
+ const len = Math.max(Math.hypot(v.x, v.z), 1e-9);
104
+ return { x: v.x / len, z: v.z / len };
105
+ }
106
+
107
+ /** smoothed subject velocities (m/s), the operator's lead signal */
108
+ function smoothedVelocities(subject, fps) {
109
+ const out = [];
110
+ let vx = 0;
111
+ let vz = 0;
112
+ const blend = 0.25;
113
+ for (let f = 0; f < subject.length; f += 1) {
114
+ if (f > 0) {
115
+ vx += ((subject[f].x - subject[f - 1].x) * fps - vx) * blend;
116
+ vz += ((subject[f].z - subject[f - 1].z) * fps - vz) * blend;
117
+ }
118
+ out.push({ x: vx, z: vz });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Free follow (no rail): steadicam behind the subject. The position target
125
+ * sits `distance` metres behind the smoothed travel direction; the camera
126
+ * spring-chases it, the aim spring-chases a lead point. Returns one
127
+ * {pos, yaw, pitch} per subject frame.
128
+ */
129
+ export function buildFollowTrack(subject, fps, params = {}) {
130
+ const p = { ...FOLLOW_DEFAULTS, ...params };
131
+ if (!subject || subject.length === 0) return [];
132
+ const dt = 1 / Math.max(fps, 1);
133
+ const dirs = travelDirections(subject, fps, p.initialDir ?? null, p.dirBlend);
134
+ const vels = smoothedVelocities(subject, fps);
135
+ const omega = omegaFor(p.response);
136
+ const aimOmega = omegaFor(p.aimResponse);
137
+ // a critically damped spring trails a moving target by 2v/ω at steady
138
+ // state; feeding the subject's velocity forward cancels that lag, so the
139
+ // held distance is the REQUESTED distance, not distance-plus-lag
140
+ const lagComp = 2 / omega;
141
+
142
+ // start settled on the frame-0 target: a shot opens composed, not sliding
143
+ let px = subject[0].x - dirs[0].x * p.distance;
144
+ let pz = subject[0].z - dirs[0].z * p.distance;
145
+ let py = p.height;
146
+ let vx = 0, vz = 0, vy = 0;
147
+ let ax = subject[0].x;
148
+ let az = subject[0].z;
149
+ let avx = 0, avz = 0;
150
+
151
+ const track = [];
152
+ for (let f = 0; f < subject.length; f += 1) {
153
+ if (f > 0) {
154
+ const tx = subject[f].x - dirs[f].x * p.distance + vels[f].x * lagComp;
155
+ const tz = subject[f].z - dirs[f].z * p.distance + vels[f].z * lagComp;
156
+ // planar spring integrated by hand so the SPEED cap binds the
157
+ // velocity vector, not each axis separately
158
+ vx += (omega * omega * (tx - px) - 2 * omega * vx) * dt;
159
+ vz += (omega * omega * (tz - pz) - 2 * omega * vz) * dt;
160
+ const speed = Math.hypot(vx, vz);
161
+ if (speed > p.maxSpeed) {
162
+ vx *= p.maxSpeed / speed;
163
+ vz *= p.maxSpeed / speed;
164
+ }
165
+ px += vx * dt;
166
+ pz += vz * dt;
167
+ [py, vy] = springStep(py, vy, p.height, omega, dt);
168
+ const aimTx = subject[f].x + vels[f].x * p.lead;
169
+ const aimTz = subject[f].z + vels[f].z * p.lead;
170
+ [ax, avx] = springStep(ax, avx, aimTx, aimOmega, dt);
171
+ [az, avz] = springStep(az, avz, aimTz, aimOmega, dt);
172
+ }
173
+ const pos = { x: px, y: py, z: pz };
174
+ const { yaw, pitch } = aimAngles(pos, { x: ax, y: p.aimHeight, z: az });
175
+ track.push({ pos, yaw, pitch });
176
+ }
177
+ return track;
178
+ }
179
+
180
+ /* ------------------------------------------------------------ the rail --- */
181
+
182
+ /**
183
+ * Ramer–Douglas–Peucker: an authored stroke is hundreds of jittery pointer
184
+ * samples; the rail wants the handful of points that carry its shape.
185
+ */
186
+ export function simplifyStroke(points, epsilon = 0.12) {
187
+ if (!points || points.length <= 2) return points ? [...points] : [];
188
+ const keep = new Array(points.length).fill(false);
189
+ keep[0] = keep[points.length - 1] = true;
190
+ const stack = [[0, points.length - 1]];
191
+ while (stack.length) {
192
+ const [a, b] = stack.pop();
193
+ const pa = points[a];
194
+ const pb = points[b];
195
+ const abx = pb.x - pa.x;
196
+ const abz = pb.z - pa.z;
197
+ const abLen = Math.max(Math.hypot(abx, abz), 1e-9);
198
+ let worst = 0;
199
+ let worstIdx = -1;
200
+ for (let i = a + 1; i < b; i += 1) {
201
+ const d = Math.abs((points[i].x - pa.x) * abz - (points[i].z - pa.z) * abx) / abLen;
202
+ if (d > worst) {
203
+ worst = d;
204
+ worstIdx = i;
205
+ }
206
+ }
207
+ if (worst > epsilon && worstIdx > 0) {
208
+ keep[worstIdx] = true;
209
+ stack.push([a, worstIdx], [worstIdx, b]);
210
+ }
211
+ }
212
+ return points.filter((_, i) => keep[i]);
213
+ }
214
+
215
+ /**
216
+ * Centripetal Catmull–Rom through the simplified control points, sampled at
217
+ * ~`spacing` metres. Returns { points, cumLen, length } — a dense polyline
218
+ * with cumulative arc length, the coordinate system the grip pushes in.
219
+ */
220
+ export function buildRail(controlPoints, { spacing = 0.05 } = {}) {
221
+ const cps = (controlPoints ?? []).filter(
222
+ (p, i, arr) => i === 0 || Math.hypot(p.x - arr[i - 1].x, p.z - arr[i - 1].z) > 1e-6,
223
+ );
224
+ if (cps.length < 2) return null;
225
+ const pts = [cps[0]];
226
+ const alpha = 0.5; // centripetal: no loops or overshoot at tight corners
227
+ for (let i = 0; i < cps.length - 1; i += 1) {
228
+ const p0 = cps[Math.max(i - 1, 0)];
229
+ const p1 = cps[i];
230
+ const p2 = cps[i + 1];
231
+ const p3 = cps[Math.min(i + 2, cps.length - 1)];
232
+ const segLen = Math.hypot(p2.x - p1.x, p2.z - p1.z);
233
+ const steps = Math.max(2, Math.ceil(segLen / spacing));
234
+ const t0 = 0;
235
+ const t1 = t0 + Math.hypot(p1.x - p0.x, p1.z - p0.z) ** alpha || t0 + 1e-6;
236
+ const t2 = t1 + segLen ** alpha;
237
+ const t3 = t2 + Math.hypot(p3.x - p2.x, p3.z - p2.z) ** alpha || t2 + 1e-6;
238
+ for (let s = 1; s <= steps; s += 1) {
239
+ const t = t1 + ((t2 - t1) * s) / steps;
240
+ pts.push(catmullRomPoint(p0, p1, p2, p3, t0, t1, t2, t3, t));
241
+ }
242
+ }
243
+ const cumLen = [0];
244
+ for (let i = 1; i < pts.length; i += 1) {
245
+ cumLen.push(cumLen[i - 1] + Math.hypot(pts[i].x - pts[i - 1].x, pts[i].z - pts[i - 1].z));
246
+ }
247
+ return { points: pts, cumLen, length: cumLen[cumLen.length - 1] };
248
+ }
249
+
250
+ function catmullRomPoint(p0, p1, p2, p3, t0, t1, t2, t3, t) {
251
+ const lerp2 = (a, b, ta, tb) => {
252
+ const w = tb - ta < 1e-9 ? 0 : (t - ta) / (tb - ta);
253
+ return { x: a.x + (b.x - a.x) * w, z: a.z + (b.z - a.z) * w };
254
+ };
255
+ const a1 = lerp2(p0, p1, t0, t1);
256
+ const a2 = lerp2(p1, p2, t1, t2);
257
+ const a3 = lerp2(p2, p3, t2, t3);
258
+ const b1 = lerp2(a1, a2, t0, t2);
259
+ const b2 = lerp2(a2, a3, t1, t3);
260
+ return lerp2(b1, b2, t1, t2);
261
+ }
262
+
263
+ /** point on the rail at arc position s (clamped) */
264
+ export function railPoint(rail, s) {
265
+ const { points, cumLen, length } = rail;
266
+ const target = Math.max(0, Math.min(s, length));
267
+ let lo = 0;
268
+ let hi = cumLen.length - 1;
269
+ while (hi - lo > 1) {
270
+ const mid = (lo + hi) >> 1;
271
+ if (cumLen[mid] <= target) lo = mid;
272
+ else hi = mid;
273
+ }
274
+ const span = cumLen[hi] - cumLen[lo];
275
+ const w = span < 1e-9 ? 0 : (target - cumLen[lo]) / span;
276
+ return {
277
+ x: points[lo].x + (points[hi].x - points[lo].x) * w,
278
+ z: points[lo].z + (points[hi].z - points[lo].z) * w,
279
+ };
280
+ }
281
+
282
+ /** arc position of the sample nearest the given point, searched globally */
283
+ function nearestS(rail, point) {
284
+ let best = 0;
285
+ let bestD = Infinity;
286
+ for (let i = 0; i < rail.points.length; i += 1) {
287
+ const d = Math.hypot(rail.points[i].x - point.x, rail.points[i].z - point.z);
288
+ if (d < bestD) {
289
+ bestD = d;
290
+ best = rail.cumLen[i];
291
+ }
292
+ }
293
+ return best;
294
+ }
295
+
296
+ /**
297
+ * Rail follow: the camera lives ON the drawn rail; each frame the grip finds
298
+ * the nearby arc position whose distance to the subject is closest to
299
+ * `distance` and spring-pushes toward it (speed-capped — a dolly has mass).
300
+ * The operator aims exactly as in the free follow. Local search only: the
301
+ * dolly never teleports across the stage to a globally better spot.
302
+ */
303
+ export function buildRailFollowTrack(subject, fps, rail, params = {}) {
304
+ const p = { ...FOLLOW_DEFAULTS, maxDollySpeed: 4, searchWindow: 2.5, backtrackPenalty: 1.0, ...params };
305
+ if (!subject || subject.length === 0 || !rail || rail.length < 1e-6) return [];
306
+ const dt = 1 / Math.max(fps, 1);
307
+ const vels = smoothedVelocities(subject, fps);
308
+ const omega = omegaFor(p.response);
309
+ const aimOmega = omegaFor(p.aimResponse);
310
+ const step = Math.max(rail.length / Math.max(rail.points.length - 1, 1), 1e-3);
311
+
312
+ const distanceErrorAt = (s, subj) => {
313
+ const rp = railPoint(rail, s);
314
+ return Math.abs(Math.hypot(rp.x - subj.x, rp.z - subj.z) - p.distance);
315
+ };
316
+ // Retreat is taxed: when the subject closes in, a real grip concedes
317
+ // distance rather than sprinting the dolly backwards — the backwards
318
+ // whip-pan looks far worse on screen than a temporarily short distance.
319
+ const bestSNear = (s0, subj, window, penalty) => {
320
+ let best = s0;
321
+ let bestErr = distanceErrorAt(s0, subj);
322
+ for (let ds = step; ds <= window; ds += step) {
323
+ for (const cand of [s0 + ds, s0 - ds]) {
324
+ if (cand < 0 || cand > rail.length) continue;
325
+ const err = distanceErrorAt(cand, subj) + penalty * Math.max(0, s0 - cand);
326
+ if (err < bestErr - 1e-9) {
327
+ bestErr = err;
328
+ best = cand;
329
+ }
330
+ }
331
+ }
332
+ return best;
333
+ };
334
+
335
+ // open settled: the whole rail is searched once (no penalty — there is
336
+ // no established mark yet) for the best opening position
337
+ let s = bestSNear(nearestS(rail, subject[0]), subject[0], rail.length, 0);
338
+ let sVel = 0;
339
+ let py = p.height;
340
+ let vy = 0;
341
+ let ax = subject[0].x;
342
+ let az = subject[0].z;
343
+ let avx = 0, avz = 0;
344
+
345
+ const track = [];
346
+ for (let f = 0; f < subject.length; f += 1) {
347
+ if (f > 0) {
348
+ const sTarget = bestSNear(s, subject[f], p.searchWindow, p.backtrackPenalty);
349
+ [s, sVel] = springStep(s, sVel, sTarget, omega, dt);
350
+ const cap = p.maxDollySpeed;
351
+ if (sVel > cap) sVel = cap;
352
+ if (sVel < -cap) sVel = -cap;
353
+ s = Math.max(0, Math.min(s, rail.length));
354
+ [py, vy] = springStep(py, vy, p.height, omega, dt);
355
+ const aimTx = subject[f].x + vels[f].x * p.lead;
356
+ const aimTz = subject[f].z + vels[f].z * p.lead;
357
+ [ax, avx] = springStep(ax, avx, aimTx, aimOmega, dt);
358
+ [az, avz] = springStep(az, avz, aimTz, aimOmega, dt);
359
+ }
360
+ const rp = railPoint(rail, s);
361
+ const pos = { x: rp.x, y: py, z: rp.z };
362
+ const { yaw, pitch } = aimAngles(pos, { x: ax, y: p.aimHeight, z: az });
363
+ track.push({ pos, yaw, pitch, s });
364
+ }
365
+ return track;
366
+ }
@@ -0,0 +1,286 @@
1
+ // Pure math + string assembly. No three.js, no React.
2
+ // A camera move is two framings and a duration — nothing else. The authoring
3
+ // model is "compose the shot twice": the director frames A, frames B, and this
4
+ // module interpolates like a dolly grip instead of a graphics library. It also
5
+ // names the move from the geometry, so the prompt claims "push-in" only when
6
+ // the camera actually pushes in.
7
+
8
+ import { FRAMING_PIVOT_Y, SUBJECT_HEIGHT_M, deriveShot, focalMmToFov, fovToFocalMm } from "./shot.js";
9
+
10
+ /* ------------------------------------------------------------ framing --- */
11
+
12
+ /**
13
+ * Snapshot the shot camera as a plain framing record.
14
+ * yaw/pitch follow the YXZ free-look convention in controls.jsx.
15
+ * @param {{pos:{x:number,y:number,z:number}, yaw:number, pitch:number, fovDeg:number}} state
16
+ */
17
+ export function captureFraming({ pos, yaw, pitch, fovDeg }) {
18
+ return { pos: { x: pos.x, y: pos.y, z: pos.z }, yaw, pitch, fovDeg };
19
+ }
20
+
21
+ // yaw/pitch -> unit forward vector. Mirrors forwardFrom() in controls.jsx,
22
+ // re-stated here so this module stays importable without three.js.
23
+ function forward(yaw, pitch) {
24
+ const cp = Math.cos(pitch);
25
+ return { x: -Math.sin(yaw) * cp, y: Math.sin(pitch), z: -Math.cos(yaw) * cp };
26
+ }
27
+
28
+ // position + target -> the yaw/pitch that looks at the target. Mirrors aimAt().
29
+ function aimAngles(position, target) {
30
+ const dx = target.x - position.x;
31
+ const dy = target.y - position.y;
32
+ const dz = target.z - position.z;
33
+ return {
34
+ yaw: Math.atan2(-dx, -dz),
35
+ pitch: Math.atan2(dy, Math.max(Math.hypot(dx, dz), 1e-6)),
36
+ };
37
+ }
38
+
39
+ const TWO_PI = Math.PI * 2;
40
+
41
+ /** signed shortest angular distance a -> b, in (-PI, PI] */
42
+ export function shortestArc(a, b) {
43
+ let delta = (b - a) % TWO_PI;
44
+ if (delta > Math.PI) delta -= TWO_PI;
45
+ if (delta <= -Math.PI) delta += TWO_PI;
46
+ return delta;
47
+ }
48
+
49
+ const clamp01 = (t) => Math.max(0, Math.min(1, t));
50
+ const lerp = (a, b, t) => a + (b - a) * t;
51
+
52
+ /** the classic film ease: slow out of A, slow into B */
53
+ export function easeInOut(t) {
54
+ return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
55
+ }
56
+
57
+ /**
58
+ * Decompose a framing into subject-centred move coordinates: horizontal
59
+ * distance, azimuth around the subject, lens height, and the world point the
60
+ * lens is aimed at (sampled at the subject's range, so an interpolated orbit
61
+ * keeps looking where the framing did instead of at empty air).
62
+ */
63
+ function decompose(framing, anchor) {
64
+ const dx = framing.pos.x - anchor.x;
65
+ const dz = framing.pos.z - anchor.z;
66
+ const r = Math.max(Math.hypot(dx, dz), 1e-6);
67
+ // pos = anchor + r * (sin az, cos az): the same convention ShotRig presets use
68
+ const azimuth = Math.atan2(dx, dz);
69
+ const range = Math.max(Math.hypot(r, framing.pos.y - FRAMING_PIVOT_Y), 1e-6);
70
+ const dir = forward(framing.yaw, framing.pitch);
71
+ const aim = {
72
+ x: framing.pos.x + dir.x * range,
73
+ y: framing.pos.y + dir.y * range,
74
+ z: framing.pos.z + dir.z * range,
75
+ };
76
+ return { r, azimuth, height: framing.pos.y, aim, focalMm: fovToFocalMm((framing.fovDeg * Math.PI) / 180) };
77
+ }
78
+
79
+ /**
80
+ * The heart of the tool. Interpolating raw positions drives an orbit through
81
+ * the chord — straight at the subject's face — so the blend runs in
82
+ * subject-centred coordinates instead: distance, azimuth, and height each
83
+ * ease independently, and azimuth takes the short way around. The lens
84
+ * interpolates in focal millimetres, which is the space a zoom ring moves in.
85
+ *
86
+ * @param {object} a start framing (captureFraming record)
87
+ * @param {object} b end framing
88
+ * @param {{x:number,z:number}} anchor subject ground position
89
+ * @param {number} t 0..1 along the move
90
+ * @param {(t:number)=>number} ease
91
+ * @returns a framing record for time t; t=0 and t=1 reproduce A and B exactly
92
+ */
93
+ export function interpolateFraming(a, b, anchor, t, ease = easeInOut) {
94
+ const k = ease(clamp01(t));
95
+ const A = decompose(a, anchor);
96
+ const B = decompose(b, anchor);
97
+ const r = lerp(A.r, B.r, k);
98
+ const azimuth = A.azimuth + shortestArc(A.azimuth, B.azimuth) * k;
99
+ const height = lerp(A.height, B.height, k);
100
+ const pos = {
101
+ x: anchor.x + r * Math.sin(azimuth),
102
+ y: height,
103
+ z: anchor.z + r * Math.cos(azimuth),
104
+ };
105
+ const aim = {
106
+ x: lerp(A.aim.x, B.aim.x, k),
107
+ y: lerp(A.aim.y, B.aim.y, k),
108
+ z: lerp(A.aim.z, B.aim.z, k),
109
+ };
110
+ const { yaw, pitch } = aimAngles(pos, aim);
111
+ const focalMm = lerp(A.focalMm, B.focalMm, k);
112
+ return { pos, yaw, pitch, fovDeg: (focalMmToFov(focalMm) * 180) / Math.PI };
113
+ }
114
+
115
+ /* ----------------------------------------------------------- naming ---- */
116
+
117
+ const DEG = 180 / Math.PI;
118
+
119
+ // Below these deltas the camera is, cinematically speaking, not doing that.
120
+ const STILL_POS_M = 0.12;
121
+ const STILL_ANGLE_DEG = 6;
122
+ const DOLLY_M = 0.3;
123
+ const CRANE_M = 0.4;
124
+ const ORBIT_DEG = 20;
125
+ const ZOOM_MM = 10;
126
+ const VERTIGO_MM = 8;
127
+ const VERTIGO_SIZE_DRIFT = 0.15; // dolly-zoom = distance changes, subject size does not
128
+
129
+ /**
130
+ * Name the move from the geometry. The CAMERA_MOVES list in shot.js is what a
131
+ * user *claims*; this is what the two framings *prove*. The phrase slots into
132
+ * composePrompt's "Camera move: ${...}." sentence.
133
+ *
134
+ * @param {object} a start framing
135
+ * @param {object} b end framing
136
+ * @param {{x:number,z:number,rot:number}} subject
137
+ * @param {{durationS?:number, height?:number}} [opts]
138
+ */
139
+ export function classifyMove(a, b, subject, { durationS = 3, height = SUBJECT_HEIGHT_M } = {}) {
140
+ const anchor = { x: subject.x, z: subject.z };
141
+ const A = decompose(a, anchor);
142
+ const B = decompose(b, anchor);
143
+ const shotA = deriveShot(a.pos, subject, (a.fovDeg * Math.PI) / 180, height);
144
+ const shotB = deriveShot(b.pos, subject, (b.fovDeg * Math.PI) / 180, height);
145
+
146
+ const dr = B.r - A.r;
147
+ const dAzDeg = shortestArc(A.azimuth, B.azimuth) * DEG;
148
+ const dh = B.height - A.height;
149
+ const dFocal = B.focalMm - A.focalMm;
150
+ const dYawDeg = shortestArc(a.yaw, b.yaw) * DEG;
151
+ const dPitchDeg = (b.pitch - a.pitch) * DEG;
152
+ const posDelta = Math.hypot(b.pos.x - a.pos.x, b.pos.y - a.pos.y, b.pos.z - a.pos.z);
153
+ const sizeDrift = Math.abs(shotB.screenFraction - shotA.screenFraction) / Math.max(shotA.screenFraction, 1e-6);
154
+
155
+ // tempo comes from how far the lens physically travels per second
156
+ const travel = Math.hypot(Math.abs(dr), (Math.abs(dAzDeg) / DEG) * ((A.r + B.r) / 2), dh);
157
+ const speed = travel / Math.max(durationS, 0.1);
158
+ const tempo = travel < STILL_POS_M ? "" : speed < 0.35 ? "slow " : speed > 1.4 ? "fast " : "";
159
+
160
+ const still = posDelta < STILL_POS_M;
161
+ const sizes = `from a ${shotA.sizeLabel} to a ${shotB.sizeLabel}`;
162
+ let id;
163
+ let label;
164
+ let phrase;
165
+
166
+ if (still && Math.abs(dYawDeg) < STILL_ANGLE_DEG && Math.abs(dPitchDeg) < STILL_ANGLE_DEG && Math.abs(dFocal) < ZOOM_MM) {
167
+ id = "static";
168
+ label = "Static / locked-off";
169
+ phrase = "static, locked-off shot";
170
+ } else if (still && Math.abs(dFocal) >= ZOOM_MM) {
171
+ id = dFocal > 0 ? "zoom-in" : "zoom-out";
172
+ label = dFocal > 0 ? "Zoom in" : "Zoom out";
173
+ phrase = `a ${tempo}zoom ${dFocal > 0 ? "in, tightening" : "out, widening"} ${sizes}`;
174
+ } else if (still) {
175
+ // the tripod stays put; only the head moves
176
+ if (Math.abs(dYawDeg) >= Math.abs(dPitchDeg)) {
177
+ const side = dYawDeg > 0 ? "left" : "right"; // +yaw turns a YXZ camera left
178
+ id = `pan-${side}`;
179
+ label = `Pan ${side}`;
180
+ phrase = `a ${tempo}pan to the ${side}`;
181
+ } else {
182
+ const way = dPitchDeg > 0 ? "up" : "down";
183
+ id = `tilt-${way}`;
184
+ label = `Tilt ${way}`;
185
+ phrase = `a ${tempo}tilt ${way}`;
186
+ }
187
+ } else if (
188
+ Math.abs(dr) >= DOLLY_M &&
189
+ Math.abs(dFocal) >= VERTIGO_MM &&
190
+ Math.sign(dFocal) === Math.sign(dr) &&
191
+ sizeDrift <= VERTIGO_SIZE_DRIFT
192
+ ) {
193
+ id = "dolly-zoom";
194
+ label = "Dolly-zoom (vertigo)";
195
+ phrase = `a dolly-zoom (vertigo effect), the camera ${dr < 0 ? "pushing in" : "pulling back"} while the lens compensates to hold the subject's size, the background ${dr < 0 ? "stretching away" : "compressing in"}`;
196
+ } else if (Math.abs(dAzDeg) >= ORBIT_DEG) {
197
+ id = "orbit";
198
+ label = "Orbit / arc";
199
+ const radial = dr <= -DOLLY_M ? ", closing in as it circles" : dr >= DOLLY_M ? ", drifting wider as it circles" : "";
200
+ phrase = `a ${tempo}orbit around the subject, arcing ${Math.round(Math.abs(dAzDeg))} degrees${radial}`;
201
+ } else if (Math.abs(dh) >= CRANE_M && Math.abs(dh) >= Math.abs(dr) * 0.8) {
202
+ const way = dh > 0 ? "up" : "down";
203
+ id = `crane-${way}`;
204
+ label = `Crane ${way}`;
205
+ phrase = `a ${tempo}crane ${way}, the camera ${dh > 0 ? "rising" : "sinking"} from ${shotA.levelLabel} to ${shotB.levelLabel}`;
206
+ } else if (dr <= -DOLLY_M) {
207
+ id = "push-in";
208
+ label = "Push-in (dolly in)";
209
+ const crane = Math.abs(dh) >= CRANE_M ? `, craning ${dh > 0 ? "up" : "down"} as it moves` : "";
210
+ phrase = `a ${tempo}push-in (dolly in) ${sizes}${crane}`;
211
+ } else if (dr >= DOLLY_M) {
212
+ id = "pull-out";
213
+ label = "Pull-out (dolly out)";
214
+ const crane = Math.abs(dh) >= CRANE_M ? `, craning ${dh > 0 ? "up" : "down"} as it moves` : "";
215
+ phrase = `a ${tempo}pull-out (dolly out) ${sizes}${crane}`;
216
+ } else {
217
+ id = "tracking";
218
+ label = "Tracking / follow";
219
+ phrase = `a ${tempo}tracking move`;
220
+ }
221
+
222
+ return {
223
+ id,
224
+ label,
225
+ phrase,
226
+ tempo: tempo.trim(),
227
+ from: shotA,
228
+ to: shotB,
229
+ deltas: { dr, dAzDeg, dh, dFocal, dYawDeg, dPitchDeg, posDelta, sizeDrift },
230
+ };
231
+ }
232
+
233
+ /** the burned-in move slate, e.g. "MEDIUM SHOT 35MM → CLOSE-UP 85MM · PUSH-IN (DOLLY IN)" */
234
+ export function moveSlate(move) {
235
+ const end = [
236
+ `${move.from.sizeLabel} ${move.from.focalMm}mm`,
237
+ `${move.to.sizeLabel} ${move.to.focalMm}mm`,
238
+ ].map((part) => part.toUpperCase());
239
+ return `${end[0]} → ${end[1]} · ${move.label.toUpperCase()}`;
240
+ }
241
+
242
+ /* ------------------------------------------------- multi-key sequences --- */
243
+
244
+ /**
245
+ * Sample an N-key camera move at a frame. Keys are sorted frame-unique
246
+ * records `{ frame, framing }`. Before the first key and after the last the
247
+ * framing holds; inside a segment it interpolates exactly like A→B.
248
+ * @param {Array<{frame:number, framing:object}>} keys sorted by frame
249
+ * @param {{x:number,z:number}} anchor subject ground position
250
+ * @param {number} frame
251
+ * @returns a framing record, or null when there are no keys
252
+ */
253
+ export function cameraMoveAt(keys, anchor, frame) {
254
+ if (!keys.length) return null;
255
+ const first = keys[0];
256
+ const last = keys[keys.length - 1];
257
+ if (frame <= first.frame) return first.framing;
258
+ if (frame >= last.frame) return last.framing;
259
+ for (let i = 0; i < keys.length - 1; i++) {
260
+ const a = keys[i];
261
+ const b = keys[i + 1];
262
+ if (frame <= b.frame) {
263
+ return interpolateFraming(a.framing, b.framing, anchor, (frame - a.frame) / (b.frame - a.frame));
264
+ }
265
+ }
266
+ return last.framing;
267
+ }
268
+
269
+ /** Chained slate for a classified segment list, e.g.
270
+ * "MEDIUM SHOT 35MM · PUSH-IN (DOLLY IN) → CLOSE-UP 85MM · ORBIT / ARC → WIDE 24MM".
271
+ * One segment renders identically to moveSlate. */
272
+ export function moveSequenceSlate(segments) {
273
+ if (!segments.length) return "";
274
+ if (segments.length === 1) return moveSlate(segments[0]);
275
+ const parts = [`${segments[0].from.sizeLabel} ${segments[0].from.focalMm}mm`.toUpperCase()];
276
+ for (const seg of segments) {
277
+ parts.push(`${seg.label.toUpperCase()} → ${`${seg.to.sizeLabel} ${seg.to.focalMm}mm`.toUpperCase()}`);
278
+ }
279
+ return parts.join(" · ");
280
+ }
281
+
282
+ /** Generation phrase for a classified segment list: each segment's proven
283
+ * phrase, chained in time order. */
284
+ export function moveSequencePhrase(segments) {
285
+ return segments.map((seg) => seg.phrase).join(", then ");
286
+ }