cozyclay 1.0.0 → 1.2.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 (94) hide show
  1. package/README.md +59 -24
  2. package/THIRD_PARTY_NOTICES.md +31 -0
  3. package/bin/cozyclay.mjs +105 -11
  4. package/dist/CNAME +1 -0
  5. package/dist/app/index.html +57 -0
  6. package/dist/assets/app-Cgpk2hwX.js +4803 -0
  7. package/dist/assets/app-DgZvaAE1.css +1 -0
  8. package/dist/favicon.ico +0 -0
  9. package/dist/icons/apple-touch-icon.png +0 -0
  10. package/dist/icons/icon-192.png +0 -0
  11. package/dist/icons/icon-32.png +0 -0
  12. package/dist/icons/icon-512.png +0 -0
  13. package/dist/icons/icon-maskable-512.png +0 -0
  14. package/dist/index.html +406 -5
  15. package/dist/manifest.webmanifest +42 -0
  16. package/dist/media/cozyclay-demo-poster.jpg +0 -0
  17. package/dist/media/cozyclay-logo.png +0 -0
  18. package/dist/robots.txt +4 -0
  19. package/dist/sitemap.xml +9 -0
  20. package/dist/sw.js +127 -0
  21. package/package.json +15 -3
  22. package/src/App.jsx +1393 -648
  23. package/src/ardy/client.js +5 -4
  24. package/src/ardy/mp4-duration.js +102 -0
  25. package/src/ardy/timeline-coordinates.js +11 -0
  26. package/src/ardy/timeline.jsx +528 -67
  27. package/src/ardy/waypoints.js +14 -9
  28. package/src/camera-block.js +60 -0
  29. package/src/camera-follow.js +49 -10
  30. package/src/camera-rail-schedule.js +176 -0
  31. package/src/controls.jsx +16 -2
  32. package/src/cuts.js +184 -0
  33. package/src/generation/client.js +56 -0
  34. package/src/generation/session.js +47 -0
  35. package/src/generation/shot-spec.js +40 -0
  36. package/src/generation/use-generation.js +55 -0
  37. package/src/hierarchy-model.js +9 -17
  38. package/src/hierarchy-panel.jsx +170 -23
  39. package/src/locale-toggle.jsx +18 -0
  40. package/src/locale.js +34 -0
  41. package/src/main.jsx +5 -0
  42. package/src/object-catalog.jsx +32 -3
  43. package/src/planview.jsx +174 -66
  44. package/src/pose-extract/browser.js +42 -0
  45. package/src/pose-extract/fit.js +194 -0
  46. package/src/pose-extract/index.js +53 -0
  47. package/src/pose-extract/keyframes.js +74 -0
  48. package/src/pose-extract/landmarks.js +162 -0
  49. package/src/poses.js +114 -0
  50. package/src/posestudio.jsx +75 -13
  51. package/src/pwa.js +42 -0
  52. package/src/result-modal.jsx +124 -0
  53. package/src/scenes.js +223 -0
  54. package/src/shot-authoring.js +204 -77
  55. package/src/shot.js +8 -4
  56. package/src/styles.css +839 -38
  57. package/src/ui.jsx +54 -5
  58. package/tools/ardy/BRIDGE.md +70 -4
  59. package/tools/ardy/README.md +55 -0
  60. package/tools/ardy/bridge.mjs +89 -389
  61. package/tools/ardy/cclay_constrained_generate.py +449 -0
  62. package/tools/ardy/cclay_motion_edit.py +74 -9
  63. package/tools/ardy/cclay_sequence_generate.py +68 -7
  64. package/tools/ardy/cclay_worker.py +304 -0
  65. package/tools/ardy/merge-text-encoder.py +160 -0
  66. package/tools/ardy/run-local.mjs +233 -0
  67. package/tools/ardy/runners/index.mjs +23 -0
  68. package/tools/ardy/runners/local.mjs +452 -0
  69. package/tools/ardy/runners/proc.mjs +125 -0
  70. package/tools/ardy/runners/remote.mjs +246 -0
  71. package/tools/ardy/setup-local.mjs +189 -0
  72. package/tools/ardy/setup-text-encoder-on-box.sh +93 -0
  73. package/tools/ardy/setup-text-encoder.py +296 -0
  74. package/tools/generation/README.md +29 -0
  75. package/tools/generation/bridge.mjs +149 -0
  76. package/tools/generation/job-store.mjs +51 -0
  77. package/tools/generation/providers/kling.mjs +76 -0
  78. package/tools/generation/providers/runway.mjs +101 -0
  79. package/tools/generation/providers/seedance.mjs +87 -0
  80. package/tools/generation/providers/shared.mjs +49 -0
  81. package/tools/generation/providers/veo.mjs +84 -0
  82. package/tools/metrics/archive-traffic.mjs +138 -0
  83. package/dist/assets/index-BW_S1YRy.js +0 -4395
  84. package/dist/assets/index-nRmmJgap.css +0 -1
  85. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  86. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  87. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  88. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  89. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  90. package/tools/qa-browser.mjs +0 -84
  91. package/tools/qa-crop3.mjs +0 -22
  92. package/tools/qa-playview.mjs +0 -49
  93. package/tools/qa-screenshot.mjs +0 -24
  94. package/tools/qa-visual.mjs +0 -51
@@ -1,4 +1,5 @@
1
1
  const cleanZero = (value) => (Object.is(value, -0) || Math.abs(value) < 1e-9 ? 0 : value);
2
+ import { isKo } from "../locale.js";
2
3
 
3
4
  /**
4
5
  * ARDY root constraints live in the actor's clip-local coordinates. CozyClay's
@@ -273,7 +274,7 @@ export function judgeNextWaypoint(last, candidate, fps, prev = null) {
273
274
  if (gap < PATH_LIMITS.minPinGapFrames) {
274
275
  return {
275
276
  ok: false,
276
- error: `pins need at least ${PATH_LIMITS.minPinGapFrames} frames between them (${(PATH_LIMITS.minPinGapFrames / fps).toFixed(1)} s) — scrub past frame ${last.frame + PATH_LIMITS.minPinGapFrames}`,
277
+ error: isKo ? `핀 사이에는 최소 ${PATH_LIMITS.minPinGapFrames}프레임(${(PATH_LIMITS.minPinGapFrames / fps).toFixed(1)}초)이 필요해요 — ${last.frame + PATH_LIMITS.minPinGapFrames}프레임 이후로 이동해 주세요` : `pins need at least ${PATH_LIMITS.minPinGapFrames} frames between them (${(PATH_LIMITS.minPinGapFrames / fps).toFixed(1)} s) — scrub past frame ${last.frame + PATH_LIMITS.minPinGapFrames}`,
277
278
  warnings: [],
278
279
  };
279
280
  }
@@ -285,7 +286,7 @@ export function judgeNextWaypoint(last, candidate, fps, prev = null) {
285
286
  const neededFrames = Math.ceil((dist / PATH_LIMITS.speedMaxMps) * fps);
286
287
  return {
287
288
  ok: false,
288
- error: `${speed.toFixed(1)} m/s is faster than anyone moves (max ${PATH_LIMITS.speedMaxMps}) — pin frame ${last.frame + neededFrames} or later, or click closer`,
289
+ error: isKo ? `${speed.toFixed(1)} m/s는 사람이 움직이기엔 너무 빨라요(최대 ${PATH_LIMITS.speedMaxMps}) — ${last.frame + neededFrames}프레임 이후에 핀을 찍거나 더 가까운 곳을 눌러 주세요` : `${speed.toFixed(1)} m/s is faster than anyone moves (max ${PATH_LIMITS.speedMaxMps}) — pin frame ${last.frame + neededFrames} or later, or click closer`,
289
290
  warnings: [],
290
291
  };
291
292
  }
@@ -294,7 +295,7 @@ export function judgeNextWaypoint(last, candidate, fps, prev = null) {
294
295
  const neededDist = (PATH_LIMITS.speedMinMps * dt).toFixed(1);
295
296
  return {
296
297
  ok: false,
297
- error: `${speed.toFixed(2)} m/s is below a sustainable walk (min ${PATH_LIMITS.speedMinMps}) — pin frame ~${walkFrame} instead, or click at least ${neededDist} m away`,
298
+ error: isKo ? `${speed.toFixed(2)} m/s는 자연스럽게 걷기엔 너무 느려요(최소 ${PATH_LIMITS.speedMinMps}) — 대신 약 ${walkFrame}프레임에 핀을 찍거나 최소 ${neededDist} m 이상 떨어진 곳을 눌러 주세요` : `${speed.toFixed(2)} m/s is below a sustainable walk (min ${PATH_LIMITS.speedMinMps}) — pin frame ~${walkFrame} instead, or click at least ${neededDist} m away`,
298
299
  warnings: [],
299
300
  };
300
301
  }
@@ -305,7 +306,7 @@ export function judgeNextWaypoint(last, candidate, fps, prev = null) {
305
306
  const prevSpeed = prevDist / ((last.frame - prev.frame) / fps);
306
307
  const ratio = Math.max(speed, prevSpeed) / Math.max(Math.min(speed, prevSpeed), 1e-6);
307
308
  if (ratio > PATH_LIMITS.speedRatioWarn) {
308
- warnings.push(`${ratio.toFixed(1)}x speed change from the previous leg — expect a visible gait shift`);
309
+ warnings.push(isKo ? `이전 구간보다 속도가 ${ratio.toFixed(1)}배 달라요 — 걸음 변화가 눈에 보일 수 있어요` : `${ratio.toFixed(1)}x speed change from the previous leg — expect a visible gait shift`);
309
310
  }
310
311
  const before = Math.atan2(last.x - prev.x, last.z - prev.z);
311
312
  const after = Math.atan2(candidate.x - last.x, candidate.z - last.z);
@@ -313,7 +314,7 @@ export function judgeNextWaypoint(last, candidate, fps, prev = null) {
313
314
  if (turn > Math.PI) turn = 2 * Math.PI - turn;
314
315
  const turnRate = (turn * 180) / Math.PI / dt;
315
316
  if (turnRate > PATH_LIMITS.turnRateWarnDegPerS) {
316
- warnings.push(`${Math.round((turn * 180) / Math.PI)}° turn in ${dt.toFixed(1)} s is sharper than a walking arc — give it more frames or distance`);
317
+ warnings.push(isKo ? `${dt.toFixed(1)}초 동안 ${Math.round((turn * 180) / Math.PI)}° 회전은 걷는 동선치고 너무 급해요 — 프레임이나 거리를 더 주세요` : `${Math.round((turn * 180) / Math.PI)}° turn in ${dt.toFixed(1)} s is sharper than a walking arc — give it more frames or distance`);
317
318
  }
318
319
  }
319
320
  }
@@ -331,21 +332,25 @@ export function judgeAuthoredPath(rootPath, fps, clipFrames, { chained = false }
331
332
  const ordered = [...rootPath].sort((a, b) => a.frame - b.frame);
332
333
  for (let i = 1; i < ordered.length; i += 1) {
333
334
  const verdict = judgeNextWaypoint(ordered[i - 1], ordered[i], fps, i >= 2 ? ordered[i - 2] : null);
334
- if (!verdict.ok) errors.push(`leg ${i} (frame ${ordered[i - 1].frame}->${ordered[i].frame}): ${verdict.error}`);
335
- else for (const warning of verdict.warnings) warnings.push(`leg ${i}: ${warning}`);
335
+ if (!verdict.ok) errors.push(isKo ? `구간 ${i} (${ordered[i - 1].frame}->${ordered[i].frame}프레임): ${verdict.error}` : `leg ${i} (frame ${ordered[i - 1].frame}->${ordered[i].frame}): ${verdict.error}`);
336
+ else for (const warning of verdict.warnings) warnings.push(isKo ? `구간 ${i}: ${warning}` : `leg ${i}: ${warning}`);
336
337
  }
337
338
  // The trained window binds ONE model call. A chained rollout (a prompt
338
339
  // schedule) makes a call per block, so the whole clip may exceed it —
339
340
  // each block is capped separately by the bridge and the box generator.
340
341
  if (!chained && clipFrames / fps > PATH_LIMITS.clipMaxS) {
341
342
  errors.push(
342
- `a root path generates the whole clip in one model call, and ARDY's trained window is ${PATH_LIMITS.clipMaxS} s — shorten the clip to ${PATH_LIMITS.clipMaxS} s, or split the prompt into blocks so the path rides a chained rollout`,
343
+ isKo
344
+ ? `이동 경로는 전체 클립을 한 번의 모델 호출로 만들어요. ARDY가 학습한 길이는 ${PATH_LIMITS.clipMaxS}초까지예요 — 클립을 ${PATH_LIMITS.clipMaxS}초로 줄이거나 프롬프트를 블록으로 나눠 이어 만들기를 사용해 주세요`
345
+ : `a root path generates the whole clip in one model call, and ARDY's trained window is ${PATH_LIMITS.clipMaxS} s — shorten the clip to ${PATH_LIMITS.clipMaxS} s, or split the prompt into blocks so the path rides a chained rollout`,
343
346
  );
344
347
  }
345
348
  const tail = (clipFrames - 1 - (ordered[ordered.length - 1]?.frame ?? 0)) / fps;
346
349
  if (ordered.length > 1 && tail > PATH_LIMITS.tailMaxS) {
347
350
  warnings.push(
348
- `${tail.toFixed(1)} s of clip remain after the last pin — the unconstrained tail continues whatever the path ends in`,
351
+ isKo
352
+ ? `마지막 핀 뒤에 클립이 ${tail.toFixed(1)}초 남아 있어요 — 제약이 없는 끝부분은 경로가 끝난 상태를 그대로 이어가요`
353
+ : `${tail.toFixed(1)} s of clip remain after the last pin — the unconstrained tail continues whatever the path ends in`,
349
354
  );
350
355
  }
351
356
  return { errors, warnings };
@@ -0,0 +1,60 @@
1
+ // Pure Camera Block model. A shot owns one complete camera instruction, like
2
+ // the camera card clipped to a single strip of film.
3
+
4
+ export const CAMERA_MODES = Object.freeze(["keys", "follow", "rail"]);
5
+
6
+ export const CAMERA_FOLLOW_DEFAULTS = Object.freeze({
7
+ distance: 3,
8
+ height: 1.6,
9
+ response: 0.7,
10
+ lead: 0.25,
11
+ railStartMode: "head",
12
+ maxDollySpeed: 4,
13
+ pitchOffsetDeg: 0,
14
+ });
15
+
16
+ function cloneRail(points) {
17
+ return Array.isArray(points) ? points.map((point) => ({ x: point.x, z: point.z })) : null;
18
+ }
19
+
20
+ function cloneRailFollow(value) {
21
+ if (!value || typeof value !== "object") return null;
22
+ if (value.mode === "off") return { mode: "off" };
23
+ if (value.mode === "range") return {
24
+ mode: "range",
25
+ startFrame: value.startFrame,
26
+ endFrame: value.endFrame,
27
+ };
28
+ return null;
29
+ }
30
+
31
+ /** Build a complete block from a partial or stored camera value. */
32
+ export function createCameraBlock(input = {}) {
33
+ const value = input && typeof input === "object" ? input : {};
34
+ return {
35
+ mode: CAMERA_MODES.includes(value.mode) ? value.mode : "keys",
36
+ followCam: { ...CAMERA_FOLLOW_DEFAULTS, ...(value.followCam ?? {}) },
37
+ cameraRail: cloneRail(value.cameraRail),
38
+ railFollow: cloneRailFollow(value.railFollow),
39
+ };
40
+ }
41
+
42
+ /** Camera Blocks must never share nested settings across shots. */
43
+ export function cloneCameraBlock(camera) {
44
+ return createCameraBlock(camera);
45
+ }
46
+
47
+ /** Immutable nested update used by both the sidebar and timeline inspector. */
48
+ export function updateCameraBlock(camera, patch = {}) {
49
+ const current = createCameraBlock(camera);
50
+ const change = patch && typeof patch === "object" ? patch : {};
51
+ return createCameraBlock({
52
+ ...current,
53
+ ...change,
54
+ followCam: change.followCam
55
+ ? { ...current.followCam, ...change.followCam }
56
+ : current.followCam,
57
+ cameraRail: Object.hasOwn(change, "cameraRail") ? change.cameraRail : current.cameraRail,
58
+ railFollow: Object.hasOwn(change, "railFollow") ? change.railFollow : current.railFollow,
59
+ });
60
+ }
@@ -29,6 +29,21 @@ function springStep(pos, vel, target, omega, dt) {
29
29
  return [pos + nextVel * dt, nextVel];
30
30
  }
31
31
 
32
+ /** scalar spring whose speed limit is applied before position integration */
33
+ function cappedSpringStep(pos, vel, target, omega, dt, maxSpeed) {
34
+ const acc = omega * omega * (target - pos) - 2 * omega * vel;
35
+ const cap = Math.max(0, Number.isFinite(maxSpeed) ? maxSpeed : 0);
36
+ const nextVel = Math.max(-cap, Math.min(vel + acc * dt, cap));
37
+ return [pos + nextVel * dt, nextVel];
38
+ }
39
+
40
+ const PITCH_LIMIT = (85 * Math.PI) / 180;
41
+
42
+ function offsetPitch(pitch, offsetDeg) {
43
+ const safeOffset = Math.max(-30, Math.min(Number.isFinite(offsetDeg) ? offsetDeg : 0, 30));
44
+ return Math.max(-PITCH_LIMIT, Math.min(pitch + (safeOffset * Math.PI) / 180, PITCH_LIMIT));
45
+ }
46
+
32
47
  function aimAngles(position, target) {
33
48
  const dx = target.x - position.x;
34
49
  const dy = target.y - position.y;
@@ -39,6 +54,25 @@ function aimAngles(position, target) {
39
54
  };
40
55
  }
41
56
 
57
+ const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
58
+ const rounded = (value, places) => Number(value.toFixed(places));
59
+
60
+ /**
61
+ * Turn the operator's current viewport framing into the three physical Follow
62
+ * settings. These are observations, not knobs: moving the camera is the input.
63
+ * Pitch is stored as an offset from the rig's automatic chest aim so replaying
64
+ * the Follow track reproduces the angle the operator composed by hand.
65
+ */
66
+ export function followFramingFromCamera(position, pitch, subject, aimHeight = FOLLOW_DEFAULTS.aimHeight) {
67
+ const planarDistance = Math.hypot(position.x - subject.x, position.z - subject.z);
68
+ const automaticPitch = aimAngles(position, { x: subject.x, y: aimHeight, z: subject.z }).pitch;
69
+ return {
70
+ distance: rounded(clamp(planarDistance, 0.5, 15), 2),
71
+ height: rounded(clamp(position.y, 0.2, 6), 2),
72
+ pitchOffsetDeg: rounded(clamp(((pitch - automaticPitch) * 180) / Math.PI, -30, 30), 1),
73
+ };
74
+ }
75
+
42
76
  export const FOLLOW_DEFAULTS = {
43
77
  /** metres the grip tries to hold between camera and subject */
44
78
  distance: 3,
@@ -52,8 +86,14 @@ export const FOLLOW_DEFAULTS = {
52
86
  lead: 0.25,
53
87
  /** where on the body the operator holds frame (chest, metres) */
54
88
  aimHeight: 1.35,
89
+ /** vertical tilt added after automatic aiming, in degrees */
90
+ pitchOffsetDeg: 0,
55
91
  /** hard cap on camera translation (m/s) — a crew has legs, not thrusters */
56
92
  maxSpeed: 2.8,
93
+ /** where a rail dolly opens: the authored head or legacy auto placement */
94
+ railStartMode: "head",
95
+ /** hard cap on rail-dolly travel in metres per second */
96
+ maxDollySpeed: 4,
57
97
  /** EMA weight for the BEHIND direction; slow on purpose, so a corner
58
98
  * sweeps the trailing position gradually and the camera cuts the corner
59
99
  * the way a steadicam op does instead of whipping around the subject.
@@ -172,7 +212,7 @@ export function buildFollowTrack(subject, fps, params = {}) {
172
212
  }
173
213
  const pos = { x: px, y: py, z: pz };
174
214
  const { yaw, pitch } = aimAngles(pos, { x: ax, y: p.aimHeight, z: az });
175
- track.push({ pos, yaw, pitch });
215
+ track.push({ pos, yaw, pitch: offsetPitch(pitch, p.pitchOffsetDeg) });
176
216
  }
177
217
  return track;
178
218
  }
@@ -301,7 +341,7 @@ function nearestS(rail, point) {
301
341
  * dolly never teleports across the stage to a globally better spot.
302
342
  */
303
343
  export function buildRailFollowTrack(subject, fps, rail, params = {}) {
304
- const p = { ...FOLLOW_DEFAULTS, maxDollySpeed: 4, searchWindow: 2.5, backtrackPenalty: 1.0, ...params };
344
+ const p = { ...FOLLOW_DEFAULTS, searchWindow: 2.5, backtrackPenalty: 1.0, ...params };
305
345
  if (!subject || subject.length === 0 || !rail || rail.length < 1e-6) return [];
306
346
  const dt = 1 / Math.max(fps, 1);
307
347
  const vels = smoothedVelocities(subject, fps);
@@ -332,9 +372,11 @@ export function buildRailFollowTrack(subject, fps, rail, params = {}) {
332
372
  return best;
333
373
  };
334
374
 
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);
375
+ // Head mode honours the authored start mark exactly. Nearest preserves the
376
+ // legacy auto-placement option by searching the whole rail once.
377
+ let s = p.railStartMode === "nearest"
378
+ ? bestSNear(nearestS(rail, subject[0]), subject[0], rail.length, 0)
379
+ : 0;
338
380
  let sVel = 0;
339
381
  let py = p.height;
340
382
  let vy = 0;
@@ -346,10 +388,7 @@ export function buildRailFollowTrack(subject, fps, rail, params = {}) {
346
388
  for (let f = 0; f < subject.length; f += 1) {
347
389
  if (f > 0) {
348
390
  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;
391
+ [s, sVel] = cappedSpringStep(s, sVel, sTarget, omega, dt, p.maxDollySpeed);
353
392
  s = Math.max(0, Math.min(s, rail.length));
354
393
  [py, vy] = springStep(py, vy, p.height, omega, dt);
355
394
  const aimTx = subject[f].x + vels[f].x * p.lead;
@@ -360,7 +399,7 @@ export function buildRailFollowTrack(subject, fps, rail, params = {}) {
360
399
  const rp = railPoint(rail, s);
361
400
  const pos = { x: rp.x, y: py, z: rp.z };
362
401
  const { yaw, pitch } = aimAngles(pos, { x: ax, y: p.aimHeight, z: az });
363
- track.push({ pos, yaw, pitch, s });
402
+ track.push({ pos, yaw, pitch: offsetPitch(pitch, p.pitchOffsetDeg), s });
364
403
  }
365
404
  return track;
366
405
  }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Rail Follow time clip — pure schedule math.
3
+ *
4
+ * The spatial rail (cameraRail) says WHERE the dolly may go; the Rail Follow
5
+ * clip says WHEN. A shot has at most one clip, drawn as a thin ribbon on the
6
+ * Camera lane, and its schedule lives in the persisted `railFollow` field:
7
+ *
8
+ * null legacy-derived — the clip is the whole timeline
9
+ * until the first edit; the serializer omits the
10
+ * field entirely so old payloads stay untouched
11
+ * {mode:"range",startFrame,endFrame} authored range, both ends inclusive
12
+ * {mode:"off"} explicit removal — never resurrects on reload
13
+ *
14
+ * Every function here is pure: storage values in, plain answers out, no
15
+ * state, no React. The camera-owner decision is a separate pure step so the
16
+ * timeline can render the clip (ribbon, progress, handles) even while the
17
+ * follow cam is off.
18
+ */
19
+
20
+ /** minimum inclusive clip length: 10 frames @ 20 fps = 0.5 s */
21
+ export const RAIL_FOLLOW_MIN_FRAMES = 10;
22
+
23
+ /** resolved schedule kinds — what the clip looks like right now */
24
+ export const RAIL_SCHEDULE_NONE = "none"; // no usable rail geometry at all
25
+ export const RAIL_SCHEDULE_TOO_SHORT = "too-short"; // timeline can't hold a 10-frame clip
26
+ export const RAIL_SCHEDULE_OFF = "off"; // explicitly removed, must not resurrect
27
+ export const RAIL_SCHEDULE_RANGE = "range"; // authored range, inclusive both ends
28
+ export const RAIL_SCHEDULE_LEGACY = "legacy"; // pre-railFollow payload: whole timeline
29
+
30
+ /** camera owners at a frame — MoveRig and FollowCamRig must never both win */
31
+ export const RAIL_OWNER_NONE = "none";
32
+ export const RAIL_OWNER_RAIL = "rail";
33
+ export const RAIL_OWNER_KEYS = "keys";
34
+
35
+ const finite = Number.isFinite;
36
+
37
+ /** the persisted rail is usable when it holds at least two finite points */
38
+ export function isRailUsable(cameraRail) {
39
+ return (
40
+ Array.isArray(cameraRail) &&
41
+ cameraRail.length >= 2 &&
42
+ cameraRail.every((point) => !!point && finite(point.x) && finite(point.z))
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Fit a range into a timeline: clamp both ends into [0, frameCount-1] and
48
+ * enforce the 10-inclusive-frame minimum by extending (never by shrinking a
49
+ * longer clip). Returns null when the range is structurally invalid or the
50
+ * timeline is too short to hold the minimum clip. Never grows a range beyond
51
+ * what was stored, so re-clamping after a timeline grows back is a no-op.
52
+ */
53
+ function fitRange(startFrame, endFrame, frameCount) {
54
+ if (!finite(startFrame) || !finite(endFrame) || !finite(frameCount)) return null;
55
+ const last = Math.floor(frameCount) - 1;
56
+ if (last < 0) return null;
57
+ let start = Math.max(0, Math.min(Math.round(startFrame), last));
58
+ let end = Math.max(0, Math.min(Math.round(endFrame), last));
59
+ if (start > end) return null;
60
+ const minSpan = RAIL_FOLLOW_MIN_FRAMES - 1;
61
+ if (end - start < minSpan) {
62
+ if (frameCount < RAIL_FOLLOW_MIN_FRAMES) return null;
63
+ end = Math.min(last, start + minSpan);
64
+ start = Math.max(0, end - minSpan);
65
+ }
66
+ return { startFrame: start, endFrame: end };
67
+ }
68
+
69
+ /** the default clip range for a freshly drawn rail: the whole timeline */
70
+ export function defaultRailRange(frameCount) {
71
+ if (!finite(frameCount) || frameCount < RAIL_FOLLOW_MIN_FRAMES) return null;
72
+ return { startFrame: 0, endFrame: Math.floor(frameCount) - 1 };
73
+ }
74
+
75
+ /**
76
+ * Shrink-safe clamp for persisting an authored range when the timeline
77
+ * changes: clamp once, store the result, and later growth cannot resurrect
78
+ * the old (now out-of-bounds) range because this never expands it.
79
+ */
80
+ export function clampRailRange(range, frameCount) {
81
+ if (!range || typeof range !== "object") return null;
82
+ return fitRange(range.startFrame, range.endFrame, frameCount);
83
+ }
84
+
85
+ /**
86
+ * Move a clip by `delta` frames, preserving its length. The whole span stays
87
+ * inside [0, frameCount-1]. Returns null when the range is not a valid
88
+ * min-10 clip or cannot fit in the timeline.
89
+ */
90
+ export function moveRailRange(range, delta, frameCount) {
91
+ if (!range || typeof range !== "object" || !finite(delta) || !finite(frameCount)) return null;
92
+ const start = Math.round(range.startFrame);
93
+ const end = Math.round(range.endFrame);
94
+ const length = end - start + 1;
95
+ if (start > end || length < RAIL_FOLLOW_MIN_FRAMES || length > frameCount) return null;
96
+ const last = Math.floor(frameCount) - 1;
97
+ if (last < 0) return null;
98
+ const maxStart = last - (length - 1);
99
+ const shifted = start + Math.round(delta);
100
+ const newStart = Math.max(0, Math.min(shifted, maxStart));
101
+ return { startFrame: newStart, endFrame: newStart + length - 1 };
102
+ }
103
+
104
+ /**
105
+ * Resize a clip by moving one edge to `frame`. The result keeps the other
106
+ * edge fixed, stays inside the timeline, and is never shorter than the
107
+ * 10-inclusive-frame minimum. Returns null when no valid range can keep the
108
+ * fixed edge (the timeline is too short at that side) or the inputs are
109
+ * structurally invalid.
110
+ */
111
+ export function resizeRailRange(range, edge, frame, frameCount) {
112
+ if (!range || typeof range !== "object" || (edge !== "start" && edge !== "end")) return null;
113
+ if (!finite(frame) || !finite(frameCount)) return null;
114
+ const last = Math.floor(frameCount) - 1;
115
+ if (last < 0) return null;
116
+ const start = Math.max(0, Math.min(Math.round(range.startFrame), last));
117
+ const end = Math.max(0, Math.min(Math.round(range.endFrame), last));
118
+ if (start > end) return null;
119
+ const target = Math.max(0, Math.min(Math.round(frame), last));
120
+ const minSpan = RAIL_FOLLOW_MIN_FRAMES - 1;
121
+ if (edge === "end") {
122
+ const minEnd = start + minSpan;
123
+ if (minEnd > last) return null;
124
+ return { startFrame: start, endFrame: Math.max(minEnd, Math.min(target, last)) };
125
+ }
126
+ const maxStart = end - minSpan;
127
+ if (maxStart < 0) return null;
128
+ return { startFrame: Math.min(maxStart, Math.max(target, 0)), endFrame: end };
129
+ }
130
+
131
+ /**
132
+ * Resolve the persisted schedule into what the clip looks like right now.
133
+ * Order matters: no rail beats everything (there is nothing to schedule),
134
+ * then a timeline too short to hold a clip hides it, then the stored modes
135
+ * apply. Corrupt ranges fold to "off" — never invent a clip the data does
136
+ * not support.
137
+ */
138
+ export function resolveRailSchedule({ railFollow, cameraRail, frameCount }) {
139
+ if (!isRailUsable(cameraRail)) return { kind: RAIL_SCHEDULE_NONE };
140
+ if (!finite(frameCount) || frameCount < RAIL_FOLLOW_MIN_FRAMES) return { kind: RAIL_SCHEDULE_TOO_SHORT };
141
+ const last = Math.floor(frameCount) - 1;
142
+ if (!railFollow) return { kind: RAIL_SCHEDULE_LEGACY, startFrame: 0, endFrame: last };
143
+ if (railFollow.mode === RAIL_SCHEDULE_OFF) return { kind: RAIL_SCHEDULE_OFF };
144
+ if (railFollow.mode === RAIL_SCHEDULE_RANGE) {
145
+ const fitted = fitRange(railFollow.startFrame, railFollow.endFrame, frameCount);
146
+ if (fitted) return { kind: RAIL_SCHEDULE_RANGE, ...fitted };
147
+ return { kind: RAIL_SCHEDULE_OFF };
148
+ }
149
+ return { kind: RAIL_SCHEDULE_OFF };
150
+ }
151
+
152
+ /** is `frame` inside the clip? Both ends are inclusive. */
153
+ export function activeAt(schedule, frame) {
154
+ if (!schedule || !finite(frame)) return false;
155
+ if (schedule.kind !== RAIL_SCHEDULE_RANGE && schedule.kind !== RAIL_SCHEDULE_LEGACY) return false;
156
+ return frame >= schedule.startFrame && frame <= schedule.endFrame;
157
+ }
158
+
159
+ /**
160
+ * Which camera owns `frame`? Kept deliberately separate from
161
+ * resolveRailSchedule so the caller passes explicit rail usability (the
162
+ * built geometry, not just the stored points) and the resolved schedule.
163
+ *
164
+ * follow off -> none (keys/free hold as before)
165
+ * no usable geometry -> none (free follow owns the whole clip)
166
+ * off / too-short / none-> none
167
+ * inside the clip -> rail
168
+ * outside the clip -> keys (the caller's existing no-keys fallback
169
+ * to free/hold applies as before)
170
+ */
171
+ export function railCameraOwner({ followEnabled, railUsable, schedule, frame }) {
172
+ if (followEnabled !== true || !railUsable) return RAIL_OWNER_NONE;
173
+ if (activeAt(schedule, frame)) return RAIL_OWNER_RAIL;
174
+ if (schedule && (schedule.kind === RAIL_SCHEDULE_RANGE || schedule.kind === RAIL_SCHEDULE_LEGACY)) return RAIL_OWNER_KEYS;
175
+ return RAIL_OWNER_NONE;
176
+ }
package/src/controls.jsx CHANGED
@@ -49,13 +49,15 @@ export function aimAt(position, target) {
49
49
  * `getPivot()` is optional and returns a world point to orbit; without one,
50
50
  * Alt-drag orbits a point straight ahead of the lens.
51
51
  */
52
- export function FlyControls({ enabled, camRef, look, getPivot, onFlyStateChange }) {
52
+ export function FlyControls({ enabled, camRef, look, getPivot, onFlyStateChange, onCameraChange }) {
53
53
  const { gl } = useThree();
54
54
  const keys = useRef(new Set());
55
55
  const gesture = useRef(null); // { kind: "fly" | "pan" | "orbit", pointerId, x, y, ... }
56
56
  const speedScale = useRef(1);
57
57
  const flyStateRef = useRef(onFlyStateChange);
58
58
  flyStateRef.current = onFlyStateChange;
59
+ const cameraChangeRef = useRef(onCameraChange);
60
+ cameraChangeRef.current = onCameraChange;
59
61
  const pivotRef = useRef(getPivot);
60
62
  pivotRef.current = getPivot;
61
63
 
@@ -102,6 +104,7 @@ export function FlyControls({ enabled, camRef, look, getPivot, onFlyStateChange
102
104
  // the fly speed set with the wheel persists between flights, as it does in Unity
103
105
  element.style.cursor = "";
104
106
  flyStateRef.current?.(false);
107
+ if (active.changed) cameraChangeRef.current?.();
105
108
  };
106
109
 
107
110
  const onPointerDown = (e) => {
@@ -116,7 +119,7 @@ export function FlyControls({ enabled, camRef, look, getPivot, onFlyStateChange
116
119
  e.stopPropagation();
117
120
  const cam = camRef.current;
118
121
  const pivot = kind === "orbit" && cam ? resolvePivot(cam) : null;
119
- gesture.current = { kind, pointerId: e.pointerId, x: e.clientX, y: e.clientY, pivot };
122
+ gesture.current = { kind, pointerId: e.pointerId, x: e.clientX, y: e.clientY, pivot, changed: false };
120
123
  element.setPointerCapture(e.pointerId);
121
124
  element.focus();
122
125
  element.style.cursor = kind === "fly" ? "crosshair" : kind === "pan" ? "grabbing" : "move";
@@ -145,6 +148,8 @@ export function FlyControls({ enabled, camRef, look, getPivot, onFlyStateChange
145
148
  look.current.yaw -= dx * LOOK_SENS;
146
149
  look.current.pitch -= dy * LOOK_SENS;
147
150
  look.current.pitch = THREE.MathUtils.clamp(look.current.pitch, -PITCH_LIMIT, PITCH_LIMIT);
151
+ active.changed = active.changed || dx !== 0 || dy !== 0;
152
+ if (dx !== 0 || dy !== 0) cameraChangeRef.current?.();
148
153
  return;
149
154
  }
150
155
  if (active.kind === "pan") {
@@ -156,6 +161,8 @@ export function FlyControls({ enabled, camRef, look, getPivot, onFlyStateChange
156
161
  cam.position.addScaledVector(right, -dx * PAN_SENS * distance);
157
162
  cam.position.addScaledVector(up, dy * PAN_SENS * distance);
158
163
  cam.position.y = Math.max(cam.position.y, 0.12);
164
+ active.changed = active.changed || dx !== 0 || dy !== 0;
165
+ if (dx !== 0 || dy !== 0) cameraChangeRef.current?.();
159
166
  return;
160
167
  }
161
168
  // orbit: swing the camera around the pivot, then re-aim at it so the
@@ -172,6 +179,8 @@ export function FlyControls({ enabled, camRef, look, getPivot, onFlyStateChange
172
179
  const angles = aimAt(cam.position, pivot);
173
180
  look.current.yaw = angles.yaw;
174
181
  look.current.pitch = angles.pitch;
182
+ active.changed = active.changed || dx !== 0 || dy !== 0;
183
+ if (dx !== 0 || dy !== 0) cameraChangeRef.current?.();
175
184
  };
176
185
 
177
186
  const onWheel = (e) => {
@@ -184,6 +193,7 @@ export function FlyControls({ enabled, camRef, look, getPivot, onFlyStateChange
184
193
  return;
185
194
  }
186
195
  cam.position.addScaledVector(forwardFrom(look.current.yaw, look.current.pitch), -e.deltaY * DOLLY_STEP);
196
+ cameraChangeRef.current?.();
187
197
  };
188
198
 
189
199
  // A right-drag is navigation, so the browser menu must not interrupt it.
@@ -239,6 +249,10 @@ export function FlyControls({ enabled, camRef, look, getPivot, onFlyStateChange
239
249
  if (keys.current.has("e")) cam.position.y += crane;
240
250
  if (keys.current.has("q")) cam.position.y -= crane;
241
251
  cam.position.y = Math.max(cam.position.y, 0.12);
252
+ if (gesture.current) {
253
+ gesture.current.changed = true;
254
+ cameraChangeRef.current?.();
255
+ }
242
256
  });
243
257
 
244
258
  return null;