cozyclay 2.1.0 → 3.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.
- package/CHANGELOG.md +65 -0
- package/README.md +12 -30
- package/bin/agent/agent-routes.mjs +40 -113
- package/bin/agent/agent-runner.mjs +9 -10
- package/bin/agent/cliproxy-provider.mjs +135 -0
- package/bin/agent/credential-store.mjs +30 -7
- package/bin/agent/pi-tools.mjs +4 -1
- package/bin/agent/provider-keys.mjs +32 -0
- package/bin/agent/provider-login.mjs +110 -0
- package/bin/agent/provider-routes.mjs +99 -0
- package/bin/agent/providers.mjs +19 -97
- package/bin/agent/script-tool.mjs +17 -4
- package/bin/agent/studio-motion-reference.mjs +23 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-rebase-text.mjs +32 -0
- package/bin/agent/studio-rebase.mjs +26 -39
- package/bin/agent/studio-tools.mjs +9 -28
- package/bin/agent/tool-arguments.mjs +49 -0
- package/bin/agent/turn-frames.mjs +66 -0
- package/bin/codex-auth.mjs +9 -2
- package/bin/cozyclay.mjs +10 -116
- package/bin/live/cli.mjs +22 -6
- package/bin/mcp-runtime.mjs +2 -12
- package/bin/package-signature.mjs +0 -4
- package/dist/app/index.html +7 -6
- package/dist/assets/EXRLoader-CvVIvGVE.js +1 -0
- package/dist/assets/analytics-C9sUnndd.js +1 -0
- package/dist/assets/app-CCh--1Hc.js +5087 -0
- package/dist/assets/app-f-fkV5uP.css +1 -0
- package/dist/assets/compile-worker-CkxiD6lH.js +5 -0
- package/dist/assets/fflate.module-KbGj5-H6.js +5 -0
- package/dist/assets/{first-shot-handoff-BlT33CGf.js → first-shot-handoff-CC4xe4HM.js} +1 -1
- package/dist/assets/{landing-BSesrCpL.js → landing-DjHD8u7k.js} +1 -1
- package/dist/assets/scene-asset-sync-Hx_FzdOH.js +20 -0
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +1 -0
- package/dist/assets/workflow-C4yKH1p4.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +3 -3
- package/dist/llms.txt +14 -0
- package/dist/sw.js +3 -6
- package/dist/workflow/index.html +5 -4
- package/mcp/LIVE-PROTOCOL.md +7 -47
- package/mcp/README.md +16 -26
- package/mcp/live-hub.mjs +6 -10
- package/mcp/package.json +1 -3
- package/mcp/tool-handlers.mjs +4 -168
- package/package.json +3 -22
- package/src/App.jsx +174 -2639
- package/src/agent-progress.js +79 -0
- package/src/agent-turn-mark.js +30 -0
- package/src/analytics.js +1 -19
- package/src/app-context.js +5 -0
- package/src/app-stage.jsx +2 -34
- package/src/asset-pane.jsx +6 -5
- package/src/camera-block.js +21 -0
- package/src/camera-direction.js +120 -0
- package/src/character-kind.js +3 -9
- package/src/command-bus.js +8 -6
- package/src/commands/cast.js +5 -34
- package/src/commands/elements/character.js +1 -1
- package/src/commands/elements/motion.js +1 -6
- package/src/commands/elements/object.js +6 -2
- package/src/commands/elements/shot.js +4 -1
- package/src/commands/elements.js +38 -14
- package/src/commands/index.js +3 -1
- package/src/commands/kits.js +37 -0
- package/src/commands/motion-timing.js +57 -0
- package/src/commands/motion.js +9 -152
- package/src/commands/motion3-compile.js +65 -0
- package/src/commands/motion3.js +207 -0
- package/src/commands/objects.js +6 -4
- package/src/commands/shot.js +49 -11
- package/src/commands/stage.js +14 -1
- package/src/commands/view.js +4 -1
- package/src/document-mirrors.js +42 -0
- package/src/document-store.js +4 -0
- package/src/domains/cast.js +19 -194
- package/src/domains/motion/motion-range-pin-rules.js +1 -1
- package/src/domains/motion/take-source.js +51 -0
- package/src/domains/motion.js +60 -1838
- package/src/domains/scenes.js +12 -55
- package/src/domains/shots.js +2 -2
- package/src/domains/stage.js +4 -2
- package/src/draft-ghost.js +46 -0
- package/src/draft-ghost.jsx +129 -0
- package/src/dualview.jsx +4 -3
- package/src/effects/EffectsLayer.jsx +112 -0
- package/src/effects/effect-meshes.js +232 -0
- package/src/effects/effect-record.js +103 -0
- package/src/effects/emitter.js +59 -0
- package/src/effects/simulate.js +261 -0
- package/src/effects/state.js +59 -0
- package/src/execution-telemetry.js +2 -3
- package/src/first-success-guide.jsx +5 -5
- package/src/ghost-set.js +257 -0
- package/src/held-object-edit.jsx +113 -0
- package/src/held-object-pose.js +175 -0
- package/src/hierarchy-model.js +1 -1
- package/src/hierarchy-panel.jsx +1 -1
- package/src/imported-mesh.jsx +103 -0
- package/src/kits/held.js +84 -0
- package/src/kits/index.js +70 -0
- package/src/kits/smoking.js +57 -0
- package/src/light-blockers.js +93 -0
- package/src/motion/beat-board-model.js +84 -0
- package/src/motion/beat-board.css +124 -0
- package/src/motion/beat-board.jsx +59 -0
- package/src/motion/center-of-mass.js +66 -0
- package/src/motion/fix-collisions.js +850 -0
- package/src/motion/physics-review.js +118 -0
- package/src/{ardy → motion}/physics-support.js +1 -76
- package/src/motion/rig-snapshot.js +22 -0
- package/src/motion/take-segments.jsx +140 -0
- package/src/{ardy → motion}/timeline-coordinates.js +0 -21
- package/src/{ardy → motion}/timeline.css +1 -2
- package/src/{ardy → motion}/timeline.jsx +48 -419
- package/src/motion/waypoints.js +76 -0
- package/src/motion3/beats.js +83 -0
- package/src/motion3/body.js +158 -0
- package/src/motion3/bones.js +47 -0
- package/src/motion3/carried.js +47 -0
- package/src/motion3/compile-job.js +75 -0
- package/src/motion3/compile-worker.js +10 -0
- package/src/motion3/compiler/anchors.js +105 -0
- package/src/motion3/compiler/arm-orient.js +128 -0
- package/src/motion3/compiler/channels.js +125 -0
- package/src/motion3/compiler/compile.js +205 -0
- package/src/motion3/compiler/gait-rhythm.js +97 -0
- package/src/motion3/compiler/grip.js +171 -0
- package/src/motion3/compiler/head-steady.js +48 -0
- package/src/motion3/compiler/hold.js +134 -0
- package/src/motion3/compiler/idle.js +113 -0
- package/src/motion3/compiler/limbs.js +170 -0
- package/src/motion3/compiler/math.js +72 -0
- package/src/motion3/compiler/pose.js +162 -0
- package/src/motion3/compiler/quat.js +49 -0
- package/src/motion3/compiler/skeleton.js +74 -0
- package/src/motion3/compiler/validate-common.js +32 -0
- package/src/motion3/compiler/validate-goals.js +118 -0
- package/src/motion3/compiler/validate-objects.js +143 -0
- package/src/motion3/compiler/validate-parts.js +116 -0
- package/src/motion3/compiler/validate.js +122 -0
- package/src/motion3/compiler/vocab.js +73 -0
- package/src/motion3/compiler/walk-pelvis.js +275 -0
- package/src/motion3/compiler/walk.js +207 -0
- package/src/motion3/compiler/wrist-clamp.js +32 -0
- package/src/motion3/delta.js +47 -0
- package/src/motion3/describe.js +174 -0
- package/src/motion3/docs.js +27 -0
- package/src/motion3/edit-carry.js +21 -0
- package/src/motion3/fix/ease.js +16 -0
- package/src/motion3/fix/ground.js +37 -0
- package/src/motion3/fix/index.js +52 -0
- package/src/motion3/fix/push.js +71 -0
- package/src/motion3/fix/reach.js +46 -0
- package/src/motion3/fix/retime.js +79 -0
- package/src/motion3/fix/self.js +211 -0
- package/src/motion3/fix/slide.js +70 -0
- package/src/motion3/fix/turn.js +51 -0
- package/src/motion3/follow.js +39 -0
- package/src/motion3/free-motion.js +173 -0
- package/src/motion3/grip-choice.js +130 -0
- package/src/motion3/hold-plan.js +157 -0
- package/src/motion3/json-patch.js +77 -0
- package/src/motion3/key-aim.js +105 -0
- package/src/motion3/key-edit.js +82 -0
- package/src/motion3/lenient.js +127 -0
- package/src/motion3/object-keys.js +236 -0
- package/src/motion3/object-pose.js +182 -0
- package/src/motion3/object-tracks.js +12 -0
- package/src/motion3/pipeline.js +102 -0
- package/src/motion3/playback.js +51 -0
- package/src/motion3/probes.js +91 -0
- package/src/motion3/qa/body-checks.js +34 -0
- package/src/motion3/qa/check-names.js +14 -0
- package/src/motion3/qa/checks.js +121 -0
- package/src/motion3/qa/contact-checks.js +51 -0
- package/src/motion3/qa/explain.js +80 -0
- package/src/motion3/qa/fmt.js +5 -0
- package/src/motion3/qa/foot-checks.js +72 -0
- package/src/motion3/qa/geometry.js +77 -0
- package/src/motion3/qa/goals.js +191 -0
- package/src/motion3/qa/ground.js +60 -0
- package/src/motion3/qa/hints-contact.js +179 -0
- package/src/motion3/qa/hints-goal.js +160 -0
- package/src/motion3/qa/hints-knee.js +24 -0
- package/src/motion3/qa/hints-motion.js +209 -0
- package/src/motion3/qa/hints-object.js +91 -0
- package/src/motion3/qa/index.js +42 -0
- package/src/motion3/qa/key-checks.js +82 -0
- package/src/motion3/qa/near.js +96 -0
- package/src/motion3/qa/object-checks.js +135 -0
- package/src/motion3/qa/patch.js +161 -0
- package/src/motion3/qa/severity.js +38 -0
- package/src/motion3/qa/spec-checks.js +65 -0
- package/src/motion3/qa/style.js +218 -0
- package/src/motion3/qa/wrist-checks.js +69 -0
- package/src/motion3/qa/wrist-obs.js +60 -0
- package/src/motion3/refs.js +120 -0
- package/src/motion3/repair-tune.js +121 -0
- package/src/motion3/repair.js +83 -0
- package/src/motion3/report.js +67 -0
- package/src/motion3/rigs/index.js +14 -0
- package/src/motion3/rigs/x-bot-tpose-body.json +1 -0
- package/src/motion3/rigs/x-bot-tpose.json +1 -0
- package/src/motion3/rigs/y-bot-tpose-body.json +1 -0
- package/src/motion3/rigs/y-bot-tpose.json +1 -0
- package/src/motion3/scene.js +60 -0
- package/src/motion3/schema/brief.js +89 -0
- package/src/motion3/schema/fields.js +44 -0
- package/src/motion3/schema/spec-examples.js +46 -0
- package/src/motion3/schema/topics.js +111 -0
- package/src/motion3/schema/types.js +147 -0
- package/src/motion3/search.js +117 -0
- package/src/motion3/second-hand.js +112 -0
- package/src/motion3/skills/basic.js +82 -0
- package/src/motion3/skills/book.js +76 -0
- package/src/motion3/skills/car.js +145 -0
- package/src/motion3/skills/frames-auto.js +29 -0
- package/src/motion3/skills/index.js +83 -0
- package/src/motion3/skills/info.js +65 -0
- package/src/motion3/skills/smoke-kit.js +109 -0
- package/src/motion3/skills/smoke.js +160 -0
- package/src/motion3/spec-store.js +59 -0
- package/src/motion3/take-edit.js +155 -0
- package/src/motion3/take.js +63 -0
- package/src/motion3/timeline.js +127 -0
- package/src/motion3/views.js +121 -0
- package/src/motion3/walk-plan.js +119 -0
- package/src/object-catalog.jsx +7 -1
- package/src/object-colors.js +98 -0
- package/src/object-gizmo-parts.js +115 -0
- package/src/object-gizmo.jsx +37 -122
- package/src/object-glow.js +139 -0
- package/src/object-hidden.js +33 -0
- package/src/object-look.js +196 -0
- package/src/object-size.js +10 -0
- package/src/object-travel.js +71 -6
- package/src/panels/CameraPanel.jsx +7 -7
- package/src/panels/HdrEnvironmentControls.jsx +59 -0
- package/src/panels/LightPanel.jsx +25 -4
- package/src/panels/ObjectLookPanel.jsx +88 -0
- package/src/panels/PoseImagePanel.jsx +1 -1
- package/src/panels/PosePanel.jsx +2 -17
- package/src/panels/RigControlPanel.jsx +5 -53
- package/src/panels/SubjectBox.jsx +4 -1
- package/src/panels/details.css +14 -0
- package/src/posestudio.jsx +4 -27
- package/src/project-browser.jsx +3 -4
- package/src/project-resources.js +18 -31
- package/src/project.js +13 -104
- package/src/props.jsx +25 -280
- package/src/rail-dot-drag.js +23 -0
- package/src/render-passes.js +10 -0
- package/src/resource-status.jsx +1 -0
- package/src/result-modal.jsx +4 -19
- package/src/room.jsx +20 -12
- package/src/scene-assets.js +8 -6
- package/src/scene-light-place.js +72 -0
- package/src/scene-lights.jsx +148 -0
- package/src/scene-objects.js +31 -139
- package/src/scenes.js +15 -105
- package/src/semantic-edit.js +2 -5
- package/src/set-pieces.jsx +183 -0
- package/src/settings-menu.jsx +3 -11
- package/src/shell/BottomDock.jsx +11 -53
- package/src/shell/CompositionGuideButton.jsx +20 -0
- package/src/shell/DetailsSlot.jsx +16 -200
- package/src/shell/GhostsMenuGroup.jsx +74 -0
- package/src/shell/LibrarySlot.jsx +1 -3
- package/src/shell/MenuBar.jsx +3 -3
- package/src/shell/PreferencesDialog.jsx +15 -264
- package/src/shell/PreferencesSlot.jsx +2 -8
- package/src/shell/ShotPreviewHeader.jsx +17 -0
- package/src/shell/TopBar.jsx +5 -83
- package/src/shell/ViewportToolbar.jsx +23 -93
- package/src/shell/agent-glass.css +19 -6
- package/src/shell/details-selection.js +42 -0
- package/src/shell/ghosts-menu.css +22 -0
- package/src/shell/glass-regions.css +29 -3
- package/src/shell/preferences.css +6 -1
- package/src/shell/viewport.css +12 -0
- package/src/shot-authoring.js +4 -7
- package/src/soft-shadows.js +37 -0
- package/src/stage-attach-frames.js +12 -12
- package/src/stage-canvas-rigs.jsx +3 -3
- package/src/stage-capture.jsx +42 -8
- package/src/stage-characters.jsx +22 -216
- package/src/stage-editor-gizmos.jsx +6 -6
- package/src/stage-environment.js +93 -0
- package/src/stage-hdr-builtins.js +107 -0
- package/src/stage-hdr-image.js +116 -0
- package/src/stage-hdr-library.js +70 -0
- package/src/stage-hdr-map.js +94 -0
- package/src/stage-hdr.js +130 -0
- package/src/stage-lighting.js +149 -0
- package/src/stage-occlusion.js +210 -0
- package/src/stage-sky.jsx +159 -0
- package/src/stage-startup-defaults.js +5 -9
- package/src/studio-actions.js +12 -14
- package/src/studio-agent-commands.js +46 -16
- package/src/studio-agent-motion.js +9 -9
- package/src/studio-agent-protocol.js +46 -73
- package/src/studio-app-binding.js +29 -45
- package/src/studio-asset-list.js +17 -0
- package/src/studio-contact-sheet.js +37 -5
- package/src/studio-elements.js +27 -7
- package/src/studio-entity-bounds.js +18 -0
- package/src/studio-error-codes.js +8 -0
- package/src/studio-export-framing.js +152 -0
- package/src/studio-lighting-check.js +103 -0
- package/src/studio-motion-visual.js +150 -0
- package/src/studio-schema-errors.js +80 -0
- package/src/studio-support-gap.js +74 -0
- package/src/styles.css +1 -19
- package/src/timeline-defaults.js +5 -0
- package/src/timeline-extent.js +8 -20
- package/src/tutorial-walk.js +17 -0
- package/src/ui-scrub.js +1 -1
- package/src/use-shot-camera-tracks.js +12 -0
- package/src/view-ghosts.js +97 -0
- package/src/view-ghosts.jsx +49 -0
- package/src/workflow/AgentPanel.jsx +47 -166
- package/src/workflow/AgentProgressCard.jsx +107 -0
- package/src/workflow/AgentReplyText.jsx +37 -0
- package/src/workflow/AgentTurnSteps.jsx +84 -0
- package/src/workflow/LoginPanel.jsx +140 -0
- package/src/workflow/WorkflowBuilder.jsx +7 -23
- package/src/workflow/agent-client.js +54 -51
- package/src/workflow/agent-panel.css +71 -1
- package/src/workflow/agent-progress-card.css +167 -0
- package/src/workflow/agent-progress-view.js +47 -0
- package/src/workflow/agent-turn-steps.css +113 -0
- package/src/workflow/agent-turn.js +191 -0
- package/src/workflow/login-client.js +34 -0
- package/src/workflow/motion-input.js +18 -31
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/reply-markdown.js +69 -0
- package/src/workflow/scene-asset-sync.js +8 -8
- package/tools/agent-session-stats.mjs +61 -0
- package/tools/dev-full.mjs +5 -56
- package/tools/eslint-baseline.json +12 -13
- package/tools/headless-studio.mjs +85 -0
- package/tools/knip-baseline.json +0 -179
- package/tools/loc-baseline.json +33 -83
- package/tools/motion3/extract-body.mjs +103 -0
- package/tools/motion3/extract-rig.mjs +85 -0
- package/tools/process-supervisor.mjs +0 -88
- package/tools/run-tests.mjs +9 -124
- package/bin/agent/motion-runtime.mjs +0 -76
- package/dist/ardy/cskel27-rest.json +0 -557
- package/dist/assets/analytics-B41FmPYI.js +0 -1
- package/dist/assets/app-Do-Og5Pu.css +0 -1
- package/dist/assets/app-DwP-tl2x.js +0 -4920
- package/dist/assets/config-Bx7pYsD9.js +0 -1
- package/dist/assets/config-DeoFTYzb.css +0 -1
- package/dist/assets/demo-Dt0qZfS7.js +0 -1
- package/dist/assets/scene-asset-sync-BCWDJB1d.js +0 -19
- package/dist/assets/scene-asset-sync-DjJ_cJ2R.css +0 -1
- package/dist/assets/ticket-jqMhqL88.js +0 -1
- package/dist/assets/workflow-Br-K7Srs.js +0 -179
- package/dist/demo/walk-then-stop.npz +0 -0
- package/mcp/ardy-prompts.mjs +0 -218
- package/src/ardy/auto-fix-panel.css +0 -108
- package/src/ardy/auto-physics.js +0 -1194
- package/src/ardy/client.js +0 -153
- package/src/ardy/convert.js +0 -313
- package/src/ardy/cskel27-neutral.js +0 -39
- package/src/ardy/cskel27.js +0 -77
- package/src/ardy/export.js +0 -165
- package/src/ardy/fix-collisions.js +0 -2257
- package/src/ardy/foot-lock.js +0 -230
- package/src/ardy/motion-calibration.js +0 -118
- package/src/ardy/motion-edit.js +0 -213
- package/src/ardy/motion-url.js +0 -55
- package/src/ardy/mp4-duration.js +0 -102
- package/src/ardy/npz.js +0 -595
- package/src/ardy/physics-panel.css +0 -21
- package/src/ardy/physics-panel.jsx +0 -89
- package/src/ardy/physics-refine.js +0 -111
- package/src/ardy/physics-review.js +0 -567
- package/src/ardy/physics-temporal.js +0 -54
- package/src/ardy/platform-fit-panel.css +0 -64
- package/src/ardy/platform-fit-panel.jsx +0 -41
- package/src/ardy/platform-fit.js +0 -326
- package/src/ardy/playback.js +0 -711
- package/src/ardy/pose-pin.js +0 -97
- package/src/ardy/prompt-clips.js +0 -30
- package/src/ardy/retime.js +0 -211
- package/src/ardy/root-drop.js +0 -369
- package/src/ardy/timeline-resize.js +0 -13
- package/src/ardy/to-cskel27.js +0 -225
- package/src/ardy/trim.js +0 -33
- package/src/ardy/waypoints.js +0 -369
- package/src/ardy-bridge-contract.js +0 -281
- package/src/domains/motion/motion-video-capture.js +0 -664
- package/src/i2v-motion-client.js +0 -77
- package/src/i2v-motion-studio.jsx +0 -180
- package/src/line-edit.js +0 -1348
- package/src/motion/generation.js +0 -105
- package/src/motion-readiness-ui.jsx +0 -67
- package/src/motion-readiness.js +0 -40
- package/src/motion-resources.js +0 -177
- package/src/motion-store.js +0 -65
- package/src/motion-trail.js +0 -601
- package/src/multimodel-ingest.js +0 -394
- package/src/panels/PromptBlocksPanel.jsx +0 -444
- package/src/panels/VideoCapturePanel.jsx +0 -181
- package/src/pose-extract/browser.js +0 -42
- package/src/pose-extract/detector.js +0 -100
- package/src/pose-extract/fit.js +0 -227
- package/src/pose-extract/image-frame.js +0 -51
- package/src/pose-extract/index.js +0 -58
- package/src/pose-extract/keyframes.js +0 -74
- package/src/pose-extract/landmarks.js +0 -162
- package/src/pose-extract/mirror.js +0 -153
- package/src/pose-extract/take.js +0 -131
- package/src/pose-extract/video-frames.js +0 -91
- package/src/take-recipe.js +0 -165
- package/src/trail-key-conflicts.js +0 -39
- package/src/trail-pick.js +0 -59
- package/src/use-i2v-capture-modal.js +0 -15
- package/tools/ardy/BRIDGE.md +0 -37
- package/tools/ardy/GVHMR-NPZ.md +0 -68
- package/tools/ardy/README.md +0 -124
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/ardy/artifacts.mjs +0 -34
- package/tools/ardy/bridge.mjs +0 -1732
- package/tools/ardy/bvh-cskel27.mjs +0 -1265
- package/tools/ardy/cclay_constrained_generate.py +0 -561
- package/tools/ardy/cclay_gvhmr_worker.py +0 -264
- package/tools/ardy/cclay_motion_edit.py +0 -510
- package/tools/ardy/cclay_sequence_generate.py +0 -705
- package/tools/ardy/cclay_worker.py +0 -304
- package/tools/ardy/dump-npz.py +0 -205
- package/tools/ardy/extract-rest.mjs +0 -299
- package/tools/ardy/extract.mjs +0 -507
- package/tools/ardy/footage.mjs +0 -467
- package/tools/ardy/gvhmr-floor.mjs +0 -102
- package/tools/ardy/gvhmr-to-cskel27.mjs +0 -21
- package/tools/ardy/gvhmr_fastpath.py +0 -168
- package/tools/ardy/gvhmr_trajectory.py +0 -240
- package/tools/ardy/merge-text-encoder.py +0 -160
- package/tools/ardy/mocap-metrics.mjs +0 -200
- package/tools/ardy/mocap-quality-gate.mjs +0 -110
- package/tools/ardy/motion-stabilize.mjs +0 -348
- package/tools/ardy/npz.mjs +0 -414
- package/tools/ardy/pose-to-npz.mjs +0 -106
- package/tools/ardy/prompt-limits.mjs +0 -5
- package/tools/ardy/run-edit-on-box.sh +0 -73
- package/tools/ardy/run-local.mjs +0 -233
- package/tools/ardy/run-on-box.sh +0 -593
- package/tools/ardy/run-sequence-on-box.sh +0 -177
- package/tools/ardy/runners/gvhmr-worker.mjs +0 -206
- package/tools/ardy/runners/index.mjs +0 -18
- package/tools/ardy/runners/local.mjs +0 -455
- package/tools/ardy/runners/proc.mjs +0 -125
- package/tools/ardy/runners/remote.mjs +0 -252
- package/tools/ardy/setup-local.mjs +0 -189
- package/tools/ardy/setup-text-encoder-on-box.sh +0 -93
- package/tools/ardy/setup-text-encoder.py +0 -306
- package/tools/ardy/smpl-cskel27.mjs +0 -178
- package/tools/ardy/visual-qa.mjs +0 -196
- package/tools/ardy/vq-car.mjs +0 -52
- package/tools/bench/EXP3.md +0 -61
- package/tools/bench/cclay_bench_extract_incam.py +0 -125
- package/tools/bench/cclay_bench_extract_obs.py +0 -204
- package/tools/bench/cclay_bench_runner.py +0 -42
- package/tools/bench/cube-contact.mjs +0 -162
- package/tools/bench/exp3.mjs +0 -152
- package/tools/bench/extract-bench-lib.mjs +0 -216
- package/tools/bench/extract-bench.mjs +0 -302
- package/tools/bench/fal-generate.mjs +0 -88
- package/tools/bench/fit/README.md +0 -189
- package/tools/bench/fit/camera.mjs +0 -59
- package/tools/bench/fit/contact.mjs +0 -419
- package/tools/bench/fit/footlock.mjs +0 -234
- package/tools/bench/fit/motion.mjs +0 -85
- package/tools/bench/fit/pin.mjs +0 -27
- package/tools/bench/fit/remote.mjs +0 -105
- package/tools/bench/fit-bench.mjs +0 -126
- package/tools/bench/fit-sanity.mjs +0 -45
- package/tools/bench/metrics.mjs +0 -313
- package/tools/bench/obs/depth.mjs +0 -258
- package/tools/bench/obs/extrinsics.mjs +0 -175
- package/tools/bench/obs/fit_mannequin_betas.py +0 -340
- package/tools/bench/obs/ground.mjs +0 -294
- package/tools/bench/obs/heading.mjs +0 -151
- package/tools/bench/obs/ladder.mjs +0 -555
- package/tools/bench/obs/mannequin-betas.json +0 -124
- package/tools/bench/obs/remote.mjs +0 -83
- package/tools/bench/obs/rest_joints.py +0 -52
- package/tools/bench/obs/ybot-targets.mjs +0 -49
- package/tools/bench/obs-bench.mjs +0 -520
- package/tools/bench/score.mjs +0 -343
- package/tools/bench/summarize.mjs +0 -86
- package/tools/demo-worker/README.md +0 -137
- package/tools/demo-worker/api-client.mjs +0 -233
- package/tools/demo-worker/generate.mjs +0 -301
- package/tools/demo-worker/index.mjs +0 -466
- package/tools/demo-worker/sign-core.mjs +0 -112
- package/tools/demo-worker/sign.mjs +0 -161
- package/tools/gt-render/browser.mjs +0 -159
- package/tools/gt-render/camera-math.mjs +0 -276
- package/tools/gt-render/page.mjs +0 -267
- package/tools/gt-render/render.mjs +0 -514
- package/tools/gt-render/scene-box.mjs +0 -31
- package/tools/gt-render/take-transform.mjs +0 -87
- package/tools/kimodo/compare-seam.mjs +0 -108
- package/tools/kimodo/constraints.mjs +0 -244
- package/tools/kimodo/edit.mjs +0 -259
- package/tools/kimodo/effector-constraints.mjs +0 -200
- package/tools/kimodo/generate.mjs +0 -646
- package/tools/kimodo/local-output.mjs +0 -265
- package/tools/kimodo/measure-pose.mjs +0 -65
- package/tools/kimodo/measure-preserve.mjs +0 -899
- package/tools/kimodo/measure-waypoints.mjs +0 -55
- package/tools/kimodo/mlx-generate.py +0 -111
- package/tools/kimodo/pose-constraints.mjs +0 -248
- package/tools/kimodo/preserve-mask.mjs +0 -543
- package/tools/kimodo/read-npz.mjs +0 -189
- package/tools/kimodo/run-edit-on-box.mjs +0 -155
- package/tools/kimodo/run-sequence-on-box.mjs +0 -178
- package/tools/kimodo/runner.mjs +0 -284
- package/tools/kimodo/seam-batch.mjs +0 -77
- package/tools/kimodo/setup-local.mjs +0 -30
- package/tools/kimodo/setup-on-box.sh +0 -339
- package/tools/kimodo/soma77-to-cskel27.mjs +0 -305
- package/tools/morphgs/assets/gen_truth.py +0 -39
- package/tools/morphgs/assets/mesh_ori_rig.txt +0 -27
- package/tools/morphgs/assets/playback-check.mjs +0 -30
- package/tools/morphgs/demo-gate.sh +0 -26
- package/tools/morphgs/fbx2morphgs.mjs +0 -68
- package/tools/morphgs/morphgs-to-cskel27.mjs +0 -105
- package/tools/morphgs/patches/preprocess_src-none-mode.patch +0 -76
- package/tools/morphgs/setup-on-cluster.sh +0 -126
- package/tools/projflow/driver.py +0 -1069
- package/tools/projflow/generate.mjs +0 -805
- package/tools/projflow/hml22-to-cskel27.mjs +0 -735
- package/tools/projflow/line-edit-job.mjs +0 -547
- package/tools/projflow/replay.mjs +0 -452
- package/tools/projflow/runner.mjs +0 -269
- package/tools/projflow/service.mjs +0 -742
- package/tools/track/DESIGN.md +0 -513
- package/tools/track/backfill-provenance.mjs +0 -116
- package/tools/track/budget.mjs +0 -8
- package/tools/track/check-rig.mjs +0 -209
- package/tools/track/diagnostics.schema.json +0 -60
- package/tools/track/export-rig.mjs +0 -256
- package/tools/track/fallback.mjs +0 -7
- package/tools/track/fk-parity-fixture.mjs +0 -161
- package/tools/track/gate.mjs +0 -349
- package/tools/track/masks.mjs +0 -318
- package/tools/track/metrics.mjs +0 -201
- package/tools/track/publish-obs.mjs +0 -108
- package/tools/track/py/check_env.py +0 -61
- package/tools/track/py/eval_lr.py +0 -229
- package/tools/track/py/lr_viterbi.py +0 -294
- package/tools/track/py/masks.py +0 -405
- package/tools/track/py/objective.py +0 -382
- package/tools/track/py/rig.py +0 -508
- package/tools/track/py/scene.py +0 -326
- package/tools/track/py/test_joint_indices.py +0 -123
- package/tools/track/py/test_lr_viterbi.py +0 -195
- package/tools/track/py/test_masks.py +0 -229
- package/tools/track/py/test_rig.py +0 -288
- package/tools/track/py/test_scene.py +0 -354
- package/tools/track/py/test_track.py +0 -328
- package/tools/track/py/track.py +0 -411
- package/tools/track/remote.mjs +0 -140
- package/tools/track/rig-dump.mjs +0 -227
- package/tools/track/run-box-tests.mjs +0 -14
- package/tools/track/run-box.mjs +0 -168
- package/tools/track/setup-box.sh +0 -59
- package/tools/track/study-2d.mjs +0 -728
- /package/src/{ardy → motion}/collision-blockers.js +0 -0
- /package/src/{ardy → motion}/ground.js +0 -0
- /package/src/{ardy → motion}/ik-drag.js +0 -0
- /package/src/{ardy → motion}/ik-key-json.js +0 -0
- /package/src/{ardy → motion}/ik.js +0 -0
- /package/src/{ardy → motion}/physics-surface.js +0 -0
- /package/src/{ardy → motion}/playback-clock.js +0 -0
- /package/src/{ardy → motion}/range-pin.js +0 -0
package/tools/ardy/bridge.mjs
DELETED
|
@@ -1,1732 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* bridge.mjs - dev-only HTTP sidecar that drives the motion-generation loop
|
|
4
|
-
* for the CozyClay SPA.
|
|
5
|
-
*
|
|
6
|
-
* CozyClay stays a static SPA: `vite build` emits a dist/ that needs no
|
|
7
|
-
* server. The box work (pose -> npz -> remote constrained generation) cannot
|
|
8
|
-
* run in a browser, so this sidecar exposes it on 127.0.0.1:5181 and the Vite
|
|
9
|
-
* dev server proxies /ardy to it. The sidecar is OPTIONAL: when it is not
|
|
10
|
-
* running the app behaves exactly as before, with the generate affordance
|
|
11
|
-
* unavailable. The production build never depends on it.
|
|
12
|
-
*
|
|
13
|
-
* The heavy lifting is done by the Kimodo runner (tools/kimodo/runner.mjs),
|
|
14
|
-
* never reimplemented here. Pose conversion, validation, streaming and motion
|
|
15
|
-
* delivery remain backend-neutral so the browser contract stays stable.
|
|
16
|
-
*
|
|
17
|
-
* Security posture (detailed in BRIDGE.md):
|
|
18
|
-
* - binds 127.0.0.1 only;
|
|
19
|
-
* - every child is spawn()ed with an argv ARRAY; request data never reaches
|
|
20
|
-
* a shell string (the only remote shell strings are built from the box's
|
|
21
|
-
* own listing, regex-whitelisted, or from operator env vars);
|
|
22
|
-
* - every request field is validated before use: prompt length-capped,
|
|
23
|
-
* duration/dstFrame range-checked, base matched against the list the box
|
|
24
|
-
* actually reported, waypoints bounds/order-checked, body size-capped;
|
|
25
|
-
* preserve object/range-checked against the clip and refused alongside the
|
|
26
|
-
* modes it cannot mean anything with;
|
|
27
|
-
* posePin boolean-checked (default true = full-body pose constraint;
|
|
28
|
-
* false = path/prompt only, pose ignored, base optional - with waypoints
|
|
29
|
-
* and no base the box free-generates the base clip first, two-pass);
|
|
30
|
-
* - generated npz files are served back through an in-memory allowlist
|
|
31
|
-
* populated only after this process generated and verified the file; a
|
|
32
|
-
* path is never accepted from the URL;
|
|
33
|
-
* - a client disconnect kills the detached child process group instead of
|
|
34
|
-
* orphaning an ssh session.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
import { createServer } from "node:http";
|
|
38
|
-
import { spawn } from "node:child_process";
|
|
39
|
-
import { randomBytes } from "node:crypto";
|
|
40
|
-
import { createReadStream, existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
41
|
-
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
42
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
43
|
-
import { decodeMotionNpz } from "../../src/ardy/npz.js";
|
|
44
|
-
import { motionArraysToNpzMembers, replaceMotionSegment, writeNpz } from "./npz.mjs";
|
|
45
|
-
import { globalChildren, killGroup, runStreaming, track } from "./runners/proc.mjs";
|
|
46
|
-
import { createRunner } from "./runners/index.mjs";
|
|
47
|
-
import { DURATION_MAX, DURATION_MIN, PROMPT_MAX_CHARS } from "./prompt-limits.mjs";
|
|
48
|
-
import { footagePath, handleFootage, serveFootage } from "./footage.mjs";
|
|
49
|
-
import { EXTRACT_BACKEND, EXTRACT_BACKEND_SUPPORTED, handleExtract } from "./extract.mjs";
|
|
50
|
-
import { createPrivateArtifactDir, evictPrivateArtifact, removePrivateArtifactDir } from "./artifacts.mjs";
|
|
51
|
-
// The IK track ids a preserve edit range may scope itself to. Imported rather
|
|
52
|
-
// than restated: tools/kimodo/preserve-mask.mjs is the single source of truth
|
|
53
|
-
// for track -> mask-group, and a list copied here would drift the first time a
|
|
54
|
-
// track is added. The module is pure (no side effects on import).
|
|
55
|
-
import { TRACK_GROUPS } from "../kimodo/preserve-mask.mjs";
|
|
56
|
-
// Line editing (contract C6). The projflow modules are imported DIRECTLY, not
|
|
57
|
-
// spawned: the runner's lineEditCommand produces raw 22-joint positions, and a
|
|
58
|
-
// take is only finished after the cskel27 lift, the 20 -> 24 retime and the
|
|
59
|
-
// splice — all of which are pure functions over files this process already
|
|
60
|
-
// holds. Running them in a child would mean a second argv contract and a second
|
|
61
|
-
// copy of the frame arithmetic for no isolation gain, so the composition layer
|
|
62
|
-
// is imported the way handleExtract already is, and the only child process on
|
|
63
|
-
// this path is the ssh inside lineEditOnBox.
|
|
64
|
-
import { createProjflowRunner } from "../projflow/runner.mjs";
|
|
65
|
-
import { runLineEditJob } from "../projflow/line-edit-job.mjs";
|
|
66
|
-
// Recipe replay (contract C10) and, with it, the per-field line-edit rules that
|
|
67
|
-
// BOTH the C6 request and every C10 entry are checked against. They live in
|
|
68
|
-
// tools/projflow/replay.mjs rather than here because a replay is pure
|
|
69
|
-
// arithmetic over an injectable job runner, and the whole contract has to be
|
|
70
|
-
// verifiable on a laptop with no GPU and no sidecar.
|
|
71
|
-
import { blockBoundaries, runReplay, validateLineEditFields, validateReplay } from "../projflow/replay.mjs";
|
|
72
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
73
|
-
const REPO = resolve(HERE, "../..");
|
|
74
|
-
const OUT_DIR = join(HERE, "out");
|
|
75
|
-
const POSE_TO_NPZ = join(HERE, "pose-to-npz.mjs");
|
|
76
|
-
|
|
77
|
-
const BIND_HOST = "127.0.0.1"; // loopback only: this process shells out
|
|
78
|
-
const DEFAULT_PORT = 5181;
|
|
79
|
-
const HEALTH_TTL_MS = 5000; // the UI polls health; a cached answer avoids hammering ssh
|
|
80
|
-
const BASES_TTL_MS = 120000; // the box's motion list changes rarely
|
|
81
|
-
const MAX_BODY_BYTES = 1024 * 1024;
|
|
82
|
-
const SEED_MAX = 2 ** 31 - 1; // optional request seed: an integer in 0..2**31-1 (bridge contract)
|
|
83
|
-
// Kimodo is retimed directly to the app's 24 fps production clock.
|
|
84
|
-
const FPS = 24;
|
|
85
|
-
const ROOT_2D_RANGE_M = 20; // |x| and |z| cap, metres (Y-up, X/Z horizontal)
|
|
86
|
-
const HEADING_RANGE_RAD = 2 * Math.PI; // |heading| cap, radians
|
|
87
|
-
const WAYPOINTS_MAX = 32; // sparse authored root keys including frame 0
|
|
88
|
-
const WAYPOINT_SPEED_MIN_MPS = 0.3; // dense-sample gait floor (authored floor 0.5, arcs dip through corners)
|
|
89
|
-
const WAYPOINT_SPEED_MAX_MPS = 3.6; // dense-sample ceiling (authored ceiling 3.0)
|
|
90
|
-
const WAYPOINT_HOLD_EPS_M = 0.06; // pairs closer than this are a deliberate hold, any duration
|
|
91
|
-
const MOTION_ALLOWLIST_MAX = 64; // newest runs only; evicted ids become stale 404s
|
|
92
|
-
// The bridge's own gen stamp (<epoch-ms>-<3 random bytes hex>); keeping the
|
|
93
|
-
// motions URL id to that shape keeps /ardy/motions/<run-id> predictable and
|
|
94
|
-
// path-free (the id is a lookup key, never a path).
|
|
95
|
-
const MOTION_ID = /^[0-9]+-[0-9a-f]{6}$/;
|
|
96
|
-
// The same id embedded in the URL a client hands back to address a take it was
|
|
97
|
-
// given. Every field that names an earlier take (motionEdit.sourceMotion,
|
|
98
|
-
// regenerateSegments' sourceMotion, preserve.sourceMotion) matches THIS, so a
|
|
99
|
-
// take that is addressable by one of them is addressable by all of them.
|
|
100
|
-
const MOTION_URL = /^\/ardy\/motions\/[0-9]+-[0-9a-f]{6}$/;
|
|
101
|
-
// Scheduled inpainting, contract C3: strength s in (0,1] maps to the diffusion
|
|
102
|
-
// -time schedule the box blends over. sigma_s is where blending starts (high
|
|
103
|
-
// noise, so the base's structure dominates) and sigma_e where the model is
|
|
104
|
-
// left alone to finish; the end cap generalises the paper's recommended
|
|
105
|
-
// 500/50 pair to any strength.
|
|
106
|
-
const PRESERVE_SIGMA_MAX = 1000;
|
|
107
|
-
const PRESERVE_SIGMA_END_CAP = 50;
|
|
108
|
-
// Round 2 (C3v2): an edit range may name the IK tracks it touched, and only the
|
|
109
|
-
// mask groups those tracks map to are freed there. The valid ids are exactly the
|
|
110
|
-
// keys of the mask builder's own table.
|
|
111
|
-
const PRESERVE_TRACK_IDS = Object.keys(TRACK_GROUPS);
|
|
112
|
-
// C6's per-field rules (track ids, points2d, camera, frameRange) now live in
|
|
113
|
-
// tools/projflow/replay.mjs: contract C10 validates every replay entry with the
|
|
114
|
-
// SAME rules, and two copies of them would drift on the first joint-mapping
|
|
115
|
-
// change. Only the rules a line edit does not share with a replay entry —
|
|
116
|
-
// exclusivity, posePin, sourceMotion, preview — are still spelled out below.
|
|
117
|
-
//
|
|
118
|
-
// Every field a line edit is exclusive with (contract C6: a line edit is its own
|
|
119
|
-
// run mode). `poses`/`pose` are in the list because posePin defaults to true and
|
|
120
|
-
// a line edit authors no poses at all.
|
|
121
|
-
const LINE_EDIT_EXCLUSIVE = ["preserve", "waypoints", "segments", "regenerateSegments", "motionEdit", "poses", "pose"];
|
|
122
|
-
|
|
123
|
-
// The runner is the ONLY part of the bridge that knows where generation
|
|
124
|
-
// actually happens. Selection (runners/index.mjs) is Kimodo-only; everything
|
|
125
|
-
// below that boundary — validation, caching, streaming and the motion
|
|
126
|
-
// allowlist — is backend-agnostic.
|
|
127
|
-
let runner; // assigned after CLI parsing, before the server starts
|
|
128
|
-
|
|
129
|
-
// ...with ONE exception, and it is deliberate: line editing is an
|
|
130
|
-
// ENGINE-PER-TASK (contract C6). A lineEdit request always runs on ProjFlow
|
|
131
|
-
// whatever CCLAY_MOTION_BACKEND says, so this second runner exists beside the
|
|
132
|
-
// selected one instead of replacing it. Construction only reads env — the box
|
|
133
|
-
// is not touched until something probes or runs — and a box with no ProjFlow
|
|
134
|
-
// configuration leaves it null, which reads as "the capability is off".
|
|
135
|
-
let projflowRunner = null; // assigned beside `runner`
|
|
136
|
-
|
|
137
|
-
// A base path is produced by the backend's own `ls outputs/*.npz` /
|
|
138
|
-
// `ls outputs/omb/*.npz` and is still whitelisted before it is embedded in a
|
|
139
|
-
// remote shell string: only a plain, metacharacter-free relative npz path is
|
|
140
|
-
// ever allowed through.
|
|
141
|
-
const SAFE_BASE_PATH = /^outputs\/(?:omb\/)?[A-Za-z0-9._-]+\.npz$/;
|
|
142
|
-
const SAFE_BASE_ID = /^[A-Za-z0-9._-]+$/;
|
|
143
|
-
|
|
144
|
-
// backend probes (health, bases) with caching
|
|
145
|
-
|
|
146
|
-
// The raw listing comes from the runner (a box's `ls outputs/*.npz` over ssh,
|
|
147
|
-
// or a local directory scan); the bridge sanitizes every entry the same way
|
|
148
|
-
// regardless of backend before anything downstream can see it.
|
|
149
|
-
async function fetchBases() {
|
|
150
|
-
const entries = await runner.listBases();
|
|
151
|
-
const bases = [];
|
|
152
|
-
for (const entry of entries) {
|
|
153
|
-
if (
|
|
154
|
-
!entry ||
|
|
155
|
-
typeof entry !== "object" ||
|
|
156
|
-
typeof entry.id !== "string" ||
|
|
157
|
-
typeof entry.path !== "string" ||
|
|
158
|
-
!Number.isInteger(entry.frames) ||
|
|
159
|
-
entry.frames < 0
|
|
160
|
-
) {
|
|
161
|
-
console.error(`[bridge] skipping malformed bases entry: ${JSON.stringify(entry)}`);
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
if (!SAFE_BASE_ID.test(entry.id) || !SAFE_BASE_PATH.test(entry.path)) {
|
|
165
|
-
console.error(`[bridge] skipping unsafe bases entry: ${JSON.stringify(entry)}`);
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
bases.push({ id: entry.id, path: entry.path, frames: entry.frames });
|
|
169
|
-
}
|
|
170
|
-
// Duplicate ids (same name under outputs/ and outputs/omb/) are kept as-is;
|
|
171
|
-
// generate matches the first, which is exactly the lookup order the
|
|
172
|
-
// generation path uses, so the two cannot pick different files.
|
|
173
|
-
return { bases };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
let healthCache = null;
|
|
177
|
-
let healthInflight = null;
|
|
178
|
-
|
|
179
|
-
// Successes AND failures are cached for the TTL so a dead box does not turn
|
|
180
|
-
// the UI's health polling into an ssh stampede.
|
|
181
|
-
async function getHealth() {
|
|
182
|
-
const now = Date.now();
|
|
183
|
-
if (healthCache && now - healthCache.at < HEALTH_TTL_MS) {
|
|
184
|
-
if (healthCache.error) throw healthCache.error;
|
|
185
|
-
return healthCache.value;
|
|
186
|
-
}
|
|
187
|
-
if (!healthInflight) {
|
|
188
|
-
healthInflight = runner.probeHealth()
|
|
189
|
-
.then((value) => {
|
|
190
|
-
healthCache = { at: Date.now(), value };
|
|
191
|
-
return value;
|
|
192
|
-
})
|
|
193
|
-
.catch((err) => {
|
|
194
|
-
healthCache = { at: Date.now(), error: err };
|
|
195
|
-
throw err;
|
|
196
|
-
})
|
|
197
|
-
.finally(() => {
|
|
198
|
-
healthInflight = null;
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
return healthInflight;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Does this box actually have a ProjFlow line-edit backend?
|
|
205
|
-
//
|
|
206
|
-
// LAZY AND AWAITED, not probed at startup. The app gates the whole draw-a-line
|
|
207
|
-
// affordance on `capabilities.lineEdit` in the health payload, so a startup
|
|
208
|
-
// probe would have to finish before the first poll or the feature would be dark
|
|
209
|
-
// on a perfectly good box until the next one — a race with a UI. Health already
|
|
210
|
-
// awaits an ssh round trip for the selected backend; this one rides along beside
|
|
211
|
-
// it under the same 5 s TTL, and the two probes run in parallel so a healthy box
|
|
212
|
-
// costs no extra wall time.
|
|
213
|
-
//
|
|
214
|
-
// A FAILED probe is never an error here, only a false: the capability is an
|
|
215
|
-
// advertisement, and a bridge whose ProjFlow env is missing must still serve
|
|
216
|
-
// Kimodo health normally. That is also the "a box without the scout env must not
|
|
217
|
-
// advertise" rule — no host, no repo, no checkpoint, or an ssh that cannot
|
|
218
|
-
// connect all land in the same catch.
|
|
219
|
-
let lineEditCapabilityCache = null;
|
|
220
|
-
let lineEditCapabilityInflight = null;
|
|
221
|
-
|
|
222
|
-
async function getLineEditCapability() {
|
|
223
|
-
if (!projflowRunner) return false;
|
|
224
|
-
const now = Date.now();
|
|
225
|
-
if (lineEditCapabilityCache && now - lineEditCapabilityCache.at < HEALTH_TTL_MS) {
|
|
226
|
-
return lineEditCapabilityCache.value;
|
|
227
|
-
}
|
|
228
|
-
if (!lineEditCapabilityInflight) {
|
|
229
|
-
lineEditCapabilityInflight = projflowRunner.probeHealth()
|
|
230
|
-
.then((probe) => Boolean(probe?.ok))
|
|
231
|
-
.catch((err) => {
|
|
232
|
-
console.error(`[bridge] line editing unavailable: ${err.message}`);
|
|
233
|
-
return false;
|
|
234
|
-
})
|
|
235
|
-
.then((value) => {
|
|
236
|
-
lineEditCapabilityCache = { at: Date.now(), value };
|
|
237
|
-
return value;
|
|
238
|
-
})
|
|
239
|
-
.finally(() => {
|
|
240
|
-
lineEditCapabilityInflight = null;
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
return lineEditCapabilityInflight;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
let basesCache = null;
|
|
247
|
-
let basesInflight = null;
|
|
248
|
-
|
|
249
|
-
async function getBases() {
|
|
250
|
-
const now = Date.now();
|
|
251
|
-
if (basesCache && now - basesCache.at < BASES_TTL_MS) {
|
|
252
|
-
if (basesCache.error) throw basesCache.error;
|
|
253
|
-
return basesCache.value;
|
|
254
|
-
}
|
|
255
|
-
if (!basesInflight) {
|
|
256
|
-
basesInflight = fetchBases()
|
|
257
|
-
.then((value) => {
|
|
258
|
-
basesCache = { at: Date.now(), value };
|
|
259
|
-
return value;
|
|
260
|
-
})
|
|
261
|
-
.catch((err) => {
|
|
262
|
-
basesCache = { at: Date.now(), error: err };
|
|
263
|
-
throw err;
|
|
264
|
-
})
|
|
265
|
-
.finally(() => {
|
|
266
|
-
basesInflight = null;
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
return basesInflight;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// request validation
|
|
273
|
-
|
|
274
|
-
// Returns an error message naming the offending field, or null when valid.
|
|
275
|
-
function validateGenerate(body) {
|
|
276
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return "request body must be a JSON object";
|
|
277
|
-
if (body.posePin !== undefined && typeof body.posePin !== "boolean") {
|
|
278
|
-
return `field 'posePin' must be a boolean, got ${JSON.stringify(body.posePin)}`;
|
|
279
|
-
}
|
|
280
|
-
if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) return "field 'prompt' must be a non-empty string";
|
|
281
|
-
if (body.prompt.length > PROMPT_MAX_CHARS) return `field 'prompt' is ${body.prompt.length} chars; the cap is ${PROMPT_MAX_CHARS}`;
|
|
282
|
-
if (typeof body.duration !== "number" || !Number.isFinite(body.duration)) return `field 'duration' must be a finite number`;
|
|
283
|
-
if (body.duration < DURATION_MIN || body.duration > DURATION_MAX) return `field 'duration' must be in ${DURATION_MIN}..${DURATION_MAX} seconds`;
|
|
284
|
-
// Optional post-processing knobs, forwarded to the box generator.
|
|
285
|
-
if (body.rootMargin !== undefined && (typeof body.rootMargin !== "number" || !Number.isFinite(body.rootMargin) || body.rootMargin < 0 || body.rootMargin > 1)) return `field 'rootMargin' must be a number in 0..1 (meters)`;
|
|
286
|
-
if (body.contactThreshold !== undefined && (typeof body.contactThreshold !== "number" || !Number.isFinite(body.contactThreshold) || body.contactThreshold < 0 || body.contactThreshold > 1)) return `field 'contactThreshold' must be a number in 0..1`;
|
|
287
|
-
// Optional history crop (per autoregressive step), forwarded to the box
|
|
288
|
-
// generators; the box enforces its own token-size multiple.
|
|
289
|
-
if (body.historyFrames !== undefined && (!Number.isInteger(body.historyFrames) || body.historyFrames <= 0 || body.historyFrames > 400)) return `field 'historyFrames' must be an integer in 1..400`;
|
|
290
|
-
const clipFrames = Math.floor(body.duration * FPS);
|
|
291
|
-
if (clipFrames < 3) return `field 'duration' yields fewer than 3 frames`;
|
|
292
|
-
|
|
293
|
-
// Checked BEFORE the pose block: a line edit carries no poses on purpose, so
|
|
294
|
-
// the posePin rule below would refuse it with a message about a field the
|
|
295
|
-
// client never sent. C6's own refusals have to win.
|
|
296
|
-
if (body.lineEdit !== undefined) {
|
|
297
|
-
const error = validateLineEdit(body, clipFrames);
|
|
298
|
-
if (error) return error;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const posePinned = body.posePin !== false;
|
|
302
|
-
if (posePinned) {
|
|
303
|
-
const poses = body.motionEdit
|
|
304
|
-
? body.motionEdit.edits
|
|
305
|
-
: Array.isArray(body.poses)
|
|
306
|
-
? body.poses
|
|
307
|
-
: body.pose
|
|
308
|
-
? [{ frame: body.dstFrame, pose: body.pose }]
|
|
309
|
-
: null;
|
|
310
|
-
if (!poses || poses.length === 0 || poses.length > 64) return `field 'poses' must have 1..64 entries when posePin is true`;
|
|
311
|
-
let previous = -1;
|
|
312
|
-
for (let i = 0; i < poses.length; i += 1) {
|
|
313
|
-
const entry = poses[i];
|
|
314
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return `field 'poses[${i}]' must be an object`;
|
|
315
|
-
if (!Number.isInteger(entry.frame) || entry.frame < 0 || entry.frame >= clipFrames) return `field 'poses[${i}].frame' must be an integer in 0..${clipFrames - 1}`;
|
|
316
|
-
if (entry.frame <= previous) return `field 'poses' frames must be strictly ascending and distinct`;
|
|
317
|
-
previous = entry.frame;
|
|
318
|
-
if (!entry.pose || typeof entry.pose !== "object" || Array.isArray(entry.pose)) return `field 'poses[${i}].pose' must be an object`;
|
|
319
|
-
if (entry.pose.schema !== "cozyclay.pose.v1") return `field 'poses[${i}].pose.schema' must be "cozyclay.pose.v1"`;
|
|
320
|
-
if (!Array.isArray(entry.pose.root) || entry.pose.root.length !== 3 || !entry.pose.root.every(Number.isFinite)) return `field 'poses[${i}].pose.root' must be [x, y, z] finite metres`;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
if (body.base !== undefined && (typeof body.base !== "string" || body.base.length === 0)) return "field 'base' must be a non-empty string";
|
|
324
|
-
if (body.segments !== undefined) {
|
|
325
|
-
const error = validateSegments(body.segments, clipFrames);
|
|
326
|
-
if (error) return error;
|
|
327
|
-
if (posePinned) return "field 'segments' uses autoregressive history and requires posePin:false";
|
|
328
|
-
// segments + waypoints run TOGETHER on the sequence generator: the
|
|
329
|
-
// Root2D constraint set is built over the whole rollout and each
|
|
330
|
-
// chained call sees its own slice (the interactive demo's pattern).
|
|
331
|
-
// The trained window then binds each segment call, not the total.
|
|
332
|
-
// Kimodo owns the segment duration; no legacy trained-window cap applies.
|
|
333
|
-
}
|
|
334
|
-
if (body.regenerateSegments !== undefined) {
|
|
335
|
-
const error = validateRegenerateSegments(body.regenerateSegments, clipFrames);
|
|
336
|
-
if (error) return error;
|
|
337
|
-
if (!posePinned) return "field 'regenerateSegments' requires posePin:true";
|
|
338
|
-
if (body.segments !== undefined || body.waypoints !== undefined) {
|
|
339
|
-
return "field 'regenerateSegments' cannot be combined with segments or waypoints";
|
|
340
|
-
}
|
|
341
|
-
if (typeof body.sourceMotion !== "string" || !MOTION_URL.test(body.sourceMotion)) {
|
|
342
|
-
return "field 'sourceMotion' must be a generated /ardy/motions/<run-id> URL";
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
if (body.motionEdit !== undefined) {
|
|
346
|
-
const edit = body.motionEdit;
|
|
347
|
-
if (!edit || typeof edit !== "object" || Array.isArray(edit)) return "field 'motionEdit' must be an object";
|
|
348
|
-
if (!posePinned) return "field 'motionEdit' requires posePin:true";
|
|
349
|
-
if (body.segments !== undefined || body.regenerateSegments !== undefined || body.waypoints !== undefined) {
|
|
350
|
-
return "field 'motionEdit' cannot be combined with segments, regenerateSegments, or waypoints";
|
|
351
|
-
}
|
|
352
|
-
if (typeof edit.sourceMotion !== "string" || !MOTION_URL.test(edit.sourceMotion)) {
|
|
353
|
-
return "field 'motionEdit.sourceMotion' must be a generated /ardy/motions/<run-id> URL";
|
|
354
|
-
}
|
|
355
|
-
if (
|
|
356
|
-
!Number.isInteger(edit.startFrame) ||
|
|
357
|
-
!Number.isInteger(edit.endFrame) ||
|
|
358
|
-
edit.startFrame < 0 ||
|
|
359
|
-
edit.endFrame > clipFrames ||
|
|
360
|
-
edit.endFrame - edit.startFrame < 3
|
|
361
|
-
) {
|
|
362
|
-
return `field 'motionEdit' range must be 3+ frames inside 0..${clipFrames}`;
|
|
363
|
-
}
|
|
364
|
-
if (!Array.isArray(edit.edits) || edit.edits.length < 1 || edit.edits.length > 64) {
|
|
365
|
-
return "field 'motionEdit.edits' must have 1..64 entries";
|
|
366
|
-
}
|
|
367
|
-
for (let index = 0; index < edit.edits.length; index += 1) {
|
|
368
|
-
const entry = edit.edits[index];
|
|
369
|
-
if (!Array.isArray(entry.tracks) || entry.tracks.length < 1 || entry.tracks.some((track) => typeof track !== "string")) {
|
|
370
|
-
return `field 'motionEdit.edits[${index}].tracks' must contain track names`;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
for (const key of ["contextBefore", "contextAfter"]) {
|
|
374
|
-
if (!Number.isInteger(edit[key]) || edit[key] < 0 || edit[key] > 160) {
|
|
375
|
-
return `field 'motionEdit.${key}' must be an integer in 0..160`;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
if (body.waypoints !== undefined) {
|
|
380
|
-
const error = validateWaypoints(body.waypoints, clipFrames);
|
|
381
|
-
if (error) return error;
|
|
382
|
-
}
|
|
383
|
-
if (body.preserve !== undefined) {
|
|
384
|
-
const error = validatePreserve(body, clipFrames);
|
|
385
|
-
if (error) return error;
|
|
386
|
-
}
|
|
387
|
-
// Recipe replay (contract C10): the line edits to re-apply once this request's
|
|
388
|
-
// take exists. Validated LAST of the run-mode fields because it rides ON one —
|
|
389
|
-
// a replay never chooses the take, it only refines whatever plain generation,
|
|
390
|
-
// prompt schedule or preserved run produced.
|
|
391
|
-
if (body.replay !== undefined) {
|
|
392
|
-
const error = validateReplay(body, clipFrames, { seedMax: SEED_MAX });
|
|
393
|
-
if (error) return error;
|
|
394
|
-
}
|
|
395
|
-
if (body.seed !== undefined && (!Number.isInteger(body.seed) || body.seed < 0 || body.seed > SEED_MAX)) return `field 'seed' must be an integer in 0..${SEED_MAX}`;
|
|
396
|
-
if (body.cpu !== undefined && typeof body.cpu !== "boolean") return `field 'cpu' must be a boolean`;
|
|
397
|
-
return null;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Line editing (contract C6): the artist draws a 2D polyline over the viewport
|
|
401
|
-
// and ONE joint is made to follow it exactly.
|
|
402
|
-
//
|
|
403
|
-
// Style and posture match validatePreserve: one specific reason, the offending
|
|
404
|
-
// field named first, first failure wins, and nothing here computes anything the
|
|
405
|
-
// backend will compute again. In particular the 24 -> 20 fps conversion is NOT
|
|
406
|
-
// done here — tools/projflow/line-edit-job.mjs owns that clock, because it is
|
|
407
|
-
// also the layer that decides how long the resampled source is, and two places
|
|
408
|
-
// rounding the same frame index is how they drift. This validator's frame
|
|
409
|
-
// numbers are all on the app's 24 fps clip clock, exactly as sent.
|
|
410
|
-
//
|
|
411
|
-
// Returns an error message naming the offending field, or null when valid.
|
|
412
|
-
function validateLineEdit(body, clipFrames) {
|
|
413
|
-
const lineEdit = body.lineEdit;
|
|
414
|
-
if (!lineEdit || typeof lineEdit !== "object" || Array.isArray(lineEdit)) {
|
|
415
|
-
return "field 'lineEdit' must be an object";
|
|
416
|
-
}
|
|
417
|
-
// Exclusivity first: a request that also carries a prompt schedule or a
|
|
418
|
-
// preserved take is not a line edit with an extra field, it is two run modes
|
|
419
|
-
// in one body, and guessing which one the artist meant is not the bridge's
|
|
420
|
-
// call. C6 is named so the refusal is traceable to the contract.
|
|
421
|
-
for (const field of LINE_EDIT_EXCLUSIVE) {
|
|
422
|
-
if (body[field] !== undefined) {
|
|
423
|
-
return `field 'lineEdit' cannot be combined with ${field}: contract C6 makes a line edit its own run mode`;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
// posePin defaults to TRUE, which demands a pose array. A line edit authors
|
|
427
|
-
// no poses, so the client must say so explicitly rather than have the bridge
|
|
428
|
-
// infer it — the same request without lineEdit would then mean something
|
|
429
|
-
// completely different.
|
|
430
|
-
if (body.posePin !== false) {
|
|
431
|
-
return "field 'lineEdit' requires posePin:false (a line edit constrains one joint's path, not a pose)";
|
|
432
|
-
}
|
|
433
|
-
if (typeof lineEdit.sourceMotion !== "string" || !MOTION_URL.test(lineEdit.sourceMotion)) {
|
|
434
|
-
return "field 'lineEdit.sourceMotion' must be a generated /ardy/motions/<run-id> URL";
|
|
435
|
-
}
|
|
436
|
-
// The 20-step draft the interactive loop asks for (contract C10's preview
|
|
437
|
-
// flag). A BOOLEAN and nothing else: the step count itself is the job's, not
|
|
438
|
-
// the client's, or a request could ask the box for a 10000-step sample.
|
|
439
|
-
if (lineEdit.preview !== undefined && typeof lineEdit.preview !== "boolean") {
|
|
440
|
-
return "field 'lineEdit.preview' must be a boolean when present";
|
|
441
|
-
}
|
|
442
|
-
// Everything else — track, frameRange, points2d, camera, prompt — is the
|
|
443
|
-
// shared rule set a C10 replay entry is held to as well.
|
|
444
|
-
return validateLineEditFields(lineEdit, clipFrames, "lineEdit");
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function validateSegments(segments, clipFrames) {
|
|
448
|
-
if (!Array.isArray(segments) || segments.length < 2 || segments.length > 64) return "field 'segments' must have 2..64 entries";
|
|
449
|
-
let cursor = 0;
|
|
450
|
-
for (let i = 0; i < segments.length; i += 1) {
|
|
451
|
-
const segment = segments[i];
|
|
452
|
-
if (!segment || typeof segment !== "object" || Array.isArray(segment)) return `field 'segments[${i}]' must be an object`;
|
|
453
|
-
if (!Number.isInteger(segment.startFrame) || !Number.isInteger(segment.endFrame)) return `field 'segments[${i}]' frames must be integers`;
|
|
454
|
-
if (segment.startFrame !== cursor || segment.endFrame <= segment.startFrame || segment.endFrame > clipFrames) return `field 'segments' must be contiguous from frame 0 through ${clipFrames}`;
|
|
455
|
-
if (segment.endFrame - segment.startFrame < 3) return `field 'segments[${i}]' must contain at least 3 frames`;
|
|
456
|
-
if (typeof segment.prompt !== "string" || !segment.prompt.trim() || segment.prompt.length > PROMPT_MAX_CHARS) return `field 'segments[${i}].prompt' must be 1..${PROMPT_MAX_CHARS} characters`;
|
|
457
|
-
cursor = segment.endFrame;
|
|
458
|
-
}
|
|
459
|
-
return cursor === clipFrames ? null : `field 'segments' must end at frame ${clipFrames}`;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
function validateRegenerateSegments(segments, clipFrames) {
|
|
463
|
-
if (!Array.isArray(segments) || segments.length < 1 || segments.length > 64) {
|
|
464
|
-
return "field 'regenerateSegments' must have 1..64 entries";
|
|
465
|
-
}
|
|
466
|
-
let previousEnd = -1;
|
|
467
|
-
for (let i = 0; i < segments.length; i += 1) {
|
|
468
|
-
const segment = segments[i];
|
|
469
|
-
if (!segment || typeof segment !== "object" || Array.isArray(segment)) {
|
|
470
|
-
return `field 'regenerateSegments[${i}]' must be an object`;
|
|
471
|
-
}
|
|
472
|
-
if (
|
|
473
|
-
!Number.isInteger(segment.startFrame) ||
|
|
474
|
-
!Number.isInteger(segment.endFrame) ||
|
|
475
|
-
segment.startFrame < 0 ||
|
|
476
|
-
segment.endFrame > clipFrames ||
|
|
477
|
-
segment.endFrame - segment.startFrame < 4
|
|
478
|
-
) {
|
|
479
|
-
return `field 'regenerateSegments[${i}]' must be a 4+ frame range inside 0..${clipFrames}`;
|
|
480
|
-
}
|
|
481
|
-
if (segment.startFrame < previousEnd) {
|
|
482
|
-
return "field 'regenerateSegments' must be sorted and non-overlapping";
|
|
483
|
-
}
|
|
484
|
-
if (typeof segment.prompt !== "string" || !segment.prompt.trim() || segment.prompt.length > PROMPT_MAX_CHARS) {
|
|
485
|
-
return `field 'regenerateSegments[${i}].prompt' must be 1..${PROMPT_MAX_CHARS} characters`;
|
|
486
|
-
}
|
|
487
|
-
previousEnd = segment.endFrame;
|
|
488
|
-
}
|
|
489
|
-
return null;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Scheduled inpainting (contract C3): reconstruct an existing take everywhere
|
|
493
|
-
// the user did NOT edit, and regenerate only the edited spans.
|
|
494
|
-
//
|
|
495
|
-
// The bridge validates the request and maps `strength` onto the diffusion-time
|
|
496
|
-
// schedule; it deliberately does NOT build the per-frame mask. That happens in
|
|
497
|
-
// tools/kimodo/generate.mjs, the only layer that knows how many frames Kimodo
|
|
498
|
-
// will actually produce (see preserveSigmas below and the note there).
|
|
499
|
-
//
|
|
500
|
-
// Returns an error message naming the offending field, or null when valid.
|
|
501
|
-
function validatePreserve(body, clipFrames) {
|
|
502
|
-
const preserve = body.preserve;
|
|
503
|
-
if (!preserve || typeof preserve !== "object" || Array.isArray(preserve)) {
|
|
504
|
-
return "field 'preserve' must be an object";
|
|
505
|
-
}
|
|
506
|
-
if (typeof preserve.sourceMotion !== "string" || !MOTION_URL.test(preserve.sourceMotion)) {
|
|
507
|
-
return "field 'preserve.sourceMotion' must be a generated /ardy/motions/<run-id> URL";
|
|
508
|
-
}
|
|
509
|
-
// 0 is not "preserve nothing", it is "do not preserve": the field carries a
|
|
510
|
-
// base motion the backend would still load and blend at sigma_s 0. The
|
|
511
|
-
// caller drops the whole field instead, so the request says what it means.
|
|
512
|
-
if (
|
|
513
|
-
typeof preserve.strength !== "number" ||
|
|
514
|
-
!Number.isFinite(preserve.strength) ||
|
|
515
|
-
preserve.strength <= 0 ||
|
|
516
|
-
preserve.strength > 1
|
|
517
|
-
) {
|
|
518
|
-
return "field 'preserve.strength' must be a number greater than 0 and at most 1 (strength 0 is preserve off: omit 'preserve')";
|
|
519
|
-
}
|
|
520
|
-
if (!Array.isArray(preserve.editRanges)) {
|
|
521
|
-
return "field 'preserve.editRanges' must be an array (an EMPTY array means preserve the whole take)";
|
|
522
|
-
}
|
|
523
|
-
for (let i = 0; i < preserve.editRanges.length; i += 1) {
|
|
524
|
-
const range = preserve.editRanges[i];
|
|
525
|
-
if (!range || typeof range !== "object" || Array.isArray(range)) {
|
|
526
|
-
return `field 'preserve.editRanges[${i}]' must be an object`;
|
|
527
|
-
}
|
|
528
|
-
if (!Number.isInteger(range.startFrame) || !Number.isInteger(range.endFrame)) {
|
|
529
|
-
return `field 'preserve.editRanges[${i}]' startFrame and endFrame must be integers`;
|
|
530
|
-
}
|
|
531
|
-
// Half-open, non-empty and inside the clip. That last bound is also what
|
|
532
|
-
// makes every range intersect the clip: a range the take does not contain
|
|
533
|
-
// cannot be scaled onto the generation clock without freeing frames at one
|
|
534
|
-
// end that the user never edited, so the mask builder refuses it too.
|
|
535
|
-
// Overlaps between ranges are NOT refused — the mask combines them by
|
|
536
|
-
// minimum, so two overlapping edits mean the same thing as one.
|
|
537
|
-
if (range.startFrame < 0 || range.endFrame <= range.startFrame || range.endFrame > clipFrames) {
|
|
538
|
-
return `field 'preserve.editRanges[${i}]' must be a non-empty half-open range inside 0..${clipFrames}`;
|
|
539
|
-
}
|
|
540
|
-
// C3v2: an OPTIONAL `tracks` list scopes the range to the mask groups those
|
|
541
|
-
// IK tracks map to (a hand edit frees the arm, not the body). Omitting the
|
|
542
|
-
// key is the v1 whole-body edit.
|
|
543
|
-
if (range.tracks !== undefined) {
|
|
544
|
-
if (!Array.isArray(range.tracks)) {
|
|
545
|
-
return `field 'preserve.editRanges[${i}].tracks' must be an array of IK track ids`;
|
|
546
|
-
}
|
|
547
|
-
// An EMPTY list is refused rather than read as either "whole body" or
|
|
548
|
-
// "nothing": the two readings differ by the entire clip and the caller
|
|
549
|
-
// meant one of them. Same rule, same wording as buildPreserveMask — a
|
|
550
|
-
// request that got past here must never die inside the mask builder.
|
|
551
|
-
if (range.tracks.length === 0) {
|
|
552
|
-
return `field 'preserve.editRanges[${i}].tracks' is empty — omit the key entirely for a whole-body edit`;
|
|
553
|
-
}
|
|
554
|
-
for (let t = 0; t < range.tracks.length; t += 1) {
|
|
555
|
-
const trackId = range.tracks[t];
|
|
556
|
-
if (typeof trackId !== "string" || !Object.hasOwn(TRACK_GROUPS, trackId)) {
|
|
557
|
-
return (
|
|
558
|
-
`field 'preserve.editRanges[${i}].tracks[${t}]' ${JSON.stringify(trackId)} is not a known IK track id; ` +
|
|
559
|
-
`valid ids are ${PRESERVE_TRACK_IDS.join(", ")}`
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
// Exclusivity. TWO fields are deliberately absent from this list:
|
|
566
|
-
//
|
|
567
|
-
// `motionEdit` — an IK edit regenerates a span of the take preserve is
|
|
568
|
-
// reconstructing, which is exactly the combination scheduled inpainting
|
|
569
|
-
// exists for.
|
|
570
|
-
//
|
|
571
|
-
// `waypoints` — ALLOWED since round 2 (contract C3v2, paper 4.4). Round 1
|
|
572
|
-
// refused it on the reasoning that an authored root path "re-plans the whole
|
|
573
|
-
// rollout, leaving nothing to preserve". That is true only of a mask that
|
|
574
|
-
// preserves the root: with the grouped mask of C1v2 the bridge frees the
|
|
575
|
-
// `root` group for the WHOLE clip (preserveParams.rootFree below) and keeps
|
|
576
|
-
// everything else, so the drawn path owns the trajectory and heading while
|
|
577
|
-
// the body still rides the preserved take — which is the feature. It stays
|
|
578
|
-
// SINGLE-SEGMENT: `segments` is still refused just below, so a preserve +
|
|
579
|
-
// waypoints run is always one prompt over one rollout.
|
|
580
|
-
if (body.regenerateSegments !== undefined) {
|
|
581
|
-
return "field 'preserve' cannot be combined with regenerateSegments: a regenerated block set replaces the take preserve would reconstruct";
|
|
582
|
-
}
|
|
583
|
-
// The box refuses this pairing too ("scheduled inpainting v1 supports a
|
|
584
|
-
// single segment"), after loading a model and a base motion. Failing here
|
|
585
|
-
// costs nothing and can name the version limit instead of a stack trace.
|
|
586
|
-
if (body.segments !== undefined) {
|
|
587
|
-
return "field 'preserve' cannot be combined with a prompt schedule: scheduled inpainting v1 supports a single segment";
|
|
588
|
-
}
|
|
589
|
-
return null;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// strength -> the (sigma_s, sigma_e) pair the backend blends between. This
|
|
593
|
-
// mapping is server-side by contract so a client never speaks in diffusion-time
|
|
594
|
-
// units. The mapping is INVERTED against sigma_s on purpose: alpha_time is 1
|
|
595
|
-
// ABOVE sigma_s and fades to 0 at sigma_e, so a SMALLER sigma_s keeps the base
|
|
596
|
-
// blended deeper into the low-noise steps and preserves more. Measured on the
|
|
597
|
-
// box (gate G3, all-ones mask, seed 5678): sigma_s 200 -> L2P 0.00455,
|
|
598
|
-
// 500 -> 0.00467, 800 -> 0.00480, 1000 -> 0.00487 — strictly looser as sigma_s
|
|
599
|
-
// rises. The first draft mapped strength straight onto sigma_s and made the
|
|
600
|
-
// slider work backwards. sigma_e is capped rather than scaled: below it the
|
|
601
|
-
// model must always be free to finish, or the take is frozen onto the base and
|
|
602
|
-
// the edit cannot happen. The floor at the cap keeps full strength a step
|
|
603
|
-
// schedule (blend until sigma_e) instead of rounding sigma_s to 0, which the
|
|
604
|
-
// backend reads as preserve fully off.
|
|
605
|
-
function preserveSigmas(strength) {
|
|
606
|
-
const sigmaS = Math.max(PRESERVE_SIGMA_END_CAP, Math.round(PRESERVE_SIGMA_MAX * (1 - strength)));
|
|
607
|
-
return { sigmaS, sigmaE: Math.min(PRESERVE_SIGMA_END_CAP, sigmaS) };
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// Returns an error message naming the offending field, or null when valid.
|
|
611
|
-
// The fixed contract: 2..32 sparse {frame,x,z,heading} keys, starting at
|
|
612
|
-
// frame 0 with strictly ascending frames. The backend generates every
|
|
613
|
-
// in-between frame. x/z are finite metres in [-20,20], heading null or finite radians
|
|
614
|
-
// in [-2π,2π].
|
|
615
|
-
// Every rejection names 'waypoints' so the client can point at the offending
|
|
616
|
-
// entry.
|
|
617
|
-
function validateWaypoints(waypoints, clipFrames) {
|
|
618
|
-
if (!Array.isArray(waypoints)) {
|
|
619
|
-
return "field 'waypoints' must be an array";
|
|
620
|
-
}
|
|
621
|
-
if (waypoints.length < 2 || waypoints.length > WAYPOINTS_MAX) {
|
|
622
|
-
return `field 'waypoints' must have 2..${WAYPOINTS_MAX} sparse entries, got ${waypoints.length}`;
|
|
623
|
-
}
|
|
624
|
-
let prevFrame = -1;
|
|
625
|
-
for (let i = 0; i < waypoints.length; i += 1) {
|
|
626
|
-
const wp = waypoints[i];
|
|
627
|
-
if (!wp || typeof wp !== "object" || Array.isArray(wp)) {
|
|
628
|
-
return `field 'waypoints[${i}]' must be an object`;
|
|
629
|
-
}
|
|
630
|
-
if (!Number.isInteger(wp.frame) || wp.frame < 0 || wp.frame >= clipFrames) {
|
|
631
|
-
return `field 'waypoints[${i}].frame' must be an integer in 0..${clipFrames - 1}, got ${JSON.stringify(wp.frame)}`;
|
|
632
|
-
}
|
|
633
|
-
if (wp.frame <= prevFrame) {
|
|
634
|
-
return `field 'waypoints' frames must be strictly ascending: frame ${wp.frame} duplicates or precedes frame ${prevFrame} (index ${i})`;
|
|
635
|
-
}
|
|
636
|
-
prevFrame = wp.frame;
|
|
637
|
-
if (i === 0 && wp.frame !== 0) {
|
|
638
|
-
return `field 'waypoints[0].frame' must be 0 (start + destination path), got ${wp.frame}`;
|
|
639
|
-
}
|
|
640
|
-
for (const axis of ["x", "z"]) {
|
|
641
|
-
const value = wp[axis];
|
|
642
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
643
|
-
return `field 'waypoints[${i}].${axis}' must be a finite number, got ${JSON.stringify(value)}`;
|
|
644
|
-
}
|
|
645
|
-
if (value < -ROOT_2D_RANGE_M || value > ROOT_2D_RANGE_M) {
|
|
646
|
-
return `field 'waypoints[${i}].${axis}' ${value} is outside -${ROOT_2D_RANGE_M}..${ROOT_2D_RANGE_M} meters`;
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
if (!("heading" in wp) || wp.heading === undefined) {
|
|
650
|
-
return `field 'waypoints[${i}].heading' is missing; must be null or a number of radians`;
|
|
651
|
-
}
|
|
652
|
-
if (wp.heading !== null) {
|
|
653
|
-
if (typeof wp.heading !== "number" || !Number.isFinite(wp.heading)) {
|
|
654
|
-
return `field 'waypoints[${i}].heading' must be null or a finite number of radians, got ${JSON.stringify(wp.heading)}`;
|
|
655
|
-
}
|
|
656
|
-
if (Math.abs(wp.heading) > HEADING_RANGE_RAD) {
|
|
657
|
-
return `field 'waypoints[${i}].heading' ${wp.heading} rad is outside -2π..2π`;
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
// Root pins are inpainting observations the model cannot refuse, so a pin
|
|
662
|
-
// pair demanding an inexpressible pace collapses the gait into
|
|
663
|
-
// foot-sliding instead of erroring. The bands are wider than the authored
|
|
664
|
-
// 0.5..3 m/s walk band because these are the dense C1 samples, which dip
|
|
665
|
-
// through corners and pause dead in authored holds.
|
|
666
|
-
// holdPair[i]: the pair ending at waypoint i is a deliberate hold.
|
|
667
|
-
// Index 0 has no pair — false, so the first real leg gets no exemption.
|
|
668
|
-
const holdPair = waypoints.map((wp, i) =>
|
|
669
|
-
i === 0 ? false : Math.hypot(wp.x - waypoints[i - 1].x, wp.z - waypoints[i - 1].z) <= WAYPOINT_HOLD_EPS_M,
|
|
670
|
-
);
|
|
671
|
-
for (let i = 1; i < waypoints.length; i += 1) {
|
|
672
|
-
if (holdPair[i]) continue; // a deliberate hold
|
|
673
|
-
const prev = waypoints[i - 1];
|
|
674
|
-
const wp = waypoints[i];
|
|
675
|
-
const speed = Math.hypot(wp.x - prev.x, wp.z - prev.z) / ((wp.frame - prev.frame) / FPS);
|
|
676
|
-
if (speed > WAYPOINT_SPEED_MAX_MPS) {
|
|
677
|
-
return `field 'waypoints[${i}]' implies ${speed.toFixed(1)} m/s from waypoint ${i - 1} — faster than the ${WAYPOINT_SPEED_MAX_MPS} m/s locomotion ceiling`;
|
|
678
|
-
}
|
|
679
|
-
// Pairs beside a hold are the C1 ramp down to (or up from) zero and
|
|
680
|
-
// legitimately pass under the floor; everywhere else, sub-gait creep
|
|
681
|
-
// means foot-sliding.
|
|
682
|
-
const besideHold = holdPair[i - 1] || (i + 1 < waypoints.length && holdPair[i + 1]);
|
|
683
|
-
if (speed < WAYPOINT_SPEED_MIN_MPS && !besideHold) {
|
|
684
|
-
return `field 'waypoints[${i}]' implies ${speed.toFixed(2)} m/s from waypoint ${i - 1} — below the ${WAYPOINT_SPEED_MIN_MPS} m/s gait floor (hold still or move at walking pace, nothing between)`;
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
return null;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
function readBody(req, limitBytes) {
|
|
691
|
-
return new Promise((resolvePromise, reject) => {
|
|
692
|
-
const chunks = [];
|
|
693
|
-
let total = 0;
|
|
694
|
-
req.on("data", (chunk) => {
|
|
695
|
-
total += chunk.length;
|
|
696
|
-
if (total > limitBytes) {
|
|
697
|
-
reject(new Error(`request body exceeds ${limitBytes} bytes`));
|
|
698
|
-
req.destroy();
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
|
-
chunks.push(chunk);
|
|
702
|
-
});
|
|
703
|
-
req.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8")));
|
|
704
|
-
req.on("error", (err) => reject(new Error(`request aborted: ${err.message}`)));
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// endpoints
|
|
709
|
-
|
|
710
|
-
function sendJson(res, status, obj) {
|
|
711
|
-
res.writeHead(status, {
|
|
712
|
-
"Content-Type": "application/json",
|
|
713
|
-
"Cache-Control": "no-store",
|
|
714
|
-
});
|
|
715
|
-
res.end(`${JSON.stringify(obj)}\n`);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// Shared 500 handler for async route failures: if headers already went out
|
|
719
|
-
// the only thing left is to drop the socket; either way the error is logged.
|
|
720
|
-
function respondInternalError(res, error, route) {
|
|
721
|
-
if (!res.headersSent) sendJson(res, 500, { ok: false, reason: `internal error: ${error.message}` });
|
|
722
|
-
else {
|
|
723
|
-
try {
|
|
724
|
-
res.end();
|
|
725
|
-
} catch {
|
|
726
|
-
/* socket gone */
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
console.error(`[bridge] ${route} threw: ${error.stack || error}`);
|
|
730
|
-
log(500);
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// The generator's single-line JSON report is the LAST stdout line of
|
|
734
|
-
// cclay_constrained_generate.py and is compact-printed (starts with "{").
|
|
735
|
-
// Constrained generation reports carry target_space; autoregressive sequence
|
|
736
|
-
// reports carry a segment table and continuity metrics.
|
|
737
|
-
function tryParseReport(line) {
|
|
738
|
-
const trimmed = line.trimStart();
|
|
739
|
-
if (!trimmed.startsWith("{")) return null;
|
|
740
|
-
let parsed;
|
|
741
|
-
try {
|
|
742
|
-
parsed = JSON.parse(trimmed);
|
|
743
|
-
} catch {
|
|
744
|
-
return null;
|
|
745
|
-
}
|
|
746
|
-
const constrained = parsed && typeof parsed === "object" && typeof parsed.target_space === "string";
|
|
747
|
-
const sequence =
|
|
748
|
-
parsed &&
|
|
749
|
-
typeof parsed === "object" &&
|
|
750
|
-
Number.isInteger(parsed.frames) &&
|
|
751
|
-
Number.isInteger(parsed.fps) &&
|
|
752
|
-
Array.isArray(parsed.segments) &&
|
|
753
|
-
parsed.continuity &&
|
|
754
|
-
typeof parsed.continuity === "object";
|
|
755
|
-
const motionEdit =
|
|
756
|
-
parsed &&
|
|
757
|
-
typeof parsed === "object" &&
|
|
758
|
-
Array.isArray(parsed.edit_range) &&
|
|
759
|
-
Array.isArray(parsed.history_range) &&
|
|
760
|
-
Array.isArray(parsed.future_range);
|
|
761
|
-
if (!constrained && !sequence && !motionEdit) {
|
|
762
|
-
return null;
|
|
763
|
-
}
|
|
764
|
-
return parsed;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
// generated-motion delivery
|
|
768
|
-
|
|
769
|
-
// run-id -> absolute npz path, populated ONLY after this process generated
|
|
770
|
-
// the file and verified it on disk (see handleGenerate's done branch). The
|
|
771
|
-
// GET handler never accepts a path from the URL: an id that is not in this
|
|
772
|
-
// map is 404 by definition. Capped so a long-lived dev sidecar cannot grow
|
|
773
|
-
// without bound; evicted ids become stale and 404, which is documented.
|
|
774
|
-
const motionAllowlist = new Map();
|
|
775
|
-
|
|
776
|
-
function registerMotion(runId, absPath) {
|
|
777
|
-
motionAllowlist.set(runId, absPath);
|
|
778
|
-
if (motionAllowlist.size > MOTION_ALLOWLIST_MAX) {
|
|
779
|
-
const oldest = motionAllowlist.keys().next().value;
|
|
780
|
-
evictPrivateArtifact(motionAllowlist, oldest);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// The Studio agent runtime pins the artifact URL to this bridge's own origin
|
|
785
|
-
// (bin/agent/motion-runtime.mjs), so the editor page on the dev/launcher port
|
|
786
|
-
// fetches it cross-origin. Only loopback pages are granted read access.
|
|
787
|
-
const LOOPBACK_ORIGIN = /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d{1,5}$/;
|
|
788
|
-
|
|
789
|
-
/** CORS headers for a generated-motion download requested from `origin`. */
|
|
790
|
-
export function motionCorsHeaders(origin) {
|
|
791
|
-
return LOOPBACK_ORIGIN.test(origin ?? "") ? { "Access-Control-Allow-Origin": origin } : {};
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// Serves /ardy/motions/<run-id>; returns the HTTP status to log.
|
|
795
|
-
function serveMotion(req, res, pathname) {
|
|
796
|
-
const match = /^\/ardy\/motions\/([^/]+)$/.exec(pathname);
|
|
797
|
-
if (!match) {
|
|
798
|
-
sendJson(res, 404, { ok: false, reason: `not found: ${req.method} ${pathname}` });
|
|
799
|
-
return 404;
|
|
800
|
-
}
|
|
801
|
-
const runId = match[1];
|
|
802
|
-
if (!MOTION_ID.test(runId) || !motionAllowlist.has(runId)) {
|
|
803
|
-
sendJson(res, 404, { ok: false, reason: `unknown or expired motion "${runId}"` });
|
|
804
|
-
return 404;
|
|
805
|
-
}
|
|
806
|
-
const absPath = motionAllowlist.get(runId);
|
|
807
|
-
// The map only ever holds paths this process joined under OUT_DIR, but
|
|
808
|
-
// the check is re-applied at serve time: nothing outside tools/ardy/out
|
|
809
|
-
// is ever served, no matter how it got in.
|
|
810
|
-
if (!absPath.startsWith(`${OUT_DIR}${sep}`)) {
|
|
811
|
-
sendJson(res, 404, { ok: false, reason: `motion "${runId}" is outside ${OUT_DIR}` });
|
|
812
|
-
return 404;
|
|
813
|
-
}
|
|
814
|
-
let size;
|
|
815
|
-
try {
|
|
816
|
-
size = statSync(absPath).size;
|
|
817
|
-
} catch {
|
|
818
|
-
sendJson(res, 404, { ok: false, reason: `motion "${runId}" is no longer on disk` });
|
|
819
|
-
return 404;
|
|
820
|
-
}
|
|
821
|
-
res.writeHead(200, {
|
|
822
|
-
"Content-Type": "application/octet-stream",
|
|
823
|
-
"Content-Length": size,
|
|
824
|
-
"Content-Disposition": `attachment; filename="${basename(absPath)}"`,
|
|
825
|
-
"Cache-Control": "no-store",
|
|
826
|
-
Vary: "Origin",
|
|
827
|
-
...motionCorsHeaders(req.headers.origin),
|
|
828
|
-
});
|
|
829
|
-
createReadStream(absPath)
|
|
830
|
-
.on("error", (err) => {
|
|
831
|
-
console.error(`[bridge] error streaming ${absPath}: ${err.message}`);
|
|
832
|
-
res.destroy();
|
|
833
|
-
})
|
|
834
|
-
.pipe(res);
|
|
835
|
-
return 200;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
async function handleGenerate(req, res) {
|
|
839
|
-
const started = Date.now();
|
|
840
|
-
const finish = (status) => {
|
|
841
|
-
console.log(`[bridge] POST /ardy/generate -> ${status} (${Date.now() - started} ms)`);
|
|
842
|
-
};
|
|
843
|
-
|
|
844
|
-
let raw;
|
|
845
|
-
try {
|
|
846
|
-
raw = await readBody(req, MAX_BODY_BYTES);
|
|
847
|
-
} catch (err) {
|
|
848
|
-
sendJson(res, 400, { ok: false, reason: err.message });
|
|
849
|
-
finish(400);
|
|
850
|
-
return;
|
|
851
|
-
}
|
|
852
|
-
let body;
|
|
853
|
-
try {
|
|
854
|
-
body = JSON.parse(raw);
|
|
855
|
-
} catch (err) {
|
|
856
|
-
sendJson(res, 400, { ok: false, reason: `malformed JSON body: ${err.message}` });
|
|
857
|
-
finish(400);
|
|
858
|
-
return;
|
|
859
|
-
}
|
|
860
|
-
const invalid = validateGenerate(body);
|
|
861
|
-
if (invalid) {
|
|
862
|
-
sendJson(res, 400, { ok: false, reason: invalid });
|
|
863
|
-
finish(400);
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
// A given base must be one of the ids the box actually reported, so a
|
|
867
|
-
// typo or a guessed id never reaches the generator. posePin:false requests
|
|
868
|
-
// may omit base entirely - with waypoints that is the two-pass mode
|
|
869
|
-
// waypoints and without
|
|
870
|
-
// waypoints it is free generation - so the listing is skipped there.
|
|
871
|
-
const posePinned = body.posePin !== false;
|
|
872
|
-
const requestedPoses = posePinned
|
|
873
|
-
? (body.motionEdit
|
|
874
|
-
? body.motionEdit.edits
|
|
875
|
-
: Array.isArray(body.poses)
|
|
876
|
-
? body.poses
|
|
877
|
-
: [{ frame: body.dstFrame, pose: body.pose }])
|
|
878
|
-
: [];
|
|
879
|
-
const baseLabel = body.base === undefined ? "internal-neutral" : body.base;
|
|
880
|
-
const dstFrameLabel = requestedPoses.map((entry) => entry.frame).join(",") || "none";
|
|
881
|
-
let match = null;
|
|
882
|
-
let sourceMotionPath = null;
|
|
883
|
-
if (body.regenerateSegments || body.motionEdit) {
|
|
884
|
-
const sourceUrl = body.motionEdit?.sourceMotion || body.sourceMotion;
|
|
885
|
-
const sourceId = sourceUrl.slice("/ardy/motions/".length);
|
|
886
|
-
sourceMotionPath = motionAllowlist.get(sourceId) || null;
|
|
887
|
-
if (!sourceMotionPath || !existsSync(sourceMotionPath)) {
|
|
888
|
-
sendJson(res, 400, { ok: false, reason: `field 'sourceMotion': unknown or expired motion "${sourceId}"` });
|
|
889
|
-
finish(400);
|
|
890
|
-
return;
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
// Scheduled inpainting needs the BACKEND's own artifact of the take being
|
|
894
|
-
// preserved: Kimodo's --base_motion reads the npz its generator wrote, and
|
|
895
|
-
// the cskel27 file served at /ardy/motions/<id> is a lossy conversion of it,
|
|
896
|
-
// not that file. Which artifact that is stays a backend question, so the
|
|
897
|
-
// runner answers it and the bridge stays backend-neutral.
|
|
898
|
-
let preserveParams = null;
|
|
899
|
-
let preserveSkipped = null;
|
|
900
|
-
if (body.preserve) {
|
|
901
|
-
const preserveId = body.preserve.sourceMotion.slice("/ardy/motions/".length);
|
|
902
|
-
const takePath = motionAllowlist.get(preserveId) || null;
|
|
903
|
-
if (!takePath || !existsSync(takePath)) {
|
|
904
|
-
sendJson(res, 400, { ok: false, reason: `field 'preserve.sourceMotion': unknown or expired motion "${preserveId}"` });
|
|
905
|
-
finish(400);
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
const basePath = runner.baseMotionFor ? runner.baseMotionFor(takePath) : null;
|
|
909
|
-
if (basePath) {
|
|
910
|
-
preserveParams = {
|
|
911
|
-
basePath,
|
|
912
|
-
// The raw slider value rides along beside the derived sigmas: the
|
|
913
|
-
// schedule alone is nearly binary in practice (gate G3 measured a
|
|
914
|
-
// 7% L2P spread across the whole sweep), so the generator also
|
|
915
|
-
// scales the blend AMPLITUDE by it to give the dial real range.
|
|
916
|
-
strength: body.preserve.strength,
|
|
917
|
-
...preserveSigmas(body.preserve.strength),
|
|
918
|
-
// `tracks` (C3v2) rides along INSIDE each range: the mask builder is
|
|
919
|
-
// the only layer that knows the generation clock, so ranges are
|
|
920
|
-
// forwarded verbatim and never resolved to groups here.
|
|
921
|
-
editRanges: body.preserve.editRanges,
|
|
922
|
-
// preserve + waypoints (C3v2, paper 4.4): the authored path owns the
|
|
923
|
-
// root for the WHOLE clip, so the mask's `root` group is zeroed end
|
|
924
|
-
// to end and the drawn trajectory is not fighting a preserved one.
|
|
925
|
-
// Everything else still rides the base take, including the groups the
|
|
926
|
-
// edit ranges free. A FLAG, not a mask: generate.mjs builds the mask
|
|
927
|
-
// in exactly one place and composes this onto it there.
|
|
928
|
-
rootFree: body.waypoints !== undefined,
|
|
929
|
-
};
|
|
930
|
-
} else {
|
|
931
|
-
// The take is real, but this backend kept no base motion for it:
|
|
932
|
-
// motion edits and regenerated block sets are SPLICED from two
|
|
933
|
-
// motions, so the backend's own artifact is not what the user is
|
|
934
|
-
// looking at, and takes generated before scheduled inpainting landed
|
|
935
|
-
// kept nothing at all. Preservation is a best-effort quality knob
|
|
936
|
-
// that is ON by default, so a missing base degrades to the plain
|
|
937
|
-
// generation this request would have been before the feature existed
|
|
938
|
-
// — loudly, on the status stream the App logs — instead of failing a
|
|
939
|
-
// run the operator did ask for.
|
|
940
|
-
preserveSkipped =
|
|
941
|
-
`[bridge] preserve SKIPPED: take "${preserveId}" has no ${runner.mode} base motion on disk ` +
|
|
942
|
-
"(edited and spliced takes keep none); generating WITHOUT scheduled inpainting";
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
// A line edit REWRITES an existing take, so the take it names is resolved the
|
|
946
|
-
// same way preserve's is — through the allowlist this process populated, never
|
|
947
|
-
// from a path in the request — and before anything opens a stream.
|
|
948
|
-
let lineEditTakePath = null;
|
|
949
|
-
if (body.lineEdit) {
|
|
950
|
-
if (!projflowRunner) {
|
|
951
|
-
sendJson(res, 503, {
|
|
952
|
-
ok: false,
|
|
953
|
-
reason: "line editing needs the ProjFlow backend: set CCLAY_PROJFLOW_HOST (or CCLAY_KIMODO_HOST)",
|
|
954
|
-
});
|
|
955
|
-
finish(503);
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
const lineEditId = body.lineEdit.sourceMotion.slice("/ardy/motions/".length);
|
|
959
|
-
lineEditTakePath = motionAllowlist.get(lineEditId) || null;
|
|
960
|
-
if (!lineEditTakePath || !existsSync(lineEditTakePath)) {
|
|
961
|
-
sendJson(res, 400, { ok: false, reason: `field 'lineEdit.sourceMotion': unknown or expired motion "${lineEditId}"` });
|
|
962
|
-
finish(400);
|
|
963
|
-
return;
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
// A replay needs the same backend a line edit does, and it needs it BEFORE the
|
|
967
|
-
// stream opens: the generation itself would succeed, and refusing halfway
|
|
968
|
-
// through would leave the client holding a take whose refinements silently
|
|
969
|
-
// never ran. Checked here rather than in the validator because "is a box
|
|
970
|
-
// configured" is process state, not a request rule.
|
|
971
|
-
if (body.replay && body.replay.length > 0 && !projflowRunner) {
|
|
972
|
-
sendJson(res, 503, {
|
|
973
|
-
ok: false,
|
|
974
|
-
reason: "recipe replay needs the ProjFlow backend: set CCLAY_PROJFLOW_HOST (or CCLAY_KIMODO_HOST)",
|
|
975
|
-
});
|
|
976
|
-
finish(503);
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
if (body.base !== undefined) {
|
|
980
|
-
let bases;
|
|
981
|
-
try {
|
|
982
|
-
bases = await getBases();
|
|
983
|
-
} catch (err) {
|
|
984
|
-
sendJson(res, 503, { ok: false, reason: `cannot list base motions: ${err.message}` });
|
|
985
|
-
finish(503);
|
|
986
|
-
return;
|
|
987
|
-
}
|
|
988
|
-
match = bases.bases.find((entry) => entry.id === body.base);
|
|
989
|
-
if (!match) {
|
|
990
|
-
sendJson(res, 400, {
|
|
991
|
-
ok: false,
|
|
992
|
-
reason: `field 'base': unknown base "${body.base}" (the box reports ${bases.bases.length} base motion(s))`,
|
|
993
|
-
});
|
|
994
|
-
finish(400);
|
|
995
|
-
return;
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
res.writeHead(200, {
|
|
999
|
-
"Content-Type": "application/x-ndjson",
|
|
1000
|
-
"Cache-Control": "no-store",
|
|
1001
|
-
});
|
|
1002
|
-
const send = (obj) => {
|
|
1003
|
-
if (res.writableEnded) return;
|
|
1004
|
-
try {
|
|
1005
|
-
res.write(`${JSON.stringify(obj)}\n`);
|
|
1006
|
-
} catch {
|
|
1007
|
-
/* socket gone; the close handler below kills the children */
|
|
1008
|
-
}
|
|
1009
|
-
};
|
|
1010
|
-
const sendStatus = (message) => send({ event: "status", message });
|
|
1011
|
-
const sendError = (message) => {
|
|
1012
|
-
send({ event: "error", message });
|
|
1013
|
-
res.end();
|
|
1014
|
-
};
|
|
1015
|
-
if (preserveSkipped) {
|
|
1016
|
-
console.error(preserveSkipped);
|
|
1017
|
-
sendStatus(preserveSkipped);
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
// Children spawned for THIS request, killed on client disconnect or any
|
|
1021
|
-
// terminal error. Detached groups, so killGroup takes down the whole tree
|
|
1022
|
-
// (bash + the ssh session it waits on).
|
|
1023
|
-
const children = new Set();
|
|
1024
|
-
const spawnTracked = (command, args, options) => {
|
|
1025
|
-
const child = spawn(command, args, options);
|
|
1026
|
-
children.add(child);
|
|
1027
|
-
track(child);
|
|
1028
|
-
return child;
|
|
1029
|
-
};
|
|
1030
|
-
const killChildren = () => {
|
|
1031
|
-
for (const child of children) killGroup(child);
|
|
1032
|
-
children.clear();
|
|
1033
|
-
};
|
|
1034
|
-
let artifactDir = null;
|
|
1035
|
-
const cleanupArtifacts = () => {
|
|
1036
|
-
if (artifactDir) removePrivateArtifactDir(artifactDir);
|
|
1037
|
-
};
|
|
1038
|
-
let clientGone = false;
|
|
1039
|
-
res.on("close", () => {
|
|
1040
|
-
if (!res.writableEnded) {
|
|
1041
|
-
clientGone = true;
|
|
1042
|
-
console.error(
|
|
1043
|
-
`[bridge] client disconnected mid-generate; killing ${children.size} child process group(s)`
|
|
1044
|
-
);
|
|
1045
|
-
killChildren();
|
|
1046
|
-
cleanupArtifacts();
|
|
1047
|
-
}
|
|
1048
|
-
});
|
|
1049
|
-
|
|
1050
|
-
const stamp = `${Date.now()}-${randomBytes(3).toString("hex")}`;
|
|
1051
|
-
artifactDir = createPrivateArtifactDir(OUT_DIR, "generate");
|
|
1052
|
-
const poseJsonPaths = requestedPoses.map((_, index) => join(artifactDir, `pose-${index}.json`));
|
|
1053
|
-
const poseNpzPaths = requestedPoses.map((_, index) => join(artifactDir, `pose-${index}.npz`));
|
|
1054
|
-
// posePin:false runs carry no pose, so the artifact name says what the
|
|
1055
|
-
// run actually was: constrained (pose pinned) vs generated (no pose).
|
|
1056
|
-
const outNpzPath = join(artifactDir, body.lineEdit ? "line-edit.npz" : posePinned ? "constrained.npz" : "generated.npz");
|
|
1057
|
-
|
|
1058
|
-
// --- recipe replay (contract C10) ---------------------------------------
|
|
1059
|
-
// A take is a RECIPE: seed + prompt blocks + the line edits drawn on top.
|
|
1060
|
-
// Regenerating or extending it therefore has to re-apply those edits, or
|
|
1061
|
-
// every "add a block" throws away the refinement work. The chain, the
|
|
1062
|
-
// per-entry failure policy and the boundary rule live in
|
|
1063
|
-
// tools/projflow/replay.mjs; what belongs HERE is only which take the chain
|
|
1064
|
-
// starts from, where its intermediates go, and what reaches the client.
|
|
1065
|
-
//
|
|
1066
|
-
// Called after the generated take is written and verified and BEFORE
|
|
1067
|
-
// anything is registered, so the motionUrl the client receives always names
|
|
1068
|
-
// the LAST good file in the chain — the take with the refinements in it.
|
|
1069
|
-
const replayEntries = Array.isArray(body.replay) ? body.replay : [];
|
|
1070
|
-
// The boundaries are a fact about how this take was GENERATED, so they come
|
|
1071
|
-
// from the request's prompt schedule and nowhere else. No `segments` field
|
|
1072
|
-
// means a single block, which has no internal boundary to warn about.
|
|
1073
|
-
const replayBoundaries = blockBoundaries(body.segments, Math.floor(body.duration * FPS));
|
|
1074
|
-
let replayReport = null;
|
|
1075
|
-
const applyReplay = async (takePath) => {
|
|
1076
|
-
if (replayEntries.length === 0) return takePath;
|
|
1077
|
-
sendStatus(
|
|
1078
|
-
`[bridge] replaying ${replayEntries.length} stored line edit(s) onto the new take` +
|
|
1079
|
-
(replayBoundaries.length ? ` (block boundaries at ${replayBoundaries.join(", ")})` : "")
|
|
1080
|
-
);
|
|
1081
|
-
const result = await runReplay({
|
|
1082
|
-
entries: replayEntries,
|
|
1083
|
-
takePath,
|
|
1084
|
-
artifactDir,
|
|
1085
|
-
boundaries: replayBoundaries,
|
|
1086
|
-
appFps: FPS,
|
|
1087
|
-
runJob: runLineEditJob,
|
|
1088
|
-
onStatus: (line) => sendStatus(line),
|
|
1089
|
-
});
|
|
1090
|
-
replayReport = result.entries;
|
|
1091
|
-
const failed = result.entries.filter((entry) => !entry.ok).length;
|
|
1092
|
-
const warned = result.entries.filter((entry) => entry.boundaryWarning).length;
|
|
1093
|
-
sendStatus(
|
|
1094
|
-
`[bridge] replay finished: ${result.entries.length - failed}/${result.entries.length} applied` +
|
|
1095
|
-
(failed ? `, ${failed} FAILED (drawn refinements missing from this take)` : "") +
|
|
1096
|
-
(warned ? `, ${warned} near a block boundary` : "")
|
|
1097
|
-
);
|
|
1098
|
-
return result.takePath;
|
|
1099
|
-
};
|
|
1100
|
-
// One report event, whatever produced it. The replay table rides on the
|
|
1101
|
-
// generator's own report when there is one and stands alone when there is
|
|
1102
|
-
// not, so a client never has to guess whether the replay ran.
|
|
1103
|
-
const sendReport = (report) => {
|
|
1104
|
-
if (!report && !replayReport) return;
|
|
1105
|
-
send({
|
|
1106
|
-
event: "report",
|
|
1107
|
-
report: { ...(report || {}), ...(replayReport ? { replay: replayReport } : {}) },
|
|
1108
|
-
});
|
|
1109
|
-
};
|
|
1110
|
-
try {
|
|
1111
|
-
|
|
1112
|
-
// --- line edit (contract C6) ----------------------------------------
|
|
1113
|
-
// FIRST, and it returns: a line edit shares none of the machinery below.
|
|
1114
|
-
// It is its own run mode (no poses, no base, no segments), it runs on the
|
|
1115
|
-
// ProjFlow backend whatever CCLAY_MOTION_BACKEND says (engine-per-task),
|
|
1116
|
-
// and it produces a take by SPLICING a converted edit into the source
|
|
1117
|
-
// rather than by writing whatever the box returned.
|
|
1118
|
-
//
|
|
1119
|
-
// One honest limitation: the ssh this awaits belongs to lineEditOnBox, not
|
|
1120
|
-
// to `children`, so a client that disconnects mid-run does not kill it —
|
|
1121
|
-
// the sampling S1 measured is under a second, the wrapper removes its own
|
|
1122
|
-
// run directory either way, and the alternative is a cancellation channel
|
|
1123
|
-
// through three modules for a sub-second job.
|
|
1124
|
-
if (body.lineEdit) {
|
|
1125
|
-
sendStatus(
|
|
1126
|
-
`[bridge] line-editing ${body.lineEdit.track} over frames ` +
|
|
1127
|
-
`${body.lineEdit.frameRange.startFrame}..${body.lineEdit.frameRange.endFrame - 1} on the projflow backend` +
|
|
1128
|
-
(body.lineEdit.preview === true ? " (20-step preview)" : "")
|
|
1129
|
-
);
|
|
1130
|
-
const meta = await runLineEditJob({
|
|
1131
|
-
lineEdit: body.lineEdit,
|
|
1132
|
-
takePath: lineEditTakePath,
|
|
1133
|
-
outputPath: outNpzPath,
|
|
1134
|
-
// C10's preview flag, forwarded verbatim. The job owns what "preview"
|
|
1135
|
-
// costs (20 ODE steps instead of 100); the bridge only relays that the
|
|
1136
|
-
// client asked for a draft. A preview take is registered like any
|
|
1137
|
-
// other — the APP decides whether to keep it.
|
|
1138
|
-
preview: body.lineEdit.preview === true,
|
|
1139
|
-
seed: body.seed,
|
|
1140
|
-
appFps: FPS,
|
|
1141
|
-
onStatus: (line) => sendStatus(line),
|
|
1142
|
-
});
|
|
1143
|
-
// The seam numbers gate GP2 reads, on the status stream as well as in
|
|
1144
|
-
// the report: a hard cut is what this pipeline ships, and an operator
|
|
1145
|
-
// watching a run should see a pop without opening a file.
|
|
1146
|
-
sendStatus(
|
|
1147
|
-
`[bridge] line edit spliced: seams ${meta.seamStartDelta.toFixed(4)} / ${meta.seamEndDelta.toFixed(4)} m ` +
|
|
1148
|
-
`vs the take's median ${meta.medianFrameDelta.toFixed(4)} m/frame`
|
|
1149
|
-
);
|
|
1150
|
-
send({
|
|
1151
|
-
event: "report",
|
|
1152
|
-
report: {
|
|
1153
|
-
target_space: "skeleton_joint_center",
|
|
1154
|
-
engine: "projflow",
|
|
1155
|
-
...meta,
|
|
1156
|
-
},
|
|
1157
|
-
});
|
|
1158
|
-
const finalSize = statSync(outNpzPath).size;
|
|
1159
|
-
// Registered exactly like every other generated take, so the app can
|
|
1160
|
-
// load it from /ardy/motions/<id> and edit it AGAIN.
|
|
1161
|
-
registerMotion(stamp, outNpzPath);
|
|
1162
|
-
send({ event: "done", output: outNpzPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
|
|
1163
|
-
res.end();
|
|
1164
|
-
console.log(`[bridge] line edit finished: ${outNpzPath} (${body.lineEdit.track})`);
|
|
1165
|
-
killChildren();
|
|
1166
|
-
finish(200);
|
|
1167
|
-
return;
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
// --- pose -> motion npz (local, fast). Skipped when posePin is
|
|
1171
|
-
// false: the pose constraint is dropped, so there is nothing to
|
|
1172
|
-
// convert, dump a reference for, or push.
|
|
1173
|
-
if (posePinned) {
|
|
1174
|
-
for (let index = 0; index < requestedPoses.length; index += 1) {
|
|
1175
|
-
const entry = requestedPoses[index];
|
|
1176
|
-
const poseJsonPath = poseJsonPaths[index];
|
|
1177
|
-
const poseNpzPath = poseNpzPaths[index];
|
|
1178
|
-
writeFileSync(poseJsonPath, `${JSON.stringify(entry.pose, null, 2)}
|
|
1179
|
-
`);
|
|
1180
|
-
sendStatus(`[bridge] pose ${index + 1}/${requestedPoses.length} frame ${entry.frame} written`);
|
|
1181
|
-
const conv = spawnTracked(
|
|
1182
|
-
process.execPath,
|
|
1183
|
-
[POSE_TO_NPZ, poseJsonPath, "--out", poseNpzPath],
|
|
1184
|
-
{ cwd: REPO, detached: true }
|
|
1185
|
-
);
|
|
1186
|
-
const convLast = { stderr: "", stdout: "" };
|
|
1187
|
-
const convCode = await runStreaming(conv, (line, streamName) => {
|
|
1188
|
-
convLast[streamName] = line;
|
|
1189
|
-
sendStatus(line);
|
|
1190
|
-
});
|
|
1191
|
-
children.delete(conv);
|
|
1192
|
-
if (convCode !== 0) {
|
|
1193
|
-
killChildren();
|
|
1194
|
-
sendError(`pose-to-npz failed (exit ${convCode}): ${convLast.stderr || convLast.stdout || "no output"}`);
|
|
1195
|
-
cleanupArtifacts();
|
|
1196
|
-
finish(200);
|
|
1197
|
-
return;
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
// --- generation on the box -----------------------------------------
|
|
1203
|
-
const clipFrames = Math.floor(body.duration * FPS);
|
|
1204
|
-
const segments = body.segments || [{ startFrame: 0, endFrame: clipFrames, prompt: body.prompt }];
|
|
1205
|
-
if (body.motionEdit) {
|
|
1206
|
-
const manifestPath = join(artifactDir, "edit-manifest.json");
|
|
1207
|
-
const manifest = {
|
|
1208
|
-
start_frame: body.motionEdit.startFrame,
|
|
1209
|
-
end_frame: body.motionEdit.endFrame,
|
|
1210
|
-
edits: body.motionEdit.edits.map((entry, index) => ({
|
|
1211
|
-
frame: entry.frame,
|
|
1212
|
-
tracks: entry.tracks,
|
|
1213
|
-
root: entry.pose.root,
|
|
1214
|
-
pose_path: `pose-${index}.npz`,
|
|
1215
|
-
})),
|
|
1216
|
-
};
|
|
1217
|
-
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
1218
|
-
const cmd = await runner.editCommand({
|
|
1219
|
-
source: sourceMotionPath,
|
|
1220
|
-
manifest: manifestPath,
|
|
1221
|
-
prompt: body.prompt,
|
|
1222
|
-
contextBefore: body.motionEdit.contextBefore,
|
|
1223
|
-
contextAfter: body.motionEdit.contextAfter,
|
|
1224
|
-
seed: body.seed,
|
|
1225
|
-
poseNpzPaths,
|
|
1226
|
-
// An edit run regenerates the whole clip and splices only the
|
|
1227
|
-
// edited span back in, so its backend artifact is NOT the take the
|
|
1228
|
-
// user ends up with — it keeps no base motion of its own. It can
|
|
1229
|
-
// still PRESERVE one: reconstructing the source outside the edit is
|
|
1230
|
-
// what makes the regenerated span line up with it.
|
|
1231
|
-
preserve: preserveParams,
|
|
1232
|
-
output: outNpzPath,
|
|
1233
|
-
});
|
|
1234
|
-
sendStatus(
|
|
1235
|
-
`[bridge] editing frames ${body.motionEdit.startFrame}..${body.motionEdit.endFrame - 1} ` +
|
|
1236
|
-
"with history and sparse constraints"
|
|
1237
|
-
);
|
|
1238
|
-
const box = spawnTracked(cmd.command, cmd.args, { cwd: REPO, detached: true, env: cmd.env });
|
|
1239
|
-
const last = { stderr: "", stdout: "" };
|
|
1240
|
-
let report = null;
|
|
1241
|
-
let done = null;
|
|
1242
|
-
const code = await runStreaming(box, (line, streamName) => {
|
|
1243
|
-
last[streamName] = line;
|
|
1244
|
-
if (streamName === "stdout") {
|
|
1245
|
-
const parsed = tryParseReport(line);
|
|
1246
|
-
if (parsed) {
|
|
1247
|
-
report = parsed;
|
|
1248
|
-
return;
|
|
1249
|
-
}
|
|
1250
|
-
const marker = cmd.doneRe.exec(line);
|
|
1251
|
-
if (marker) {
|
|
1252
|
-
done = { path: marker[1], bytes: Number(marker[2]) };
|
|
1253
|
-
return;
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
sendStatus(line);
|
|
1257
|
-
});
|
|
1258
|
-
children.delete(box);
|
|
1259
|
-
if (code !== 0) throw new Error(`${cmd.label} failed (exit ${code}): ${last.stderr || last.stdout || "no output"}`);
|
|
1260
|
-
if (!done || done.path !== outNpzPath) throw new Error(`${cmd.label} did not return the requested output`);
|
|
1261
|
-
const finalSize = statSync(outNpzPath).size;
|
|
1262
|
-
if (finalSize !== done.bytes) throw new Error(`${cmd.label} output size mismatch`);
|
|
1263
|
-
if (report) send({ event: "report", report });
|
|
1264
|
-
registerMotion(stamp, outNpzPath);
|
|
1265
|
-
send({ event: "done", output: outNpzPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
|
|
1266
|
-
res.end();
|
|
1267
|
-
console.log(`[bridge] context-aware motion edit finished: ${outNpzPath}`);
|
|
1268
|
-
killChildren();
|
|
1269
|
-
finish(200);
|
|
1270
|
-
return;
|
|
1271
|
-
}
|
|
1272
|
-
if (body.segments) {
|
|
1273
|
-
// Root waypoints ride the same chained rollout (rollout-global
|
|
1274
|
-
// frames): the sequence generator slices the constraint set per
|
|
1275
|
-
// segment call, so a path and a prompt schedule coexist.
|
|
1276
|
-
const cmd = await runner.sequenceCommand({
|
|
1277
|
-
segments: segments.map((segment) => ({
|
|
1278
|
-
prompt: segment.prompt,
|
|
1279
|
-
durationS: (segment.endFrame - segment.startFrame) / FPS,
|
|
1280
|
-
})),
|
|
1281
|
-
waypoints: body.waypoints,
|
|
1282
|
-
seed: body.seed,
|
|
1283
|
-
cpu: body.cpu,
|
|
1284
|
-
rootMargin: body.rootMargin,
|
|
1285
|
-
contactThreshold: body.contactThreshold,
|
|
1286
|
-
historyFrames: body.historyFrames,
|
|
1287
|
-
// The written take IS this run's generation, retimed — so keep the
|
|
1288
|
-
// backend's own npz beside it as a base a later preserve run can
|
|
1289
|
-
// reconstruct. (A prompt schedule can never itself preserve: v1 is
|
|
1290
|
-
// single-segment. Being preserved FROM is unrestricted.)
|
|
1291
|
-
keepNative: true,
|
|
1292
|
-
output: outNpzPath,
|
|
1293
|
-
});
|
|
1294
|
-
sendStatus(
|
|
1295
|
-
`[bridge] generating ${segments.length} blocks in one autoregressive session` +
|
|
1296
|
-
(body.waypoints?.length ? ` with a ${body.waypoints.length}-pin root path` : "")
|
|
1297
|
-
);
|
|
1298
|
-
|
|
1299
|
-
const box = spawnTracked(cmd.command, cmd.args, { cwd: REPO, detached: true, env: cmd.env });
|
|
1300
|
-
const last = { stderr: "", stdout: "" };
|
|
1301
|
-
let finalReport = null;
|
|
1302
|
-
let done = null;
|
|
1303
|
-
const code = await runStreaming(box, (line, streamName) => {
|
|
1304
|
-
last[streamName] = line;
|
|
1305
|
-
if (streamName === "stdout") {
|
|
1306
|
-
const parsed = tryParseReport(line);
|
|
1307
|
-
if (parsed) {
|
|
1308
|
-
finalReport = parsed;
|
|
1309
|
-
return;
|
|
1310
|
-
}
|
|
1311
|
-
const marker = cmd.doneRe.exec(line);
|
|
1312
|
-
if (marker) {
|
|
1313
|
-
done = { path: marker[1], bytes: Number(marker[2]) };
|
|
1314
|
-
return;
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
sendStatus(line);
|
|
1318
|
-
});
|
|
1319
|
-
children.delete(box);
|
|
1320
|
-
if (code !== 0) {
|
|
1321
|
-
throw new Error(`${cmd.label} failed (exit ${code}): ${last.stderr || last.stdout || "no output"}`);
|
|
1322
|
-
}
|
|
1323
|
-
if (!done) throw new Error(`${cmd.label} exited 0 without a "done" marker`);
|
|
1324
|
-
if (done.path !== outNpzPath) throw new Error(`${cmd.label} returned unexpected output ${done.path}`);
|
|
1325
|
-
const finalSize = statSync(outNpzPath).size;
|
|
1326
|
-
if (finalSize === 0 || finalSize !== done.bytes) {
|
|
1327
|
-
throw new Error(`${cmd.label} output size mismatch for ${outNpzPath}`);
|
|
1328
|
-
}
|
|
1329
|
-
// The take exists and is verified; NOW the recipe's line edits go back
|
|
1330
|
-
// on, and the file they leave behind is the one that gets registered.
|
|
1331
|
-
const deliveredPath = await applyReplay(outNpzPath);
|
|
1332
|
-
sendReport(finalReport);
|
|
1333
|
-
const deliveredSize = statSync(deliveredPath).size;
|
|
1334
|
-
registerMotion(stamp, deliveredPath);
|
|
1335
|
-
send({ event: "done", output: deliveredPath, bytes: deliveredSize, motionUrl: `/ardy/motions/${stamp}` });
|
|
1336
|
-
res.end();
|
|
1337
|
-
console.log(`[bridge] sequence generation finished: ${deliveredPath} (${segments.length} blocks)`);
|
|
1338
|
-
killChildren();
|
|
1339
|
-
finish(200);
|
|
1340
|
-
return;
|
|
1341
|
-
}
|
|
1342
|
-
const runSingle = async (segment, outputPath = outNpzPath) => {
|
|
1343
|
-
const localPoses = requestedPoses
|
|
1344
|
-
.map((entry, poseIndex) => ({ ...entry, poseIndex }))
|
|
1345
|
-
.filter((entry) => entry.frame >= segment.startFrame && entry.frame < segment.endFrame);
|
|
1346
|
-
if (posePinned && localPoses.length === 0) {
|
|
1347
|
-
throw new Error("generation has no pose constraint");
|
|
1348
|
-
}
|
|
1349
|
-
const segmentFrames = segment.endFrame - segment.startFrame;
|
|
1350
|
-
const cmd = await runner.singleCommand({
|
|
1351
|
-
poseFroms: localPoses.map((entry) => ({
|
|
1352
|
-
npz: poseNpzPaths[entry.poseIndex],
|
|
1353
|
-
srcFrame: 0,
|
|
1354
|
-
dstFrame: entry.frame - segment.startFrame,
|
|
1355
|
-
})),
|
|
1356
|
-
basePath: match ? match.path : null,
|
|
1357
|
-
prompt: segment.prompt,
|
|
1358
|
-
durationS: segmentFrames / FPS,
|
|
1359
|
-
seed: body.seed,
|
|
1360
|
-
cpu: body.cpu,
|
|
1361
|
-
waypoints: body.waypoints,
|
|
1362
|
-
rootMargin: body.rootMargin,
|
|
1363
|
-
contactThreshold: body.contactThreshold,
|
|
1364
|
-
historyFrames: body.historyFrames,
|
|
1365
|
-
preserve: preserveParams,
|
|
1366
|
-
// regenerateSegments calls this once per block and splices the
|
|
1367
|
-
// results into the source take, so no single block's backend npz is
|
|
1368
|
-
// the delivered take; only a whole-clip run keeps a base motion.
|
|
1369
|
-
keepNative: body.regenerateSegments === undefined,
|
|
1370
|
-
output: outputPath,
|
|
1371
|
-
});
|
|
1372
|
-
sendStatus(`[bridge] generating frames ${segment.startFrame}..${segment.endFrame - 1}`);
|
|
1373
|
-
|
|
1374
|
-
const box = spawnTracked(cmd.command, cmd.args, { cwd: REPO, detached: true, env: cmd.env });
|
|
1375
|
-
const last = { stderr: "", stdout: "" };
|
|
1376
|
-
let report = null;
|
|
1377
|
-
let done = null;
|
|
1378
|
-
const code = await runStreaming(box, (line, streamName) => {
|
|
1379
|
-
last[streamName] = line;
|
|
1380
|
-
if (streamName === "stdout") {
|
|
1381
|
-
const parsed = tryParseReport(line);
|
|
1382
|
-
if (parsed) {
|
|
1383
|
-
report = parsed;
|
|
1384
|
-
return;
|
|
1385
|
-
}
|
|
1386
|
-
const marker = cmd.doneRe.exec(line);
|
|
1387
|
-
if (marker) {
|
|
1388
|
-
done = { path: marker[1], bytes: Number(marker[2]) };
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
sendStatus(line);
|
|
1393
|
-
});
|
|
1394
|
-
children.delete(box);
|
|
1395
|
-
if (code !== 0) throw new Error(`${cmd.label} failed (exit ${code}): ${last.stderr || last.stdout || "no output"}`);
|
|
1396
|
-
if (!done) throw new Error(`${cmd.label} exited 0 without a "done" marker`);
|
|
1397
|
-
if (done.path !== outputPath) throw new Error(`${cmd.label} returned unexpected output ${done.path}`);
|
|
1398
|
-
const size = statSync(outputPath).size;
|
|
1399
|
-
if (size === 0 || size !== done.bytes) throw new Error(`${cmd.label} output size mismatch for ${outputPath}`);
|
|
1400
|
-
return report;
|
|
1401
|
-
};
|
|
1402
|
-
|
|
1403
|
-
if (body.regenerateSegments) {
|
|
1404
|
-
const source = await decodeMotionNpz(new Uint8Array(readFileSync(sourceMotionPath)));
|
|
1405
|
-
if (source.frames !== clipFrames || source.fps !== FPS) {
|
|
1406
|
-
throw new Error(`source motion is ${source.frames} frames @ ${source.fps} fps; expected ${clipFrames} @ ${FPS}`);
|
|
1407
|
-
}
|
|
1408
|
-
let result = source;
|
|
1409
|
-
const reports = [];
|
|
1410
|
-
for (let index = 0; index < body.regenerateSegments.length; index += 1) {
|
|
1411
|
-
const segment = body.regenerateSegments[index];
|
|
1412
|
-
const segmentPath = join(artifactDir, `edit-${index}.npz`);
|
|
1413
|
-
const report = await runSingle(segment, segmentPath);
|
|
1414
|
-
const generated = await decodeMotionNpz(new Uint8Array(readFileSync(segmentPath)));
|
|
1415
|
-
const segmentFrames = segment.endFrame - segment.startFrame;
|
|
1416
|
-
if (generated.frames !== segmentFrames || generated.fps !== FPS) {
|
|
1417
|
-
throw new Error(`edited block ${index + 1} returned ${generated.frames} frames @ ${generated.fps} fps`);
|
|
1418
|
-
}
|
|
1419
|
-
result = replaceMotionSegment(result, generated, segment.startFrame);
|
|
1420
|
-
if (report) reports.push({ ...report, startFrame: segment.startFrame, endFrame: segment.endFrame });
|
|
1421
|
-
}
|
|
1422
|
-
writeNpz(outNpzPath, motionArraysToNpzMembers(result));
|
|
1423
|
-
const boundaryJumps = [];
|
|
1424
|
-
for (const segment of body.regenerateSegments) {
|
|
1425
|
-
for (const frame of [segment.startFrame, segment.endFrame]) {
|
|
1426
|
-
if (frame <= 0 || frame >= result.frames) continue;
|
|
1427
|
-
let maxJump = 0;
|
|
1428
|
-
for (let joint = 0; joint < 27; joint += 1) {
|
|
1429
|
-
const current = (frame * 27 + joint) * 3;
|
|
1430
|
-
const previous = current - 27 * 3;
|
|
1431
|
-
maxJump = Math.max(
|
|
1432
|
-
maxJump,
|
|
1433
|
-
Math.hypot(
|
|
1434
|
-
result.posedJoints[current] - result.posedJoints[previous],
|
|
1435
|
-
result.posedJoints[current + 1] - result.posedJoints[previous + 1],
|
|
1436
|
-
result.posedJoints[current + 2] - result.posedJoints[previous + 2]
|
|
1437
|
-
)
|
|
1438
|
-
);
|
|
1439
|
-
}
|
|
1440
|
-
boundaryJumps.push({ frame, max_joint_jump_m: maxJump });
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
send({
|
|
1444
|
-
event: "report",
|
|
1445
|
-
report: {
|
|
1446
|
-
target_space: "skeleton_joint_center",
|
|
1447
|
-
frames: result.frames,
|
|
1448
|
-
fps: result.fps,
|
|
1449
|
-
regenerated_segments: body.regenerateSegments,
|
|
1450
|
-
segments: reports,
|
|
1451
|
-
boundaries: boundaryJumps,
|
|
1452
|
-
},
|
|
1453
|
-
});
|
|
1454
|
-
const finalSize = statSync(outNpzPath).size;
|
|
1455
|
-
registerMotion(stamp, outNpzPath);
|
|
1456
|
-
send({ event: "done", output: outNpzPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
|
|
1457
|
-
res.end();
|
|
1458
|
-
console.log(`[bridge] regenerated ${body.regenerateSegments.length} edited block(s): ${outNpzPath}`);
|
|
1459
|
-
killChildren();
|
|
1460
|
-
finish(200);
|
|
1461
|
-
return;
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
let finalReport;
|
|
1465
|
-
try {
|
|
1466
|
-
finalReport = await runSingle(segments[0]);
|
|
1467
|
-
} catch (error) {
|
|
1468
|
-
// The base take is a different length than this generation window —
|
|
1469
|
-
// Kimodo's preserve prep refuses it outright (there is no principled
|
|
1470
|
-
// resample). The app now avoids sending the pair, but any other client
|
|
1471
|
-
// (MCP, replay, an older app) can still ask; degrade to a plain
|
|
1472
|
-
// generation LOUDLY, the same policy as a missing base motion, instead
|
|
1473
|
-
// of failing a run the operator did ask for.
|
|
1474
|
-
if (!preserveParams || !/Base motion duration does not match/.test(String(error?.message ?? ""))) throw error;
|
|
1475
|
-
const skipped =
|
|
1476
|
-
"[bridge] preserve SKIPPED: the take being preserved is a different length than this " +
|
|
1477
|
-
"generation window; generating WITHOUT scheduled inpainting";
|
|
1478
|
-
console.error(skipped);
|
|
1479
|
-
sendStatus(skipped);
|
|
1480
|
-
preserveParams = null;
|
|
1481
|
-
finalReport = await runSingle(segments[0]);
|
|
1482
|
-
}
|
|
1483
|
-
if (finalReport) {
|
|
1484
|
-
const poseResults = finalReport.poses || [];
|
|
1485
|
-
const worst = (key) => poseResults.length ? Math.max(...poseResults.map((pose) => pose[key] ?? 0)) : null;
|
|
1486
|
-
finalReport.root_error_m = worst("root_error_m");
|
|
1487
|
-
finalReport.shape_mean_error_m = worst("shape_mean_error_m");
|
|
1488
|
-
finalReport.shape_max_error_m = worst("shape_max_error_m");
|
|
1489
|
-
finalReport.base_root_error_m = worst("base_root_error_m");
|
|
1490
|
-
finalReport.base_shape_mean_error_m = worst("base_shape_mean_error_m");
|
|
1491
|
-
finalReport.base_shape_max_error_m = worst("base_shape_max_error_m");
|
|
1492
|
-
}
|
|
1493
|
-
// Same order as every other branch: generate, verify, replay, report,
|
|
1494
|
-
// register, done. runSingle already checked the npz's size against the
|
|
1495
|
-
// generator's own "done" marker, so the file the replay reads is real.
|
|
1496
|
-
const deliveredPath = await applyReplay(outNpzPath);
|
|
1497
|
-
sendReport(finalReport);
|
|
1498
|
-
const finalSize = statSync(deliveredPath).size;
|
|
1499
|
-
registerMotion(stamp, deliveredPath);
|
|
1500
|
-
send({ event: "done", output: deliveredPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
|
|
1501
|
-
res.end();
|
|
1502
|
-
console.log(`[bridge] generate finished: ${deliveredPath} (base ${baseLabel}, ${body.duration}s, dstFrame ${dstFrameLabel})`);
|
|
1503
|
-
|
|
1504
|
-
killChildren();
|
|
1505
|
-
finish(200);
|
|
1506
|
-
} catch (err) {
|
|
1507
|
-
killChildren();
|
|
1508
|
-
cleanupArtifacts();
|
|
1509
|
-
if (!res.writableEnded) sendError(`generate failed: ${err.message}`);
|
|
1510
|
-
// A run whose client already hung up dies of collateral damage — its
|
|
1511
|
-
// artifact dir was just removed underneath it (see the close handler), so
|
|
1512
|
-
// the ENOENT that follows is the abort working, not a generation failure.
|
|
1513
|
-
if (clientGone) console.error(`[bridge] generate aborted by client disconnect (${err.message.split("\n")[0]})`);
|
|
1514
|
-
else console.error(`[bridge] generate error: ${err.stack || err}`);
|
|
1515
|
-
finish(200);
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
// server
|
|
1520
|
-
|
|
1521
|
-
function usage() {
|
|
1522
|
-
console.log(`usage: node tools/ardy/bridge.mjs [--port <n>]
|
|
1523
|
-
|
|
1524
|
-
Dev-only HTTP sidecar for motion generation. See tools/ardy/BRIDGE.md.
|
|
1525
|
-
|
|
1526
|
-
--port <n> port to listen on (default 5181, loopback only)
|
|
1527
|
-
|
|
1528
|
-
env:
|
|
1529
|
-
COZYCLAY_BRIDGE_PORT same as --port (default 5181)
|
|
1530
|
-
CCLAY_MOTION_BACKEND only "kimodo" is supported (default)
|
|
1531
|
-
CCLAY_KIMODO_HOST ssh destination for the Kimodo host
|
|
1532
|
-
CCLAY_KIMODO_REPO Kimodo checkout on the host (default $HOME/kimodo)
|
|
1533
|
-
CCLAY_KIMODO_MODEL model id (default Kimodo-SOMA-RP-v1.1)
|
|
1534
|
-
|
|
1535
|
-
line editing (contract C6) runs on the ProjFlow backend regardless of
|
|
1536
|
-
CCLAY_MOTION_BACKEND, and advertises itself in /ardy/health as
|
|
1537
|
-
capabilities.lineEdit once the box probes clean:
|
|
1538
|
-
CCLAY_PROJFLOW_HOST ssh destination (defaults to CCLAY_KIMODO_HOST)
|
|
1539
|
-
CCLAY_PROJFLOW_REPO ProjFlow checkout (default /home/yun/projflow-scout/repo)
|
|
1540
|
-
CCLAY_PROJFLOW_PYTHON the scout venv's python
|
|
1541
|
-
CCLAY_PROJFLOW_HOME HOME override for box runs
|
|
1542
|
-
`);
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
function die(message) {
|
|
1546
|
-
console.error(`[bridge] ${message}`);
|
|
1547
|
-
process.exit(2);
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
|
-
function resolvePort(argv) {
|
|
1551
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
1552
|
-
if (argv[i] === "--help" || argv[i] === "-h") {
|
|
1553
|
-
usage();
|
|
1554
|
-
process.exit(0);
|
|
1555
|
-
}
|
|
1556
|
-
if (argv[i] === "--port") {
|
|
1557
|
-
const value = argv[i + 1];
|
|
1558
|
-
if (value === undefined) die("--port needs a value");
|
|
1559
|
-
const port = Number(value);
|
|
1560
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) die(`invalid --port value '${value}'`);
|
|
1561
|
-
return port;
|
|
1562
|
-
}
|
|
1563
|
-
}
|
|
1564
|
-
if (process.env.COZYCLAY_BRIDGE_PORT !== undefined) {
|
|
1565
|
-
const port = Number(process.env.COZYCLAY_BRIDGE_PORT);
|
|
1566
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
1567
|
-
die(`invalid COZYCLAY_BRIDGE_PORT value '${process.env.COZYCLAY_BRIDGE_PORT}'`);
|
|
1568
|
-
}
|
|
1569
|
-
return port;
|
|
1570
|
-
}
|
|
1571
|
-
return DEFAULT_PORT;
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
// Importing the module (tests) must not bind a port or pick a runner.
|
|
1575
|
-
function startBridge() {
|
|
1576
|
-
const port = resolvePort(process.argv.slice(2));
|
|
1577
|
-
try {
|
|
1578
|
-
runner = createRunner();
|
|
1579
|
-
} catch (err) {
|
|
1580
|
-
die(err.message);
|
|
1581
|
-
}
|
|
1582
|
-
// Beside the selected backend, never instead of it. A missing ProjFlow
|
|
1583
|
-
// configuration is NOT fatal — the bridge's other run modes do not need it —
|
|
1584
|
-
// so the failure is reported once at startup and the capability stays off.
|
|
1585
|
-
try {
|
|
1586
|
-
projflowRunner = createProjflowRunner();
|
|
1587
|
-
} catch (err) {
|
|
1588
|
-
console.error(`[bridge] line editing is unavailable: ${err.message}`);
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
const server = createServer((req, res) => {
|
|
1592
|
-
const started = Date.now();
|
|
1593
|
-
const pathname = (req.url || "/").split("?")[0];
|
|
1594
|
-
const log = (status) => {
|
|
1595
|
-
console.log(`[bridge] ${req.method} ${pathname} -> ${status} (${Date.now() - started} ms)`);
|
|
1596
|
-
};
|
|
1597
|
-
|
|
1598
|
-
if (req.method === "OPTIONS") {
|
|
1599
|
-
// 204 with NO CORS headers on purpose: a cross-origin browser
|
|
1600
|
-
// preflight must fail, and the same-origin Vite proxy never
|
|
1601
|
-
// preflights the bridge (it forwards server-side).
|
|
1602
|
-
res.writeHead(204);
|
|
1603
|
-
res.end();
|
|
1604
|
-
log(204);
|
|
1605
|
-
return;
|
|
1606
|
-
}
|
|
1607
|
-
if (pathname === "/ardy/health" && req.method === "GET") {
|
|
1608
|
-
// `capabilities` is how the app learns that an OPTIONAL run mode is wired
|
|
1609
|
-
// on this box. The draw-a-line affordance is gated on capabilities.lineEdit
|
|
1610
|
-
// (App.jsx accepts the object or an array spelling), so a bridge whose
|
|
1611
|
-
// ProjFlow env is missing keeps the feature dark instead of letting the
|
|
1612
|
-
// artist draw a stroke that would 503. Probed, never assumed.
|
|
1613
|
-
Promise.all([getHealth(), getLineEditCapability()])
|
|
1614
|
-
.then(([value, lineEdit]) => {
|
|
1615
|
-
sendJson(res, 200, {
|
|
1616
|
-
...value,
|
|
1617
|
-
extractionBackend: EXTRACT_BACKEND_SUPPORTED ? EXTRACT_BACKEND : "unsupported",
|
|
1618
|
-
capabilities: { lineEdit, extractionBackend: EXTRACT_BACKEND_SUPPORTED ? EXTRACT_BACKEND : "unsupported" },
|
|
1619
|
-
});
|
|
1620
|
-
log(200);
|
|
1621
|
-
})
|
|
1622
|
-
.catch((err) => {
|
|
1623
|
-
// The selected Kimodo route is configured even when its probe fails;
|
|
1624
|
-
// expose only the safe backend bucket and boolean configuration state.
|
|
1625
|
-
sendJson(res, 503, {
|
|
1626
|
-
ok: false,
|
|
1627
|
-
backend: "local_kimodo",
|
|
1628
|
-
host_configured: Boolean(process.env.CCLAY_KIMODO_HOST?.trim()),
|
|
1629
|
-
reason: err.message,
|
|
1630
|
-
});
|
|
1631
|
-
log(503);
|
|
1632
|
-
});
|
|
1633
|
-
return;
|
|
1634
|
-
}
|
|
1635
|
-
if (pathname === "/ardy/bases" && req.method === "GET") {
|
|
1636
|
-
getBases()
|
|
1637
|
-
.then((value) => {
|
|
1638
|
-
sendJson(res, 200, value);
|
|
1639
|
-
log(200);
|
|
1640
|
-
})
|
|
1641
|
-
.catch((err) => {
|
|
1642
|
-
sendJson(res, 503, { ok: false, reason: err.message });
|
|
1643
|
-
log(503);
|
|
1644
|
-
});
|
|
1645
|
-
return;
|
|
1646
|
-
}
|
|
1647
|
-
if (pathname === "/ardy/generate" && req.method === "POST") {
|
|
1648
|
-
handleGenerate(req, res).catch((err) => respondInternalError(res, err, `${req.method} ${pathname}`));
|
|
1649
|
-
return;
|
|
1650
|
-
}
|
|
1651
|
-
if (/^\/ardy\/motions\//.test(pathname) && req.method === "GET") {
|
|
1652
|
-
log(serveMotion(req, res, pathname));
|
|
1653
|
-
return;
|
|
1654
|
-
}
|
|
1655
|
-
if (pathname === "/ardy/footage" && req.method === "POST") {
|
|
1656
|
-
handleFootage(req, res, (request) => readBody(request, MAX_BODY_BYTES)).catch((err) =>
|
|
1657
|
-
respondInternalError(res, err, `${req.method} ${pathname}`),
|
|
1658
|
-
);
|
|
1659
|
-
return;
|
|
1660
|
-
}
|
|
1661
|
-
if (/^\/ardy\/footage\//.test(pathname) && req.method === "GET") {
|
|
1662
|
-
log(serveFootage(req, res, pathname));
|
|
1663
|
-
return;
|
|
1664
|
-
}
|
|
1665
|
-
if (pathname === "/ardy/extract" && req.method === "POST") {
|
|
1666
|
-
handleExtract(req, res, {
|
|
1667
|
-
readBody: (request) => readBody(request, MAX_BODY_BYTES),
|
|
1668
|
-
footagePath,
|
|
1669
|
-
registerMotion,
|
|
1670
|
-
artifactRoot: OUT_DIR,
|
|
1671
|
-
}).catch((err) => respondInternalError(res, err, `${req.method} ${pathname}`));
|
|
1672
|
-
return;
|
|
1673
|
-
}
|
|
1674
|
-
if (
|
|
1675
|
-
pathname === "/ardy/health" ||
|
|
1676
|
-
pathname === "/ardy/bases" ||
|
|
1677
|
-
pathname === "/ardy/generate" ||
|
|
1678
|
-
pathname === "/ardy/footage" ||
|
|
1679
|
-
pathname === "/ardy/extract" ||
|
|
1680
|
-
/^\/ardy\/motions\//.test(pathname) ||
|
|
1681
|
-
/^\/ardy\/footage\//.test(pathname)
|
|
1682
|
-
) {
|
|
1683
|
-
sendJson(res, 405, { ok: false, reason: `method ${req.method} not allowed on ${pathname}` });
|
|
1684
|
-
log(405);
|
|
1685
|
-
return;
|
|
1686
|
-
}
|
|
1687
|
-
sendJson(res, 404, { ok: false, reason: `not found: ${req.method} ${pathname}` });
|
|
1688
|
-
log(404);
|
|
1689
|
-
});
|
|
1690
|
-
|
|
1691
|
-
server.on("clientError", (err, socket) => {
|
|
1692
|
-
if (socket.writable) {
|
|
1693
|
-
socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
1694
|
-
}
|
|
1695
|
-
console.error(`[bridge] client error: ${err.message}`);
|
|
1696
|
-
});
|
|
1697
|
-
|
|
1698
|
-
server.listen(port, BIND_HOST, () => {
|
|
1699
|
-
const address = server.address();
|
|
1700
|
-
const boundPort = typeof address === "object" && address ? address.port : port;
|
|
1701
|
-
process.send?.({ type: "cozyclay-bridge-ready", port: boundPort });
|
|
1702
|
-
console.log(`[bridge] motion dev bridge listening on http://${BIND_HOST}:${boundPort}`);
|
|
1703
|
-
console.log("[bridge] dev-only sidecar: the static dist/ build does not need it; stop with Ctrl-C");
|
|
1704
|
-
console.log(`[bridge] ${runner.mode} backend: ${runner.describe()}`);
|
|
1705
|
-
});
|
|
1706
|
-
|
|
1707
|
-
server.on("error", (err) => {
|
|
1708
|
-
const override = "set COZYCLAY_BRIDGE_PORT to a free port or pass --port <n>";
|
|
1709
|
-
console.error(
|
|
1710
|
-
`[bridge] cannot listen on ${BIND_HOST}:${port}: ${err.message}` +
|
|
1711
|
-
(err?.code === "EADDRINUSE" ? `; ${override}` : ""),
|
|
1712
|
-
);
|
|
1713
|
-
if (process.send && process.connected) {
|
|
1714
|
-
process.send({ type: "cozyclay-bridge-listen-error", port, code: err?.code }, () => process.exit(1));
|
|
1715
|
-
} else {
|
|
1716
|
-
process.exit(1);
|
|
1717
|
-
}
|
|
1718
|
-
});
|
|
1719
|
-
|
|
1720
|
-
// Ctrl-C / SIGTERM: take the in-flight process groups down with us; ssh dies,
|
|
1721
|
-
// sshd closes the session, and the remote generation is not orphaned.
|
|
1722
|
-
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
1723
|
-
process.on(signal, () => {
|
|
1724
|
-
console.error(`[bridge] received ${signal}; killing ${globalChildren.size} in-flight child process group(s)`);
|
|
1725
|
-
for (const child of globalChildren) killGroup(child);
|
|
1726
|
-
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
1727
|
-
});
|
|
1728
|
-
}
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
const isEntry = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
1732
|
-
if (isEntry) startBridge();
|