cozyclay 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/bin/cozyclay.mjs +194 -0
  5. package/dist/ardy/cskel27-rest.json +557 -0
  6. package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
  8. package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
  9. package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
  10. package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
  11. package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
  12. package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
  13. package/dist/assets/index-BW_S1YRy.js +4395 -0
  14. package/dist/assets/index-nRmmJgap.css +1 -0
  15. package/dist/demo/walk-then-stop.npz +0 -0
  16. package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
  17. package/dist/fonts/instrument-serif-latin.woff2 +0 -0
  18. package/dist/fonts/inter-latin.woff2 +0 -0
  19. package/dist/index.html +13 -0
  20. package/dist/models/x-bot-tpose.fbx +0 -0
  21. package/dist/models/y-bot-tpose.fbx +0 -0
  22. package/package.json +80 -0
  23. package/src/App.jsx +3840 -0
  24. package/src/ardy/client.js +140 -0
  25. package/src/ardy/convert.js +313 -0
  26. package/src/ardy/cskel27-neutral.js +39 -0
  27. package/src/ardy/cskel27.js +68 -0
  28. package/src/ardy/export.js +157 -0
  29. package/src/ardy/ik.js +610 -0
  30. package/src/ardy/npz.js +520 -0
  31. package/src/ardy/playback.js +414 -0
  32. package/src/ardy/prompt-clips.js +16 -0
  33. package/src/ardy/timeline-coordinates.js +17 -0
  34. package/src/ardy/timeline-resize.js +11 -0
  35. package/src/ardy/timeline.jsx +723 -0
  36. package/src/ardy/to-cskel27.js +205 -0
  37. package/src/ardy/waypoints.js +364 -0
  38. package/src/camera-follow.js +366 -0
  39. package/src/camera-move.js +286 -0
  40. package/src/controls.jsx +245 -0
  41. package/src/dualview.jsx +305 -0
  42. package/src/hierarchy-model.js +77 -0
  43. package/src/hierarchy-panel.jsx +365 -0
  44. package/src/history.js +90 -0
  45. package/src/main.jsx +10 -0
  46. package/src/object-catalog.jsx +90 -0
  47. package/src/object-gizmo.jsx +755 -0
  48. package/src/planview.jsx +653 -0
  49. package/src/poses.js +424 -0
  50. package/src/posestudio.jsx +811 -0
  51. package/src/props.jsx +302 -0
  52. package/src/room.jsx +64 -0
  53. package/src/scene-history.js +125 -0
  54. package/src/scene-objects.js +420 -0
  55. package/src/shot-authoring.js +113 -0
  56. package/src/shot.js +239 -0
  57. package/src/styles.css +5639 -0
  58. package/src/ui.jsx +391 -0
  59. package/src/use-render-activity.js +96 -0
  60. package/tools/ardy/BRIDGE.md +255 -0
  61. package/tools/ardy/README.md +136 -0
  62. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  63. package/tools/ardy/bridge.mjs +1427 -0
  64. package/tools/ardy/cclay_motion_edit.py +445 -0
  65. package/tools/ardy/cclay_sequence_generate.py +595 -0
  66. package/tools/ardy/dump-npz.py +205 -0
  67. package/tools/ardy/extract-rest.mjs +299 -0
  68. package/tools/ardy/npz.mjs +335 -0
  69. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  70. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  71. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  72. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  73. package/tools/ardy/pose-to-npz.mjs +106 -0
  74. package/tools/ardy/run-edit-on-box.sh +73 -0
  75. package/tools/ardy/run-on-box.sh +568 -0
  76. package/tools/ardy/run-sequence-on-box.sh +162 -0
  77. package/tools/ardy/visual-qa.mjs +188 -0
  78. package/tools/ardy/vq-car.mjs +52 -0
  79. package/tools/dev-full.mjs +29 -0
  80. package/tools/process-supervisor.mjs +63 -0
  81. package/tools/qa-browser.mjs +84 -0
  82. package/tools/qa-crop3.mjs +22 -0
  83. package/tools/qa-playview.mjs +49 -0
  84. package/tools/qa-screenshot.mjs +24 -0
  85. package/tools/qa-visual.mjs +51 -0
@@ -0,0 +1,723 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { frameFromClientX, promptMoveStartFrame } from "./timeline-coordinates.js";
3
+ import { promptResizeFrame } from "./timeline-resize.js";
4
+
5
+ /**
6
+ * ARDY Viser-style animation timeline — the live motion workspace.
7
+ *
8
+ * Frame, playback, waypoints and the motion badge are owned by App: this
9
+ * component is controlled and reports every interaction through callbacks,
10
+ * so the scene (character rig, root path) can react to playhead moves.
11
+ * The 2D Root lane authors temporal keyframes directly: left-clicking an empty
12
+ * track cell creates/selects a numbered waypoint, then Top-View owns its
13
+ * spatial position through direct marker dragging.
14
+ * The Camera lane authors shot-camera keyframings directly: left-clicking an
15
+ * empty track cell keys the CURRENT camera framing at that frame, dots jump
16
+ * the playhead on click, drag to re-time, right-click to remove. Playback
17
+ * and PlayView ride the keys segment by segment.
18
+ */
19
+
20
+ const DEFAULT_FRAME_COUNT = 300; // 15 s @ 20 fps, the ARDY Core cadence
21
+ const DEFAULT_FPS = 20;
22
+ // Trackpad/wheel zoom over the FRAME ruler: the gesture changes only the
23
+ // horizontal visual scale of the time surface — never frameCount, fps,
24
+ // duration, waypoints or the generation request.
25
+ const ZOOM_MIN = 1 / 3; // show up to 3× more frames than the 1× viewport
26
+ const ZOOM_DEFAULT = 1;
27
+ const ZOOM_MAX = 8;
28
+ const ZOOM_STEP = 0.25; // one wheel notch = one zoom step
29
+ // Wheel deltas are accumulated in pixels; LINE/PAGE deltas are normalized
30
+ // (a line-mode notch is ~3 lines). A step fires every 50 px, capped at 3
31
+ // steps per event so a violent flick cannot jump the whole range.
32
+ const WHEEL_STEP_PX = 50;
33
+ const MAX_WHEEL_STEPS = 3;
34
+
35
+ const TRACKS = [
36
+ "Prompts",
37
+ "Full-Body",
38
+ "2D Root",
39
+ "Camera",
40
+ ];
41
+
42
+ /** IK keys live on the Full-Body lane: one marker per keyed frame, holding
43
+ * a sparse set of the limbs the user has moved (never every joint). */
44
+ const IK_LANE = "Full-Body";
45
+ const CAMERA_LANE = "Camera";
46
+ // Ruler labels and lane gridlines share one 10-frame cadence, so authored
47
+ // elements (40-frame prompt blocks, camera key dots) always land on visible
48
+ // lines. Label density adapts to zoom in 10-based steps.
49
+ const GRID_STEP_FRAMES = 10;
50
+ const LABEL_STEPS = [10, 20, 50, 100, 200, 500, 1000];
51
+ const MAX_LABELS = 30;
52
+
53
+ const framePct = (f, count) => (count > 1 ? f / (count - 1) : 0);
54
+
55
+ export default function Timeline({
56
+ frame,
57
+ frameCount = DEFAULT_FRAME_COUNT,
58
+ fps = DEFAULT_FPS,
59
+ playbackSpeed = 1,
60
+ playing,
61
+ waypointMode,
62
+ waypointFrames = [],
63
+ pathSpeed = null,
64
+ badge,
65
+ promptClips = [],
66
+ selectedPromptId,
67
+ pendingWaypointFrame = null,
68
+ ikMode = false,
69
+ ikDisabled = false, // a loaded motion owns the rig
70
+ ikFrames = [], // sorted full-body key frames
71
+ footSnap = true, // feet stay planted while the body moves
72
+ cameraKeyFrames = [], // sorted camera key frames — dots on the Camera lane
73
+ onScrub,
74
+ onAdvance,
75
+ onStep,
76
+ onPlayToggle,
77
+ onWaypointToggle,
78
+ onMarkerSelect,
79
+ onMarkerRemove,
80
+ onRootKeyframeAdd,
81
+ onPromptAdd,
82
+ onPromptSelect,
83
+ onPromptChange,
84
+ onPromptResize,
85
+ onPromptMove,
86
+ onPromptRemove,
87
+ onClearMotion,
88
+ onIkToggle,
89
+ onIkKeyframeAdd,
90
+ onIkKeyframeRemove,
91
+ onFootSnapToggle,
92
+ onCameraMoveSelect,
93
+ onCameraKeyframeAdd,
94
+ onCameraKeyframeMove,
95
+ onCameraKeyframeRemove,
96
+ }) {
97
+ const [expanded, setExpanded] = useState(true);
98
+ const [zoom, setZoom] = useState(ZOOM_DEFAULT);
99
+ const [movingPromptId, setMovingPromptId] = useState(null);
100
+ const rulerRef = useRef(null);
101
+ const bodyRef = useRef(null);
102
+ const scrubbing = useRef(false);
103
+ // Wheel zoom is driven by the pointer's live gesture: zoomRef tracks the
104
+ // intended zoom synchronously between renders, renderedRef the zoom that
105
+ // is actually in the DOM, and pendingScrollRef the scrollLeft to apply
106
+ // right after the next render so the frame under the pointer stays put.
107
+ const zoomRef = useRef(ZOOM_DEFAULT);
108
+ const renderedRef = useRef(ZOOM_DEFAULT);
109
+ const pendingScrollRef = useRef(null);
110
+ // The window key/interval handlers register once; the latest callbacks
111
+ // are read through a ref so they never go stale mid-playback.
112
+ const handlers = useRef({});
113
+ handlers.current = { onScrub, onAdvance, onStep, onPlayToggle, onWaypointToggle, onMarkerSelect, onMarkerRemove, onRootKeyframeAdd, onPromptAdd, onPromptSelect, onPromptChange, onPromptResize, onPromptMove, onPromptRemove, onIkToggle, onIkKeyframeAdd, onIkKeyframeRemove, onFootSnapToggle, onCameraMoveSelect, onCameraKeyframeAdd, onCameraKeyframeMove, onCameraKeyframeRemove };
114
+
115
+ // Trackpad/wheel zoom over the FRAME ruler lane only. React registers
116
+ // onWheel as passive, so a synthetic onWheel could never preventDefault —
117
+ // attach a real non-passive listener instead, so a vertical gesture over
118
+ // the ruler zooms the timeline instead of scrolling the page. Under Mac
119
+ // natural scrolling the signed delta follows the physical gesture: two
120
+ // fingers up (deltaY > 0) zooms IN, two fingers down (deltaY < 0) zooms
121
+ // OUT. Horizontal swipes (deltaY === 0) are left alone so they scroll the
122
+ // zoomed surface natively within .tl-body.
123
+ useEffect(() => {
124
+ const el = rulerRef.current;
125
+ if (!el) return;
126
+ let acc = 0;
127
+ const onWheel = (e) => {
128
+ if (e.deltaY === 0) return;
129
+ e.preventDefault();
130
+ const dy =
131
+ e.deltaMode === 1 ? (e.deltaY * WHEEL_STEP_PX) / 3 : e.deltaMode === 2 ? e.deltaY * WHEEL_STEP_PX * 8 : e.deltaY;
132
+ acc += dy;
133
+ let steps = Math.trunc(acc / WHEEL_STEP_PX);
134
+ steps = Math.max(-MAX_WHEEL_STEPS, Math.min(MAX_WHEEL_STEPS, steps));
135
+ acc -= steps * WHEEL_STEP_PX;
136
+ if (steps === 0) return;
137
+ const zR = renderedRef.current;
138
+ const z1 = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoomRef.current + steps * ZOOM_STEP));
139
+ if (z1 === zR) return;
140
+ zoomRef.current = z1;
141
+ setZoom(z1);
142
+ // Keep the frame under the pointer anchored. `dx` already includes
143
+ // the sticky label offset and the body's current horizontal scroll,
144
+ // so only add the extra scaled distance.
145
+ const body = bodyRef.current;
146
+ const lane = rulerRef.current;
147
+ if (!body || !lane) return;
148
+ const rect = lane.getBoundingClientRect();
149
+ if (rect.width <= 0) return;
150
+ const dx = e.clientX - rect.left;
151
+ const labelW = parseFloat(getComputedStyle(body).getPropertyValue("--tl-label-w")) || 148;
152
+ const surfaceR = Math.max(ZOOM_DEFAULT, zR);
153
+ const surface1 = Math.max(ZOOM_DEFAULT, z1);
154
+ const maxScroll = Math.max(0, labelW + (rect.width / surfaceR) * surface1 - body.clientWidth);
155
+ const target = body.scrollLeft + dx * (surface1 / surfaceR - 1);
156
+ pendingScrollRef.current = Math.max(0, Math.min(target, maxScroll));
157
+ };
158
+ el.addEventListener("wheel", onWheel, { passive: false });
159
+ return () => el.removeEventListener("wheel", onWheel);
160
+ // The ruler lane unmounts when the timeline collapses, so re-attach
161
+ // whenever it comes back; zoom state itself lives on in the component.
162
+ }, [expanded]);
163
+
164
+ // Commit the gesture's scroll anchor right after the zoomed layout lands,
165
+ // before paint — otherwise the surface would snap back to frame 0.
166
+ useLayoutEffect(() => {
167
+ renderedRef.current = zoom;
168
+ if (pendingScrollRef.current == null) return;
169
+ const body = bodyRef.current;
170
+ if (body) {
171
+ const max = Math.max(0, body.scrollWidth - body.clientWidth);
172
+ body.scrollLeft = Math.max(0, Math.min(pendingScrollRef.current, max));
173
+ }
174
+ pendingScrollRef.current = null;
175
+ }, [zoom]);
176
+
177
+ function resetZoom() {
178
+ if (renderedRef.current === ZOOM_DEFAULT && zoomRef.current === ZOOM_DEFAULT) return;
179
+ zoomRef.current = ZOOM_DEFAULT;
180
+ setZoom(ZOOM_DEFAULT);
181
+ pendingScrollRef.current = 0; // at 1× the surface fits exactly: no scroll
182
+ }
183
+
184
+ // Playhead advance at the loaded clip's fps multiplied by preview speed;
185
+ // wraps at the end without resampling the underlying motion.
186
+ useEffect(() => {
187
+ if (!playing) return;
188
+ const id = window.setInterval(
189
+ () => handlers.current.onAdvance?.(),
190
+ 1000 / Math.max(1, fps * playbackSpeed),
191
+ );
192
+ return () => window.clearInterval(id);
193
+ }, [playing, fps, playbackSpeed]);
194
+
195
+ // Space toggles playback, j/k step the playhead, p toggles waypoint mode —
196
+ // but only when focus is outside interactive native controls: a focused
197
+ // input/select owns its keys, and Space on a focused button would trigger
198
+ // the button's native activation on top of the global toggle. Focus on
199
+ // the body or canvas keeps the shortcuts live.
200
+ useEffect(() => {
201
+ function onKey(e) {
202
+ const el = document.activeElement;
203
+ const interactive =
204
+ el &&
205
+ (el.tagName === "INPUT" ||
206
+ el.tagName === "TEXTAREA" ||
207
+ el.tagName === "SELECT" ||
208
+ el.tagName === "BUTTON" ||
209
+ el.isContentEditable);
210
+ if (interactive) return;
211
+ // Physical key codes, not characters — Hangul IME turns j/k/p into
212
+ // ㅓ/ㅔ/ㅔ and the shortcuts would go dead (same fix as FlyControls).
213
+ // Space and P are toggles — ignore OS key-repeat so a held key
214
+ // cannot flap playback or waypoint mode; j/k keep stepping.
215
+ if (e.repeat && (e.code === "Space" || e.code === "KeyP")) return;
216
+ const h = handlers.current;
217
+ if (e.code === "Space") {
218
+ e.preventDefault();
219
+ h.onPlayToggle?.();
220
+ } else if (e.code === "KeyJ") {
221
+ h.onStep?.(1);
222
+ } else if (e.code === "KeyK") {
223
+ h.onStep?.(-1);
224
+ } else if (e.code === "KeyP") {
225
+ h.onWaypointToggle?.();
226
+ }
227
+ }
228
+ window.addEventListener("keydown", onKey);
229
+ return () => window.removeEventListener("keydown", onKey);
230
+ }, []);
231
+
232
+ // Below 1× the surface still fills the viewport; the ruler exposes a wider
233
+ // virtual frame range instead of physically shrinking into the left edge.
234
+ const displayFrameCount = zoom < ZOOM_DEFAULT ? Math.ceil(frameCount / zoom) : frameCount;
235
+ const surfaceZoom = Math.max(ZOOM_DEFAULT, zoom);
236
+ const labelStep = LABEL_STEPS.find((s) => (displayFrameCount - 1) / s <= MAX_LABELS) ?? LABEL_STEPS[LABEL_STEPS.length - 1];
237
+ const labels = useMemo(() => {
238
+ const out = [];
239
+ for (let f = 0; f < displayFrameCount; f += labelStep) out.push(f);
240
+ return out;
241
+ }, [displayFrameCount, labelStep]);
242
+ // Lane gridlines ride the same 10-frame cadence and the same framePct as
243
+ // the ruler ticks, so lines and labels can never drift apart at any zoom.
244
+ const gridFrames = useMemo(() => {
245
+ const out = [];
246
+ for (let f = 0; f < displayFrameCount; f += GRID_STEP_FRAMES) out.push(f);
247
+ return out;
248
+ }, [displayFrameCount]);
249
+ // Chips clamp into the surface; markers use framePct directly. One scale
250
+ // for everything — the old /count chip scale drifted off the gridlines.
251
+ const clipPct = (value) => Math.max(0, Math.min(1, framePct(value, displayFrameCount)));
252
+ const moveRef = useRef(null);
253
+ const suppressPromptClickRef = useRef(false);
254
+ const resizeRef = useRef(null);
255
+ const camDragRef = useRef(null);
256
+ const camSuppressClickRef = useRef(false);
257
+
258
+ function beginPromptMove(e, clip) {
259
+ if (e.button !== 0 || e.target.closest(".tl-chip-handle")) return;
260
+ const lane = e.currentTarget.closest(".tl-lane");
261
+ if (!lane) return;
262
+ const rect = lane.getBoundingClientRect();
263
+ moveRef.current = {
264
+ id: clip.id,
265
+ pointerId: e.pointerId,
266
+ startClientX: e.clientX,
267
+ startFrame: clip.startFrame,
268
+ laneWidth: rect.width,
269
+ displayFrameCount,
270
+ moving: false,
271
+ };
272
+ handlers.current.onPromptSelect?.(clip.id);
273
+ }
274
+
275
+ function movePrompt(e) {
276
+ const active = moveRef.current;
277
+ if (!active || e.pointerId !== active.pointerId) return;
278
+ if (!active.moving) {
279
+ if (Math.abs(e.clientX - active.startClientX) < 4) return;
280
+ active.moving = true;
281
+ e.currentTarget.setPointerCapture(e.pointerId);
282
+ setMovingPromptId(active.id);
283
+ document.activeElement?.blur();
284
+ }
285
+ e.preventDefault();
286
+ e.stopPropagation();
287
+ const rawStart = promptMoveStartFrame(
288
+ active.startFrame,
289
+ active.startClientX,
290
+ e.clientX,
291
+ active.laneWidth,
292
+ active.displayFrameCount
293
+ );
294
+ handlers.current.onPromptMove?.(active.id, rawStart);
295
+ }
296
+
297
+ function endPromptMove(e) {
298
+ const active = moveRef.current;
299
+ if (!active || e.pointerId !== active.pointerId) return;
300
+ if (active.moving) {
301
+ e.preventDefault();
302
+ e.stopPropagation();
303
+ suppressPromptClickRef.current = true;
304
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
305
+ queueMicrotask(() => { suppressPromptClickRef.current = false; });
306
+ }
307
+ moveRef.current = null;
308
+ setMovingPromptId(null);
309
+ }
310
+
311
+ function blockPromptClick(e) {
312
+ if (!suppressPromptClickRef.current) return;
313
+ e.preventDefault();
314
+ e.stopPropagation();
315
+ }
316
+
317
+ function beginPromptResize(e, clip, edge) {
318
+ if (e.button !== 0) return;
319
+ e.preventDefault();
320
+ e.stopPropagation();
321
+ e.currentTarget.setPointerCapture?.(e.pointerId);
322
+ resizeRef.current = {
323
+ id: clip.id,
324
+ edge,
325
+ startClientX: e.clientX,
326
+ startFrame: edge === "start" ? clip.startFrame : clip.endFrame,
327
+ lastFrame: edge === "start" ? clip.startFrame : clip.endFrame,
328
+ };
329
+ handlers.current.onPromptSelect?.(clip.id);
330
+ }
331
+
332
+ function movePromptResize(e) {
333
+ const active = resizeRef.current;
334
+ if (!active) return;
335
+ const nextFrame = promptResizeFrame(active.startFrame, active.startClientX, e.clientX);
336
+ if (nextFrame === active.lastFrame) return;
337
+ active.lastFrame = nextFrame;
338
+ handlers.current.onPromptResize?.(active.id, active.edge, nextFrame);
339
+ }
340
+
341
+ function endPromptResize(e) {
342
+ if (!resizeRef.current) return;
343
+ resizeRef.current = null;
344
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
345
+ }
346
+
347
+ // Camera key dots re-time by dragging, like prompt clips move: the frame
348
+ // delta comes from the pointer-down geometry so a growing timeline cannot
349
+ // feed back into the next pointermove.
350
+ function beginCameraKeyDrag(e, keyFrame) {
351
+ if (e.button !== 0) return;
352
+ e.stopPropagation();
353
+ // A 10px dot loses the pointer almost immediately without capture —
354
+ // grab it on pointerdown so the drag survives past the marker's edge.
355
+ e.currentTarget.setPointerCapture?.(e.pointerId);
356
+ const lane = e.currentTarget.closest(".tl-lane");
357
+ const rect = lane?.getBoundingClientRect();
358
+ camDragRef.current = {
359
+ pointerId: e.pointerId,
360
+ startClientX: e.clientX,
361
+ fromFrame: keyFrame,
362
+ currentFrame: keyFrame,
363
+ laneWidth: rect?.width ?? 1,
364
+ displayFrameCount,
365
+ moved: false,
366
+ };
367
+ }
368
+
369
+ function moveCameraKeyDrag(e) {
370
+ const active = camDragRef.current;
371
+ if (!active || e.pointerId !== active.pointerId) return;
372
+ if (!active.moved) {
373
+ if (Math.abs(e.clientX - active.startClientX) < 4) return;
374
+ active.moved = true;
375
+ }
376
+ e.preventDefault();
377
+ e.stopPropagation();
378
+ const framesPerPixel = Math.max(0, active.displayFrameCount - 1) / Math.max(1, active.laneWidth);
379
+ const raw = active.fromFrame + (e.clientX - active.startClientX) * framesPerPixel;
380
+ const next = Math.max(0, Math.min(frameCount - 1, Math.round(raw)));
381
+ if (next === active.currentFrame) return;
382
+ const from = active.currentFrame;
383
+ active.currentFrame = next;
384
+ handlers.current.onCameraKeyframeMove?.(from, next);
385
+ }
386
+
387
+ function endCameraKeyDrag(e) {
388
+ const active = camDragRef.current;
389
+ if (!active || e.pointerId !== active.pointerId) return;
390
+ if (active.moved) {
391
+ e.preventDefault();
392
+ e.stopPropagation();
393
+ camSuppressClickRef.current = true;
394
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
395
+ queueMicrotask(() => { camSuppressClickRef.current = false; });
396
+ }
397
+ camDragRef.current = null;
398
+ }
399
+
400
+ function onCameraKeyClick(e, keyFrame) {
401
+ if (camSuppressClickRef.current) {
402
+ e.preventDefault();
403
+ e.stopPropagation();
404
+ return;
405
+ }
406
+ e.stopPropagation();
407
+ handlers.current.onScrub?.(keyFrame);
408
+ handlers.current.onCameraMoveSelect?.();
409
+ }
410
+
411
+ function frameFromEvent(e) {
412
+ const el = rulerRef.current;
413
+ if (!el) return frame;
414
+ const rect = el.getBoundingClientRect();
415
+ return frameFromClientX(e.clientX, rect.left, rect.width, displayFrameCount, frameCount);
416
+ }
417
+
418
+ function rootFrameFromEvent(e) {
419
+ const rect = e.currentTarget.getBoundingClientRect();
420
+ return frameFromClientX(e.clientX, rect.left, rect.width, displayFrameCount, frameCount);
421
+ }
422
+
423
+ function onRulerDown(e) {
424
+ if (e.button !== 0) return;
425
+ scrubbing.current = true;
426
+ rulerRef.current?.setPointerCapture?.(e.pointerId);
427
+ handlers.current.onScrub?.(frameFromEvent(e));
428
+ }
429
+
430
+ function onRulerMove(e) {
431
+ if (!scrubbing.current) return;
432
+ handlers.current.onScrub?.(frameFromEvent(e));
433
+ }
434
+
435
+ function onRulerUp(e) {
436
+ if (!scrubbing.current) return;
437
+ scrubbing.current = false;
438
+ rulerRef.current?.releasePointerCapture?.(e.pointerId);
439
+ }
440
+
441
+ return (
442
+ <section className={"timeline" + (expanded ? "" : " collapsed")} aria-label="Animation timeline">
443
+ {expanded ? (
444
+ <>
445
+ <div className="tl-head">
446
+ <div className="tl-transport" aria-label="Playback transport">
447
+ <button
448
+ type="button"
449
+ className="tl-btn"
450
+ aria-label="Previous frame"
451
+ title="Previous frame (k)"
452
+ onClick={() => handlers.current.onStep?.(-1)}
453
+ >
454
+
455
+ </button>
456
+ <button
457
+ type="button"
458
+ className={"tl-btn play" + (playing ? " on" : "")}
459
+ aria-label={playing ? "Pause playback" : "Play playback"}
460
+ title="Play / pause (Space)"
461
+ onClick={() => handlers.current.onPlayToggle?.()}
462
+ >
463
+ {playing ? "❚❚" : "▶"}
464
+ </button>
465
+ <button
466
+ type="button"
467
+ className="tl-btn"
468
+ aria-label="Next frame"
469
+ title="Next frame (j)"
470
+ onClick={() => handlers.current.onStep?.(1)}
471
+ >
472
+
473
+ </button>
474
+ <span className="tl-readout" aria-live="polite">
475
+ <b>{frame}</b> / {frameCount - 1} · {fps} fps · {playbackSpeed.toFixed(2)}×
476
+ </span>
477
+ </div>
478
+ <button
479
+ type="button"
480
+ className={"tl-btn zoom" + (zoom !== ZOOM_DEFAULT ? " on" : "")}
481
+ title="Two-finger up/down over FRAME ruler to zoom — click to reset to 1×"
482
+ onClick={resetZoom}
483
+ >
484
+ {zoom.toFixed(2)}×
485
+ </button>
486
+ <button
487
+ type="button"
488
+ className={"tl-btn wp" + (waypointMode ? " on" : "")}
489
+ aria-pressed={waypointMode}
490
+ title="Enable or disable 2D Root path constraints (P)"
491
+ onClick={() => handlers.current.onWaypointToggle?.()}
492
+ >
493
+ Waypoint {waypointMode ? "on" : "off"}
494
+ </button>
495
+ <button
496
+ type="button"
497
+ className={"tl-btn ik" + (ikMode ? " on" : "")}
498
+ aria-pressed={ikMode}
499
+ disabled={ikDisabled && !ikMode}
500
+ title={ikDisabled && !ikMode ? "IK needs Subject 1's rig loaded" : "IK mode — drag a wrist / ankle handle; keys land on the Full-Body lane. With a motion loaded, keys correct it layer-style"}
501
+ onClick={() => handlers.current.onIkToggle?.()}
502
+ >
503
+ IK {ikMode ? "on" : "off"}
504
+ </button>
505
+ <button
506
+ type="button"
507
+ className={"tl-btn ik snap" + (footSnap ? " on" : "")}
508
+ aria-pressed={footSnap}
509
+ title="Foot snap — keep the feet planted while you move the body (hips); the knees bend instead of the feet sinking through the floor"
510
+ onClick={() => handlers.current.onFootSnapToggle?.()}
511
+ >
512
+ Snap {footSnap ? "on" : "off"}
513
+ </button>
514
+ {waypointMode && (
515
+ <span className={"tl-wp-hint" + (waypointFrames.length < 2 || pathSpeed?.warn ? " warn" : "")}>
516
+ {waypointFrames.length < 2
517
+ ? "Click the set floor in the Shot view to drop waypoints"
518
+ : `${waypointFrames.length} root waypoints` +
519
+ (pathSpeed
520
+ ? ` · ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s${pathSpeed.warn ? " — outside the natural 0.5–3 m/s locomotion band" : ""}`
521
+ : "") +
522
+ " · click the set floor to add more"}
523
+ </span>
524
+ )}
525
+ {badge && <span className={"tl-badge " + badge.kind}>{badge.label}</span>}
526
+ {onClearMotion && (
527
+ <button
528
+ type="button"
529
+ className="tl-btn clear"
530
+ title="Clear motion and restore the blocking pose"
531
+ onClick={onClearMotion}
532
+ >
533
+ ✕ Motion
534
+ </button>
535
+ )}
536
+ <button
537
+ type="button"
538
+ className="tl-toggle"
539
+ aria-expanded="true"
540
+ aria-label="Collapse timeline"
541
+ title="Collapse timeline"
542
+ onClick={() => setExpanded(false)}
543
+ >
544
+
545
+ </button>
546
+ </div>
547
+
548
+ <div className="tl-body" ref={bodyRef}>
549
+ <div className="tl-surface" style={{ "--tl-zoom": surfaceZoom }}>
550
+ <div className="tl-ruler">
551
+ <span className="tl-ruler-label">Frame</span>
552
+ <div
553
+ className="tl-ruler-lane"
554
+ ref={rulerRef}
555
+ role="slider"
556
+ aria-label="Scrub timeline"
557
+ aria-valuemin={0}
558
+ aria-valuemax={frameCount - 1}
559
+ aria-valuenow={frame}
560
+ tabIndex={0}
561
+ onPointerDown={onRulerDown}
562
+ onPointerMove={onRulerMove}
563
+ onPointerUp={onRulerUp}
564
+ onPointerCancel={onRulerUp}
565
+ onKeyDown={(e) => {
566
+ if (e.key === "ArrowRight") {
567
+ e.preventDefault();
568
+ handlers.current.onStep?.(1);
569
+ } else if (e.key === "ArrowLeft") {
570
+ e.preventDefault();
571
+ handlers.current.onStep?.(-1);
572
+ }
573
+ }}
574
+ >
575
+ <span className="tl-frame-box" style={{ "--tl-f": framePct(frame, displayFrameCount) }} aria-hidden="true">
576
+ {frame}
577
+ </span>
578
+ {labels.map((f) => (
579
+ <span key={f} className="tl-tick" style={{ "--tl-f": framePct(f, displayFrameCount) }}>
580
+ <i />
581
+ {f}
582
+ </span>
583
+ ))}
584
+ </div>
585
+ </div>
586
+
587
+ {TRACKS.map((name) => (
588
+ <div className={"tl-track" + (name === "Prompts" ? " prompts" : "") + (name === IK_LANE ? " ik" : "")} key={name}>
589
+ <span className="tl-track-label">
590
+ {name}
591
+ {name === "Prompts" && <button className="tl-track-add" type="button" title="Add 2 second prompt clip" onClick={() => handlers.current.onPromptAdd?.(frame)}>+</button>}
592
+ {name === IK_LANE && ikMode && (
593
+ <button
594
+ className="tl-track-add ik"
595
+ type="button"
596
+ title={`Key the current pose at frame ${frame}`}
597
+ onClick={() => handlers.current.onIkKeyframeAdd?.()}
598
+ >
599
+ +
600
+ </button>
601
+ )}
602
+ </span>
603
+ <div
604
+ className={"tl-lane" + (name === "2D Root" ? " root" : "") + (name === CAMERA_LANE ? " cam" : "")}
605
+ onPointerDown={
606
+ name === "2D Root"
607
+ ? (e) => {
608
+ if (e.button !== 0 || e.target !== e.currentTarget) return;
609
+ handlers.current.onRootKeyframeAdd?.(rootFrameFromEvent(e));
610
+ }
611
+ : name === CAMERA_LANE
612
+ ? (e) => {
613
+ if (e.button !== 0 || e.target !== e.currentTarget) return;
614
+ handlers.current.onCameraKeyframeAdd?.(rootFrameFromEvent(e));
615
+ }
616
+ : undefined
617
+ }
618
+ title={name === CAMERA_LANE ? "Click to key the current camera framing at this frame" : undefined}
619
+ >
620
+ {gridFrames.map((f) => (
621
+ <i key={f} className="tl-grid" style={{ "--tl-f": framePct(f, displayFrameCount) }} aria-hidden="true" />
622
+ ))}
623
+ {name === "Prompts" && promptClips.map((clip) => {
624
+ const duration = ((clip.endFrame - clip.startFrame) / Math.max(1, fps)).toFixed(1);
625
+ return (
626
+ <div key={clip.id} className={"tl-chip" + (selectedPromptId === clip.id ? " selected" : "") + (movingPromptId === clip.id ? " moving" : "")} style={{ "--tl-f-start": clipPct(clip.startFrame), "--tl-f-end": clipPct(clip.endFrame) }} title="Drag to move · edge handles resize · right-click removes" onPointerDown={(e) => beginPromptMove(e, clip)} onPointerMove={movePrompt} onPointerUp={endPromptMove} onPointerCancel={endPromptMove} onClick={blockPromptClick} onContextMenu={(e) => { e.preventDefault(); handlers.current.onPromptRemove?.(clip.id); }}>
627
+ <button className="tl-chip-handle start" type="button" aria-label="Resize prompt start" onPointerDown={(e) => beginPromptResize(e, clip, "start")} onPointerMove={movePromptResize} onPointerUp={endPromptResize} onPointerCancel={endPromptResize} />
628
+ <input className="tl-chip-input" value={clip.text} placeholder={`${duration}s · motion prompt`} maxLength={500} onChange={(e) => handlers.current.onPromptChange?.(clip.id, e.target.value)} />
629
+ <button className="tl-chip-handle end" type="button" aria-label="Resize prompt end" onPointerDown={(e) => beginPromptResize(e, clip, "end")} onPointerMove={movePromptResize} onPointerUp={endPromptResize} onPointerCancel={endPromptResize} />
630
+ </div>
631
+ );
632
+ })}
633
+ {name === IK_LANE &&
634
+ ikFrames.map((f) => (
635
+ <span
636
+ key={f}
637
+ className="tl-marker ik"
638
+ style={{ "--tl-f": framePct(f, displayFrameCount) }}
639
+ title={`Full-body IK key at frame ${f} — click to jump, right-click to remove`}
640
+ onPointerDown={(e) => {
641
+ if (e.button !== 0) return;
642
+ e.stopPropagation();
643
+ handlers.current.onScrub?.(f);
644
+ }}
645
+ onContextMenu={(e) => {
646
+ e.preventDefault();
647
+ e.stopPropagation();
648
+ handlers.current.onIkKeyframeRemove?.(f);
649
+ }}
650
+ />
651
+ ))}
652
+ {name === "2D Root" &&
653
+ [...waypointFrames, ...(pendingWaypointFrame == null || waypointFrames.includes(pendingWaypointFrame) ? [] : [pendingWaypointFrame])].map((f) => (
654
+ <span
655
+ key={f}
656
+ className={"tl-marker wp" + (f === pendingWaypointFrame ? " pending" : "")}
657
+ style={{ "--tl-f": framePct(f, displayFrameCount) }}
658
+ title={`Root waypoint at frame ${f} — click to select, right-click to remove; drag marker ${waypointFrames.indexOf(f) + 1} in Top-View to move`}
659
+ onPointerDown={(e) => {
660
+ // Select only on the primary button — a right click
661
+ // must reach contextmenu so it removes without scrubbing.
662
+ if (e.button !== 0) return;
663
+ e.stopPropagation();
664
+ handlers.current.onMarkerSelect?.(f);
665
+ }}
666
+ onContextMenu={(e) => {
667
+ e.preventDefault();
668
+ e.stopPropagation();
669
+ handlers.current.onMarkerRemove?.(f);
670
+ }}
671
+ />
672
+ ))}
673
+ {name === CAMERA_LANE && cameraKeyFrames.map((f) => (
674
+ <span
675
+ key={f}
676
+ className="tl-marker cam"
677
+ style={{ "--tl-f": framePct(f, displayFrameCount) }}
678
+ title={`Camera key at frame ${f} — click to jump, drag to re-time, right-click to remove`}
679
+ onPointerDown={(e) => beginCameraKeyDrag(e, f)}
680
+ onPointerMove={moveCameraKeyDrag}
681
+ onPointerUp={endCameraKeyDrag}
682
+ onPointerCancel={endCameraKeyDrag}
683
+ onClick={(e) => onCameraKeyClick(e, f)}
684
+ onContextMenu={(e) => {
685
+ e.preventDefault();
686
+ e.stopPropagation();
687
+ handlers.current.onCameraKeyframeRemove?.(f);
688
+ }}
689
+ />
690
+ ))}
691
+ </div>
692
+ </div>
693
+ ))}
694
+
695
+ <div className="tl-playhead" style={{ "--tl-f": framePct(frame, displayFrameCount) }} aria-hidden="true" />
696
+ </div>
697
+ </div>
698
+ </>
699
+ ) : (
700
+ <div className="tl-collapsed">
701
+ {badge && <span className={"tl-badge " + badge.kind}>{badge.label}</span>}
702
+ {waypointMode && (
703
+ <span className={"tl-wp-hint" + (waypointFrames.length < 2 ? " warn" : "")}>
704
+ {waypointFrames.length < 2
705
+ ? "Click the set floor in the Shot view to add waypoints"
706
+ : `${waypointFrames.length} root waypoints · click the set floor to add more`}
707
+ </span>
708
+ )}
709
+ <button
710
+ type="button"
711
+ className="tl-toggle"
712
+ aria-expanded="false"
713
+ aria-label="Expand timeline"
714
+ title="Expand timeline"
715
+ onClick={() => setExpanded(true)}
716
+ >
717
+
718
+ </button>
719
+ </div>
720
+ )}
721
+ </section>
722
+ );
723
+ }