hyperstudy-perception 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. hyperstudy_perception-0.1.0/LICENSE +21 -0
  2. hyperstudy_perception-0.1.0/PKG-INFO +53 -0
  3. hyperstudy_perception-0.1.0/README.md +19 -0
  4. hyperstudy_perception-0.1.0/pyproject.toml +64 -0
  5. hyperstudy_perception-0.1.0/src/perception/__init__.py +0 -0
  6. hyperstudy_perception-0.1.0/src/perception/_pipeline/RELEASE.json +59 -0
  7. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/runpod/guard.sh +37 -0
  8. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
  9. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/runpod/image/start.sh +11 -0
  10. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/runpod/image/tag.py +40 -0
  11. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
  12. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
  13. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/skypilot/guard.sh +20 -0
  14. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
  15. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/skypilot/launch.sh +63 -0
  16. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
  17. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
  18. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
  19. hyperstudy_perception-0.1.0/src/perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
  20. hyperstudy_perception-0.1.0/src/perception/_pipeline/envs/api/pyproject.toml +12 -0
  21. hyperstudy_perception-0.1.0/src/perception/_pipeline/envs/api/uv.lock +276 -0
  22. hyperstudy_perception-0.1.0/src/perception/_pipeline/envs/audio/pyproject.toml +12 -0
  23. hyperstudy_perception-0.1.0/src/perception/_pipeline/envs/audio/uv.lock +575 -0
  24. hyperstudy_perception-0.1.0/src/perception/_pipeline/envs/core/pyproject.toml +12 -0
  25. hyperstudy_perception-0.1.0/src/perception/_pipeline/envs/core/uv.lock +157 -0
  26. hyperstudy_perception-0.1.0/src/perception/_pipeline/envs/faces/pyproject.toml +12 -0
  27. hyperstudy_perception-0.1.0/src/perception/_pipeline/envs/faces/uv.lock +1180 -0
  28. hyperstudy_perception-0.1.0/src/perception/_pipeline/graphs/decode.yaml +11 -0
  29. hyperstudy_perception-0.1.0/src/perception/_pipeline/graphs/faces.yaml +14 -0
  30. hyperstudy_perception-0.1.0/src/perception/_pipeline/graphs/full.yaml +31 -0
  31. hyperstudy_perception-0.1.0/src/perception/_pipeline/graphs/mvp-captions.yaml +27 -0
  32. hyperstudy_perception-0.1.0/src/perception/_pipeline/graphs/mvp.yaml +17 -0
  33. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
  34. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/audio/asr/node.py +74 -0
  35. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
  36. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
  37. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/audio/asr/wav.py +19 -0
  38. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/conftest.py +64 -0
  39. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
  40. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/core/decode/media.py +104 -0
  41. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/core/decode/node.py +56 -0
  42. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/core/decode/test_media.py +65 -0
  43. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/testdata/README.md +10 -0
  44. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
  45. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
  46. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
  47. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
  48. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vision/face-track/node.py +117 -0
  49. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
  50. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
  51. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
  52. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
  53. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
  54. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
  55. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
  56. hyperstudy_perception-0.1.0/src/perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
  57. hyperstudy_perception-0.1.0/src/perception/_pipeline/schema/README.md +54 -0
  58. hyperstudy_perception-0.1.0/src/perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
  59. hyperstudy_perception-0.1.0/src/perception/_pipeline/sdk/pyproject.toml +13 -0
  60. hyperstudy_perception-0.1.0/src/perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
  61. hyperstudy_perception-0.1.0/src/perception/artifacts.py +180 -0
  62. hyperstudy_perception-0.1.0/src/perception/backends/__init__.py +0 -0
  63. hyperstudy_perception-0.1.0/src/perception/backends/base.py +179 -0
  64. hyperstudy_perception-0.1.0/src/perception/backends/cloud.py +214 -0
  65. hyperstudy_perception-0.1.0/src/perception/backends/native.py +140 -0
  66. hyperstudy_perception-0.1.0/src/perception/backends/probe.py +44 -0
  67. hyperstudy_perception-0.1.0/src/perception/backends/remote.py +140 -0
  68. hyperstudy_perception-0.1.0/src/perception/cli.py +780 -0
  69. hyperstudy_perception-0.1.0/src/perception/estimates.py +68 -0
  70. hyperstudy_perception-0.1.0/src/perception/export.py +100 -0
  71. hyperstudy_perception-0.1.0/src/perception/hosts.py +201 -0
  72. hyperstudy_perception-0.1.0/src/perception/ir.py +245 -0
  73. hyperstudy_perception-0.1.0/src/perception/keys.py +259 -0
  74. hyperstudy_perception-0.1.0/src/perception/manifests.py +169 -0
  75. hyperstudy_perception-0.1.0/src/perception/plan.py +170 -0
  76. hyperstudy_perception-0.1.0/src/perception/release.py +61 -0
  77. hyperstudy_perception-0.1.0/src/perception/remote_driver.py +990 -0
  78. hyperstudy_perception-0.1.0/src/perception/runpod.py +100 -0
  79. hyperstudy_perception-0.1.0/src/perception/scheduler.py +552 -0
  80. hyperstudy_perception-0.1.0/src/perception/setup.py +72 -0
  81. hyperstudy_perception-0.1.0/src/perception/store.py +219 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luke Chang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.5
2
+ Name: hyperstudy-perception
3
+ Version: 0.1.0
4
+ Summary: Video perception pipeline: shots, speech, faces and captions from a video, as a versioned track bundle, run locally, on cloud GPUs or lab servers
5
+ Project-URL: Homepage, https://github.com/hyperstudyio/hyperstudy-perception
6
+ Project-URL: Changelog, https://github.com/hyperstudyio/hyperstudy-perception/releases
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: annotated-types==0.8.0
11
+ Requires-Dist: attrs==26.1.0
12
+ Requires-Dist: av==16.1.0
13
+ Requires-Dist: cffi==2.1.1; platform_python_implementation != 'PyPy' and sys_platform == 'linux'
14
+ Requires-Dist: cryptography==50.0.2; sys_platform == 'linux'
15
+ Requires-Dist: jaraco-classes==3.4.0
16
+ Requires-Dist: jaraco-context==6.1.2
17
+ Requires-Dist: jaraco-functools==4.6.0
18
+ Requires-Dist: jeepney==0.9.0; sys_platform == 'linux'
19
+ Requires-Dist: jsonschema-specifications==2025.9.1
20
+ Requires-Dist: jsonschema==4.26.0
21
+ Requires-Dist: keyring==25.7.0
22
+ Requires-Dist: more-itertools==11.2.1
23
+ Requires-Dist: pycparser==3.12; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'
24
+ Requires-Dist: pydantic-core==2.50.1
25
+ Requires-Dist: pydantic==2.14.1
26
+ Requires-Dist: pywin32-ctypes==0.2.3; sys_platform == 'win32'
27
+ Requires-Dist: pyyaml==6.0.3
28
+ Requires-Dist: referencing==0.37.0
29
+ Requires-Dist: rpds-py==2026.9.1
30
+ Requires-Dist: secretstorage==3.5.0; sys_platform == 'linux'
31
+ Requires-Dist: typing-extensions==4.16.0
32
+ Requires-Dist: typing-inspection==0.4.4
33
+ Description-Content-Type: text/markdown
34
+
35
+ # hyperstudy-perception
36
+
37
+ Turn a video into a time-indexed **perception track**: shots and keyframes, a word-timed transcript, face tracks
38
+ clustered into people, and shot captions. The result is a versioned track bundle that the
39
+ [HyperStudy Annotator](https://github.com/hyperstudyio/hyperstudy-annotator) imports for review and correction.
40
+
41
+ ```sh
42
+ uv tool install hyperstudy-perception
43
+ perception doctor # what can run on this machine
44
+ perception graphs # the graphs it can run, by name
45
+ perception run full.yaml --input video=clip.mp4 --only asr,faces --allow-downloads
46
+ ```
47
+
48
+ Each step runs in its own locked environment, made by [uv](https://docs.astral.sh/uv/) the first time it is needed
49
+ (`perception setup` makes them all ahead of time), under `~/.hyperstudy-perception`. Nothing else is needed on the
50
+ machine: video is read with PyAV, whose wheels carry FFmpeg.
51
+
52
+ Model weights are downloaded at run time under their own licences; several (py-feat, ArcFace) are non-commercial, and
53
+ each bundle records the most restrictive licence among its layers.
@@ -0,0 +1,19 @@
1
+ # hyperstudy-perception
2
+
3
+ Turn a video into a time-indexed **perception track**: shots and keyframes, a word-timed transcript, face tracks
4
+ clustered into people, and shot captions. The result is a versioned track bundle that the
5
+ [HyperStudy Annotator](https://github.com/hyperstudyio/hyperstudy-annotator) imports for review and correction.
6
+
7
+ ```sh
8
+ uv tool install hyperstudy-perception
9
+ perception doctor # what can run on this machine
10
+ perception graphs # the graphs it can run, by name
11
+ perception run full.yaml --input video=clip.mp4 --only asr,faces --allow-downloads
12
+ ```
13
+
14
+ Each step runs in its own locked environment, made by [uv](https://docs.astral.sh/uv/) the first time it is needed
15
+ (`perception setup` makes them all ahead of time), under `~/.hyperstudy-perception`. Nothing else is needed on the
16
+ machine: video is read with PyAV, whose wheels carry FFmpeg.
17
+
18
+ Model weights are downloaded at run time under their own licences; several (py-feat, ArcFace) are non-commercial, and
19
+ each bundle records the most restrictive licence among its layers.
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "hyperstudy-perception"
3
+ version = "0.1.0"
4
+ description = "Video perception pipeline: shots, speech, faces and captions from a video, as a versioned track bundle, run locally, on cloud GPUs or lab servers"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"] # the repository's, copied in by scripts/build-dist.sh
8
+ requires-python = ">=3.12"
9
+ dependencies = [
10
+ "annotated-types==0.8.0",
11
+ "attrs==26.1.0",
12
+ "av==16.1.0",
13
+ "cffi==2.1.1 ; platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
14
+ "cryptography==50.0.2 ; sys_platform == 'linux'",
15
+ "jaraco-classes==3.4.0",
16
+ "jaraco-context==6.1.2",
17
+ "jaraco-functools==4.6.0",
18
+ "jeepney==0.9.0 ; sys_platform == 'linux'",
19
+ "jsonschema==4.26.0",
20
+ "jsonschema-specifications==2025.9.1",
21
+ "keyring==25.7.0",
22
+ "more-itertools==11.2.1",
23
+ "pycparser==3.12 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
24
+ "pydantic==2.14.1",
25
+ "pydantic-core==2.50.1",
26
+ "pywin32-ctypes==0.2.3 ; sys_platform == 'win32'",
27
+ "pyyaml==6.0.3",
28
+ "referencing==0.37.0",
29
+ "rpds-py==2026.9.1",
30
+ "secretstorage==3.5.0 ; sys_platform == 'linux'",
31
+ "typing-extensions==4.16.0",
32
+ "typing-inspection==0.4.4",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/hyperstudyio/hyperstudy-perception"
37
+ Changelog = "https://github.com/hyperstudyio/hyperstudy-perception/releases"
38
+
39
+ [project.scripts]
40
+ perception = "perception.cli:main"
41
+
42
+ [tool.uv.sources]
43
+ perception-node = { path = "../sdk", editable = true }
44
+
45
+ [dependency-groups]
46
+ # the node SDK only for the tests' stand-in nodes, which run in this interpreter: real nodes get it in their own
47
+ # environments (envs/*), from the copy the package carries
48
+ dev = ["pytest>=8", "perception-node"]
49
+
50
+ [build-system]
51
+ requires = ["hatchling"]
52
+ build-backend = "hatchling.build"
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["src/perception"]
56
+ # the pipeline's files the package carries (scripts/build-dist.sh puts them there; ignored by git, so named here)
57
+ artifacts = ["src/perception/_pipeline/**"]
58
+
59
+ [tool.hatch.build.targets.sdist]
60
+ include = ["src/perception", "README.md"]
61
+ artifacts = ["src/perception/_pipeline/**"]
62
+
63
+ [tool.pytest.ini_options]
64
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,59 @@
1
+ {
2
+ "commit": "9beebed7f711b6c85e2a735810db16841a741f39",
3
+ "files": [
4
+ "cloud/runpod/guard.sh",
5
+ "cloud/runpod/image/Dockerfile",
6
+ "cloud/runpod/image/start.sh",
7
+ "cloud/runpod/image/tag.py",
8
+ "cloud/skypilot/gpu-faces.yaml",
9
+ "cloud/skypilot/gpu-tune.yaml",
10
+ "cloud/skypilot/guard.sh",
11
+ "cloud/skypilot/iam-perception-node.json",
12
+ "cloud/skypilot/launch.sh",
13
+ "cloud/skypilot/node-gpu.yaml",
14
+ "cloud/skypilot/node-setup.sh",
15
+ "cloud/skypilot/sky.yaml",
16
+ "cloud/skypilot/smoke.yaml",
17
+ "envs/api/pyproject.toml",
18
+ "envs/api/uv.lock",
19
+ "envs/audio/pyproject.toml",
20
+ "envs/audio/uv.lock",
21
+ "envs/core/pyproject.toml",
22
+ "envs/core/uv.lock",
23
+ "envs/faces/pyproject.toml",
24
+ "envs/faces/uv.lock",
25
+ "graphs/decode.yaml",
26
+ "graphs/faces.yaml",
27
+ "graphs/full.yaml",
28
+ "graphs/mvp-captions.yaml",
29
+ "graphs/mvp.yaml",
30
+ "nodes/audio/asr/manifest.yaml",
31
+ "nodes/audio/asr/node.py",
32
+ "nodes/audio/asr/prepare.py",
33
+ "nodes/audio/asr/test_wav.py",
34
+ "nodes/audio/asr/wav.py",
35
+ "nodes/conftest.py",
36
+ "nodes/core/decode/manifest.yaml",
37
+ "nodes/core/decode/media.py",
38
+ "nodes/core/decode/node.py",
39
+ "nodes/core/decode/test_media.py",
40
+ "nodes/testdata/README.md",
41
+ "nodes/testdata/rotated-90.mp4",
42
+ "nodes/vision/face-track/frames.py",
43
+ "nodes/vision/face-track/loader.py",
44
+ "nodes/vision/face-track/manifest.yaml",
45
+ "nodes/vision/face-track/node.py",
46
+ "nodes/vision/face-track/prepare.py",
47
+ "nodes/vision/face-track/test_frames.py",
48
+ "nodes/vision/face-track/test_loader.py",
49
+ "nodes/vision/face-track/test_thumbnails.py",
50
+ "nodes/vision/face-track/thumbnails.py",
51
+ "nodes/vision/face-track/tracking.py",
52
+ "nodes/vlm/shot-captions/manifest.yaml",
53
+ "nodes/vlm/shot-captions/node.py",
54
+ "schema/README.md",
55
+ "schema/bundle-0.1.schema.json",
56
+ "sdk/pyproject.toml",
57
+ "sdk/src/perception_node/__init__.py"
58
+ ]
59
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # First step on every RunPod pod, before anything is sent (remote_driver.py runs it over ssh):
3
+ # bash -s MIN_DRIVER < cloud/runpod/guard.sh (the oldest NVIDIA driver the nodes' torch runs on, e.g. 580)
4
+ # 1. The pod's removal at its deadline is running: its start command set it (runpodctl, with the pod's own key), the
5
+ # backstop if the laptop never comes back to delete it. That key is scoped to the pod: it removes the pod, and can
6
+ # neither list the account's pods nor remove another (both checked on RunPod, 2026-10-25).
7
+ # 2. No key to the account on the pod: the pod's own key is refused by the API, and there is no RunPod or AWS config.
8
+ set -euo pipefail
9
+ min_driver=$1
10
+ environ=${PERCEPTION_POD_ENVIRON:-/proc/1/environ} # the container's own variables (ssh sessions do not inherit them)
11
+ var() { tr '\0' '\n' < "$environ" | sed -n "s/^$1=//p"; }
12
+ pod=$(var RUNPOD_POD_ID)
13
+ [ -n "$pod" ] || { echo "guard: no RUNPOD_POD_ID in the pod's environment: not a RunPod pod" >&2; exit 1; }
14
+ command -v runpodctl > /dev/null || { echo "guard: runpodctl is missing: the pod could not remove itself at its deadline" >&2; exit 1; }
15
+ for f in ~/.runpod/config.toml ~/.runpod/config.yaml ~/.aws/credentials ~/.aws/config; do
16
+ [ ! -e "$f" ] || { echo "guard: $f exists on the pod: a key to an account must never be there" >&2; exit 1; }
17
+ done
18
+ # its own key: what removes it at the deadline
19
+ [ -n "$(var RUNPOD_API_KEY)" ] || { echo "guard: no RUNPOD_API_KEY in the pod's environment: it could not remove itself at its deadline" >&2; exit 1; }
20
+ # the pod's key must not reach the account: RunPod answers 401 or 403 to it for the account's pods. Exit 75: the check could
21
+ # not be made (no answer, RunPod's own trouble), which says nothing about the key (the driver tries another pod)
22
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 -A hyperstudy-perception \
23
+ -H "Authorization: Bearer $(var RUNPOD_API_KEY)" "${PERCEPTION_RUNPOD_API:-https://rest.runpod.io/v1}/pods" || true)
24
+ case "$code" in
25
+ 401|403) ;;
26
+ 2??) echo "guard: the pod's own key got $code listing the account's pods (401 or 403 expected): not used" >&2; exit 1 ;;
27
+ *) echo "guard: could not check the pod's own key (RunPod answered ${code:-nothing}); another pod is tried" >&2; exit 75 ;;
28
+ esac
29
+ # its removal at the deadline, which its start command set before anything else ran (remote_driver.removal_at_boot)
30
+ pgrep -f perception-deadline > /dev/null || { echo "guard: the pod's removal at its deadline is not running" >&2; exit 1; }
31
+ # a driver new enough for the nodes' CUDA, checked here only (RunPod's allowedCudaVersions is not used: see
32
+ # remote_driver.runpod_launch): exit 75, another pod may have one
33
+ driver=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2> /dev/null | head -1 || true)
34
+ [ "${driver%%.*}" -ge "$min_driver" ] 2> /dev/null || {
35
+ echo "guard: the pod's NVIDIA driver is ${driver:-not known}, older than $min_driver (the nodes' CUDA); another pod is tried" >&2; exit 75; }
36
+ gpu=$(nvidia-smi --query-gpu=name --format=csv,noheader 2> /dev/null | head -1 || true)
37
+ echo "guard: ${gpu:-no GPU seen} (driver $driver) in $(var RUNPOD_DC_ID); no key to the account; its removal at its deadline is running"
@@ -0,0 +1,39 @@
1
+ # The RunPod image for perception's nodes (RunPodBackend.image): software only, no repository code, weights or data
2
+ # (it is public). Built by .github/workflows/runpod-image.yml from the context tag.py stages (these two files).
3
+ #
4
+ # On RunPod's own PyTorch image, which its hosts keep cached: our image's start then pulls only the small layers below
5
+ # (measured: a 6.8 GB image of our own took 145 s to start, the cached one 12-32 s). The nodes' environments are not in
6
+ # it: uv fetches them from PyPI in the datacenter faster than a host pulls them from GHCR. Its Python and torch are
7
+ # not used (uv brings each node's own).
8
+ FROM runpod/pytorch:2.1.0-py3.10-cuda11.8.0-devel-ubuntu22.04@sha256:2594f25beeef751aeebcc207ba4c45a382ba174655f01e15cbbfad0e2bdb5ef8
9
+ ENV DEBIAN_FRONTEND=noninteractive
10
+
11
+ # what nodes and the driver use (setup then installs nothing); sshd takes keys only. Each pod makes its own host keys
12
+ # at start (start.sh): the ones in the base image are public, and are never used
13
+ RUN apt-get update \
14
+ && apt-get install -y --no-install-recommends ffmpeg rsync \
15
+ && rm -rf /var/lib/apt/lists/* /etc/ssh/ssh_host_* \
16
+ && mkdir -p /run/sshd /etc/ssh/sshd_config.d \
17
+ && printf 'PermitRootLogin prohibit-password\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n' \
18
+ > /etc/ssh/sshd_config.d/perception.conf \
19
+ && { grep -q '^Include /etc/ssh/sshd_config.d' /etc/ssh/sshd_config || sed -i '1i Include /etc/ssh/sshd_config.d/*.conf' /etc/ssh/sshd_config; }
20
+
21
+ # runpodctl 3, for the pod's removal at its deadline with its own key (remote_driver.removal_at_boot)
22
+ ARG RUNPODCTL=3.0.1
23
+ ARG RUNPODCTL_SHA256=b66d11eca8cacbf18a2de6943c6e18a79ffbdc8a3c30aa13a49dc6cfc15d4239
24
+ RUN curl -fsSL -o /usr/local/bin/runpodctl https://github.com/runpod/runpodctl/releases/download/v${RUNPODCTL}/runpodctl-linux-amd64 \
25
+ && echo "${RUNPODCTL_SHA256} /usr/local/bin/runpodctl" | sha256sum -c - \
26
+ && chmod 755 /usr/local/bin/runpodctl
27
+
28
+ # uv, the version the annotator app bundles, where node-setup.sh puts it
29
+ ARG UV=0.13.5
30
+ ARG UV_SHA256=f68d291c75783122dad58ab77def0e13f3233f358731db5d6d7e8715603b928f
31
+ RUN curl -fsSL -o /tmp/uv.tgz https://github.com/astral-sh/uv/releases/download/${UV}/uv-x86_64-unknown-linux-gnu.tar.gz \
32
+ && echo "${UV_SHA256} /tmp/uv.tgz" | sha256sum -c - \
33
+ && mkdir -p /root/.perception-uv \
34
+ && tar -xzf /tmp/uv.tgz -C /root/.perception-uv --strip-components=1 \
35
+ && rm /tmp/uv.tgz \
36
+ && /root/.perception-uv/uv --version
37
+
38
+ COPY start.sh /perception-start.sh
39
+ CMD ["/perception-start.sh"]
@@ -0,0 +1,11 @@
1
+ #!/bin/bash
2
+ # A pod's start (the image's CMD; remote_driver.removal_at_boot runs it after setting the pod's removal): ssh host keys
3
+ # of its own, the key the driver made for it (PUBLIC_KEY, from RunPod), and sshd. Nothing else runs until the driver
4
+ # connects.
5
+ set -eu
6
+ ssh-keygen -A
7
+ mkdir -p /root/.ssh
8
+ chmod 700 /root/.ssh
9
+ printf '%s\n' "${PUBLIC_KEY:?the pod was given no PUBLIC_KEY}" > /root/.ssh/authorized_keys
10
+ chmod 600 /root/.ssh/authorized_keys
11
+ exec /usr/sbin/sshd -D -e
@@ -0,0 +1,40 @@
1
+ """The RunPod image's tag, from everything that goes into it (its Dockerfile and start.sh), and its build context:
2
+
3
+ python3 cloud/runpod/image/tag.py prints the tag (12 hex digits)
4
+ python3 cloud/runpod/image/tag.py --context DIR also copies exactly those files into DIR (what is built)
5
+
6
+ The same files give the same tag: RunPodBackend.image_tag must equal it (a test checks), so a change to the image needs
7
+ a new build (.github/workflows/runpod-image.yml) and that tag. Standard library only."""
8
+
9
+ import hashlib
10
+ import shutil
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ HERE = Path(__file__).resolve().parent
15
+
16
+
17
+ def files() -> dict[str, Path]:
18
+ """Each file in the context, by its path there."""
19
+ return {name: HERE / name for name in ("Dockerfile", "start.sh")}
20
+
21
+
22
+ def tag() -> str:
23
+ h = hashlib.sha256()
24
+ for name, path in sorted(files().items()):
25
+ h.update(name.encode() + b"\0" + path.read_bytes() + b"\0")
26
+ return h.hexdigest()[:12]
27
+
28
+
29
+ def stage(dest: Path):
30
+ for name, path in files().items():
31
+ (dest / name).parent.mkdir(parents=True, exist_ok=True)
32
+ shutil.copy2(path, dest / name)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ if len(sys.argv) == 3 and sys.argv[1] == "--context":
37
+ stage(Path(sys.argv[2]))
38
+ elif len(sys.argv) != 1:
39
+ sys.exit(__doc__)
40
+ print(tag())
@@ -0,0 +1,39 @@
1
+ # First NVIDIA run: decode + face tracks on one clip, on an L4 or A10G machine. The clip is copied to the machine only
2
+ # (file mount from the laptop) and deleted with it. Results are exported to ~/out for the laptop to fetch before the
3
+ # cluster is deleted. Sending a study's stimulus to a cloud machine needs that study's approval: a whole graph run
4
+ # off the laptop is not checked by the graph's privacy policy.
5
+ # cloud/skypilot/launch.sh cloud/skypilot/gpu-faces.yaml perception-gpu --env CLIP_SRC=/path/to/clip.mp4
6
+ resources:
7
+ cloud: aws
8
+ region: us-east-1
9
+ # L4 (g6) or A10G (g5): both 24 GB. On 2026-10-23 us-east-1 had no g6.xlarge spot capacity in any zone, and the
10
+ # account's G spot quota is 0 outside us-east-1.
11
+ accelerators: {L4: 1, A10G: 1}
12
+ use_spot: true
13
+ disk_size: 60
14
+
15
+ envs:
16
+ CLIP_SRC: null # required: the clip on the laptop
17
+
18
+ file_mounts:
19
+ /data/clip.mp4: ${CLIP_SRC}
20
+
21
+ setup: |
22
+ set -e
23
+ bash cloud/skypilot/guard.sh 90
24
+ sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg > /dev/null
25
+ command -v uv || curl -LsSf https://astral.sh/uv/install.sh | sh
26
+ export PATH=$HOME/.local/bin:$PATH
27
+ (cd orchestrator && uv sync -q) && (cd envs/core && uv sync -q) && (cd envs/faces && uv sync -q)
28
+
29
+ run: |
30
+ set -e
31
+ export PATH=$HOME/.local/bin:$PATH
32
+ nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv
33
+ (cd envs/faces && uv run python -c "import torch; print('torch', torch.__version__, 'cuda', torch.cuda.is_available(), torch.version.cuda)")
34
+ cd orchestrator
35
+ uv run perception doctor
36
+ time uv run perception run ../graphs/faces.yaml --input video=/data/clip.mp4 --allow-downloads
37
+ run=$(uv run perception status | awk 'NR==1{print $1}')
38
+ uv run perception export "$run" --output ~/out/bundle
39
+ cp -r ~/.hyperstudy-perception/work/"$run" ~/out/work
@@ -0,0 +1,55 @@
1
+ # What limits the face node on an L4: one machine, one decode, then the face node at several batch sizes and numbers
2
+ # of frame-decoding workers, with GPU utilisation logged every second. g6.2xlarge (8 vCPUs), so batch 32 with no
3
+ # workers compares directly with the first run on a g6.xlarge (4 vCPUs). Results go to ~/out for the laptop to fetch.
4
+ # Copies the clip to the machine, like gpu-faces.yaml (see there on approval).
5
+ # cloud/skypilot/launch.sh cloud/skypilot/gpu-tune.yaml perception-tune --env CLIP_SRC=/path/to/clip.mp4
6
+ resources:
7
+ cloud: aws
8
+ region: us-east-1
9
+ accelerators: L4:1
10
+ cpus: 8
11
+ use_spot: false # no L4 spot capacity in us-east-1 on 2026-10-23
12
+ disk_size: 60
13
+
14
+ envs:
15
+ CLIP_SRC: null # required: the clip on the laptop
16
+ # batch_size:num_workers. Batch 64 ran out of GPU memory on 2026-10-24 (py-feat feeds whole 1080p frames).
17
+ SWEEP: "8:0 16:0 32:0 16:4 32:4"
18
+
19
+ file_mounts:
20
+ /data/clip.mp4: ${CLIP_SRC}
21
+
22
+ setup: |
23
+ set -e
24
+ bash cloud/skypilot/guard.sh 90
25
+ sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg > /dev/null
26
+ command -v uv || curl -LsSf https://astral.sh/uv/install.sh | sh
27
+ export PATH=$HOME/.local/bin:$PATH
28
+ (cd orchestrator && uv sync -q) && (cd envs/core && uv sync -q) && (cd envs/faces && uv sync -q)
29
+
30
+ run: |
31
+ set -e
32
+ export PATH=$HOME/.local/bin:$PATH
33
+ mkdir -p ~/out
34
+ # Background processes end with the job however it ends: a GPU logger left running kept a failed job open on
35
+ # 2026-10-24 until it was cancelled by hand.
36
+ trap 'kill $(jobs -p) 2>/dev/null || :' EXIT
37
+ nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv | tee ~/out/gpu.txt
38
+ # Ray, which runs SkyPilot jobs, sets OMP_NUM_THREADS=1, and PyTorch takes its CPU thread count from it.
39
+ echo "vCPUs $(nproc --all); OMP_NUM_THREADS from the job: ${OMP_NUM_THREADS:-unset}" | tee -a ~/out/gpu.txt
40
+ export OMP_NUM_THREADS=$(nproc --all)
41
+ nvidia-smi dmon -s um -o T > ~/out/dmon.txt 2>&1 &
42
+ cd orchestrator
43
+ uv run perception run ../graphs/decode.yaml --input video=/data/clip.mp4
44
+ for point in $SWEEP; do
45
+ b=${point%:*}; w=${point#*:}
46
+ sed "s/uses: vision.face-track@1.0.0/&\n config: {batch_size: $b, num_workers: $w}/" ../graphs/faces.yaml > ../graphs/tune-$b-$w.yaml
47
+ echo "=== batch $b workers $w $(date +%T)" | tee -a ~/out/sweep.txt
48
+ SECONDS=0
49
+ # A failed point is recorded (with its error code, from status) and the sweep goes on.
50
+ if uv run perception run ../graphs/tune-$b-$w.yaml --input video=/data/clip.mp4 --allow-downloads; then result=ok; else result=failed; fi
51
+ echo "wall ${SECONDS}s $result end $(date +%T)" | tee -a ~/out/sweep.txt
52
+ run=$(uv run perception status | awk 'NR==1{print $1}')
53
+ uv run perception status "$run" | tee -a ~/out/sweep.txt
54
+ cp -r ~/.hyperstudy-perception/work/"$run" ~/out/work-$b-$w
55
+ done
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ # First step of every task's setup: a shutdown deadline, and a check that the machine holds no AWS credentials
3
+ # and no role beyond perception-node.
4
+ # bash cloud/skypilot/guard.sh MINUTES
5
+ # The deadline is the backstop if the laptop never comes back to delete the machine. launch.sh makes an OS shutdown
6
+ # terminate the instance (EC2's default for on-demand machines is to stop it, which keeps the disk billing).
7
+ set -euo pipefail
8
+ sudo shutdown -h "+$1"
9
+ if [ -e ~/.aws/credentials ] || [ -e ~/.aws/config ]; then
10
+ echo "guard: ~/.aws exists on the machine; sky.yaml was not applied" >&2; exit 1
11
+ fi
12
+ token=$(curl -sf -X PUT http://169.254.169.254/latest/api/token -H "X-aws-ec2-metadata-token-ttl-seconds: 60")
13
+ # The only role allowed is perception-node, which can end perception-* machines and nothing else (sky.yaml).
14
+ profile=$(curl -sf -H "X-aws-ec2-metadata-token: $token" http://169.254.169.254/latest/meta-data/iam/info \
15
+ | python3 -c 'import json,sys; print(json.load(sys.stdin)["InstanceProfileArn"])' || echo none)
16
+ case "$profile" in
17
+ */perception-node) ;;
18
+ *) echo "guard: the instance profile is $profile, not perception-node; sky.yaml was not applied" >&2; exit 1 ;;
19
+ esac
20
+ echo "guard: no AWS credentials; role perception-node; shutdown in $1 min"
@@ -0,0 +1,18 @@
1
+ {
2
+ "Version": "2012-10-17",
3
+ "Statement": [
4
+ {
5
+ "Sid": "FindTheCluster",
6
+ "Effect": "Allow",
7
+ "Action": ["ec2:DescribeInstances", "ec2:DescribeSecurityGroups", "ec2:DescribeVpcs"],
8
+ "Resource": "*"
9
+ },
10
+ {
11
+ "Sid": "EndPerceptionMachinesOnly",
12
+ "Effect": "Allow",
13
+ "Action": "ec2:TerminateInstances",
14
+ "Resource": "arn:aws:ec2:*:*:instance/*",
15
+ "Condition": {"StringLike": {"aws:ResourceTag/skypilot-cluster-name": "perception-*"}}
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # Run one task on AWS and delete the machine afterwards.
3
+ # [OUT=DIR] cloud/skypilot/launch.sh TASK.yaml CLUSTER [sky launch options, e.g. --env CLIP_SRC=/path/to/clip.mp4]
4
+ # With OUT set, the machine's ~/out is copied into DIR before the machine is deleted (also when the job failed).
5
+ # - SkyPilot reads sky.yaml as the project config: no credentials copied, and only the perception-node role.
6
+ # - The workdir is a staged copy of the files git tracks, nothing else: commit a new file before a launch that needs
7
+ # it. (Untracked files were staged too until 2026-10-24; .gitignore does not cover every image, crop or bundle
8
+ # folder that can sit in a working copy.)
9
+ # - Once the instance exists, its OS shutdown is set to terminate it, so guard.sh's deadline deletes on-demand
10
+ # machines as well as spot ones.
11
+ # - sky down runs on exit, whether the job succeeded, failed or was interrupted.
12
+ # - A wall-clock deadline (MAX_MINUTES, default 60) ends the launch, and so deletes the machine, whatever the job is
13
+ # doing: a hang of any kind (a stray process holding the job open, a stuck download) costs at most that long.
14
+ set -euo pipefail
15
+ task=$(cd "$(dirname "$1")" && pwd)/$(basename "$1"); cluster=$2; shift 2
16
+ case "$cluster" in
17
+ perception-*) ;;
18
+ *) echo "launch.sh: cluster names start with perception- (the machine's role can only end those machines)" >&2; exit 2 ;;
19
+ esac
20
+ repo=$(git -C "$(dirname "$0")" rev-parse --show-toplevel)
21
+ region=us-east-1
22
+ export SKYPILOT_PROJECT_CONFIG=$repo/cloud/skypilot/sky.yaml
23
+
24
+ stage=$(mktemp -d)
25
+ git -C "$repo" ls-files -z | rsync -a --from0 --files-from=- "$repo/" "$stage/"
26
+ finish() {
27
+ kill "${watcher:-}" "${deadline:-}" 2>/dev/null || :
28
+ if [ -n "${OUT:-}" ]; then mkdir -p "$OUT"; rsync -a "$cluster:out/" "$OUT/" || echo "launch.sh: could not fetch ~/out" >&2; fi
29
+ sky down -y "$cluster"
30
+ rm -rf "$stage"
31
+ }
32
+ trap finish EXIT
33
+
34
+ # SkyPilot provisions the instance a few seconds to minutes into `sky launch`; set its shutdown behaviour as soon as it
35
+ # exists. Gives up loudly after 15 minutes (then only `sky down`, autodown or MAX_MINUTES deletes it). Not under
36
+ # set -e: one failed AWS call must not end the watcher silently.
37
+ (
38
+ set +e
39
+ for _ in $(seq 90); do
40
+ ids=$(aws ec2 describe-instances --region "$region" --output text --query 'Reservations[].Instances[].InstanceId' \
41
+ --filters "Name=tag:skypilot-cluster-name,Values=$cluster*" Name=instance-state-name,Values=pending,running)
42
+ if [ -n "$ids" ]; then
43
+ for id in $ids; do
44
+ if aws ec2 modify-instance-attribute --region "$region" --instance-id "$id" --instance-initiated-shutdown-behavior terminate; then
45
+ echo "launch.sh: $id terminates on OS shutdown"
46
+ else
47
+ echo "launch.sh: WARNING could not make $id terminate on shutdown; its deadline will only stop it" >&2
48
+ fi
49
+ done
50
+ exit 0
51
+ fi
52
+ sleep 10
53
+ done
54
+ echo "launch.sh: WARNING no instance for $cluster after 15 min; its shutdown deadline will only stop it" >&2
55
+ ) &
56
+ watcher=$!
57
+
58
+ sky launch -c "$cluster" -i 15 --down -y "$task" --workdir "$stage" "$@" &
59
+ launch=$!
60
+ minutes=${MAX_MINUTES:-60}
61
+ ( sleep $((minutes * 60)); echo "launch.sh: past MAX_MINUTES=$minutes; deleting the machine" >&2; kill "$launch" ) &
62
+ deadline=$!
63
+ wait "$launch"
@@ -0,0 +1,25 @@
1
+ # The machine the cloud backend (orchestrator/src/perception/backends/cloud.py) starts for one node, and deletes after it:
2
+ # an L4 with 8 vCPUs, the face node's fastest measured setup (perception #4; frame decoding needs the cores). Only
3
+ # set-up here: the driver checks the machine with guard.sh before sending anything, then runs the node over SSH.
4
+ # Started with SKYPILOT_PROJECT_CONFIG=sky.yaml (no laptop credentials on the machine; only the perception-node role).
5
+ resources:
6
+ cloud: aws
7
+ region: us-east-1
8
+ instance_type: g6.2xlarge
9
+ # on-demand: on 2026-10-23 us-east-1 had no L4 or A10G spot capacity, and the account's G spot quota is 0 elsewhere
10
+ use_spot: false
11
+ disk_size: 60
12
+
13
+ envs:
14
+ DEADLINE_EPOCH: null # required: the driver passes the node's deadline, as a moment (Unix seconds)
15
+
16
+ setup: |
17
+ # SkyPilot runs setup detached (as a job: `sky launch` returns before it ends), so only what must come first is here:
18
+ # the machine shuts itself down (terminating: the driver sets that while it launches) at the deadline, even if the
19
+ # laptop that started it never comes back. guard.sh later sets it again, to what is left. What the node needs (ffmpeg,
20
+ # uv) the driver installs itself and waits for: node-setup.sh.
21
+ # Up to the moment, not a fixed delay: this runs detached, perhaps after guard.sh, and must not move the deadline later.
22
+ # Whole minutes left, rounded down; none (shut down now) once it has passed. (`shutdown HH:MM` would take a time
23
+ # already gone as tomorrow's.)
24
+ left=$(( (DEADLINE_EPOCH - $(date +%s)) / 60 )); [ "$left" -gt 0 ] || left=0
25
+ sudo shutdown -h "+$left"
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # What a cloud node needs on its machine (AWS or RunPod), installed by the driver (remote_driver.py) over ssh after guard.sh
3
+ # and before anything is sent, and waited for: SkyPilot runs a task's setup detached (as a job, `sky launch` returns
4
+ # before it ends), so a node started after the launch could find ffmpeg half installed.
5
+ # bash -s < node-setup.sh prints the uv it installed on its last line
6
+ set -euo pipefail
7
+ export DEBIAN_FRONTEND=noninteractive
8
+ sudo=sudo; [ "$(id -u)" = 0 ] && sudo= # a RunPod pod is root, with no sudo
9
+ # a fresh Ubuntu machine runs its own updates at boot: wait for apt's lock rather than fail on it. rsync: the driver sends
10
+ # with it (RunPod's image has none)
11
+ if ! command -v ffmpeg > /dev/null || ! command -v ffprobe > /dev/null || ! command -v rsync > /dev/null; then # an image may have them
12
+ $sudo apt-get -o DPkg::Lock::Timeout=600 update -qq
13
+ $sudo apt-get -o DPkg::Lock::Timeout=600 install -y -qq ffmpeg rsync > /dev/null
14
+ fi
15
+ # uv, the version the annotator app bundles, in a folder of its own (SkyPilot installs a uv of its own on the machine)
16
+ dir="$HOME/.perception-uv"
17
+ if [ ! -x "$dir/uv" ]; then
18
+ curl -LsSf https://astral.sh/uv/0.13.5/install.sh | env UV_INSTALL_DIR="$dir" UV_NO_MODIFY_PATH=1 sh > /dev/null
19
+ fi
20
+ "$dir/uv" --version | grep -q '^uv 0\.13\.5' || { echo "node-setup: $dir/uv is not uv 0.13.5" >&2; exit 1; }
21
+ command -v ffmpeg > /dev/null && command -v ffprobe > /dev/null && command -v rsync > /dev/null
22
+ echo "$dir/uv"
@@ -0,0 +1,7 @@
1
+ # SkyPilot project config for this repo's tasks; launch.sh points SKYPILOT_PROJECT_CONFIG here. A task YAML cannot set
2
+ # this for AWS (SkyPilot only lets tasks override remote_identity on Kubernetes and Azure).
3
+ aws:
4
+ # An instance profile that can only end perception-* machines (iam-perception-node.json). Naming one does two
5
+ # things: no laptop credentials are copied to the machine, and SkyPilot does not attach its default skypilot-v1
6
+ # role (EC2 and S3 full access), which it does even with NO_UPLOAD. guard.sh checks both on the machine.
7
+ remote_identity: perception-node
@@ -0,0 +1,28 @@
1
+ # Smoke test of a cloud machine: install the orchestrator, run the decode graph on a synthetic video (ffmpeg test
2
+ # pattern and tone, so no stimulus leaves the laptop), print timings, then the cluster is deleted.
3
+ # cloud/skypilot/launch.sh cloud/skypilot/smoke.yaml perception-smoke
4
+ # --cpus/--gpus after the cluster name pick the machine; the defaults are the cheapest spot CPU machine.
5
+ resources:
6
+ cloud: aws
7
+ region: us-east-1
8
+ cpus: 2+
9
+ use_spot: true
10
+
11
+ setup: |
12
+ set -e
13
+ bash cloud/skypilot/guard.sh 45
14
+ sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg > /dev/null
15
+ command -v uv || curl -LsSf https://astral.sh/uv/install.sh | sh
16
+ export PATH=$HOME/.local/bin:$PATH
17
+ (cd orchestrator && uv sync -q) && (cd envs/core && uv sync -q)
18
+
19
+ run: |
20
+ set -e
21
+ export PATH=$HOME/.local/bin:$PATH
22
+ nproc; free -g | head -2; nvidia-smi --query-gpu=name,memory.total --format=csv 2>/dev/null || echo "no GPU"
23
+ ffmpeg -v error -y -f lavfi -i testsrc2=size=1280x720:rate=30:duration=120 -f lavfi -i sine=frequency=440:duration=120 \
24
+ -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest /tmp/synthetic.mp4
25
+ cd orchestrator
26
+ uv run perception doctor
27
+ time uv run perception run ../graphs/decode.yaml --input video=/tmp/synthetic.mp4
28
+ uv run perception status