motion-intelligence 0.2.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.
motion/dockerfile.py ADDED
@@ -0,0 +1,110 @@
1
+ """Turn a MotionConfig into a Dockerfile string."""
2
+ from .config import MotionConfig
3
+
4
+
5
+ def generate_dockerfile(cfg: MotionConfig) -> str:
6
+ b = cfg.build
7
+ py = b.python_version
8
+
9
+ if b.gpu:
10
+ base = f"nvidia/cuda:{b.cuda}.0-cudnn8-runtime-ubuntu22.04"
11
+ # Ubuntu 22.04 ships only python3.10; the deadsnakes PPA provides any
12
+ # other version, then we bootstrap pip for that interpreter via get-pip.
13
+ python_setup = (
14
+ "RUN apt-get update && apt-get install -y --no-install-recommends \\\n"
15
+ " software-properties-common gnupg2 curl ca-certificates \\\n"
16
+ " && add-apt-repository -y ppa:deadsnakes/ppa \\\n"
17
+ " && apt-get update && apt-get install -y --no-install-recommends \\\n"
18
+ f" python{py} python{py}-dev python{py}-distutils \\\n"
19
+ f" && ln -sf /usr/bin/python{py} /usr/bin/python \\\n"
20
+ f" && curl -sS https://bootstrap.pypa.io/get-pip.py | python{py} \\\n"
21
+ " && rm -rf /var/lib/apt/lists/*"
22
+ )
23
+ else:
24
+ base = f"python:{py}-slim"
25
+ python_setup = "# python provided by base image"
26
+
27
+ sys_pkgs = "# (none)"
28
+ if b.system_packages:
29
+ pkgs = " ".join(b.system_packages)
30
+ sys_pkgs = (
31
+ "RUN apt-get update && apt-get install -y --no-install-recommends \\\n"
32
+ f" {pkgs} \\\n"
33
+ " && rm -rf /var/lib/apt/lists/*"
34
+ )
35
+
36
+ find_links = " ".join(f"-f {url}" for url in b.python_find_links)
37
+ index_url = f"--index-url {b.python_index_url}" if b.python_index_url else ""
38
+ py_pkgs = "# (none in motion.yml)"
39
+ if b.python_packages:
40
+ pkgs_str = " ".join(f'"{p}"' for p in b.python_packages)
41
+ if b.python_index_url:
42
+ # Three-pass install when a custom index (e.g. PyTorch CPU) is given:
43
+ # 1. Install torch & torchvision from the custom index (CPU-only wheels).
44
+ # 2. Install everything else from PyPI with --upgrade-strategy only-if-needed
45
+ # so pip won't upgrade the already-pinned torch to the GPU variant.
46
+ # 3. Re-pin torch from the custom index again to undo any accidental upgrade
47
+ # that transitive deps might have triggered in step 2.
48
+ torch_pkgs = [p for p in b.python_packages if "torch" in p.lower()]
49
+ other_pkgs = [p for p in b.python_packages if "torch" not in p.lower()]
50
+ lines = []
51
+ if torch_pkgs:
52
+ tp = " ".join(f'"{p}"' for p in torch_pkgs)
53
+ lines.append(f"RUN pip install --no-cache-dir {tp} {index_url}")
54
+ if other_pkgs:
55
+ op = " ".join(f'"{p}"' for p in other_pkgs)
56
+ fl = f" {find_links}" if find_links else ""
57
+ lines.append(
58
+ f"RUN pip install --no-cache-dir --upgrade-strategy only-if-needed {op}{fl}"
59
+ )
60
+ # Re-pin torch so nothing in step 2 silently upgraded it to a GPU wheel
61
+ if torch_pkgs:
62
+ tp = " ".join(f'"{p}"' for p in torch_pkgs)
63
+ lines.append(f"RUN pip install --no-cache-dir --force-reinstall --no-deps {tp} {index_url}")
64
+ py_pkgs = "\n".join(lines)
65
+ else:
66
+ extras = f" {find_links}" if find_links else ""
67
+ py_pkgs = f"RUN pip install --no-cache-dir {pkgs_str}{extras}"
68
+
69
+ # weights are pulled at startup, not baked — only the small manifest is copied
70
+ weights_copy = "# (no weights: section — nothing pulled at startup)"
71
+ if cfg.weights:
72
+ weights_copy = "COPY .motion/weights.json /opt/motion-runtime/weights.json"
73
+
74
+ return f"""# ─── generated by `motion build` — do not edit ───
75
+ FROM {base}
76
+
77
+ ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 DEBIAN_FRONTEND=noninteractive TZ=UTC
78
+ {python_setup}
79
+
80
+ # --- system packages from motion.yml ---
81
+ {sys_pkgs}
82
+
83
+ # --- the motion runtime (server + contract), vendored into the image ---
84
+ COPY .motion/runtime /opt/motion-runtime
85
+ ENV PYTHONPATH=/opt/motion-runtime
86
+ RUN pip install --no-cache-dir fastapi "uvicorn[standard]" pyyaml python-multipart requests boto3
87
+
88
+ # --- weights manifest (files fetched at startup, see motion.yml `weights:`) ---
89
+ {weights_copy}
90
+
91
+ # --- model python deps from motion.yml ---
92
+ {py_pkgs}
93
+
94
+ WORKDIR /src
95
+ # install requirements.txt first (better layer caching) if present
96
+ COPY requirements.tx[t] /src/
97
+ RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi
98
+
99
+ # --- the model code ---
100
+ COPY . /src
101
+
102
+ # entrypoint metadata read by motion.server / motion.batch at boot
103
+ ENV MOTION_PREDICT="{cfg.predict}"
104
+ ENV MOTION_NAME="{cfg.name}"
105
+ EXPOSE 8000
106
+
107
+ # Batch mode : JOB_ID is set by the Lambda dispatcher → runs motion.batch (one-shot)
108
+ # HTTP mode : no JOB_ID → runs motion.server (uvicorn)
109
+ CMD ["python", "-c", "import os, runpy; runpy.run_module(\\"motion.batch\\" if os.environ.get(\\"JOB_ID\\") else \\"motion.server\\", run_name=\\"__main__\\")"]
110
+ """
motion/predictor.py ADDED
@@ -0,0 +1,62 @@
1
+ """The contract every model's predict.py implements.
2
+
3
+ A model author writes a class that subclasses BasePredictor:
4
+
5
+ from motion import BasePredictor, Input, Path
6
+
7
+ class MyPredictor(BasePredictor):
8
+ def setup(self):
9
+ self.model = load_weights(...) # runs ONCE at container boot
10
+
11
+ def predict(self, video: Path = Input(description="clip"),
12
+ fps: int = Input(default=10)) -> Path:
13
+ ... # runs PER request
14
+ return Path("/tmp/out.mp4")
15
+
16
+ `setup()` is separated from `predict()` on purpose: setup is expensive
17
+ (load 5GB of weights into VRAM) and must happen only once, while predict is
18
+ cheap and runs on every request. That split is what makes autoscaling cheap.
19
+ """
20
+ from pathlib import Path # re-exported so authors do `from motion import Path`
21
+
22
+ # sentinel meaning "no default → this input is required"
23
+ _REQUIRED = object()
24
+
25
+
26
+ class Input:
27
+ """Describes one argument of predict(). Used as the parameter default:
28
+
29
+ fps: int = Input(default=10, description="sampling fps", ge=1, le=60)
30
+ """
31
+
32
+ def __init__(self, default=_REQUIRED, description=None, ge=None, le=None,
33
+ choices=None):
34
+ self.default = default
35
+ self.description = description
36
+ self.ge = ge
37
+ self.le = le
38
+ self.choices = choices
39
+
40
+ @property
41
+ def required(self) -> bool:
42
+ return self.default is _REQUIRED
43
+
44
+ def validate(self, name, value):
45
+ if self.choices is not None and value not in self.choices:
46
+ raise ValueError(f"{name}={value!r} not in allowed {self.choices}")
47
+ if self.ge is not None and value < self.ge:
48
+ raise ValueError(f"{name}={value!r} must be >= {self.ge}")
49
+ if self.le is not None and value > self.le:
50
+ raise ValueError(f"{name}={value!r} must be <= {self.le}")
51
+ return value
52
+
53
+
54
+ class BasePredictor:
55
+ """Subclass this in predict.py."""
56
+
57
+ def setup(self) -> None:
58
+ """Load weights / warm the model. Called once before serving traffic."""
59
+ pass
60
+
61
+ def predict(self, **kwargs):
62
+ raise NotImplementedError("Your predictor must implement predict()")
motion/profile.py ADDED
@@ -0,0 +1,345 @@
1
+ """Measure container CPU/memory with docker stats during `motion deploy`."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import os
6
+ import re
7
+ import shlex
8
+ import subprocess
9
+ import tempfile
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Callable, Optional
14
+
15
+ MEMORY_HEADROOM = 1.30
16
+ MIN_MEMORY_MIB = 512
17
+ MAX_MEMORY_MIB = 122880
18
+ MIN_CPU_VCPUS = 1
19
+ MAX_CPU_VCPUS = 32
20
+ DEFAULT_TIMEOUT = 120
21
+
22
+ _SAMPLE_SEARCH = [
23
+ "sample.mp4",
24
+ "sample.jpg",
25
+ "sample.png",
26
+ "tests/sample.mp4",
27
+ "tests/sample.jpg",
28
+ "demo_files/input_courthouse.mp4",
29
+ ]
30
+
31
+
32
+ def resolve_compute_profile(
33
+ *,
34
+ image: str,
35
+ project_dir: str,
36
+ is_gpu: bool,
37
+ declared_compute: Optional[dict],
38
+ sample_hint: str,
39
+ sample_override: Optional[str],
40
+ no_profile: bool,
41
+ timeout: int,
42
+ log: Callable[[str], None],
43
+ log_warn: Callable[[str], None],
44
+ ) -> dict:
45
+ """Return compute fields for model registration."""
46
+ if declared_compute:
47
+ cpu = declared_compute.get("cpu") or declared_compute.get("cpu_vcpus")
48
+ mem = declared_compute.get("memory") or declared_compute.get("memory_mib")
49
+ gpu = declared_compute.get("gpu", is_gpu)
50
+ log("using declared compute from motion.yml")
51
+ return {
52
+ "cpu_vcpus": int(cpu) if cpu else None,
53
+ "memory_mib": int(mem) if mem else None,
54
+ "gpu": bool(gpu),
55
+ "compute_source": "declared",
56
+ }
57
+
58
+ if no_profile:
59
+ log_warn("profiling skipped (--no-profile)")
60
+ return {
61
+ "cpu_vcpus": None,
62
+ "memory_mib": None,
63
+ "gpu": is_gpu,
64
+ "compute_source": "declared",
65
+ }
66
+
67
+ sample = _find_sample(project_dir, sample_hint, sample_override)
68
+ if not sample:
69
+ log_warn(
70
+ "no sample input for profiling — add `sample:` to motion.yml "
71
+ "or pass --sample. Skipping compute measurement."
72
+ )
73
+ return {
74
+ "cpu_vcpus": None,
75
+ "memory_mib": None,
76
+ "gpu": is_gpu,
77
+ "compute_source": "declared",
78
+ }
79
+
80
+ log(f"profiling image with sample {sample} (timeout {timeout}s) …")
81
+ if is_gpu:
82
+ log_warn("GPU model — measuring CPU/RAM only on this machine")
83
+
84
+ try:
85
+ raw = _profile_container(image, sample, timeout, is_gpu, log, log_warn)
86
+ except Exception as exc:
87
+ log_warn(f"profiling failed: {exc}")
88
+ return {
89
+ "cpu_vcpus": None,
90
+ "memory_mib": None,
91
+ "gpu": is_gpu,
92
+ "compute_source": "declared",
93
+ }
94
+
95
+ if raw.get("error"):
96
+ log_warn(f"profiling error: {raw['error']}")
97
+ return {
98
+ "cpu_vcpus": None,
99
+ "memory_mib": None,
100
+ "gpu": is_gpu,
101
+ "compute_source": "declared",
102
+ }
103
+
104
+ reqs = _derive_requirements(raw, is_gpu)
105
+ if reqs["cpu_vcpus"]:
106
+ log(f"measured cpu_vcpus={reqs['cpu_vcpus']}")
107
+ if reqs["memory_mib"]:
108
+ log(f"measured memory_mib={reqs['memory_mib']}")
109
+ return {**reqs, "compute_source": "measured"}
110
+
111
+
112
+ def _find_sample(
113
+ project_dir: str,
114
+ sample_hint: str,
115
+ sample_override: Optional[str],
116
+ ) -> Optional[str]:
117
+ if sample_override:
118
+ path = Path(sample_override)
119
+ if not path.is_absolute():
120
+ path = Path(project_dir) / path
121
+ return str(path) if path.is_file() else None
122
+
123
+ if sample_hint:
124
+ path = Path(sample_hint)
125
+ if not path.is_absolute():
126
+ path = Path(project_dir) / path
127
+ if path.is_file():
128
+ return str(path)
129
+
130
+ for rel in _SAMPLE_SEARCH:
131
+ path = Path(project_dir) / rel
132
+ if path.is_file():
133
+ return str(path)
134
+ return None
135
+
136
+
137
+ def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
138
+ return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
139
+
140
+
141
+ def _container_running(cid: str) -> bool:
142
+ result = _run(["docker", "inspect", "--format", "{{.State.Running}}", cid])
143
+ return result.stdout.strip() == "true"
144
+
145
+
146
+ def _read_cgroup_peak(cid: str) -> Optional[int]:
147
+ result = _run([
148
+ "docker", "exec", cid,
149
+ "sh", "-c",
150
+ "cat /sys/fs/cgroup/memory.peak 2>/dev/null "
151
+ "|| cat /sys/fs/cgroup/memory/memory.max_usage_in_bytes 2>/dev/null",
152
+ ])
153
+ try:
154
+ val = int(result.stdout.strip())
155
+ if val > 0:
156
+ return val
157
+ except (ValueError, TypeError):
158
+ pass
159
+ return None
160
+
161
+
162
+ def _parse_mem_string(value: str) -> int:
163
+ match = re.match(r"^([\d.]+)\s*([a-zA-Z]*)$", value.strip())
164
+ if not match:
165
+ return 0
166
+ amount, unit = float(match.group(1)), match.group(2).lower()
167
+ multipliers = {
168
+ "b": 1,
169
+ "kb": 1000,
170
+ "kib": 1024,
171
+ "mb": 1_000_000,
172
+ "mib": 1 << 20,
173
+ "gb": 1_000_000_000,
174
+ "gib": 1 << 30,
175
+ }
176
+ return int(amount * multipliers.get(unit, 1))
177
+
178
+
179
+ def _docker_stats_sample(cid: str) -> tuple[float, int]:
180
+ result = _run([
181
+ "docker", "stats", "--no-stream", "--format",
182
+ "{{.CPUPerc}}\t{{.MemUsage}}",
183
+ cid,
184
+ ])
185
+ cpu_pct = 0.0
186
+ mem_bytes = 0
187
+ if result.returncode != 0:
188
+ return cpu_pct, mem_bytes
189
+ for line in result.stdout.splitlines():
190
+ parts = line.strip().split("\t")
191
+ if len(parts) < 2:
192
+ continue
193
+ try:
194
+ cpu_pct = float(parts[0].rstrip("%"))
195
+ except ValueError:
196
+ pass
197
+ mem_part = parts[1].split("/")[0].strip()
198
+ mem_bytes = _parse_mem_string(mem_part)
199
+ return cpu_pct, mem_bytes
200
+
201
+
202
+ class _StatsCollector(threading.Thread):
203
+ def __init__(self, cid: str, interval: float = 1.0):
204
+ super().__init__(daemon=True)
205
+ self.cid = cid
206
+ self.interval = interval
207
+ self.cpu_samples: list[float] = []
208
+ self.mem_samples: list[int] = []
209
+ self._stop = threading.Event()
210
+
211
+ def run(self) -> None:
212
+ while not self._stop.is_set():
213
+ cpu, mem = _docker_stats_sample(self.cid)
214
+ if cpu > 0 or mem > 0:
215
+ self.cpu_samples.append(cpu)
216
+ self.mem_samples.append(mem)
217
+ self._stop.wait(self.interval)
218
+
219
+ def stop(self) -> None:
220
+ self._stop.set()
221
+
222
+ @property
223
+ def avg_cpu_pct(self) -> float:
224
+ return sum(self.cpu_samples) / len(self.cpu_samples) if self.cpu_samples else 0.0
225
+
226
+ @property
227
+ def peak_mem_bytes(self) -> int:
228
+ return max(self.mem_samples, default=0)
229
+
230
+
231
+ def _profile_container(
232
+ image: str,
233
+ sample_path: str,
234
+ timeout: int,
235
+ is_gpu: bool,
236
+ log: Callable[[str], None],
237
+ log_warn: Callable[[str], None],
238
+ ) -> dict:
239
+ result: dict = {
240
+ "peak_memory_bytes": 0,
241
+ "avg_cpu_pct": 0.0,
242
+ "wall_seconds": 0.0,
243
+ "error": None,
244
+ }
245
+
246
+ host_path = str(Path(sample_path).resolve())
247
+ filename = Path(sample_path).name
248
+ container_input = f"/input/{filename}"
249
+
250
+ cid_file = tempfile.NamedTemporaryFile(delete=False, suffix=".cid")
251
+ cid_path = cid_file.name
252
+ cid_file.close()
253
+
254
+ run_cmd = [
255
+ "docker", "run", "-d",
256
+ "--cidfile", cid_path,
257
+ "--name", f"motion-profile-{int(time.time())}",
258
+ "--platform", "linux/amd64",
259
+ "-v", f"{host_path}:{container_input}:ro",
260
+ image,
261
+ ]
262
+
263
+ log(f"docker run (detached) {' '.join(shlex.quote(part) for part in run_cmd)}")
264
+ start = subprocess.Popen(run_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
265
+ _, start_err = start.communicate(timeout=30)
266
+ if start.returncode != 0:
267
+ result["error"] = start_err.decode().strip() or "docker run failed"
268
+ return result
269
+
270
+ deadline = time.monotonic() + 10
271
+ cid = ""
272
+ while time.monotonic() < deadline:
273
+ try:
274
+ cid = Path(cid_path).read_text().strip()
275
+ if cid:
276
+ break
277
+ except FileNotFoundError:
278
+ pass
279
+ time.sleep(0.2)
280
+ if not cid:
281
+ result["error"] = "could not read container id"
282
+ return result
283
+
284
+ collector = _StatsCollector(cid, interval=1.0)
285
+ collector.start()
286
+ t0 = time.monotonic()
287
+ waited = 0.0
288
+ while waited < timeout:
289
+ if not _container_running(cid):
290
+ break
291
+ time.sleep(2.0)
292
+ waited += 2.0
293
+
294
+ if waited >= timeout:
295
+ log_warn(f"container exceeded {timeout}s — stopping")
296
+ subprocess.run(["docker", "kill", cid], capture_output=True)
297
+
298
+ cgroup_peak = _read_cgroup_peak(cid)
299
+ collector.stop()
300
+ collector.join(timeout=5)
301
+
302
+ if cgroup_peak and cgroup_peak > 0:
303
+ result["peak_memory_bytes"] = cgroup_peak
304
+ elif collector.peak_mem_bytes > 0:
305
+ result["peak_memory_bytes"] = collector.peak_mem_bytes
306
+
307
+ result["avg_cpu_pct"] = round(collector.avg_cpu_pct, 1)
308
+ result["wall_seconds"] = round(time.monotonic() - t0, 1)
309
+ subprocess.run(["docker", "wait", cid], capture_output=True, timeout=15)
310
+ try:
311
+ os.unlink(cid_path)
312
+ except OSError:
313
+ pass
314
+
315
+ if is_gpu:
316
+ # Trust motion.yml on dev machines without CUDA.
317
+ pass
318
+
319
+ return result
320
+
321
+
322
+ def _derive_requirements(raw: dict, is_gpu: bool) -> dict:
323
+ peak_bytes = raw.get("peak_memory_bytes", 0)
324
+ avg_cpu_pct = raw.get("avg_cpu_pct", 0.0)
325
+
326
+ if peak_bytes > 0:
327
+ peak_mib = peak_bytes / (1 << 20)
328
+ memory_mib = math.ceil(peak_mib * MEMORY_HEADROOM)
329
+ memory_mib = max(MIN_MEMORY_MIB, min(MAX_MEMORY_MIB, memory_mib))
330
+ else:
331
+ memory_mib = None
332
+
333
+ if avg_cpu_pct > 0:
334
+ cpu_vcpus = max(
335
+ MIN_CPU_VCPUS,
336
+ min(MAX_CPU_VCPUS, math.ceil(avg_cpu_pct / 100.0)),
337
+ )
338
+ else:
339
+ cpu_vcpus = None
340
+
341
+ return {
342
+ "cpu_vcpus": cpu_vcpus,
343
+ "memory_mib": memory_mib,
344
+ "gpu": is_gpu,
345
+ }
motion/server.py ADDED
@@ -0,0 +1,138 @@
1
+ """Uniform HTTP server baked into every model image.
2
+
3
+ At boot it reads MOTION_PREDICT ("predict.py:ClassName"), imports the user's
4
+ predictor, runs setup() once, then serves /predict per request. Identical for
5
+ every model — that's the whole point of the packaging contract.
6
+
7
+ Run with: python -m motion.server (this is the container CMD)
8
+ """
9
+ import importlib.util
10
+ import inspect
11
+ import os
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ from .predictor import BasePredictor, Input
16
+
17
+
18
+ def _load_predictor() -> BasePredictor:
19
+ spec_str = os.environ.get("MOTION_PREDICT")
20
+ if not spec_str or ":" not in spec_str:
21
+ raise SystemExit("MOTION_PREDICT must be set to 'file.py:ClassName'")
22
+ file_part, class_name = spec_str.split(":", 1)
23
+ file_path = os.path.join("/src", file_part)
24
+
25
+ spec = importlib.util.spec_from_file_location("motion_user_predict", file_path)
26
+ module = importlib.util.module_from_spec(spec)
27
+ spec.loader.exec_module(module)
28
+ klass = getattr(module, class_name)
29
+ return klass()
30
+
31
+
32
+ def _coerce(param, raw_value):
33
+ """Download Path inputs; validate via the Input descriptor."""
34
+ meta = param.default if isinstance(param.default, Input) else None
35
+ value = raw_value
36
+ if param.annotation is Path or (meta and meta.description and "path" in str(param.annotation)):
37
+ # input is a file → fetch URL to a local temp path
38
+ import requests
39
+ suffix = os.path.splitext(str(raw_value).split("?")[0])[1] or ""
40
+ fd, local = tempfile.mkstemp(suffix=suffix)
41
+ os.close(fd)
42
+ with requests.get(raw_value, stream=True) as r:
43
+ r.raise_for_status()
44
+ with open(local, "wb") as f:
45
+ for chunk in r.iter_content(chunk_size=1 << 20):
46
+ f.write(chunk)
47
+ value = Path(local)
48
+ if meta:
49
+ value = meta.validate(param.name, value)
50
+ return value
51
+
52
+
53
+ def _fetch_weights():
54
+ """Download weights declared in motion.yml `weights:` before setup().
55
+
56
+ Kept out of the image to avoid bloat / slow cold starts; pulled here at boot
57
+ into /src/<dest> (skipped if already present, e.g. a mounted volume).
58
+ """
59
+ manifest = "/opt/motion-runtime/weights.json"
60
+ if not os.path.exists(manifest):
61
+ return
62
+ import json
63
+ with open(manifest) as f:
64
+ items = json.load(f)
65
+ for w in items:
66
+ dest = os.path.join("/src", w["dest"])
67
+ if os.path.exists(dest):
68
+ print(f"[motion] weight present, skipping: {w['dest']}")
69
+ continue
70
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
71
+ url = w["url"]
72
+ print(f"[motion] fetching weight {url} -> {dest}")
73
+ if url.startswith("s3://"):
74
+ import boto3
75
+ bucket, key = url[5:].split("/", 1)
76
+ boto3.client("s3").download_file(bucket, key, dest)
77
+ else:
78
+ import requests
79
+ with requests.get(url, stream=True) as r:
80
+ r.raise_for_status()
81
+ with open(dest, "wb") as out:
82
+ for chunk in r.iter_content(chunk_size=1 << 20):
83
+ out.write(chunk)
84
+ print("[motion] weights ready")
85
+
86
+
87
+ def build_app():
88
+ from fastapi import FastAPI, HTTPException
89
+
90
+ _fetch_weights() # ← pull declared weights before the model loads
91
+ predictor = _load_predictor()
92
+ predictor.setup() # ← runs exactly once, before any request
93
+ sig = inspect.signature(predictor.predict)
94
+
95
+ app = FastAPI(title=os.environ.get("MOTION_NAME", "motion-model"))
96
+
97
+ @app.get("/health")
98
+ def health():
99
+ return {"status": "ok", "model": os.environ.get("MOTION_NAME")}
100
+
101
+ @app.get("/schema")
102
+ def schema():
103
+ out = {}
104
+ for name, p in sig.parameters.items():
105
+ m = p.default if isinstance(p.default, Input) else None
106
+ out[name] = {
107
+ "type": getattr(p.annotation, "__name__", str(p.annotation)),
108
+ "required": m.required if m else p.default is inspect._empty,
109
+ "default": None if (m is None or m.required) else m.default,
110
+ "description": m.description if m else None,
111
+ }
112
+ return out
113
+
114
+ @app.post("/predict")
115
+ def predict(payload: dict):
116
+ given = payload.get("input", payload) or {}
117
+ kwargs = {}
118
+ for name, p in sig.parameters.items():
119
+ m = p.default if isinstance(p.default, Input) else None
120
+ if name in given:
121
+ kwargs[name] = _coerce(p, given[name])
122
+ elif m and not m.required:
123
+ kwargs[name] = m.default
124
+ elif m and m.required:
125
+ raise HTTPException(400, f"missing required input '{name}'")
126
+ try:
127
+ result = predictor.predict(**kwargs)
128
+ except Exception as e:
129
+ raise HTTPException(500, f"prediction failed: {e}")
130
+ # Phase 0: return local path. Phase 'deploy' will upload to S3 here.
131
+ return {"output": str(result)}
132
+
133
+ return app
134
+
135
+
136
+ if __name__ == "__main__":
137
+ import uvicorn
138
+ uvicorn.run(build_app(), host="0.0.0.0", port=8000)