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
package/src/ui.jsx ADDED
@@ -0,0 +1,391 @@
1
+ import { useEffect, useId, useRef, useState } from "react";
2
+
3
+ // Custom dropdown, structurally matched to the reference (`.dropdown`,
4
+ // `dd-caret`, `dropdown-menu`, `dropdown-item`, `dd-check`) with the keyboard
5
+ // support the reference lacks. `highlighted` marks the arrow-key cursor;
6
+ // the selection itself is `active`.
7
+ export function Dropdown({ value, options, onChange, ariaLabel }) {
8
+ const [open, setOpen] = useState(false);
9
+ const [highlight, setHighlight] = useState(-1);
10
+ const rootRef = useRef(null);
11
+ const triggerRef = useRef(null);
12
+ const uid = useId();
13
+ const listId = `${uid}-list`;
14
+ const selectedIndex = options.findIndex((o) => o.value === value);
15
+ const selected = selectedIndex >= 0 ? options[selectedIndex] : null;
16
+
17
+ // Close on any mousedown outside the dropdown; effect cleanup also covers
18
+ // unmounting while open.
19
+ useEffect(() => {
20
+ if (!open) return;
21
+ const onDocMouseDown = (e) => {
22
+ if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
23
+ };
24
+ document.addEventListener("mousedown", onDocMouseDown);
25
+ return () => document.removeEventListener("mousedown", onDocMouseDown);
26
+ }, [open]);
27
+
28
+ // Keyboard navigation starts from the current selection.
29
+ useEffect(() => {
30
+ if (open) setHighlight(selectedIndex);
31
+ }, [open, selectedIndex]);
32
+
33
+ const close = () => setOpen(false);
34
+
35
+ const moveHighlight = (dir) => {
36
+ if (options.length === 0) return;
37
+ setHighlight((h) =>
38
+ h < 0
39
+ ? dir > 0
40
+ ? 0
41
+ : options.length - 1
42
+ : (h + dir + options.length) % options.length,
43
+ );
44
+ };
45
+
46
+ const onKeyDown = (e) => {
47
+ if (!open) return;
48
+ if (e.key === "Escape") {
49
+ e.preventDefault();
50
+ close();
51
+ triggerRef.current?.focus();
52
+ } else if (e.key === "ArrowDown") {
53
+ e.preventDefault();
54
+ moveHighlight(1);
55
+ } else if (e.key === "ArrowUp") {
56
+ e.preventDefault();
57
+ moveHighlight(-1);
58
+ } else if (e.key === "Enter" && highlight >= 0 && highlight < options.length) {
59
+ e.preventDefault();
60
+ onChange(options[highlight].value);
61
+ close();
62
+ }
63
+ };
64
+
65
+ return (
66
+ <div className={"dropdown" + (open ? " open" : "")} ref={rootRef} onKeyDown={onKeyDown}>
67
+ <button
68
+ type="button"
69
+ className="trigger"
70
+ ref={triggerRef}
71
+ aria-expanded={open}
72
+ aria-haspopup="listbox"
73
+ aria-controls={listId}
74
+ aria-label={ariaLabel}
75
+ onClick={() => setOpen((o) => !o)}
76
+ >
77
+ <span>{selected ? selected.label : ""}</span>
78
+ <svg
79
+ className="dd-caret"
80
+ viewBox="0 0 24 24"
81
+ fill="none"
82
+ stroke="currentColor"
83
+ strokeWidth="2.5"
84
+ strokeLinecap="round"
85
+ strokeLinejoin="round"
86
+ >
87
+ <path d="M6 9l6 6 6-6" />
88
+ </svg>
89
+ </button>
90
+ {open && (
91
+ <div
92
+ className="dropdown-menu"
93
+ id={listId}
94
+ role="listbox"
95
+ aria-activedescendant={highlight >= 0 && highlight < options.length ? `${uid}-opt-${highlight}` : undefined}
96
+ >
97
+ {options.map((opt, i) => (
98
+ <button
99
+ type="button"
100
+ key={opt.value}
101
+ id={`${uid}-opt-${i}`}
102
+ role="option"
103
+ aria-selected={opt.value === value}
104
+ className={
105
+ "dropdown-item" +
106
+ (opt.value === value ? " active" : "") +
107
+ (i === highlight ? " highlighted" : "")
108
+ }
109
+ onClick={() => {
110
+ onChange(opt.value);
111
+ close();
112
+ }}
113
+ >
114
+ <span>{opt.label}</span>
115
+ {opt.value === value && (
116
+ <svg
117
+ className="dd-check"
118
+ viewBox="0 0 24 24"
119
+ fill="none"
120
+ stroke="currentColor"
121
+ strokeWidth="2.5"
122
+ strokeLinecap="round"
123
+ strokeLinejoin="round"
124
+ >
125
+ <path d="M20 6L9 17l-5-5" />
126
+ </svg>
127
+ )}
128
+ </button>
129
+ ))}
130
+ </div>
131
+ )}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ // Auto-dismissing toast; the timer restarts whenever the message (or
137
+ // duration) changes and is always cleared on unmount.
138
+ export function Toast({ message, onDone, duration = 2200 }) {
139
+ useEffect(() => {
140
+ if (!message) return;
141
+ const timer = setTimeout(onDone, duration);
142
+ return () => clearTimeout(timer);
143
+ // onDone deliberately excluded: callers pass inline callbacks and the
144
+ // timer must survive parent re-renders within one message lifetime.
145
+ }, [message, duration]);
146
+ if (!message) return null;
147
+ return <div className="toast">{message}</div>;
148
+ }
149
+
150
+ // Range slider with the reference's filled-track gradient. `value` may be a
151
+ // raw float (e.g. from drag interactions), so it is clamped and the label is
152
+ // rounded to the slider's own step precision.
153
+ export function Slider({ label, min, max, step, value, unit = "", onChange, compact }) {
154
+ const clamped = Math.min(max, Math.max(min, value));
155
+ const percent = Math.max(0, Math.min(100, ((clamped - min) / (max - min)) * 100));
156
+ const decimals = (String(step).split(".")[1] || "").length;
157
+ const snapped = Math.round((clamped - min) / step) * step + min;
158
+ const text = String(decimals > 0 ? Number(snapped.toFixed(decimals)) : Math.round(snapped));
159
+ const input = (
160
+ <input
161
+ type="range"
162
+ min={min}
163
+ max={max}
164
+ step={step}
165
+ value={clamped}
166
+ style={{ background: `linear-gradient(to right, var(--accent) ${percent}%, var(--track) ${percent}%)` }}
167
+ onChange={(e) => onChange(parseFloat(e.target.value))}
168
+ />
169
+ );
170
+ if (compact) {
171
+ return (
172
+ <div className="cslider">
173
+ <div className="cslider-head">
174
+ <span>{label}</span>
175
+ <span className="val">
176
+ {text}
177
+ {unit}
178
+ </span>
179
+ </div>
180
+ {input}
181
+ </div>
182
+ );
183
+ }
184
+ return (
185
+ <div className="row">
186
+ <span>{label}</span>
187
+ {input}
188
+ <span className="val">
189
+ {text}
190
+ {unit}
191
+ </span>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ export function Field({ label, children }) {
197
+ return (
198
+ <div className="field">
199
+ <label>{label}</label>
200
+ {children}
201
+ </div>
202
+ );
203
+ }
204
+ export function NumberField({ label, value, step, precision = 2, onChange, onScrubStart, onScrubEnd, title }) {
205
+ const [focused, setFocused] = useState(false);
206
+ const [draft, setDraft] = useState(null);
207
+ // The draft must be readable synchronously: blur() fires the commit
208
+ // handler before React re-renders, so state alone would commit the
209
+ // pre-Escape value.
210
+ const draftRef = useRef(null);
211
+ // Freshest committed prop for a drag baseline, even while a parent
212
+ // re-render from the previous commit is still pending.
213
+ const valueRef = useRef(value);
214
+ const inputRef = useRef(null);
215
+ // The scrub lifecycle's window listeners are registered once (Escape,
216
+ // blur), so the freshest end callback is mirrored like `valueRef`.
217
+ const scrubEndRef = useRef(onScrubEnd);
218
+ scrubEndRef.current = onScrubEnd;
219
+ // `{ x, value, element, pointerId, token }` of the scrub start, null while
220
+ // not scrubbing. `element`/`pointerId` let the teardown release capture;
221
+ // `token` is the open store transaction, null when no scrub props exist.
222
+ const dragRef = useRef(null);
223
+
224
+ valueRef.current = value;
225
+
226
+ const setDraftValue = (text) => {
227
+ draftRef.current = text;
228
+ setDraft(text);
229
+ };
230
+
231
+ // Push the draft to the store when it parses; either way clear it so the
232
+ // field falls back to the incoming prop. Returns the parsed number (or
233
+ // null) so a drag can baseline from what was just committed.
234
+ const commitDraft = () => {
235
+ const parsed = parseFloat(draftRef.current);
236
+ const ok = Number.isFinite(parsed);
237
+ if (ok) onChange(parsed);
238
+ setDraftValue(null);
239
+ return ok ? parsed : null;
240
+ };
241
+
242
+ // Round to `precision` decimals, then let String() drop trailing zeros:
243
+ // 1.50 reads 1.5, 45.0 reads 45.
244
+ const formatValue = (n) => String(Number(n.toFixed(precision)));
245
+
246
+ const onFocus = (e) => {
247
+ setFocused(true);
248
+ // Start from the formatted number so typing replaces the whole value.
249
+ setDraftValue(formatValue(value));
250
+ e.target.select();
251
+ };
252
+
253
+ const onBlur = () => {
254
+ commitDraft();
255
+ setFocused(false);
256
+ };
257
+
258
+ const onKeyDown = (e) => {
259
+ if (e.key === "Enter") {
260
+ e.preventDefault();
261
+ commitDraft();
262
+ // Keep focus and select the committed value so the next keystroke
263
+ // replaces it, like Unity's inspector.
264
+ e.currentTarget.select();
265
+ } else if (e.key === "Escape") {
266
+ e.preventDefault();
267
+ // Clear the ref first: the blur below commits, and a cleared
268
+ // draft parses to NaN, so the revert survives.
269
+ setDraftValue(null);
270
+ inputRef.current?.blur();
271
+ }
272
+ };
273
+
274
+ // Unity-style scrubbing: drag the axis label to change the value. Pointer
275
+ // capture keeps the drag alive when the cursor leaves the window; Shift
276
+ // moves 10x per pixel, Alt 0.1x.
277
+ const onAxisPointerDown = (e) => {
278
+ e.preventDefault();
279
+ const base = commitDraft();
280
+ const drag = {
281
+ x: e.clientX,
282
+ value: base ?? valueRef.current,
283
+ element: e.currentTarget,
284
+ pointerId: e.pointerId,
285
+ };
286
+ // A scrub is one store transaction: begin before any apply so the
287
+ // whole drag lands as a single undo entry. The teardown is registered
288
+ // as the store's cancel so a settle leaves the scrub inert without
289
+ // this component closing the token itself.
290
+ dragRef.current = drag;
291
+ drag.token = onScrubStart?.({ owner: "field", cancel: () => teardownScrub(drag) });
292
+ e.currentTarget.setPointerCapture(e.pointerId);
293
+ };
294
+
295
+ const onAxisPointerMove = (e) => {
296
+ const drag = dragRef.current;
297
+ if (!drag) return;
298
+ const multiplier = e.shiftKey ? 10 : e.altKey ? 0.1 : 1;
299
+ onChange(drag.value + (e.clientX - drag.x) * step * multiplier, drag.token);
300
+ };
301
+
302
+ // Drop the scrub ref and release the capture held on the axis label.
303
+ // Shared by every close path AND the store's cancel; never closes the
304
+ // transaction itself — the caller decides the commit value.
305
+ const teardownScrub = (drag) => {
306
+ if (!drag || dragRef.current !== drag) return;
307
+ dragRef.current = null;
308
+ if (drag.element.hasPointerCapture(drag.pointerId)) {
309
+ drag.element.releasePointerCapture(drag.pointerId);
310
+ }
311
+ };
312
+
313
+ const closeScrub = (commit) => {
314
+ const drag = dragRef.current;
315
+ if (!drag) return;
316
+ teardownScrub(drag);
317
+ scrubEndRef.current?.(drag.token, { commit });
318
+ };
319
+
320
+ const endAxisDrag = () => closeScrub(true);
321
+
322
+ useEffect(() => {
323
+ // While a scrub is live, Escape cancels the whole drag. It must run in
324
+ // the capture phase and stop propagation so the same press cannot also
325
+ // reach the input's revert-and-blur handler or App's Escape handler.
326
+ // Window blur commits like pointerup: travel already applied is real
327
+ // work, not intent to discard (plan §6.3).
328
+ const onEscape = (event) => {
329
+ if (event.key !== "Escape") return;
330
+ if (!dragRef.current) return;
331
+ event.stopPropagation();
332
+ closeScrub(false);
333
+ };
334
+ const onBlur = () => closeScrub(true);
335
+ window.addEventListener("keydown", onEscape, true);
336
+ window.addEventListener("blur", onBlur);
337
+ return () => {
338
+ window.removeEventListener("keydown", onEscape, true);
339
+ window.removeEventListener("blur", onBlur);
340
+ closeScrub(true);
341
+ };
342
+ }, []);
343
+
344
+ const display = focused && draft !== null ? draft : formatValue(value);
345
+
346
+ return (
347
+ <span className="number-field" title={title}>
348
+ <span
349
+ className="axis"
350
+ onPointerDown={onAxisPointerDown}
351
+ onPointerMove={onAxisPointerMove}
352
+ onPointerUp={endAxisDrag}
353
+ onPointerCancel={endAxisDrag}
354
+ >
355
+ {label}
356
+ </span>
357
+ <input
358
+ ref={inputRef}
359
+ type="text"
360
+ inputMode="decimal"
361
+ value={display}
362
+ onFocus={onFocus}
363
+ onBlur={onBlur}
364
+ onChange={(e) => setDraftValue(e.target.value)}
365
+ onKeyDown={onKeyDown}
366
+ />
367
+ </span>
368
+ );
369
+ }
370
+
371
+ // One Unity-style transform row: a row label plus one scrubbable NumberField
372
+ // per axis. `fields` entries are `{ axis, value, step, precision, onChange }`.
373
+ export function Vector3Row({ label, fields }) {
374
+ return (
375
+ <div className="vec3-row">
376
+ <span className="vec3-label">{label}</span>
377
+ {fields.map((field) => (
378
+ <NumberField
379
+ key={field.axis}
380
+ label={field.axis}
381
+ value={field.value}
382
+ step={field.step}
383
+ precision={field.precision}
384
+ onChange={field.onChange}
385
+ onScrubStart={field.onScrubStart}
386
+ onScrubEnd={field.onScrubEnd}
387
+ />
388
+ ))}
389
+ </div>
390
+ );
391
+ }
@@ -0,0 +1,96 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export const RENDER_ACTIVITY_KEYS = new Set([
4
+ "KeyW",
5
+ "KeyA",
6
+ "KeyS",
7
+ "KeyD",
8
+ "KeyQ",
9
+ "KeyE",
10
+ ]);
11
+ export const RENDER_ACTIVITY_EVENT = "cozyclay-render-activity";
12
+
13
+ export function hasContinuousRenderActivity(pointerIds, keyCodes, transient) {
14
+ return pointerIds.size > 0 || keyCodes.size > 0 || transient;
15
+ }
16
+
17
+ /**
18
+ * Keep the WebGL loop hot only while the user is actively manipulating the
19
+ * scene. React/R3F still invalidates demand frames for ordinary state changes;
20
+ * this hook covers direct ref mutations such as IK drags, camera look/dolly,
21
+ * plan puck drags, scrubbing, and held WASD/QE movement.
22
+ */
23
+ export function useRenderActivity(alwaysActive) {
24
+ const [interactive, setInteractive] = useState(false);
25
+
26
+ useEffect(() => {
27
+ const pointerIds = new Set();
28
+ const keyCodes = new Set();
29
+ let transient = false;
30
+ let transientTimer = null;
31
+ const sync = () => setInteractive(hasContinuousRenderActivity(pointerIds, keyCodes, transient));
32
+ const endTransient = () => {
33
+ transient = false;
34
+ transientTimer = null;
35
+ sync();
36
+ };
37
+ const pulse = () => {
38
+ transient = true;
39
+ if (transientTimer !== null) clearTimeout(transientTimer);
40
+ transientTimer = setTimeout(endTransient, 180);
41
+ sync();
42
+ };
43
+ const onPointerDown = (event) => {
44
+ pointerIds.add(event.pointerId);
45
+ sync();
46
+ };
47
+ const onPointerEnd = (event) => {
48
+ pointerIds.delete(event.pointerId);
49
+ sync();
50
+ };
51
+ const onKeyDown = (event) => {
52
+ if (!RENDER_ACTIVITY_KEYS.has(event.code)) return;
53
+ const element = document.activeElement;
54
+ if (element && ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName)) return;
55
+ keyCodes.add(event.code);
56
+ sync();
57
+ };
58
+ const onKeyUp = (event) => {
59
+ if (!RENDER_ACTIVITY_KEYS.has(event.code)) return;
60
+ keyCodes.delete(event.code);
61
+ sync();
62
+ };
63
+ const reset = () => {
64
+ pointerIds.clear();
65
+ keyCodes.clear();
66
+ transient = false;
67
+ if (transientTimer !== null) clearTimeout(transientTimer);
68
+ transientTimer = null;
69
+ sync();
70
+ };
71
+
72
+ window.addEventListener("pointerdown", onPointerDown, true);
73
+ window.addEventListener("pointerup", onPointerEnd, true);
74
+ window.addEventListener("pointercancel", onPointerEnd, true);
75
+ window.addEventListener("keydown", onKeyDown, true);
76
+ window.addEventListener("keyup", onKeyUp, true);
77
+ window.addEventListener("wheel", pulse, { capture: true, passive: true });
78
+ window.addEventListener("blur", reset);
79
+ return () => {
80
+ if (transientTimer !== null) clearTimeout(transientTimer);
81
+ window.removeEventListener("pointerdown", onPointerDown, true);
82
+ window.removeEventListener("pointerup", onPointerEnd, true);
83
+ window.removeEventListener("pointercancel", onPointerEnd, true);
84
+ window.removeEventListener("keydown", onKeyDown, true);
85
+ window.removeEventListener("keyup", onKeyUp, true);
86
+ window.removeEventListener("wheel", pulse, true);
87
+ window.removeEventListener("blur", reset);
88
+ };
89
+ }, []);
90
+
91
+ const active = alwaysActive || interactive;
92
+ useEffect(() => {
93
+ window.dispatchEvent(new CustomEvent(RENDER_ACTIVITY_EVENT, { detail: active }));
94
+ }, [active]);
95
+ return active;
96
+ }