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
|
@@ -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
|
+
}
|