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,568 @@
1
+ #!/usr/bin/env bash
2
+ # run-on-box.sh - generate an ARDY clip on the remote box.
3
+ #
4
+ # run-on-box.sh [<pose.npz>] --base <motion-id|npz-path> --prompt "<prompt>" \
5
+ # --duration <seconds> [--dst-frame <N>] [--src-frame <N>] [--seed <S>] \
6
+ # [--root-2d FRAME X Z HEADING ...] [--output <local.npz>] [--dry-run]
7
+
8
+ #
9
+ # The pose npz is an ARDY motion npz (it must carry local_rot_mats and
10
+ # posed_joints; the frame at SRC_FRAME holds the pose). It is pushed to a
11
+ # fresh remote temp dir and handed to the generator as
12
+ # --pose-from <tmp>/pose.npz <src-frame> <dst-frame>
13
+ # which pins the FULL-BODY pose onto <dst-frame> of the new clip. The base
14
+ # motion supplies the unconstrained first pass (--base) and the generated
15
+ # npz is pulled back. Nothing is ever written inside the ARDY checkout: the
16
+ # base npz is read from ~/ardy/outputs/ (or outputs/omb/), the pose and the
17
+ # result live under the remote temp dir, and the temp dir is removed on
18
+ # exit.
19
+ #
20
+ # Three modes, chosen by what is given:
21
+ # pose <pose.npz> present (the default): the pose npz is pushed to a
22
+ # fresh remote temp dir and handed to the constrained generator
23
+ # as --pose-from <tmp>/pose.npz <src-frame> <dst-frame>, pinning
24
+ # the FULL-BODY pose onto <dst-frame> of the new clip. Requires
25
+ # --base and --dst-frame.
26
+ # waypoints no pose npz but one or more --root-2d groups: the constrained
27
+ # generator runs WITHOUT --pose-from, so the clip follows the
28
+ # path and prompt only, with no full-body pose constraint.
29
+ # --dst-frame is ignored. --base is optional: given, it is the
30
+ # unconstrained first pass; omitted, the script free-generates
31
+ # the base clip itself first (two-pass mode: scripts/generate.py
32
+ # with the same prompt/duration/seed under the remote temp dir,
33
+ # then the constrained pass with --base pointing at that npz).
34
+ # free no pose npz and no --root-2d: scripts/generate.py on the box,
35
+ # the unconstrained generator (the constrained script refuses a
36
+ # run with no target, pose or waypoint). --base and --dst-frame
37
+ # are ignored.
38
+ #
39
+ # Each --root-2d group pins the root on the X/Z ground plane at one 0-based
40
+ # clip frame (ARDY is Y-up; the horizontal plane is X and Z in meters, Y is
41
+ # not constrained). X and Z must be within -20..20, HEADING is a yaw in
42
+ # radians within -2π..2π or the literal 'none' to leave facing free. The
43
+ # request carries 2..32 sparse authored keys starting at frame 0. Intermediate
44
+ # root positions are left for the model to generate. Values are validated
45
+ # locally and %q-quoted into the remote command like all other args.
46
+ #
47
+ # CPU is forced with CUDA_VISIBLE_DEVICES="" and that is deliberate: the
48
+ # box's RTX 3070 has only 8.2 GB VRAM against 94 GB RAM, and nvidia-smi is
49
+ # broken there (NVML driver/library version mismatch) even though
50
+ # torch.cuda.is_available() still returns True - so without the override the
51
+ # generator's `device = "cuda:0" if torch.cuda.is_available() else "cpu"`
52
+ # line would pick a GPU that cannot hold the model. The preflight probe
53
+ # below mirrors that exact line under the same env and prints the device the
54
+ # generator will actually use.
55
+ #
56
+ # env (names shared with CozyClay scripts/ardy/sync-to-box):
57
+ # CCLAY_ARDY_HOST ssh destination for the ARDY host (required)
58
+ # CCLAY_ARDY_REPO ARDY checkout on the box (default $HOME/ardy)
59
+ # CCLAY_ARDY_VENV venv python on the box (default ~/ardy/.venv/bin/python)
60
+ set -euo pipefail
61
+
62
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
63
+ HOST="${CCLAY_ARDY_HOST:-}"
64
+ # REMOTE's default is escaped so the REMOTE shell expands $HOME (the same
65
+ # trick sync-to-box uses); VENV_PY's default keeps a literal ~ so the REMOTE
66
+ # shell tilde-expands it. Both resolve to the same ~/ardy on the box.
67
+ REMOTE="${CCLAY_ARDY_REPO:-\$HOME/ardy}"
68
+ # The box carries two venvs and they are NOT interchangeable: .venv runs the
69
+ # CPU text-encoder service, .venv-cuda is what the motion generator was actually
70
+ # run under (scripts/generate.py in generate_pc_monitor.log, 1060 MiB of VRAM).
71
+ VENV_PY="${CCLAY_ARDY_VENV:-~/ardy/.venv-cuda/bin/python}"
72
+ # Text encoder URL the generator falls back to when TEXT_ENCODER_URL is unset:
73
+ # ardy/model/registry.py:47 DEFAULT_TEXT_ENCODER_URL. Generation dies without it,
74
+ # so the preflight checks it rather than letting the run fail minutes in.
75
+ ENCODER_URL="${CCLAY_ARDY_ENCODER_URL:-http://127.0.0.1:9550/}"
76
+ # Force the motion model onto the CPU as well. Off by default: the encoder --
77
+ # the part that does not fit beside the model in 8.2 GB -- already runs on the
78
+ # CPU as its own service, and the model itself only needs about 1 GB.
79
+ FORCE_CPU=0
80
+
81
+ # BatchMode: never hang on a password prompt mid-pipeline. ConnectTimeout
82
+ # fails fast on a dead host; ServerAlive* drops a wedged connection instead
83
+ # of leaving the user staring at a silent ssh (30 s interval * 240 = 2 h).
84
+ SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=30 -o ServerAliveCountMax=240)
85
+ SCP_OPTS=(-o BatchMode=yes -o ConnectTimeout=10)
86
+
87
+ usage() {
88
+ cat >&2 <<'EOF'
89
+ usage: run-on-box.sh [<pose.npz>] --base <motion-id|npz-path> --prompt "<prompt>" \
90
+ --duration <seconds> [--dst-frame <N>] [--src-frame <N>] [--seed <S>] \
91
+ [--root-2d FRAME X Z HEADING ...] [--output <local.npz>] [--dry-run]
92
+
93
+
94
+ <pose.npz> OPTIONAL local ARDY motion npz (local_rot_mats +
95
+ posed_joints) whose frame <src-frame> holds the CozyClay
96
+ pose to honour. Omit it to generate without a full-body pose
97
+ constraint: with --root-2d groups the clip is
98
+ path/prompt-only constrained (with --base, or two-pass: the
99
+ free generator produces the base clip first); without them
100
+ it is free generation (scripts/generate.py on the box).
101
+ --base base motion on the box: a bare id or bare <name>.npz
102
+ (looked up in ~/ardy/outputs/<name>.npz, then
103
+ ~/ardy/outputs/omb/<name>.npz), or a path containing /
104
+ relative to the ARDY checkout / absolute. Required in pose
105
+ mode; optional in waypoints mode (omitted = two-pass: the
106
+ free generator first produces the base clip on the box);
107
+ ignored in free mode.
108
+ --prompt generation prompt for the constrained clip
109
+ --duration clip duration in seconds (20 fps, 0.15..1200)
110
+ --dst-frame clip frame to pin the pose onto, 0 <= dst < duration*20
111
+ (pose mode only)
112
+ --src-frame frame of <pose.npz> to copy (default 0; pose mode only)
113
+ --seed optional seed for reproducible results
114
+ --output local destination for the generated npz
115
+ (default tools/ardy/out/<pose>-constrained.npz, or
116
+ tools/ardy/out/<epoch>-generated.npz without a pose)
117
+
118
+ --dry-run run the preflight checks, print the exact remote command,
119
+ and exit without pushing, generating, or pulling
120
+ --root-2d repeatable root waypoint: FRAME (0-based, inside the clip),
121
+ X and Z in meters within -20..20 (ARDY is Y-up; the ground
122
+ plane is X/Z, Y is not constrained), HEADING a yaw in
123
+ radians within -2π..2π or the literal 'none' to leave
124
+ facing free. Supply 2..32 sparse keys beginning at frame 0;
125
+ ARDY generates all intermediate root motion.
126
+
127
+ env:
128
+ CCLAY_ARDY_HOST ssh destination for the ARDY host (required)
129
+ CCLAY_ARDY_REPO ARDY checkout on the box (default $HOME/ardy)
130
+ --cpu also run the motion model on the CPU (slower; the text encoder
131
+ is on the CPU either way, as its own service)
132
+
133
+ env:
134
+ CCLAY_ARDY_VENV venv python on the box (default ~/ardy/.venv-cuda/bin/python)
135
+ CCLAY_ARDY_ENCODER_URL text encoder service (default http://127.0.0.1:9550/)
136
+ EOF
137
+ exit 2
138
+ }
139
+
140
+ POSE_NPZ="" # legacy positional pose; normalized into POSE_ARGS after parsing
141
+ POSE_ARGS=() # repeatable triples: LOCAL_NPZ SRC_FRAME DST_FRAME
142
+ BASE=""
143
+ PROMPT=""
144
+ DURATION=""
145
+ DST_FRAME=""
146
+ SRC_FRAME=0
147
+ SEED=""
148
+ OUTPUT=""
149
+ DRY_RUN=0
150
+ ROOT_2D_ARGS=()
151
+
152
+ while [[ $# -gt 0 ]]; do
153
+ case "$1" in
154
+ --base)
155
+ [[ $# -ge 2 ]] || { echo "run-on-box: --base needs a motion id or npz path" >&2; usage; }
156
+ BASE="$2"; shift 2 ;;
157
+ --prompt)
158
+ [[ $# -ge 2 ]] || { echo "run-on-box: --prompt needs text" >&2; usage; }
159
+ PROMPT="$2"; shift 2 ;;
160
+ --duration)
161
+ [[ $# -ge 2 ]] || { echo "run-on-box: --duration needs seconds" >&2; usage; }
162
+ [[ "$2" =~ ^[0-9]+([.][0-9]+)?$ ]] || { echo "run-on-box: --duration must be a number, got '$2'" >&2; usage; }
163
+ DURATION="$2"; shift 2 ;;
164
+ --dst-frame)
165
+ [[ $# -ge 2 ]] || { echo "run-on-box: --dst-frame needs a frame number" >&2; usage; }
166
+ [[ "$2" =~ ^[0-9]+$ ]] || { echo "run-on-box: --dst-frame must be a non-negative integer, got '$2'" >&2; usage; }
167
+ DST_FRAME="$2"; shift 2 ;;
168
+ --src-frame)
169
+ [[ $# -ge 2 ]] || { echo "run-on-box: --src-frame needs a frame number" >&2; usage; }
170
+ [[ "$2" =~ ^[0-9]+$ ]] || { echo "run-on-box: --src-frame must be a non-negative integer, got '$2'" >&2; usage; }
171
+ SRC_FRAME="$2"; shift 2 ;;
172
+ --seed)
173
+ [[ $# -ge 2 ]] || { echo "run-on-box: --seed needs an integer" >&2; usage; }
174
+ [[ "$2" =~ ^[0-9]+$ ]] || { echo "run-on-box: --seed must be a non-negative integer, got '$2'" >&2; usage; }
175
+ SEED="$2"; shift 2 ;;
176
+ --pose-from)
177
+ [[ $# -ge 4 ]] || { echo "run-on-box: --pose-from needs LOCAL_NPZ SRC_FRAME DST_FRAME" >&2; usage; }
178
+ [[ "$3" =~ ^[0-9]+$ && "$4" =~ ^[0-9]+$ ]] || { echo "run-on-box: --pose-from frames must be non-negative integers" >&2; usage; }
179
+ POSE_ARGS+=("$2" "$3" "$4"); shift 4 ;;
180
+ --root-2d)
181
+ [[ $# -ge 5 ]] || { echo "run-on-box: --root-2d needs FRAME X Z HEADING" >&2; usage; }
182
+ ROOT_2D_ARGS+=("$2" "$3" "$4" "$5"); shift 5 ;;
183
+ --cpu)
184
+ FORCE_CPU=1; shift ;;
185
+ --output)
186
+ [[ $# -ge 2 ]] || { echo "run-on-box: --output needs a path" >&2; usage; }
187
+ OUTPUT="$2"; shift 2 ;;
188
+ --dry-run)
189
+ DRY_RUN=1; shift ;;
190
+ -h|--help)
191
+ usage ;;
192
+ -*)
193
+ echo "run-on-box: unknown option '$1'" >&2; usage ;;
194
+ *)
195
+ [[ -z "$POSE_NPZ" ]] || { echo "run-on-box: one pose npz only, got '$1'" >&2; usage; }
196
+ POSE_NPZ="$1"; shift ;;
197
+ esac
198
+ done
199
+
200
+ [[ -n "$HOST" ]] || {
201
+ echo "run-on-box: CCLAY_ARDY_HOST is required (for example: user@ardy-host)" >&2
202
+ exit 2
203
+ }
204
+
205
+ # Normalize the legacy positional pose into the repeatable pose list.
206
+ if [[ -n "$POSE_NPZ" ]]; then
207
+ [[ -n "$DST_FRAME" ]] || usage
208
+ POSE_ARGS+=("$POSE_NPZ" "$SRC_FRAME" "$DST_FRAME")
209
+ fi
210
+
211
+ # Mode by what is given: one or more pose npz files pin the full body,
212
+ # waypoints alone constrain the path, and neither is free generation.
213
+ MODE=""
214
+ if [[ ${#POSE_ARGS[@]} -gt 0 ]]; then
215
+ MODE="pose"
216
+ elif [[ ${#ROOT_2D_ARGS[@]} -gt 0 ]]; then
217
+ MODE="waypoints"
218
+ else
219
+ MODE="free"
220
+ fi
221
+ # Waypoints run the constrained generator: ONE model sampling call for the
222
+ # whole clip. ARDY's trained window is 10 s — frames beyond it sit outside
223
+ # the model's temporal horizon and the motion visibly degrades, so the last
224
+ # line of defence refuses what every upstream layer should already have.
225
+ if [[ "$MODE" == "waypoints" ]] && ! awk -v d="$DURATION" 'BEGIN { exit !(d <= 10) }'; then
226
+ echo "run-on-box: --root-2d requires --duration <= 10 s (one-shot constrained generation; ARDY trained window), got ${DURATION}s" >&2
227
+ exit 1
228
+ fi
229
+ # Waypoints without --base is two-pass: the free generator first produces
230
+ # the base clip on the box (pass 1), then the constrained pass uses it.
231
+ TWO_PASS=0
232
+ [[ "$MODE" == "waypoints" && -z "$BASE" ]] && TWO_PASS=1
233
+ IMPLICIT_POSE_BASE=0
234
+
235
+ [[ -n "$PROMPT" && -n "$DURATION" ]] || usage
236
+ if [[ "$MODE" == "pose" && -z "$BASE" ]]; then
237
+ # Internal parser/reference baseline only. Full-body poses are the actual
238
+ # constraints; users never select a semantic base motion.
239
+ BASE="${CCLAY_ARDY_REFERENCE_BASE:-stand-upright-facing-forward-bring-both--0722140757}"
240
+ IMPLICIT_POSE_BASE=1
241
+ fi
242
+ # --- local validation, mirroring the CozyClay wrapper's bounds -------------
243
+ if [[ "$MODE" == "pose" ]]; then
244
+ for ((i = 0; i < ${#POSE_ARGS[@]}; i += 3)); do
245
+ pose_path="${POSE_ARGS[$i]}"
246
+ [[ -f "$pose_path" ]] || { echo "run-on-box: pose npz not found: $pose_path" >&2; exit 1; }
247
+ done
248
+ fi
249
+
250
+ [[ -n "$PROMPT" ]] || { echo "run-on-box: --prompt must not be empty" >&2; exit 1; }
251
+ # The clip is int(duration * 20) frames at ARDY's 20 fps, capped at 1200 s
252
+ # like the wrapper; the remote rejects anything below 3 frames because
253
+ # inter-frame continuity is undefined there.
254
+ awk -v d="$DURATION" 'BEGIN { exit !(d > 0 && d <= 1200) }' || {
255
+ echo "run-on-box: --duration must be > 0 and <= 1200 seconds, got '$DURATION'" >&2; exit 1; }
256
+ CLIP_FRAMES="$(awk -v d="$DURATION" 'BEGIN { printf "%d", d * 20 }')"
257
+ [[ "$CLIP_FRAMES" -ge 3 ]] || {
258
+ echo "run-on-box: --duration ${DURATION}s yields ${CLIP_FRAMES} frame(s) at 20 fps; a clip needs at least 3" >&2; exit 1; }
259
+ if [[ "$MODE" == "pose" ]]; then
260
+ for ((i = 0; i < ${#POSE_ARGS[@]}; i += 3)); do
261
+ dst="${POSE_ARGS[$((i + 2))]}"
262
+ awk -v f="$dst" -v m="$CLIP_FRAMES" 'BEGIN { exit !(f < m) }' || {
263
+ echo "run-on-box: --pose-from destination $dst is outside 0..$((CLIP_FRAMES - 1)) (duration ${DURATION}s at 20 fps)" >&2; exit 1; }
264
+ done
265
+ fi
266
+ # --- --root-2d validation: each group, then the set as a whole -------------
267
+ # Mirrors the bridge contract: 2..32 sparse keys beginning at frame 0,
268
+ # strictly ascending, X/Z in meters within -20..20, HEADING a finite number
269
+ # of radians within -2π..2π or the literal 'none'.
270
+ FLOAT_RE='^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)([eE][+-]?[0-9]+)?$'
271
+ validate_root_2d() {
272
+ local n="${#ROOT_2D_ARGS[@]}" i frame x z heading prev=-1 count
273
+ [[ $((n % 4)) -eq 0 ]] || { echo "run-on-box: internal error: --root-2d group list is misaligned" >&2; exit 1; }
274
+ count=$((n / 4))
275
+ if [[ "$count" -ne 0 && ( "$count" -lt 2 || "$count" -gt 32 ) ]]; then
276
+ echo "run-on-box: --root-2d needs 2..32 sparse waypoints, got $count" >&2
277
+ exit 1
278
+ fi
279
+ for ((i = 0; i < n; i += 4)); do
280
+ frame="${ROOT_2D_ARGS[$i]}"
281
+ x="${ROOT_2D_ARGS[$((i + 1))]}"
282
+ z="${ROOT_2D_ARGS[$((i + 2))]}"
283
+ heading="${ROOT_2D_ARGS[$((i + 3))]}"
284
+ [[ "$frame" =~ ^[0-9]+$ ]] || { echo "run-on-box: --root-2d FRAME must be a non-negative integer, got '$frame'" >&2; exit 1; }
285
+ awk -v f="$frame" -v m="$CLIP_FRAMES" 'BEGIN { exit !(f >= 0 && f < m) }' || {
286
+ echo "run-on-box: --root-2d frame $frame is outside 0..$((CLIP_FRAMES - 1)) (duration ${DURATION}s at 20 fps)" >&2
287
+ exit 1
288
+ }
289
+ awk -v f="$frame" -v p="$prev" 'BEGIN { exit !(f > p) }' || {
290
+ echo "run-on-box: --root-2d frames must be strictly ascending; frame $frame repeats or precedes frame $prev" >&2
291
+ exit 1
292
+ }
293
+ if [[ $i -eq 0 && "$frame" -ne 0 ]]; then
294
+ echo "run-on-box: --root-2d start must be at frame 0, got frame $frame" >&2
295
+ exit 1
296
+ fi
297
+ prev="$frame"
298
+ [[ "$x" =~ $FLOAT_RE ]] || { echo "run-on-box: --root-2d X must be a number, got '$x'" >&2; exit 1; }
299
+ awk -v v="$x" 'BEGIN { exit !(v >= -20 && v <= 20) }' || {
300
+ echo "run-on-box: --root-2d X $x is outside -20..20 meters" >&2
301
+ exit 1
302
+ }
303
+ [[ "$z" =~ $FLOAT_RE ]] || { echo "run-on-box: --root-2d Z must be a number, got '$z'" >&2; exit 1; }
304
+ awk -v v="$z" 'BEGIN { exit !(v >= -20 && v <= 20) }' || {
305
+ echo "run-on-box: --root-2d Z $z is outside -20..20 meters" >&2
306
+ exit 1
307
+ }
308
+ if [[ "$heading" != "none" ]]; then
309
+ [[ "$heading" =~ $FLOAT_RE ]] || {
310
+ echo "run-on-box: --root-2d HEADING must be a number of radians or the literal 'none', got '$heading'" >&2
311
+ exit 1
312
+ }
313
+ awk -v h="$heading" 'BEGIN { pi = atan2(0, -1); exit !(h >= -2*pi && h <= 2*pi) }' || {
314
+ echo "run-on-box: --root-2d HEADING $heading rad is outside -2π..2π" >&2
315
+ exit 1
316
+ }
317
+ fi
318
+ done
319
+ }
320
+ validate_root_2d
321
+ # SRC_FRAME indexes the pose npz; its real bound is only knowable on the
322
+ # box, so like the reference wrapper we accept any non-negative integer here
323
+ # and let the remote range-check it against the npz's frame count.
324
+ if [[ "$MODE" == "pose" ]]; then
325
+ FIRST_POSE="${POSE_ARGS[0]}"
326
+ OUTPUT="${OUTPUT:-$HERE/out/$(basename "${FIRST_POSE%.npz}")-constrained.npz}"
327
+ else
328
+ OUTPUT="${OUTPUT:-$HERE/out/$(date +%s)-generated.npz}"
329
+ fi
330
+
331
+
332
+ # --- preflight: fail fast, cheapest first ---------------------------------
333
+ if ! ssh "${SSH_OPTS[@]}" "$HOST" ":" </dev/null; then
334
+ echo "run-on-box: cannot ssh to ${HOST} (BatchMode). Check network reachability and the SSH key agent." >&2
335
+ exit 1
336
+ fi
337
+ echo "run-on-box: ssh to ${HOST} ok"
338
+
339
+ # Free generation runs scripts/generate.py (the constrained script refuses a
340
+ # run with no target, pose or waypoint); every other mode runs the
341
+ # constrained generator. Two-pass waypoints mode needs BOTH scripts:
342
+ # generate.py for the free base pass, then the constrained script.
343
+ GEN_SCRIPT="scripts/cclay_constrained_generate.py"
344
+ [[ "$MODE" == "free" ]] && GEN_SCRIPT="scripts/generate.py"
345
+ GEN_CHECK="test -x ${VENV_PY} && test -f ${REMOTE}/${GEN_SCRIPT}"
346
+ GEN_LABEL="${REMOTE}/${GEN_SCRIPT}"
347
+ if [[ "$TWO_PASS" -eq 1 || "$IMPLICIT_POSE_BASE" -eq 1 ]]; then
348
+ GEN_CHECK="test -x ${VENV_PY} && test -f ${REMOTE}/scripts/generate.py && test -f ${REMOTE}/scripts/cclay_constrained_generate.py"
349
+ GEN_LABEL="${REMOTE}/scripts/generate.py and ${REMOTE}/scripts/cclay_constrained_generate.py"
350
+ fi
351
+ # shellcheck disable=SC2029 # REMOTE/VENV_PY expand remotely by design
352
+ if ! ssh "${SSH_OPTS[@]}" "$HOST" "$GEN_CHECK" </dev/null; then
353
+ echo "run-on-box: ${HOST} is missing ${VENV_PY} or ${GEN_LABEL}; sync the ARDY sources to the box first (CozyClay scripts/ardy/sync-to-box --apply)" >&2
354
+ exit 1
355
+ fi
356
+ echo "run-on-box: venv python and generator script present on ${HOST}"
357
+
358
+
359
+ # Resolve --base on the box: anything containing a slash is a repo-relative
360
+ # or absolute path and passes through; a bare id or bare <name>.npz is
361
+ # looked up under outputs/ then outputs/omb/ (repo-relative, i.e.
362
+ # ~/ardy/outputs*). Free mode takes no base, and waypoints mode without one
363
+ # is two-pass: the pass-1 output is a fresh absolute temp path verified with
364
+ # a plain test -f at the pass-1 step, never through this candidate lookup.
365
+ if [[ -n "$BASE" ]]; then
366
+ if [[ "$BASE" == */* ]]; then
367
+ BASE_CANDIDATES="$(printf '%q' "$BASE")"
368
+ BASE_LOOKED="'${BASE}'"
369
+ else
370
+ BASE_NAME="${BASE%.npz}"
371
+ BASE_CANDIDATES="$(printf '%q' "outputs/${BASE_NAME}.npz") $(printf '%q' "outputs/omb/${BASE_NAME}.npz")"
372
+ BASE_LOOKED="outputs/${BASE_NAME}.npz or outputs/omb/${BASE_NAME}.npz under ${REMOTE}"
373
+ fi
374
+ # shellcheck disable=SC2029 # REMOTE expands remotely by design
375
+ if ! BASE_RESOLVED="$(ssh "${SSH_OPTS[@]}" "$HOST" "cd ${REMOTE} && for c in ${BASE_CANDIDATES}; do if test -f \"\$c\"; then printf '%s' \"\$c\"; exit 0; fi; done; exit 1" </dev/null)"; then
376
+ echo "run-on-box: base motion '${BASE}' does not exist on ${HOST} (looked for ${BASE_LOOKED})" >&2
377
+ exit 1
378
+ fi
379
+ echo "run-on-box: base motion on ${HOST}: ${BASE_RESOLVED}"
380
+ fi
381
+
382
+ # The hidden upright reference is only a convenient baseline when it is long
383
+ # enough. A loaded clip can be longer than that fixture; in that case generate
384
+ # an exact-duration base first instead of letting cclay_constrained_generate.py
385
+ # fail after model load with "base has N frames but requested M".
386
+ if [[ "$IMPLICIT_POSE_BASE" -eq 1 ]]; then
387
+ # shellcheck disable=SC2029 # resolved path is selected from the box listing
388
+ if ! BASE_FRAMES="$(ssh "${SSH_OPTS[@]}" "$HOST" \
389
+ "cd ${REMOTE} && ${VENV_PY} -c 'import numpy as np,sys; print(np.load(sys.argv[1])[\"local_rot_mats\"].shape[0])' $(printf '%q' "$BASE_RESOLVED")" </dev/null)"; then
390
+ echo "run-on-box: could not inspect implicit pose base ${BASE_RESOLVED}" >&2
391
+ exit 1
392
+ fi
393
+ [[ "$BASE_FRAMES" =~ ^[0-9]+$ ]] || {
394
+ echo "run-on-box: implicit pose base returned invalid frame count '${BASE_FRAMES}'" >&2
395
+ exit 1
396
+ }
397
+ if [[ "$BASE_FRAMES" -lt "$CLIP_FRAMES" ]]; then
398
+ echo "run-on-box: implicit pose base has ${BASE_FRAMES} frames; will free-generate ${CLIP_FRAMES}-frame base first"
399
+ BASE_RESOLVED=""
400
+ TWO_PASS=1
401
+ fi
402
+ fi
403
+
404
+
405
+ # Probe the device the generator will use, with the SAME env it will run
406
+ # under (CUDA_VISIBLE_DEVICES="" included). The one-liner mirrors the
407
+ # generator's `device = "cuda:0" if torch.cuda.is_available() else "cpu"`
408
+ # exactly, so what is printed is what the generation will pick.
409
+ # shellcheck disable=SC2029 # REMOTE/VENV_PY expand remotely by design
410
+ CUDA_ENV=""
411
+ [[ "$FORCE_CPU" -eq 1 ]] && CUDA_ENV='CUDA_VISIBLE_DEVICES="" '
412
+
413
+ # The text encoder is a separate CPU service; without it the generator loads the
414
+ # model, then dies. Check it before spending minutes on the rest.
415
+ if ! ENCODER_CODE="$(ssh "${SSH_OPTS[@]}" "$HOST" "curl -s -o /dev/null -w '%{http_code}' -m 10 ${ENCODER_URL}" </dev/null)"; then
416
+ echo "run-on-box: could not reach ${HOST} to probe the text encoder" >&2
417
+ exit 1
418
+ fi
419
+ if [[ "$ENCODER_CODE" != "200" ]]; then
420
+ echo "run-on-box: text encoder at ${ENCODER_URL} answered '${ENCODER_CODE}', not 200." >&2
421
+ echo "run-on-box: start it with: ssh ${HOST} 'cd ~/ardy && .venv/bin/python -u scripts/run_text_encoder_server.py --host 127.0.0.1 --port 9550 --device cpu'" >&2
422
+ exit 1
423
+ fi
424
+ echo "run-on-box: text encoder ${ENCODER_URL} responding (cpu service)"
425
+
426
+ if ! DEVICE="$(ssh "${SSH_OPTS[@]}" "$HOST" "cd ${REMOTE} && ${CUDA_ENV}${VENV_PY} -c 'import torch; print(\"cuda:0\" if torch.cuda.is_available() else \"cpu\")'" </dev/null)"; then
427
+ echo "run-on-box: device probe on ${HOST} failed (venv python erroring?)" >&2
428
+ exit 1
429
+ fi
430
+ [[ -n "$DEVICE" ]] || { echo "run-on-box: device probe on ${HOST} returned nothing" >&2; exit 1; }
431
+ echo "run-on-box: generator will use device '${DEVICE}' on ${HOST}"
432
+
433
+ # --- the generator invocation ---------------------------------------------
434
+ # Args are shell-quoted with printf %q so prompts and paths survive the
435
+ # remote shell. The output base is absolute (under the remote temp dir), so
436
+ # the generator's _resolve_output_base honors it instead of placing it under
437
+ # outputs/.
438
+ # scripts/generate.py is the unconstrained generator: its prompt is
439
+ # positional and it has no --base / --pose-from / --root-2d. Used for free
440
+ # mode and for the two-pass base pass (pass 1); the npz lands at
441
+ # <out_base>.npz.
442
+ build_free_cmd() {
443
+ local out_base="$1" cmd
444
+ cmd="cd ${REMOTE} && ${CUDA_ENV}${VENV_PY} scripts/generate.py $(printf '%q' "$PROMPT")"
445
+ cmd+=" --duration $(printf '%q' "$DURATION")"
446
+ cmd+=" --output $(printf '%q' "$out_base")"
447
+ if [[ -n "$SEED" ]]; then
448
+ cmd+=" --seed $(printf '%q' "$SEED")"
449
+ fi
450
+ printf '%s' "$cmd"
451
+ }
452
+
453
+ build_remote_cmd() {
454
+ local tmp_dir="$1" cmd i
455
+ # Two-pass waypoints mode has no user base: BASE_RESOLVED stays empty and
456
+ # pass 1's output npz (under the same remote temp dir, cleaned by the EXIT
457
+ # trap) becomes the --base of the constrained pass.
458
+ local base_arg="${BASE_RESOLVED:-${tmp_dir}/base.npz}"
459
+ if [[ "$MODE" == "free" ]]; then
460
+ cmd="$(build_free_cmd "${tmp_dir}/out")"
461
+ else
462
+ cmd="cd ${REMOTE} && ${CUDA_ENV}${VENV_PY} scripts/cclay_constrained_generate.py"
463
+ cmd+=" --prompt $(printf '%q' "$PROMPT")"
464
+ cmd+=" --duration $(printf '%q' "$DURATION")"
465
+ cmd+=" --base $(printf '%q' "$base_arg")"
466
+ if [[ "$MODE" == "pose" ]]; then
467
+ for ((i = 0; i < ${#POSE_ARGS[@]}; i += 3)); do
468
+ pose_index=$((i / 3))
469
+ cmd+=" --pose-from $(printf '%q' "${tmp_dir}/pose-${pose_index}.npz")"
470
+ cmd+=" $(printf '%q' "${POSE_ARGS[$((i + 1))]}") $(printf '%q' "${POSE_ARGS[$((i + 2))]}")"
471
+ done
472
+ fi
473
+ # Each validated --root-2d group is %q-quoted value by value, so no
474
+ # waypoint text can ever reach the remote shell unquoted.
475
+ for ((i = 0; i < ${#ROOT_2D_ARGS[@]}; i += 4)); do
476
+ cmd+=" --root-2d $(printf '%q' "${ROOT_2D_ARGS[$i]}")"
477
+ cmd+=" $(printf '%q' "${ROOT_2D_ARGS[$((i + 1))]}")"
478
+ cmd+=" $(printf '%q' "${ROOT_2D_ARGS[$((i + 2))]}")"
479
+ cmd+=" $(printf '%q' "${ROOT_2D_ARGS[$((i + 3))]}")"
480
+ done
481
+ cmd+=" --output $(printf '%q' "${tmp_dir}/out")"
482
+ if [[ -n "$SEED" ]]; then
483
+ cmd+=" --seed $(printf '%q' "$SEED")"
484
+ fi
485
+ fi
486
+ printf '%s' "$cmd"
487
+ }
488
+
489
+
490
+ if [[ $DRY_RUN -eq 1 ]]; then
491
+ REMOTE_TMP_PLACEHOLDER="<mktemp-dir-on-box>"
492
+ MODE_LABEL="free generation (generate.py)"
493
+ [[ "$MODE" == "pose" ]] && MODE_LABEL="full-body pose pin"
494
+ [[ "$MODE" == "waypoints" ]] && MODE_LABEL="frame-0 root start + prompt (no pose)"
495
+ [[ "$MODE" == "waypoints" && "$TWO_PASS" -eq 1 ]] && MODE_LABEL="frame-0 root start + prompt (no pose) - two-pass (free base first)"
496
+ echo "run-on-box: DRY RUN - preflight done, nothing pushed, nothing generated, nothing pulled."
497
+ echo "run-on-box: mode ${MODE} - ${MODE_LABEL}"
498
+ [[ "$MODE" == "pose" ]] || echo "run-on-box: pose npz <none>"
499
+ [[ "$MODE" != "pose" ]] || echo "run-on-box: pose pins $(( ${#POSE_ARGS[@]} / 3 ))"
500
+ if [[ "$TWO_PASS" -eq 1 ]]; then
501
+ echo "run-on-box: base motion pass-1 output ${REMOTE_TMP_PLACEHOLDER}/base.npz on ${HOST} (free-generated first)"
502
+ else
503
+ [[ -z "${BASE_RESOLVED:-}" ]] || echo "run-on-box: base motion ${BASE_RESOLVED} on ${HOST}"
504
+ fi
505
+ echo "run-on-box: device ${DEVICE}"
506
+ echo "run-on-box: root-2d $(( ${#ROOT_2D_ARGS[@]} / 4 )) waypoint(s)"
507
+ [[ "$MODE" != "pose" ]] || echo "run-on-box: would push $(( ${#POSE_ARGS[@]} / 3 )) pose npz file(s) to ${HOST}:${REMOTE_TMP_PLACEHOLDER}"
508
+ if [[ "$TWO_PASS" -eq 1 ]]; then
509
+ echo "run-on-box: pass 1/2 would run ssh ${HOST} \"$(build_free_cmd "${REMOTE_TMP_PLACEHOLDER}/base")\""
510
+ echo "run-on-box: pass 2/2 would run ssh ${HOST} \"$(build_remote_cmd "$REMOTE_TMP_PLACEHOLDER")\""
511
+ else
512
+ echo "run-on-box: would run ssh ${HOST} \"$(build_remote_cmd "$REMOTE_TMP_PLACEHOLDER")\""
513
+ fi
514
+ echo "run-on-box: would pull scp -q -o BatchMode=yes ${HOST}:${REMOTE_TMP_PLACEHOLDER}/out.npz ${OUTPUT}"
515
+ echo "run-on-box: would clean ssh ${HOST} \"rm -rf ${REMOTE_TMP_PLACEHOLDER}\""
516
+ exit 0
517
+ fi
518
+
519
+ mkdir -p "$(dirname "$OUTPUT")"
520
+
521
+ # Fresh temp dir on the box; the EXIT trap removes it even on failure, and
522
+ # cleanup failure never masks the real result. Guard the path (must be
523
+ # absolute, as mktemp guarantees) before it can reach the cleanup rm -rf.
524
+ if ! REMOTE_TMP="$(ssh "${SSH_OPTS[@]}" "$HOST" "mktemp -d" </dev/null)"; then
525
+ echo "run-on-box: could not create a temp dir on ${HOST} (mktemp failed)" >&2
526
+ exit 1
527
+ fi
528
+ [[ "$REMOTE_TMP" == /* ]] || { echo "run-on-box: remote mktemp returned a non-absolute path '${REMOTE_TMP}'; aborting" >&2; exit 1; }
529
+ trap 'ssh "${SSH_OPTS[@]}" "$HOST" "rm -rf ${REMOTE_TMP}" </dev/null >/dev/null 2>&1 || true' EXIT
530
+
531
+ if [[ "$TWO_PASS" -eq 1 ]]; then
532
+ # Pass 1: free-generate the base clip with the same prompt/duration/seed
533
+ # the constrained pass will use. Its npz lives under REMOTE_TMP, so the
534
+ # EXIT trap removes it with everything else.
535
+ PASS1_CMD="$(build_free_cmd "${REMOTE_TMP}/base")"
536
+ echo "run-on-box: pass 1/2: free-generating base clip on ${HOST} ..."
537
+ # shellcheck disable=SC2029 # PASS1_CMD is a remote command by design
538
+ ssh "${SSH_OPTS[@]}" "$HOST" "$PASS1_CMD" </dev/null
539
+ BASE_RESOLVED="${REMOTE_TMP}/base.npz"
540
+ # The pass-1 output is a fresh absolute temp path, not a listed base: no
541
+ # BASE_CANDIDATES lookup, just verify the file appeared.
542
+ # shellcheck disable=SC2029 # REMOTE_TMP expands remotely by design
543
+ if ! ssh "${SSH_OPTS[@]}" "$HOST" "test -f ${BASE_RESOLVED}" </dev/null; then
544
+ echo "run-on-box: pass 1/2 failed: ${BASE_RESOLVED} was not produced on ${HOST}" >&2
545
+ exit 1
546
+ fi
547
+ echo "run-on-box: pass 1/2: base clip at ${HOST}:${BASE_RESOLVED}"
548
+ fi
549
+
550
+ if [[ "$MODE" == "pose" ]]; then
551
+ for ((i = 0; i < ${#POSE_ARGS[@]}; i += 3)); do
552
+ pose_index=$((i / 3))
553
+ pose_path="${POSE_ARGS[$i]}"
554
+ echo "run-on-box: pushing pose $((pose_index + 1))/$(( ${#POSE_ARGS[@]} / 3 )) to ${HOST}:${REMOTE_TMP}/pose-${pose_index}.npz ..."
555
+ scp -q "${SCP_OPTS[@]}" "$pose_path" "$HOST:${REMOTE_TMP}/pose-${pose_index}.npz"
556
+ done
557
+ fi
558
+
559
+ REMOTE_CMD="$(build_remote_cmd "$REMOTE_TMP")"
560
+ echo "run-on-box: generating on ${HOST} ..."
561
+ # shellcheck disable=SC2029 # REMOTE_CMD is a remote command by design
562
+ ssh "${SSH_OPTS[@]}" "$HOST" "$REMOTE_CMD" </dev/null
563
+
564
+ echo "run-on-box: pulling ${HOST}:${REMOTE_TMP}/out.npz -> ${OUTPUT} ..."
565
+ # shellcheck disable=SC2029 # HOST:REMOTE_TMP is a remote path by design
566
+ scp -q "${SCP_OPTS[@]}" "$HOST:${REMOTE_TMP}/out.npz" "$OUTPUT"
567
+ [[ -s "$OUTPUT" ]] || { echo "run-on-box: pulled ${OUTPUT} is empty; the generation failed" >&2; exit 1; }
568
+ echo "run-on-box: done - ${OUTPUT} ($(wc -c < "$OUTPUT" | tr -d ' ') bytes)"