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.
- package/README.md +59 -24
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/bin/cozyclay.mjs +105 -11
- package/dist/CNAME +1 -0
- package/dist/app/index.html +57 -0
- package/dist/assets/app-Cgpk2hwX.js +4803 -0
- package/dist/assets/app-DgZvaAE1.css +1 -0
- package/dist/favicon.ico +0 -0
- package/dist/icons/apple-touch-icon.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-32.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/icon-maskable-512.png +0 -0
- package/dist/index.html +406 -5
- package/dist/manifest.webmanifest +42 -0
- package/dist/media/cozyclay-demo-poster.jpg +0 -0
- package/dist/media/cozyclay-logo.png +0 -0
- package/dist/robots.txt +4 -0
- package/dist/sitemap.xml +9 -0
- package/dist/sw.js +127 -0
- package/package.json +15 -3
- package/src/App.jsx +1393 -648
- package/src/ardy/client.js +5 -4
- package/src/ardy/mp4-duration.js +102 -0
- package/src/ardy/timeline-coordinates.js +11 -0
- package/src/ardy/timeline.jsx +528 -67
- package/src/ardy/waypoints.js +14 -9
- package/src/camera-block.js +60 -0
- package/src/camera-follow.js +49 -10
- package/src/camera-rail-schedule.js +176 -0
- package/src/controls.jsx +16 -2
- package/src/cuts.js +184 -0
- package/src/generation/client.js +56 -0
- package/src/generation/session.js +47 -0
- package/src/generation/shot-spec.js +40 -0
- package/src/generation/use-generation.js +55 -0
- package/src/hierarchy-model.js +9 -17
- package/src/hierarchy-panel.jsx +170 -23
- package/src/locale-toggle.jsx +18 -0
- package/src/locale.js +34 -0
- package/src/main.jsx +5 -0
- package/src/object-catalog.jsx +32 -3
- package/src/planview.jsx +174 -66
- package/src/pose-extract/browser.js +42 -0
- package/src/pose-extract/fit.js +194 -0
- package/src/pose-extract/index.js +53 -0
- package/src/pose-extract/keyframes.js +74 -0
- package/src/pose-extract/landmarks.js +162 -0
- package/src/poses.js +114 -0
- package/src/posestudio.jsx +75 -13
- package/src/pwa.js +42 -0
- package/src/result-modal.jsx +124 -0
- package/src/scenes.js +223 -0
- package/src/shot-authoring.js +204 -77
- package/src/shot.js +8 -4
- package/src/styles.css +839 -38
- package/src/ui.jsx +54 -5
- package/tools/ardy/BRIDGE.md +70 -4
- package/tools/ardy/README.md +55 -0
- package/tools/ardy/bridge.mjs +89 -389
- package/tools/ardy/cclay_constrained_generate.py +449 -0
- package/tools/ardy/cclay_motion_edit.py +74 -9
- package/tools/ardy/cclay_sequence_generate.py +68 -7
- package/tools/ardy/cclay_worker.py +304 -0
- package/tools/ardy/merge-text-encoder.py +160 -0
- package/tools/ardy/run-local.mjs +233 -0
- package/tools/ardy/runners/index.mjs +23 -0
- package/tools/ardy/runners/local.mjs +452 -0
- package/tools/ardy/runners/proc.mjs +125 -0
- package/tools/ardy/runners/remote.mjs +246 -0
- package/tools/ardy/setup-local.mjs +189 -0
- package/tools/ardy/setup-text-encoder-on-box.sh +93 -0
- package/tools/ardy/setup-text-encoder.py +296 -0
- package/tools/generation/README.md +29 -0
- package/tools/generation/bridge.mjs +149 -0
- package/tools/generation/job-store.mjs +51 -0
- package/tools/generation/providers/kling.mjs +76 -0
- package/tools/generation/providers/runway.mjs +101 -0
- package/tools/generation/providers/seedance.mjs +87 -0
- package/tools/generation/providers/shared.mjs +49 -0
- package/tools/generation/providers/veo.mjs +84 -0
- package/tools/metrics/archive-traffic.mjs +138 -0
- package/dist/assets/index-BW_S1YRy.js +0 -4395
- package/dist/assets/index-nRmmJgap.css +0 -1
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -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/qa-browser.mjs +0 -84
- package/tools/qa-crop3.mjs +0 -22
- package/tools/qa-playview.mjs +0 -49
- package/tools/qa-screenshot.mjs +0 -24
- package/tools/qa-visual.mjs +0 -51
package/src/ardy/waypoints.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/camera-follow.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
336
|
-
//
|
|
337
|
-
let s =
|
|
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] =
|
|
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;
|