hyperstudy-perception 0.1.0__py3-none-any.whl
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.
- hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
- hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
- hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
- hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
- hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
- perception/__init__.py +0 -0
- perception/_pipeline/RELEASE.json +59 -0
- perception/_pipeline/cloud/runpod/guard.sh +37 -0
- perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
- perception/_pipeline/cloud/runpod/image/start.sh +11 -0
- perception/_pipeline/cloud/runpod/image/tag.py +40 -0
- perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
- perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
- perception/_pipeline/cloud/skypilot/guard.sh +20 -0
- perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
- perception/_pipeline/cloud/skypilot/launch.sh +63 -0
- perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
- perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
- perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
- perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
- perception/_pipeline/envs/api/pyproject.toml +12 -0
- perception/_pipeline/envs/api/uv.lock +276 -0
- perception/_pipeline/envs/audio/pyproject.toml +12 -0
- perception/_pipeline/envs/audio/uv.lock +575 -0
- perception/_pipeline/envs/core/pyproject.toml +12 -0
- perception/_pipeline/envs/core/uv.lock +157 -0
- perception/_pipeline/envs/faces/pyproject.toml +12 -0
- perception/_pipeline/envs/faces/uv.lock +1180 -0
- perception/_pipeline/graphs/decode.yaml +11 -0
- perception/_pipeline/graphs/faces.yaml +14 -0
- perception/_pipeline/graphs/full.yaml +31 -0
- perception/_pipeline/graphs/mvp-captions.yaml +27 -0
- perception/_pipeline/graphs/mvp.yaml +17 -0
- perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
- perception/_pipeline/nodes/audio/asr/node.py +74 -0
- perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
- perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
- perception/_pipeline/nodes/audio/asr/wav.py +19 -0
- perception/_pipeline/nodes/conftest.py +64 -0
- perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
- perception/_pipeline/nodes/core/decode/media.py +104 -0
- perception/_pipeline/nodes/core/decode/node.py +56 -0
- perception/_pipeline/nodes/core/decode/test_media.py +65 -0
- perception/_pipeline/nodes/testdata/README.md +10 -0
- perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
- perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
- perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
- perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
- perception/_pipeline/nodes/vision/face-track/node.py +117 -0
- perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
- perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
- perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
- perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
- perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
- perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
- perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
- perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
- perception/_pipeline/schema/README.md +54 -0
- perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
- perception/_pipeline/sdk/pyproject.toml +13 -0
- perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
- perception/artifacts.py +180 -0
- perception/backends/__init__.py +0 -0
- perception/backends/base.py +179 -0
- perception/backends/cloud.py +214 -0
- perception/backends/native.py +140 -0
- perception/backends/probe.py +44 -0
- perception/backends/remote.py +140 -0
- perception/cli.py +780 -0
- perception/estimates.py +68 -0
- perception/export.py +100 -0
- perception/hosts.py +201 -0
- perception/ir.py +245 -0
- perception/keys.py +259 -0
- perception/manifests.py +169 -0
- perception/plan.py +170 -0
- perception/release.py +61 -0
- perception/remote_driver.py +990 -0
- perception/runpod.py +100 -0
- perception/scheduler.py +552 -0
- perception/setup.py +72 -0
- perception/store.py +219 -0
perception/estimates.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""How long a node will take, from how long it took here: seconds per minute of video, per node and place, measured
|
|
2
|
+
from this store's finished runs. A node that has not run on this machine in that place gets no estimate, not a guess.
|
|
3
|
+
|
|
4
|
+
The video's length comes from a run's media.v1 artifact (decode writes it), or for a run being planned, from its
|
|
5
|
+
cached decode or the file itself, read with PyAV as decode reads it (no ffprobe on the machine: an app started from
|
|
6
|
+
the Finder has none on its PATH).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import statistics
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .store import Artifact, Store
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def media_seconds(outputs: dict) -> float | None:
|
|
19
|
+
"""The video length in a media.v1 output, if these outputs hold one."""
|
|
20
|
+
for a in outputs.values():
|
|
21
|
+
art = Artifact.from_json(a)
|
|
22
|
+
if art.type == "media.v1" and art.path.is_file():
|
|
23
|
+
duration = json.loads(art.path.read_text()).get("duration")
|
|
24
|
+
if duration:
|
|
25
|
+
return float(duration)
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_seconds(store: Store, run_id: str) -> float | None:
|
|
30
|
+
for t in store.db.execute("SELECT outputs FROM tasks WHERE run_id=? AND outputs IS NOT NULL", (run_id,)):
|
|
31
|
+
seconds = media_seconds(json.loads(t["outputs"]))
|
|
32
|
+
if seconds:
|
|
33
|
+
return seconds
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def probe_seconds(video: Path) -> float | None:
|
|
38
|
+
"""A video's length, as decode's media.v1 will say it (the container's duration), when no decode output says it
|
|
39
|
+
yet; None for a file that is not a video."""
|
|
40
|
+
import av
|
|
41
|
+
|
|
42
|
+
if not video.is_file():
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
with av.open(str(video)) as container:
|
|
46
|
+
return container.duration / av.time_base if container.duration else None
|
|
47
|
+
except av.FFmpegError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def estimate(store: Store, ref: str, place: str, video_seconds: float | None) -> tuple[float | None, str]:
|
|
52
|
+
"""Seconds this node should take here on a video this long, and what that rests on."""
|
|
53
|
+
rows = store.db.execute(
|
|
54
|
+
"""SELECT t.run_id, t.usage FROM tasks t JOIN cache c ON c.key = t.cache_key
|
|
55
|
+
WHERE t.state = 'succeeded' AND c.node = ? AND t.runner = ?""", (ref, place)).fetchall()
|
|
56
|
+
rates = []
|
|
57
|
+
for r in rows:
|
|
58
|
+
# the seconds the node itself ran (the SDK reports them), not the coordinator's clock: a node that finished
|
|
59
|
+
# while no coordinator watched would otherwise count that time too
|
|
60
|
+
ran = json.loads(r["usage"] or "{}").get("seconds")
|
|
61
|
+
seconds = run_seconds(store, r["run_id"])
|
|
62
|
+
if ran and seconds:
|
|
63
|
+
rates.append(ran / (seconds / 60))
|
|
64
|
+
if not rates:
|
|
65
|
+
return None, f"no runs of {ref} on {place} here yet"
|
|
66
|
+
if not video_seconds:
|
|
67
|
+
return None, f"the video's length is not known yet ({len(rates)} runs of {ref} on {place} here)"
|
|
68
|
+
return round(statistics.median(rates) * video_seconds / 60, 1), f"median of {len(rates)} runs of {ref} on {place} here"
|
perception/export.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Publishing a run as a track bundle (schema/bundle-0.1): the view the annotator and HyperStudy read.
|
|
2
|
+
|
|
3
|
+
A bundle is not a second copy of the truth. Its layers are the run's committed `bundle.<layer>@0.1` artifacts, copied
|
|
4
|
+
byte for byte, and its manifest records their checksums, the video (from the run's `media.v1` artifact) and each
|
|
5
|
+
layer's licence mode: the strictest of its node's models and of every node upstream of it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import artifacts
|
|
17
|
+
from .release import release
|
|
18
|
+
from .scheduler import Coordinator, LICENCE_ORDER
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = "0.1.0"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _git_commit() -> str | None:
|
|
24
|
+
if r := release():
|
|
25
|
+
return r["commit"][:7]
|
|
26
|
+
out = subprocess.run(["git", "-C", str(Path(__file__).parent), "rev-parse", "--short", "HEAD"], capture_output=True, text=True)
|
|
27
|
+
return out.stdout.strip() or None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def bundle(coord: Coordinator, run_id: str, out: Path) -> Path:
|
|
31
|
+
graph = coord.graph_of(run_id)
|
|
32
|
+
tasks = coord._tasks(run_id)
|
|
33
|
+
not_done = [i for i, t in tasks.items() if t["state"] not in ("succeeded", "cached")]
|
|
34
|
+
if not_done:
|
|
35
|
+
raise SystemExit(f"run {run_id} has unfinished nodes: {', '.join(not_done)}")
|
|
36
|
+
if (out / "manifest.json").exists():
|
|
37
|
+
raise SystemExit(f"{out} already holds a bundle")
|
|
38
|
+
|
|
39
|
+
own = {i: max([x.licence_mode for x in n.manifest.models] or ["commercial-ok"], key=LICENCE_ORDER.index) for i, n in graph.nodes.items()}
|
|
40
|
+
effective: dict[str, str] = {}
|
|
41
|
+
|
|
42
|
+
def licence(i: str) -> str:
|
|
43
|
+
if i not in effective:
|
|
44
|
+
effective[i] = max([own[i], *(licence(u) for u in graph.nodes[i].upstream)], key=LICENCE_ORDER.index)
|
|
45
|
+
return effective[i]
|
|
46
|
+
|
|
47
|
+
media, layers, asset_trees, writers = None, {}, [], {}
|
|
48
|
+
for node_id, t in tasks.items():
|
|
49
|
+
for name, a in json.loads(t["outputs"]).items():
|
|
50
|
+
kind = artifacts.get(a["type"])
|
|
51
|
+
if a["type"] == "assets.v1":
|
|
52
|
+
asset_trees.append(Path(a["path"]))
|
|
53
|
+
elif a["type"] == "media.v1":
|
|
54
|
+
media = json.loads(Path(a["path"]).read_text())
|
|
55
|
+
elif kind.layer:
|
|
56
|
+
writers.setdefault(kind.layer, []).append((node_id, Path(a["path"])))
|
|
57
|
+
for layer_name, ws in writers.items():
|
|
58
|
+
# a node that refines an upstream node's layer (captions on the shots layer) supersedes it; two unrelated
|
|
59
|
+
# writers of one layer are a graph error
|
|
60
|
+
last = [w for w in ws if not any(o[0] in graph.descendants(w[0]) for o in ws)] # no other writer downstream
|
|
61
|
+
if len(last) != 1 or any(o is not last[0] and last[0][0] not in graph.descendants(o[0]) for o in ws):
|
|
62
|
+
raise SystemExit(f"the {layer_name} layer is written by unrelated nodes: {', '.join(w[0] for w in ws)}")
|
|
63
|
+
layers[layer_name] = last[0]
|
|
64
|
+
if media is None:
|
|
65
|
+
raise SystemExit("the run has no media.v1 artifact (a decode node): the bundle cannot name its video")
|
|
66
|
+
|
|
67
|
+
(out / "layers").mkdir(parents=True)
|
|
68
|
+
entries = {}
|
|
69
|
+
for name, (node_id, path) in layers.items():
|
|
70
|
+
dest = out / "layers" / f"{name}.json"
|
|
71
|
+
# stored layers carry no run time or timings (they would defeat the cache); the bundle gets them from the run
|
|
72
|
+
# that produced the layer, which for a cache hit is the original run
|
|
73
|
+
usage = json.loads(tasks[node_id]["usage"] or "{}")
|
|
74
|
+
doc = json.loads(path.read_text())
|
|
75
|
+
doc["producer"]["run_at"] = usage.get("finished_at")
|
|
76
|
+
doc["producer"]["timings"] = {**usage.get("timings", {}), "seconds": usage.get("seconds")}
|
|
77
|
+
dest.write_text(json.dumps(doc, separators=(",", ":")))
|
|
78
|
+
entries[name] = {"file": f"layers/{name}.json", "sha256": artifacts.sha256_file(dest), "bytes": dest.stat().st_size,
|
|
79
|
+
"licence_mode": licence(node_id), "stage": graph.nodes[node_id].manifest.stage or graph.nodes[node_id].manifest.id}
|
|
80
|
+
assets = []
|
|
81
|
+
for tree in asset_trees: # each node's assets tree merges into assets/; layers refer to "assets/<path>"
|
|
82
|
+
for src in sorted(p for p in tree.rglob("*") if p.is_file()):
|
|
83
|
+
rel = src.relative_to(tree).as_posix()
|
|
84
|
+
dest = out / "assets" / rel
|
|
85
|
+
if dest.exists():
|
|
86
|
+
raise SystemExit(f"two nodes write assets/{rel}")
|
|
87
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
shutil.copyfile(src, dest)
|
|
89
|
+
dest.chmod(0o644)
|
|
90
|
+
assets.append({"file": f"assets/{rel}", "sha256": artifacts.sha256_file(dest), "bytes": dest.stat().st_size})
|
|
91
|
+
manifest = {
|
|
92
|
+
"format": "hyperstudy-perception-bundle", "schema_version": SCHEMA_VERSION,
|
|
93
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime()),
|
|
94
|
+
"video": {k: media[k] for k in ("sha256", "duration", "fps", "width", "height")} | ({"filename": media["filename"]} if media.get("filename") else {}),
|
|
95
|
+
"pipeline": {"name": "hyperstudy-perception", "version": "0.1.0", "git_commit": _git_commit(), "source": f"perception run {run_id}"},
|
|
96
|
+
"layers": entries,
|
|
97
|
+
"assets": assets,
|
|
98
|
+
}
|
|
99
|
+
(out / "manifest.json").write_text(json.dumps(manifest, indent=1))
|
|
100
|
+
return out
|
perception/hosts.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Other machines nodes can run on: lab servers reached over SSH (`perception hosts add NAME DEST`).
|
|
2
|
+
|
|
3
|
+
A host is probed when it is added (and by `hosts check`): its platform, cores, memory, GPU and tools are recorded in
|
|
4
|
+
$PERCEPTION_HOME/hosts.json, so planning never waits on the network. SSH runs in batch mode with the user's own keys
|
|
5
|
+
and ~/.ssh/config: nothing here asks for or stores a password. Connections are shared (ControlMaster), so the many short
|
|
6
|
+
commands a remote node needs cost one handshake.
|
|
7
|
+
|
|
8
|
+
On the host everything lives under its root (default ~/.cache/hyperstudy-perception), which is private: created 0700, and
|
|
9
|
+
refused if someone else owns it, so the code, inputs and markers kept there can be trusted and are not readable by others:
|
|
10
|
+
code/<hash>/ the files git tracks for the node, its uv project and the node SDK, by content
|
|
11
|
+
objects/<sha256>/data inputs, by content: the video is sent once per host (each attempt links it under its name)
|
|
12
|
+
work/<run>/<node>/<attempt>/ the request, out/, response.json, log.txt and pid of one attempt: removed after it,
|
|
13
|
+
or by the host's watchdog when no driver has asked about it for a while (a finished node's
|
|
14
|
+
results wait a day for its driver)
|
|
15
|
+
An attempt's unfinished uploads (objects/.incoming-*, code/.incoming-*) are removed when it ends.
|
|
16
|
+
`perception hosts clean NAME` removes the root's inputs and code (what stays on the host between runs), unless a node
|
|
17
|
+
is running there or the root is not marked as perception's. A host with a node running there is not removed either: that node could no longer be stopped.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import asdict, dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .backends.base import Capabilities
|
|
31
|
+
|
|
32
|
+
NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}$")
|
|
33
|
+
# an ssh destination: user@host, a host, or a ~/.ssh/config alias; never an option
|
|
34
|
+
DEST = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.@%+:\[\]-]*$")
|
|
35
|
+
# a path on the host: plain characters, no `..`; relative ones are under the host's home
|
|
36
|
+
ROOT = re.compile(r"^[A-Za-z0-9_./~-]+$")
|
|
37
|
+
DEFAULT_ROOT = ".cache/hyperstudy-perception"
|
|
38
|
+
RESERVED = {"native", "self", "cloud", "runpod", "local"}
|
|
39
|
+
|
|
40
|
+
# What the host has, as key=value lines. POSIX sh: Linux servers and Macs, no Python needed. Tools are looked for on the
|
|
41
|
+
# PATH a login shell would add too (~/.local/bin is where uv installs itself), since ssh commands get a minimal PATH.
|
|
42
|
+
PROBE = r"""
|
|
43
|
+
PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
44
|
+
echo "home=$HOME"
|
|
45
|
+
echo "system=$(uname -s)"
|
|
46
|
+
echo "machine=$(uname -m)"
|
|
47
|
+
echo "cores=$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu)"
|
|
48
|
+
if [ -r /proc/meminfo ]; then echo "memory_kb=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)"
|
|
49
|
+
else echo "memory_kb=$(( $(sysctl -n hw.memsize) / 1024 ))"; fi
|
|
50
|
+
for t in uv ffmpeg ffprobe; do echo "tool_$t=$(command -v $t || true)"; done
|
|
51
|
+
if command -v nvidia-smi >/dev/null 2>&1; then
|
|
52
|
+
echo "vram_mib=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | sort -n | tail -1)"
|
|
53
|
+
fi
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HostError(RuntimeError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Host:
|
|
63
|
+
name: str
|
|
64
|
+
dest: str
|
|
65
|
+
root: str # absolute, on the host
|
|
66
|
+
caps: Capabilities
|
|
67
|
+
probed_at: float
|
|
68
|
+
|
|
69
|
+
def to_json(self) -> dict:
|
|
70
|
+
d = asdict(self)
|
|
71
|
+
d["caps"]["accelerators"] = sorted(self.caps.accelerators)
|
|
72
|
+
return d
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_json(cls, d: dict) -> "Host":
|
|
76
|
+
caps = Capabilities(**{**d["caps"], "accelerators": set(d["caps"]["accelerators"])})
|
|
77
|
+
return cls(d["name"], d["dest"], d["root"], caps, d["probed_at"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ssh_command() -> list[str]:
|
|
81
|
+
"""ssh with the options every remote command uses. PERCEPTION_SSH replaces the ssh program (the tests use a
|
|
82
|
+
stand-in that runs commands on this machine)."""
|
|
83
|
+
control = Path(f"/tmp/perception-ssh-{os.getuid()}") # short: a socket path must fit in 104 bytes on macOS
|
|
84
|
+
control.mkdir(mode=0o700, exist_ok=True)
|
|
85
|
+
st = control.stat()
|
|
86
|
+
if st.st_uid != os.getuid() or st.st_mode & 0o077:
|
|
87
|
+
raise HostError(f"{control} must be a directory only you can use")
|
|
88
|
+
return [os.environ.get("PERCEPTION_SSH", "ssh"), "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
|
|
89
|
+
"-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=4",
|
|
90
|
+
"-o", "ControlMaster=auto", "-o", f"ControlPath={control}/%C", "-o", "ControlPersist=120"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_names(name: str, dest: str, root: str):
|
|
94
|
+
if not NAME.match(name) or name in RESERVED:
|
|
95
|
+
raise HostError(f"a host's name is lower-case letters, digits and -, and not {', '.join(sorted(RESERVED))}: {name!r}")
|
|
96
|
+
if not DEST.match(dest):
|
|
97
|
+
raise HostError(f"not an ssh destination: {dest!r}")
|
|
98
|
+
parts = [x for x in root.removeprefix("~/").split("/") if x not in ("", ".")]
|
|
99
|
+
if not ROOT.match(root) or ".." in parts or "~" in root.removeprefix("~/") or not parts:
|
|
100
|
+
raise HostError(f"a host's root is a plain path below / or ~ (its own folder: `hosts clean` empties it): {root!r}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# The root is perception's own and private. It is used only if it is missing, empty or already marked as perception's
|
|
104
|
+
# (MARKER): a folder of the user's own given by mistake is never changed or cleaned. It is created 0700, and refused if
|
|
105
|
+
# someone else owns it: only the user can then write the code, inputs and completion markers kept there, so they are
|
|
106
|
+
# trusted across runs, and others on a shared server cannot read the inputs.
|
|
107
|
+
MARKER = ".hyperstudy-perception"
|
|
108
|
+
PRIVATE_ROOT = r"""
|
|
109
|
+
umask 077
|
|
110
|
+
# the contents first, then the marker: perception writes there only after the marker exists, and never removes it, so
|
|
111
|
+
# a root that another attempt is setting up at this moment is never taken for someone else's
|
|
112
|
+
if [ -d "$root" ] && [ -n "$(ls -A "$root")" ] && [ ! -e "$root/.hyperstudy-perception" ]; then
|
|
113
|
+
echo "$root already holds other files: give perception a folder of its own (--root)" >&2; exit 3
|
|
114
|
+
fi
|
|
115
|
+
mkdir -p "$root"
|
|
116
|
+
if [ "$(ls -ldn "$root" | awk '{print $3}')" != "$(id -u)" ]; then echo "$root belongs to someone else" >&2; exit 3; fi
|
|
117
|
+
chmod 700 "$root"
|
|
118
|
+
touch "$root/.hyperstudy-perception"
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def private_root_script(root: str) -> str:
|
|
123
|
+
"""sh that makes `root` (absolute, or under the home) the user's own private folder, or fails."""
|
|
124
|
+
import shlex
|
|
125
|
+
path = shlex.quote(root) if root.startswith("/") else '"$HOME"/' + shlex.quote(root.removeprefix("~/"))
|
|
126
|
+
return f"root={path}\n" + PRIVATE_ROOT
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def probe(dest: str, root: str = DEFAULT_ROOT, ssh: list[str] | None = None) -> tuple[Capabilities, str]:
|
|
130
|
+
"""What the host has, and its root as an absolute path (made private there, or refused: PRIVATE_ROOT). `ssh`: the
|
|
131
|
+
command to reach it, when it needs options of its own (a RunPod pod's key and port)."""
|
|
132
|
+
out = subprocess.run([*(ssh or ssh_command()), dest, "sh -s"], input=private_root_script(root) + PROBE, capture_output=True,
|
|
133
|
+
text=True, timeout=120)
|
|
134
|
+
if out.returncode:
|
|
135
|
+
raise HostError(f"could not reach {dest} (ssh exit {out.returncode}): {out.stderr.strip()[-500:]}")
|
|
136
|
+
facts = dict(line.split("=", 1) for line in out.stdout.splitlines() if "=" in line)
|
|
137
|
+
try:
|
|
138
|
+
system = {"Linux": "linux", "Darwin": "darwin"}[facts["system"]]
|
|
139
|
+
arch = {"x86_64": "amd64", "amd64": "amd64", "arm64": "arm64", "aarch64": "arm64"}[facts["machine"]]
|
|
140
|
+
memory = int(facts["memory_kb"]) / 2**20
|
|
141
|
+
cores = int(facts["cores"])
|
|
142
|
+
except (KeyError, ValueError) as e:
|
|
143
|
+
raise HostError(f"{dest} answered, but not as expected ({e}): {out.stdout[-500:]}")
|
|
144
|
+
accelerators, vram = {"cpu"}, 0.0
|
|
145
|
+
if facts.get("vram_mib", "").strip().isdigit():
|
|
146
|
+
accelerators.add("cuda")
|
|
147
|
+
vram = int(facts["vram_mib"]) / 1024
|
|
148
|
+
if (system, arch) == ("darwin", "arm64"):
|
|
149
|
+
accelerators.add("mps")
|
|
150
|
+
vram = vram or memory # unified memory
|
|
151
|
+
caps = Capabilities(platform=f"{system}/{arch}", cpu_cores=cores, memory_gb=round(memory, 1), accelerators=accelerators,
|
|
152
|
+
vram_gb=round(vram, 1), tools={t: facts.get(f"tool_{t}") or None for t in ("uv", "ffmpeg", "ffprobe")})
|
|
153
|
+
home = facts["home"]
|
|
154
|
+
absolute = (root if root.startswith("/") else f"{home}/{root.removeprefix('~/')}").rstrip("/")
|
|
155
|
+
# plain characters in the whole path, the host's home too: rsync passes remote paths through the host's shell
|
|
156
|
+
if not ROOT.match(absolute):
|
|
157
|
+
raise HostError(f"{dest}'s folder for perception would be {absolute!r}, which is not a plain path (spaces?): give --root /a/plain/path")
|
|
158
|
+
return caps, absolute
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Hosts:
|
|
162
|
+
"""hosts.json in the store's home."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, home: Path):
|
|
165
|
+
self.path = home / "hosts.json"
|
|
166
|
+
|
|
167
|
+
def load(self) -> dict[str, Host]:
|
|
168
|
+
if not self.path.exists():
|
|
169
|
+
return {}
|
|
170
|
+
return {name: Host.from_json(d) for name, d in json.loads(self.path.read_text()).items()}
|
|
171
|
+
|
|
172
|
+
def save(self, hosts: dict[str, Host]):
|
|
173
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
tmp = self.path.with_suffix(".tmp")
|
|
175
|
+
tmp.write_text(json.dumps({n: h.to_json() for n, h in sorted(hosts.items())}, indent=1))
|
|
176
|
+
os.replace(tmp, self.path)
|
|
177
|
+
|
|
178
|
+
def add(self, name: str, dest: str, root: str = DEFAULT_ROOT) -> Host:
|
|
179
|
+
check_names(name, dest, root)
|
|
180
|
+
caps, absolute = probe(dest, root)
|
|
181
|
+
host = Host(name, dest, absolute, caps, time.time())
|
|
182
|
+
hosts = self.load()
|
|
183
|
+
hosts[name] = host
|
|
184
|
+
self.save(hosts)
|
|
185
|
+
return host
|
|
186
|
+
|
|
187
|
+
def check(self, name: str) -> Host:
|
|
188
|
+
hosts = self.load()
|
|
189
|
+
if name not in hosts:
|
|
190
|
+
raise HostError(f"no host {name!r} (perception hosts list)")
|
|
191
|
+
old = hosts[name]
|
|
192
|
+
caps, _ = probe(old.dest, old.root)
|
|
193
|
+
hosts[name] = Host(name, old.dest, old.root, caps, time.time())
|
|
194
|
+
self.save(hosts)
|
|
195
|
+
return hosts[name]
|
|
196
|
+
|
|
197
|
+
def remove(self, name: str):
|
|
198
|
+
hosts = self.load()
|
|
199
|
+
if hosts.pop(name, None) is None:
|
|
200
|
+
raise HostError(f"no host {name!r}")
|
|
201
|
+
self.save(hosts)
|
perception/ir.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Graph files: parse, check against the node manifests, and compile into an ordered graph.
|
|
2
|
+
|
|
3
|
+
schema_version: 1
|
|
4
|
+
pipeline: perception-mvp
|
|
5
|
+
inputs:
|
|
6
|
+
video: {type: video.v1, uri: 'file:///data/clip.mp4'}
|
|
7
|
+
policy: {privacy: local-only, max_cost_usd: 0, retries: 2}
|
|
8
|
+
nodes:
|
|
9
|
+
- id: decode
|
|
10
|
+
uses: core.decode@1.0.0
|
|
11
|
+
inputs: {video: $inputs.video}
|
|
12
|
+
- id: faces
|
|
13
|
+
uses: vision.face-track@1.0.0
|
|
14
|
+
inputs: {video: $inputs.video, shots: $nodes.decode.shots}
|
|
15
|
+
config: {fps: 8}
|
|
16
|
+
|
|
17
|
+
References are `$inputs.<name>` or `$nodes.<node id>.<output name>`. Compilation checks that every reference
|
|
18
|
+
exists, that its artifact type is the one the consuming node's manifest declares, that every declared input is
|
|
19
|
+
supplied, that config keys are known and valid, and that the graph has no cycles. The privacy policy is checked here
|
|
20
|
+
too, so a graph that would upload what it may not never starts.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Literal
|
|
29
|
+
from urllib.parse import unquote, urlparse
|
|
30
|
+
|
|
31
|
+
import yaml
|
|
32
|
+
from jsonschema import Draft202012Validator
|
|
33
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
34
|
+
|
|
35
|
+
from . import artifacts
|
|
36
|
+
from .manifests import Manifest, Registry
|
|
37
|
+
|
|
38
|
+
REF = re.compile(r"^\$(inputs)\.([A-Za-z0-9_-]+)$|^\$nodes\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GraphError(Exception):
|
|
42
|
+
def __init__(self, problems: list[str]):
|
|
43
|
+
super().__init__("\n".join(problems))
|
|
44
|
+
self.problems = problems
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Strict(BaseModel):
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class InputSpec(Strict):
|
|
52
|
+
type: str
|
|
53
|
+
uri: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
UploadKind = Literal["video", "audio", "frames", "faces", "transcript", "embeddings"]
|
|
57
|
+
UPLOAD_KINDS = UploadKind.__args__
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Policy(Strict):
|
|
61
|
+
placement: Literal["local", "hybrid", "remote"] = "local"
|
|
62
|
+
# local-only: nothing leaves the machine. Otherwise a node may send out only the kinds of data allow_upload names
|
|
63
|
+
# (spec: video, audio, frames, faces, transcripts and embeddings are permitted separately).
|
|
64
|
+
privacy: Literal["local-only", "local-preferred", "allow-external"] = "local-only"
|
|
65
|
+
allow_upload: list[UploadKind] = Field(default_factory=list)
|
|
66
|
+
max_cost_usd: float = 0.0
|
|
67
|
+
retries: int = Field(default=2, ge=0, le=10)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NodeSpec(Strict):
|
|
71
|
+
id: str = Field(pattern=r"^[A-Za-z0-9_-]+$")
|
|
72
|
+
uses: str = Field(pattern=r"^[a-z0-9_.-]+@\d+\.\d+\.\d+$")
|
|
73
|
+
inputs: dict[str, str] = Field(default_factory=dict)
|
|
74
|
+
config: dict = Field(default_factory=dict)
|
|
75
|
+
backend: Literal["auto", "native", "self"] = "auto"
|
|
76
|
+
# where it runs, among the places it can run (`perception plan` lists them): native:mps, native:cpu, ssh-gpu1:cuda...
|
|
77
|
+
place: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9-]*:[a-z]+$")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GraphSpec(Strict):
|
|
81
|
+
schema_version: Literal[1]
|
|
82
|
+
pipeline: str
|
|
83
|
+
inputs: dict[str, InputSpec]
|
|
84
|
+
outputs: dict[str, str] = Field(default_factory=dict)
|
|
85
|
+
policy: Policy = Field(default_factory=Policy)
|
|
86
|
+
nodes: list[NodeSpec]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Ref:
|
|
91
|
+
node: str | None # None: a graph input
|
|
92
|
+
name: str
|
|
93
|
+
|
|
94
|
+
def __str__(self):
|
|
95
|
+
return f"$inputs.{self.name}" if self.node is None else f"$nodes.{self.node}.{self.name}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class CompiledNode:
|
|
100
|
+
spec: NodeSpec
|
|
101
|
+
manifest: Manifest
|
|
102
|
+
inputs: dict[str, Ref]
|
|
103
|
+
config: dict # manifest defaults + the graph's values
|
|
104
|
+
upstream: set[str] = field(default_factory=set)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def id(self) -> str:
|
|
108
|
+
return self.spec.id
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class Graph:
|
|
113
|
+
spec: GraphSpec
|
|
114
|
+
nodes: dict[str, CompiledNode] # in topological order
|
|
115
|
+
source: Path | None = None
|
|
116
|
+
|
|
117
|
+
def input_path(self, name: str) -> Path:
|
|
118
|
+
uri = self.spec.inputs[name].uri
|
|
119
|
+
parsed = urlparse(uri)
|
|
120
|
+
if parsed.scheme not in ("file", ""):
|
|
121
|
+
raise GraphError([f"input {name}: only file:// inputs are supported locally ({uri})"])
|
|
122
|
+
path = Path(unquote(parsed.path)) if parsed.scheme else Path(uri)
|
|
123
|
+
if not path.is_absolute() and self.source:
|
|
124
|
+
path = self.source.parent / path
|
|
125
|
+
return path
|
|
126
|
+
|
|
127
|
+
def descendants(self, node_id: str) -> set[str]:
|
|
128
|
+
out, frontier = set(), {node_id}
|
|
129
|
+
while frontier:
|
|
130
|
+
frontier = {n.id for n in self.nodes.values() if n.upstream & frontier} - out
|
|
131
|
+
out |= frontier
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_ref(text: str) -> Ref | None:
|
|
136
|
+
m = REF.match(text)
|
|
137
|
+
if not m:
|
|
138
|
+
return None
|
|
139
|
+
return Ref(None, m.group(2)) if m.group(1) else Ref(m.group(3), m.group(4))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load(path: Path, registry: Registry) -> Graph:
|
|
143
|
+
doc = yaml.safe_load(path.read_text())
|
|
144
|
+
graph = compile_graph(doc, registry)
|
|
145
|
+
graph.source = path.resolve()
|
|
146
|
+
return graph
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def compile_graph(doc: dict, registry: Registry) -> Graph:
|
|
150
|
+
try:
|
|
151
|
+
spec = GraphSpec.model_validate(doc)
|
|
152
|
+
except ValidationError as e:
|
|
153
|
+
raise GraphError([f"{'.'.join(map(str, err['loc']))}: {err['msg']}" for err in e.errors()]) from None
|
|
154
|
+
|
|
155
|
+
problems: list[str] = []
|
|
156
|
+
for name, inp in spec.inputs.items():
|
|
157
|
+
if inp.type not in artifacts.TYPES:
|
|
158
|
+
problems.append(f"input {name}: unknown artifact type {inp.type}")
|
|
159
|
+
ids = [n.id for n in spec.nodes]
|
|
160
|
+
for dup in {i for i in ids if ids.count(i) > 1}:
|
|
161
|
+
problems.append(f"node id {dup} is used twice")
|
|
162
|
+
|
|
163
|
+
manifests: dict[str, Manifest] = {}
|
|
164
|
+
for n in spec.nodes:
|
|
165
|
+
try:
|
|
166
|
+
manifests[n.id] = registry.get(n.uses)
|
|
167
|
+
except KeyError as e:
|
|
168
|
+
problems.append(f"node {n.id}: {e.args[0]}")
|
|
169
|
+
|
|
170
|
+
compiled: dict[str, CompiledNode] = {}
|
|
171
|
+
for n in spec.nodes:
|
|
172
|
+
m = manifests.get(n.id)
|
|
173
|
+
if m is None:
|
|
174
|
+
continue
|
|
175
|
+
refs: dict[str, Ref] = {}
|
|
176
|
+
for name in n.inputs.keys() - m.inputs.keys():
|
|
177
|
+
problems.append(f"node {n.id}: {m.ref} has no input {name!r} (inputs: {', '.join(m.inputs) or 'none'})")
|
|
178
|
+
for name, want in m.inputs.items():
|
|
179
|
+
text = n.inputs.get(name)
|
|
180
|
+
if text is None:
|
|
181
|
+
problems.append(f"node {n.id}: input {name!r} ({want}) is not connected")
|
|
182
|
+
continue
|
|
183
|
+
ref = parse_ref(text)
|
|
184
|
+
if ref is None:
|
|
185
|
+
problems.append(f"node {n.id}: input {name}: {text!r} is not $inputs.<name> or $nodes.<id>.<output>")
|
|
186
|
+
continue
|
|
187
|
+
if ref.node is None:
|
|
188
|
+
got = spec.inputs[ref.name].type if ref.name in spec.inputs else None
|
|
189
|
+
if got is None:
|
|
190
|
+
problems.append(f"node {n.id}: input {name}: graph has no input {ref.name!r}")
|
|
191
|
+
else:
|
|
192
|
+
src = manifests.get(ref.node)
|
|
193
|
+
if ref.node not in ids:
|
|
194
|
+
problems.append(f"node {n.id}: input {name}: no node {ref.node!r}")
|
|
195
|
+
continue
|
|
196
|
+
if src is None:
|
|
197
|
+
continue
|
|
198
|
+
got = src.outputs.get(ref.name)
|
|
199
|
+
if got is None:
|
|
200
|
+
problems.append(f"node {n.id}: input {name}: {src.ref} has no output {ref.name!r} (outputs: {', '.join(src.outputs)})")
|
|
201
|
+
if got is not None and got != want:
|
|
202
|
+
problems.append(f"node {n.id}: input {name} wants {want} but {text} is {got}")
|
|
203
|
+
refs[name] = ref
|
|
204
|
+
|
|
205
|
+
for key in n.config.keys() - m.config.keys():
|
|
206
|
+
problems.append(f"node {n.id}: {m.ref} has no config {key!r} (config: {', '.join(m.config) or 'none'})")
|
|
207
|
+
config = {**m.defaults(), **{k: v for k, v in n.config.items() if k in m.config}}
|
|
208
|
+
for key, value in config.items():
|
|
209
|
+
for err in Draft202012Validator(m.config[key]).iter_errors(value):
|
|
210
|
+
problems.append(f"node {n.id}: config {key}: {err.message}")
|
|
211
|
+
|
|
212
|
+
if spec.policy.privacy == "local-only" and m.privacy.external_upload:
|
|
213
|
+
problems.append(f"node {n.id}: {m.ref} uploads {', '.join(m.privacy.external_upload)}, but the graph's privacy policy is local-only")
|
|
214
|
+
elif not_allowed := [k for k in m.privacy.external_upload if k not in spec.policy.allow_upload]:
|
|
215
|
+
problems.append(f"node {n.id}: {m.ref} uploads {', '.join(not_allowed)}, which policy.allow_upload does not permit")
|
|
216
|
+
|
|
217
|
+
compiled[n.id] = CompiledNode(n, m, refs, config, {r.node for r in refs.values() if r.node})
|
|
218
|
+
|
|
219
|
+
order = _topological(compiled, problems)
|
|
220
|
+
if problems:
|
|
221
|
+
raise GraphError(problems)
|
|
222
|
+
return Graph(spec, {i: compiled[i] for i in order})
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _topological(nodes: dict[str, CompiledNode], problems: list[str]) -> list[str]:
|
|
226
|
+
order, done, visiting = [], set(), []
|
|
227
|
+
|
|
228
|
+
def visit(i: str):
|
|
229
|
+
if i in done:
|
|
230
|
+
return
|
|
231
|
+
if i in visiting:
|
|
232
|
+
cycle = visiting[visiting.index(i):] + [i]
|
|
233
|
+
problems.append(f"cycle: {' -> '.join(cycle)}")
|
|
234
|
+
return
|
|
235
|
+
visiting.append(i)
|
|
236
|
+
for up in sorted(nodes[i].upstream):
|
|
237
|
+
if up in nodes:
|
|
238
|
+
visit(up)
|
|
239
|
+
visiting.pop()
|
|
240
|
+
done.add(i)
|
|
241
|
+
order.append(i)
|
|
242
|
+
|
|
243
|
+
for i in nodes:
|
|
244
|
+
visit(i)
|
|
245
|
+
return order
|