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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "perception-node"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Node side of the perception graph's file protocol (standard library only)"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = []
|
|
7
|
+
|
|
8
|
+
[build-system]
|
|
9
|
+
requires = ["hatchling"]
|
|
10
|
+
build-backend = "hatchling.build"
|
|
11
|
+
|
|
12
|
+
[tool.hatch.build.targets.wheel]
|
|
13
|
+
packages = ["src/perception_node"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""The node side of the perception graph's file protocol. Standard library only: it is installed into every model
|
|
2
|
+
environment, which share nothing else.
|
|
3
|
+
|
|
4
|
+
The coordinator makes a fresh attempt directory, writes request.json into it and runs the node with
|
|
5
|
+
`--request PATH`. The node reads its inputs (paths), writes outputs under the request's out_dir, and finishes by
|
|
6
|
+
writing response.json next to the request. The coordinator then validates, hashes and publishes the outputs; a node
|
|
7
|
+
never writes into the artifact store, so a node that crashes or is orphaned cannot corrupt it.
|
|
8
|
+
|
|
9
|
+
from perception_node import Node, NodeError
|
|
10
|
+
|
|
11
|
+
with Node() as node:
|
|
12
|
+
video = node.input("video") # a Path
|
|
13
|
+
shots = detect(video, node.config["threshold"])
|
|
14
|
+
node.write_json("shots", "shots.json", shots)
|
|
15
|
+
node.model("PySceneDetect AdaptiveDetector", licence_mode="commercial-ok", licence="BSD-3-Clause")
|
|
16
|
+
|
|
17
|
+
Errors: raise NodeError(code, message). Codes: transient (worth retrying: a network error, a busy device),
|
|
18
|
+
input (the inputs cannot be processed), schema, privacy, licence (terminal). Any other exception is reported as
|
|
19
|
+
`internal` with its traceback, and is terminal: retrying a bug does not fix it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import platform
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import traceback
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
PROTOCOL = 1
|
|
34
|
+
# resources: the node ran out of memory (GPU or RAM). Terminal like the others: the same input on the same device would
|
|
35
|
+
# run out again, so the fix is a smaller memory setting in the graph's config, or another device.
|
|
36
|
+
ERROR_CODES = ("transient", "input", "schema", "privacy", "licence", "resources", "internal")
|
|
37
|
+
MEMORY_SETTINGS = ("batch_size",) # config keys that trade memory for speed, named in the message
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def out_of_memory(exc: BaseException) -> bool:
|
|
41
|
+
"""PyTorch's OutOfMemoryError (CUDA), MPS's RuntimeError, and Python's MemoryError."""
|
|
42
|
+
return (isinstance(exc, MemoryError) or type(exc).__name__ == "OutOfMemoryError"
|
|
43
|
+
or (isinstance(exc, RuntimeError) and "out of memory" in str(exc).lower()))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NodeError(Exception):
|
|
47
|
+
def __init__(self, code: str, message: str):
|
|
48
|
+
if code not in ERROR_CODES:
|
|
49
|
+
raise ValueError(f"unknown error code {code!r}")
|
|
50
|
+
super().__init__(message)
|
|
51
|
+
self.code = code
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Node:
|
|
55
|
+
def __init__(self, argv: list[str] | None = None):
|
|
56
|
+
parser = argparse.ArgumentParser()
|
|
57
|
+
parser.add_argument("--request", type=Path, required=True)
|
|
58
|
+
args = parser.parse_args(argv)
|
|
59
|
+
self.request_path: Path = args.request
|
|
60
|
+
self.request: dict = json.loads(self.request_path.read_text())
|
|
61
|
+
if self.request.get("protocol") != PROTOCOL:
|
|
62
|
+
raise SystemExit(f"request protocol {self.request.get('protocol')} is not {PROTOCOL}")
|
|
63
|
+
self.out_dir = Path(self.request["out_dir"])
|
|
64
|
+
self.out_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
self.config: dict = self.request.get("config", {})
|
|
66
|
+
self.accelerator: str = self.request.get("accelerator", "cpu") # cpu | mps | cuda: where placement put this run
|
|
67
|
+
self._outputs: dict[str, dict] = {}
|
|
68
|
+
self._models: list[dict] = []
|
|
69
|
+
self._usage: dict = {}
|
|
70
|
+
self._t0 = time.time()
|
|
71
|
+
|
|
72
|
+
# --- inputs ---------------------------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def input(self, name: str) -> Path:
|
|
75
|
+
try:
|
|
76
|
+
return Path(self.request["inputs"][name]["path"])
|
|
77
|
+
except KeyError:
|
|
78
|
+
raise NodeError("input", f"no input {name!r} in the request") from None
|
|
79
|
+
|
|
80
|
+
def input_json(self, name: str):
|
|
81
|
+
return json.loads(self.input(name).read_text())
|
|
82
|
+
|
|
83
|
+
def secret(self, name: str) -> str:
|
|
84
|
+
"""A secret the request names, from the environment the coordinator gave this process."""
|
|
85
|
+
if name not in self.request.get("secrets", []):
|
|
86
|
+
raise NodeError("input", f"secret {name} was not requested by this node's manifest")
|
|
87
|
+
value = os.environ.get(name)
|
|
88
|
+
if not value:
|
|
89
|
+
raise NodeError("input", f"secret {name} is not set")
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
# --- outputs ---------------------------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def path(self, name: str, relative: str) -> Path:
|
|
95
|
+
"""Where to write output `name` (a file, or a directory for tree outputs). Declares the output."""
|
|
96
|
+
p = self.out_dir / relative
|
|
97
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
self._outputs[name] = {"path": relative}
|
|
99
|
+
return p
|
|
100
|
+
|
|
101
|
+
def write_json(self, name: str, relative: str, doc) -> Path:
|
|
102
|
+
p = self.path(name, relative)
|
|
103
|
+
p.write_text(json.dumps(doc, separators=(",", ":")))
|
|
104
|
+
return p
|
|
105
|
+
|
|
106
|
+
def producer(self, timings: dict | None = None, device: str | None = None) -> dict:
|
|
107
|
+
"""The bundle layer `producer` block: stage and models from the manifest, and the device.
|
|
108
|
+
|
|
109
|
+
Deliberately without the run time or timings: a stored layer must be the same bytes whenever the same inputs
|
|
110
|
+
produce the same data, or every downstream node would miss the cache after an upstream rerun. Timings go to
|
|
111
|
+
the response's usage; `perception export` writes run_at and timings into the bundle's copy of the layer."""
|
|
112
|
+
if timings:
|
|
113
|
+
self._usage.setdefault("timings", {}).update(timings)
|
|
114
|
+
base = self.request.get("producer", {})
|
|
115
|
+
return {**base, "models": self._models or base.get("models", []), "config": self.config,
|
|
116
|
+
"device": device, "run_at": None, "timings": {}}
|
|
117
|
+
|
|
118
|
+
def model(self, id: str, licence_mode: str, source: str | None = None, revision: str | None = None, licence: str | None = None):
|
|
119
|
+
"""Record a model this run used (the coordinator checks it against the manifest's licence mode)."""
|
|
120
|
+
self._models.append({"id": id, "source": source, "revision": revision, "licence": licence, "licence_mode": licence_mode})
|
|
121
|
+
|
|
122
|
+
def usage(self, **values):
|
|
123
|
+
"""Billable or measured usage: seconds, tokens_in, tokens_out, usd, device..."""
|
|
124
|
+
self._usage.update(values)
|
|
125
|
+
|
|
126
|
+
# --- lifecycle -------------------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def __enter__(self) -> "Node":
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
132
|
+
error = None
|
|
133
|
+
if exc is not None:
|
|
134
|
+
if isinstance(exc, NodeError):
|
|
135
|
+
error = {"code": exc.code, "message": str(exc)}
|
|
136
|
+
elif isinstance(exc, KeyboardInterrupt):
|
|
137
|
+
error = {"code": "transient", "message": "interrupted"}
|
|
138
|
+
elif out_of_memory(exc):
|
|
139
|
+
knobs = [f"{k} (now {self.config[k]})" for k in MEMORY_SETTINGS if k in self.config]
|
|
140
|
+
hint = f"lower {' or '.join(knobs)} in the graph's config for this node" if knobs else "run it on a device with more memory"
|
|
141
|
+
error = {"code": "resources", "message": f"out of memory on {self.accelerator}: {exc}. To fix: {hint}.",
|
|
142
|
+
"traceback": traceback.format_exc()}
|
|
143
|
+
else:
|
|
144
|
+
error = {"code": "internal", "message": f"{exc_type.__name__}: {exc}", "traceback": traceback.format_exc()}
|
|
145
|
+
response = {
|
|
146
|
+
"protocol": PROTOCOL, "ok": error is None, "outputs": self._outputs if error is None else {},
|
|
147
|
+
"models": self._models, "usage": {"seconds": round(time.time() - self._t0, 3), **self._usage},
|
|
148
|
+
"host": {"platform": sys.platform, "machine": platform.machine(), "python": platform.python_version()},
|
|
149
|
+
"error": error,
|
|
150
|
+
}
|
|
151
|
+
tmp = self.request_path.with_name("response.json.tmp")
|
|
152
|
+
tmp.write_text(json.dumps(response, indent=1))
|
|
153
|
+
os.replace(tmp, self.request_path.with_name("response.json")) # the coordinator never sees half a response
|
|
154
|
+
if error:
|
|
155
|
+
print(f"node failed ({error['code']}): {error['message']}", file=sys.stderr)
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
return False
|
perception/artifacts.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Artifact types: what flows between nodes, and how a node's output is checked before it is published.
|
|
2
|
+
|
|
3
|
+
The bundle's layer schemas are artifact types (`bundle.<layer>@0.1`): a layer a node writes is exactly the layer the
|
|
4
|
+
annotator and HyperStudy read. A few internal types carry what bundles do not (media metadata, audio, keyframes).
|
|
5
|
+
A type is a file or a tree (a directory); its validator returns a list of problems (empty: valid).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from functools import cache
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, Literal
|
|
16
|
+
|
|
17
|
+
from jsonschema import Draft202012Validator
|
|
18
|
+
from referencing import Registry, Resource
|
|
19
|
+
|
|
20
|
+
from .release import REPO
|
|
21
|
+
|
|
22
|
+
SCHEMA_DIR = REPO / "schema"
|
|
23
|
+
BUNDLE_SCHEMA = SCHEMA_DIR / "bundle-0.1.schema.json"
|
|
24
|
+
BUNDLE_LAYERS = ("shots", "transcript", "speakers", "faces", "bodies", "hands", "gestures", "facing", "gaze", "objects",
|
|
25
|
+
"sounds", "text", "prosody", "waveform")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ArtifactType:
|
|
30
|
+
id: str
|
|
31
|
+
shape: Literal["file", "tree", "source"] # source: an input that stays where it is (the video), never copied
|
|
32
|
+
validate: Callable[[Path], list[str]]
|
|
33
|
+
layer: str | None = None # the bundle layer this type is, if any
|
|
34
|
+
# the kinds of data it holds, in policy.allow_upload's terms: what leaves the machine when a node that reads it runs
|
|
35
|
+
# elsewhere (a lab server, a cloud machine). None: anything (a JSON or text file could hold any of them)
|
|
36
|
+
kinds: tuple[str, ...] | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@cache
|
|
40
|
+
def _bundle_schema():
|
|
41
|
+
schema = json.loads(BUNDLE_SCHEMA.read_text())
|
|
42
|
+
return schema, Registry().with_resource(schema["$id"], Resource.from_contents(schema))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _json_schema(schema: dict) -> Callable[[Path], list[str]]:
|
|
46
|
+
validator = Draft202012Validator(schema)
|
|
47
|
+
|
|
48
|
+
def check(path: Path) -> list[str]:
|
|
49
|
+
try:
|
|
50
|
+
doc = json.loads(path.read_text())
|
|
51
|
+
except (OSError, ValueError) as e:
|
|
52
|
+
return [f"not JSON: {e}"]
|
|
53
|
+
return [f"{e.json_path}: {e.message[:200]}" for e in list(validator.iter_errors(doc))[:20]]
|
|
54
|
+
return check
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _layer(name: str) -> Callable[[Path], list[str]]:
|
|
58
|
+
def check(path: Path) -> list[str]:
|
|
59
|
+
schema, registry = _bundle_schema()
|
|
60
|
+
try:
|
|
61
|
+
doc = json.loads(path.read_text())
|
|
62
|
+
except (OSError, ValueError) as e:
|
|
63
|
+
return [f"not JSON: {e}"]
|
|
64
|
+
validator = Draft202012Validator({"$ref": f"{schema['$id']}#/$defs/layer/{name}"}, registry=registry)
|
|
65
|
+
problems = [f"{e.json_path}: {e.message[:200]}" for e in list(validator.iter_errors(doc))[:20]]
|
|
66
|
+
if doc.get("layer") != name:
|
|
67
|
+
problems.append(f"layer is {doc.get('layer')!r}, not {name!r}")
|
|
68
|
+
return problems
|
|
69
|
+
return check
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _wav(path: Path) -> list[str]:
|
|
73
|
+
with open(path, "rb") as f:
|
|
74
|
+
head = f.read(12)
|
|
75
|
+
return [] if head[:4] == b"RIFF" and head[8:12] == b"WAVE" else ["not a RIFF/WAVE file"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _tree_of(suffixes: tuple[str, ...]) -> Callable[[Path], list[str]]:
|
|
79
|
+
def check(path: Path) -> list[str]:
|
|
80
|
+
if not path.is_dir():
|
|
81
|
+
return ["not a directory"]
|
|
82
|
+
files = [p for p in path.rglob("*") if p.is_file()]
|
|
83
|
+
bad = [str(p.relative_to(path)) for p in files if p.suffix.lower() not in suffixes and p.name != "index.json"]
|
|
84
|
+
return [f"unexpected files: {bad[:5]}"] if bad else []
|
|
85
|
+
return check
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
MEDIA_SCHEMA = {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"required": ["sha256", "duration", "width", "height", "fps", "time_base", "variable_frame_rate"],
|
|
91
|
+
"properties": {
|
|
92
|
+
"sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
|
93
|
+
"duration": {"type": "number", "exclusiveMinimum": 0},
|
|
94
|
+
"width": {"type": "integer"}, "height": {"type": "integer"},
|
|
95
|
+
"fps": {"type": "number", "exclusiveMinimum": 0, "description": "average frame rate"},
|
|
96
|
+
"r_frame_rate": {"type": "string"}, "avg_frame_rate": {"type": "string"},
|
|
97
|
+
"time_base": {"type": "string", "description": "the video stream's PTS time base, e.g. 1/30000"},
|
|
98
|
+
"start_pts": {"type": ["integer", "null"]},
|
|
99
|
+
"variable_frame_rate": {"type": "boolean"},
|
|
100
|
+
"audio": {"type": ["object", "null"]},
|
|
101
|
+
"filename": {"type": "string"},
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# What each layer can hold. Shots carry captions, which describe and name the people on screen; prosody measures the
|
|
106
|
+
# audio and carries each utterance's words; the waveform is a measure of the audio. Every layer is listed: a new one must say what it holds.
|
|
107
|
+
LAYER_KINDS: dict[str, tuple[str, ...]] = {
|
|
108
|
+
"shots": ("transcript", "faces"), "transcript": ("transcript",), "speakers": ("transcript",), "text": ("transcript",),
|
|
109
|
+
"faces": ("faces", "embeddings"), "bodies": (), "hands": (), "gestures": (), "facing": (), "gaze": (), "objects": (),
|
|
110
|
+
"sounds": (), "prosody": ("audio", "transcript"), "waveform": ("audio",),
|
|
111
|
+
}
|
|
112
|
+
assert set(LAYER_KINDS) == set(BUNDLE_LAYERS)
|
|
113
|
+
|
|
114
|
+
TYPES: dict[str, ArtifactType] = {
|
|
115
|
+
"video.v1": ArtifactType("video.v1", "source", lambda p: [] if p.is_file() else ["missing"], kinds=("video",)),
|
|
116
|
+
"media.v1": ArtifactType("media.v1", "file", _json_schema(MEDIA_SCHEMA), kinds=()), # the video's format and timing
|
|
117
|
+
"audio.wav.v1": ArtifactType("audio.wav.v1", "file", _wav, kinds=("audio",)),
|
|
118
|
+
# small images (keyframes, crops) a layer refers to as "assets/<path>"; export merges every assets.v1 tree into the
|
|
119
|
+
# bundle's assets/ directory. Either kind of image: both kinds are counted
|
|
120
|
+
"assets.v1": ArtifactType("assets.v1", "tree", _tree_of((".jpg", ".jpeg", ".png", ".webp")), kinds=("frames", "faces")),
|
|
121
|
+
"json.v1": ArtifactType("json.v1", "file", _json_schema({})),
|
|
122
|
+
"text.v1": ArtifactType("text.v1", "file", lambda p: []),
|
|
123
|
+
**{f"bundle.{name}@0.1": ArtifactType(f"bundle.{name}@0.1", "file", _layer(name), layer=name, kinds=LAYER_KINDS[name])
|
|
124
|
+
for name in BUNDLE_LAYERS},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get(type_id: str) -> ArtifactType:
|
|
129
|
+
if type_id not in TYPES:
|
|
130
|
+
raise KeyError(f"unknown artifact type {type_id}")
|
|
131
|
+
return TYPES[type_id]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- content hashes ---------------------------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def sha256_file(path: Path) -> str:
|
|
137
|
+
h = hashlib.sha256()
|
|
138
|
+
with open(path, "rb") as f:
|
|
139
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
140
|
+
h.update(chunk)
|
|
141
|
+
return h.hexdigest()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def sha256_tree(path: Path) -> str:
|
|
145
|
+
"""A directory's hash: over its sorted relative paths and their file hashes (so renames and edits both count)."""
|
|
146
|
+
h = hashlib.sha256()
|
|
147
|
+
for p in sorted(x for x in path.rglob("*") if x.is_file()):
|
|
148
|
+
h.update(str(p.relative_to(path).as_posix()).encode() + b"\0" + sha256_file(p).encode() + b"\n")
|
|
149
|
+
return h.hexdigest()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def special_files(path: Path) -> list[str]:
|
|
153
|
+
"""Entries that are not plain files or directories (symlinks, sockets, devices), found without following links.
|
|
154
|
+
Outputs must have none: publishing a symlink would copy whatever it points at on the host."""
|
|
155
|
+
import os
|
|
156
|
+
import stat
|
|
157
|
+
bad = []
|
|
158
|
+
st = os.lstat(path)
|
|
159
|
+
if stat.S_ISLNK(st.st_mode):
|
|
160
|
+
return [f"{path.name} (symlink)"]
|
|
161
|
+
if stat.S_ISDIR(st.st_mode):
|
|
162
|
+
for root, dirs, files in os.walk(path, followlinks=False):
|
|
163
|
+
for name in dirs + files:
|
|
164
|
+
mode = os.lstat(os.path.join(root, name)).st_mode
|
|
165
|
+
if stat.S_ISLNK(mode):
|
|
166
|
+
bad.append(f"{os.path.relpath(os.path.join(root, name), path)} (symlink)")
|
|
167
|
+
elif not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
|
|
168
|
+
bad.append(f"{os.path.relpath(os.path.join(root, name), path)} (not a regular file)")
|
|
169
|
+
elif not stat.S_ISREG(st.st_mode):
|
|
170
|
+
bad.append(f"{path.name} (not a regular file)")
|
|
171
|
+
return bad
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def content_hash(path: Path) -> str:
|
|
175
|
+
return sha256_tree(path) if path.is_dir() else sha256_file(path)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def canonical(value) -> str:
|
|
179
|
+
"""JSON with sorted keys and no whitespace: equal values hash equal."""
|
|
180
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
File without changes
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""What every execution backend provides. The graph never names a provider; placement picks a backend whose runner
|
|
2
|
+
fits the node, and the backend turns the file protocol (request.json in, out/ + response.json back) into a process,
|
|
3
|
+
a remote job or an API call.
|
|
4
|
+
|
|
5
|
+
probe_capabilities() what this backend can run here (platform, accelerators, memory, tools)
|
|
6
|
+
runner_for(manifest) the manifest runner this backend would use, or why none fits
|
|
7
|
+
identity(manifest, r) what makes this runner's results this runner's (env lock, code, SDK): cache key
|
|
8
|
+
estimate(...) cost and time, before anything is reserved
|
|
9
|
+
stage_inputs(...) make the inputs readable where the node runs; returns the request's input entries
|
|
10
|
+
submit(...) start it; returns a JSON-able handle (kept in the run state, so a restarted coordinator
|
|
11
|
+
can find the job again)
|
|
12
|
+
poll(handle) running | exited
|
|
13
|
+
cancel(handle)
|
|
14
|
+
collect(attempt_dir) the node's response.json, or None if it never wrote one (a crash)
|
|
15
|
+
cleanup(attempt_dir)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import platform
|
|
23
|
+
import re
|
|
24
|
+
import stat
|
|
25
|
+
from abc import ABC, abstractmethod
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from ..manifests import Manifest, Runner
|
|
30
|
+
from ..store import Artifact
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Capabilities:
|
|
35
|
+
platform: str # darwin/arm64, linux/amd64, windows/amd64...
|
|
36
|
+
cpu_cores: int
|
|
37
|
+
memory_gb: float
|
|
38
|
+
accelerators: set[str] # cpu, mps, cuda
|
|
39
|
+
vram_gb: float = 0 # CUDA: total; Apple GPU: unified memory (memory_gb)
|
|
40
|
+
tools: dict[str, str | None] = field(default_factory=dict) # uv, ffmpeg, ffprobe -> path or None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Estimate:
|
|
45
|
+
cost_usd: float # 0 for local runs; a range's upper end for billable ones
|
|
46
|
+
seconds: float | None = None # None: not known before a first run
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def this_platform() -> str:
|
|
50
|
+
machine = platform.machine().lower()
|
|
51
|
+
arch = {"x86_64": "amd64", "amd64": "amd64", "arm64": "arm64", "aarch64": "arm64"}.get(machine, machine)
|
|
52
|
+
system = {"Darwin": "darwin", "Linux": "linux", "Windows": "windows"}.get(platform.system(), platform.system().lower())
|
|
53
|
+
return f"{system}/{arch}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Backend(ABC):
|
|
57
|
+
kind: str # names the backend in a place (`kind:accelerator`): unique among a coordinator's backends
|
|
58
|
+
runner_kind: str = "" # the manifest runners it runs (default: its kind); a remote machine runs `native` ones
|
|
59
|
+
remote = False # runs elsewhere: a node's inputs leave this machine to reach it
|
|
60
|
+
title = "This machine" # where it is, for people: "gpu1 (SSH)", "Cloud GPU (AWS L4)"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def machine(self) -> str | None:
|
|
64
|
+
"""Which machine's CPU, memory and GPU a node placed here shares with other nodes; None when every node gets a
|
|
65
|
+
machine of its own (a cloud machine started for it)."""
|
|
66
|
+
return "local"
|
|
67
|
+
|
|
68
|
+
def sends(self, manifest: Manifest) -> list[str]:
|
|
69
|
+
"""The kinds of data that leave this machine when the node runs here: its inputs', for a remote machine (every
|
|
70
|
+
kind, for an input that could hold anything)."""
|
|
71
|
+
if not self.remote:
|
|
72
|
+
return []
|
|
73
|
+
from ..artifacts import get
|
|
74
|
+
from ..ir import UPLOAD_KINDS
|
|
75
|
+
kinds = [get(t).kinds for t in manifest.inputs.values()]
|
|
76
|
+
return sorted(UPLOAD_KINDS) if None in kinds else sorted({k for ks in kinds for k in ks})
|
|
77
|
+
|
|
78
|
+
def remote_refusal(self, manifest: Manifest) -> str:
|
|
79
|
+
"""Why a node may not run on this remote machine at all, or "" (local backends: never)."""
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def probe_capabilities(self) -> Capabilities: ...
|
|
84
|
+
|
|
85
|
+
def runner_for(self, manifest: Manifest) -> tuple[Runner | None, str]:
|
|
86
|
+
"""The first of the manifest's runners this backend can use here, preferring an accelerated one."""
|
|
87
|
+
runners, why = self.runners_for(manifest)
|
|
88
|
+
return (runners[0], "") if runners else (None, why)
|
|
89
|
+
|
|
90
|
+
def runners_for(self, manifest: Manifest) -> tuple[list[Runner], str]:
|
|
91
|
+
"""Every runner of the manifest this backend can use here, accelerated ones first; why none, when none."""
|
|
92
|
+
caps = self.probe_capabilities()
|
|
93
|
+
reasons, usable = [], []
|
|
94
|
+
kind = self.runner_kind or self.kind
|
|
95
|
+
mine = [r for r in manifest.runners if r.kind == kind]
|
|
96
|
+
if not mine:
|
|
97
|
+
return [], f"no {kind} runner"
|
|
98
|
+
if refusal := self.remote_refusal(manifest):
|
|
99
|
+
return [], refusal
|
|
100
|
+
for r in sorted(mine, key=lambda r: r.accelerator == "cpu"):
|
|
101
|
+
if caps.platform not in r.platforms:
|
|
102
|
+
reasons.append(f"{r.accelerator} runner is for {', '.join(r.platforms)}, not {caps.platform}")
|
|
103
|
+
elif r.accelerator not in caps.accelerators:
|
|
104
|
+
reasons.append(f"{r.accelerator} runner needs {r.accelerator}, not available here")
|
|
105
|
+
elif r.minimum_vram_gb > caps.vram_gb:
|
|
106
|
+
reasons.append(f"{r.accelerator} runner needs {r.minimum_vram_gb} GB VRAM, {caps.vram_gb:.0f} here")
|
|
107
|
+
else:
|
|
108
|
+
missing = self.missing_tools(r)
|
|
109
|
+
if missing:
|
|
110
|
+
reasons.append(f"{', '.join(missing)} not installed")
|
|
111
|
+
else:
|
|
112
|
+
usable.append(r)
|
|
113
|
+
return usable, "" if usable else "; ".join(reasons)
|
|
114
|
+
|
|
115
|
+
def missing_tools(self, runner: Runner) -> list[str]:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
def identity(self, manifest: Manifest, runner: Runner) -> str: ...
|
|
120
|
+
|
|
121
|
+
def estimate(self, manifest: Manifest, runner: Runner, inputs: dict[str, Artifact], video_s: float | None = None) -> Estimate:
|
|
122
|
+
"""`video_s`: the video's length when the caller knows it (a plan, before decode has run)."""
|
|
123
|
+
return Estimate(0.0)
|
|
124
|
+
|
|
125
|
+
@abstractmethod
|
|
126
|
+
def stage_inputs(self, attempt_dir: Path, inputs: dict[str, Artifact]) -> dict[str, dict]: ...
|
|
127
|
+
|
|
128
|
+
@abstractmethod
|
|
129
|
+
def submit(self, manifest: Manifest, runner: Runner, attempt_dir: Path, env: dict[str, str]) -> dict: ...
|
|
130
|
+
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def poll(self, handle: dict) -> str: ...
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
def cancel(self, handle: dict) -> None: ...
|
|
136
|
+
|
|
137
|
+
def work_dir(self, attempt_dir: Path) -> Path:
|
|
138
|
+
"""Where the node itself reads and writes (its request, out/, response.json)."""
|
|
139
|
+
return attempt_dir
|
|
140
|
+
|
|
141
|
+
def output_dir(self, attempt_dir: Path) -> Path:
|
|
142
|
+
return self.work_dir(attempt_dir) / "out"
|
|
143
|
+
|
|
144
|
+
def collect(self, attempt_dir: Path) -> dict | None:
|
|
145
|
+
"""The node's response, read only if it is a regular file: the node controls this directory, and a symlink
|
|
146
|
+
here could point anywhere."""
|
|
147
|
+
path = self.work_dir(attempt_dir) / "response.json"
|
|
148
|
+
try:
|
|
149
|
+
st = os.lstat(path)
|
|
150
|
+
except FileNotFoundError:
|
|
151
|
+
return None
|
|
152
|
+
if not stat.S_ISREG(st.st_mode):
|
|
153
|
+
return None
|
|
154
|
+
return json.loads(path.read_text())
|
|
155
|
+
|
|
156
|
+
def cleanup(self, attempt_dir: Path) -> None:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def recorded_handle(self, attempt_dir: Path) -> dict | None:
|
|
160
|
+
"""The handle `submit` recorded in the attempt's folder as soon as the node started, if it did: a coordinator that
|
|
161
|
+
died before recording the task running leaves that node to be adopted, not started a second time."""
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --- the child's environment: no credentials it did not ask for -------------------------------------------------
|
|
166
|
+
|
|
167
|
+
SECRET_NAME = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)", re.I)
|
|
168
|
+
# the coordinator's own interpreter settings: a node runs in its own environment, never the coordinator's
|
|
169
|
+
INTERPRETER_VARS = {"VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME", "UV_PROJECT_ENVIRONMENT", "CONDA_PREFIX"}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def child_env(requested: list[str], secrets: dict[str, str]) -> dict[str, str]:
|
|
173
|
+
"""The parent's environment without anything that looks like a credential, plus the secrets the node's manifest
|
|
174
|
+
names. A node never sees a key it did not declare."""
|
|
175
|
+
env = {k: v for k, v in os.environ.items() if not SECRET_NAME.search(k) and k not in INTERPRETER_VARS}
|
|
176
|
+
for name in requested:
|
|
177
|
+
if name in secrets:
|
|
178
|
+
env[name] = secrets[name]
|
|
179
|
+
return env
|