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/cli.py ADDED
@@ -0,0 +1,503 @@
1
+ """The `motion` command (Python).
2
+
3
+ `motion build` — uploads weights to S3 then packages the model into a Docker image.
4
+ `motion deploy` — profile compute (docker stats), push image to ECR, register model.
5
+
6
+ Weight upload flow:
7
+ 1. For each weight with a local `path` in motion.yml, call the Motion backend
8
+ for presigned S3 upload URLs (no AWS credentials needed on the developer machine).
9
+ 2. Small files: single HTTP PUT. Large files (≥64 MiB): S3 multipart upload
10
+ (init → per-part PUT with fresh URLs → complete).
11
+ 3. Write a weights.json manifest into the image with the final S3 URL.
12
+ 4. The container fetches weights from S3 at startup using the MotionBatchJobRole
13
+ IAM role — users never touch AWS credentials.
14
+ """
15
+ import argparse
16
+ import json
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+
22
+ from .categories import resolve_category_id
23
+ from .config import MotionConfig
24
+ from .dockerfile import generate_dockerfile
25
+ from .profile import resolve_compute_profile
26
+ from .weight_upload import upload_weight_via_backend
27
+
28
+ _PKG_DIR = os.path.dirname(os.path.abspath(__file__))
29
+ _RUNTIME_FILES = ("__init__.py", "predictor.py", "server.py", "batch.py")
30
+ _DEFAULT_API_URL = "https://adwrzdrdes.us-east-1.awsapprunner.com"
31
+
32
+
33
+ def _c(msg, code="36"):
34
+ return f"\033[{code}m{msg}\033[0m"
35
+
36
+
37
+ def _api_url():
38
+ return os.environ.get("MOTION_API_URL", _DEFAULT_API_URL).rstrip("/")
39
+
40
+
41
+ def cmd_build(args):
42
+ project_dir = os.path.abspath(args.path)
43
+ print(_c(f"▶ motion build ({project_dir})"))
44
+
45
+ # 1. Parse and validate motion.yml
46
+ cfg = MotionConfig.load(project_dir)
47
+ cfg.validate_files()
48
+ cat_label = ""
49
+ if cfg.category_id is not None:
50
+ cat_label = f" category_id={cfg.category_id}"
51
+ elif cfg.category:
52
+ cat_label = f" category={cfg.category}"
53
+ print(f" ✓ {_c('motion.yml')} name={cfg.name} gpu={cfg.build.gpu}{cat_label}")
54
+ print(f" ✓ predictor {cfg.predict}")
55
+
56
+ # 2. Vendor runtime into .motion/runtime/motion/
57
+ motion_dir = os.path.join(project_dir, ".motion")
58
+ if os.path.exists(motion_dir):
59
+ shutil.rmtree(motion_dir)
60
+ staging = os.path.join(motion_dir, "runtime", "motion")
61
+ os.makedirs(staging)
62
+ for fn in _RUNTIME_FILES:
63
+ shutil.copy(os.path.join(_PKG_DIR, fn), os.path.join(staging, fn))
64
+ print(" ✓ vendored runtime → .motion/runtime/motion")
65
+
66
+ # 3. Weights — upload local files to S3, write manifest, exclude from image
67
+ if cfg.weights:
68
+ api_key = os.environ.get("MOTION_API_KEY", "")
69
+
70
+ if not args.dry_run and not api_key:
71
+ print(_c(" ✗ MOTION_API_KEY not set — required to upload weights.", "31"))
72
+ print(" Get your key from the Motion dashboard and run:")
73
+ print(" export MOTION_API_KEY=<your_key>")
74
+ return 1
75
+
76
+ manifest = []
77
+ for w in cfg.weights:
78
+ local = os.path.join(project_dir, w.get("path", w["dest"]))
79
+ if not args.dry_run and os.path.exists(local):
80
+ # Upload local file → S3 via backend presigned URL
81
+ s3_url = upload_weight_via_backend(
82
+ local,
83
+ cfg.name,
84
+ api_key,
85
+ api_url=_api_url(),
86
+ log_ok=lambda m: _c(m, "32"),
87
+ log_warn=lambda m: _c(m, "33"),
88
+ log_err=lambda m: _c(m, "31"),
89
+ )
90
+ if s3_url is None:
91
+ return 1
92
+ manifest.append({"url": s3_url, "dest": w["dest"]})
93
+ else:
94
+ # No local file — weight already hosted at declared url
95
+ if not args.dry_run:
96
+ print(f" • {w.get('path', w['dest'])} not found locally — using declared url: {w['url']}")
97
+ manifest.append({"url": w["url"], "dest": w["dest"]})
98
+
99
+ # Manifest the container reads at startup to fetch weights
100
+ with open(os.path.join(motion_dir, "weights.json"), "w") as f:
101
+ json.dump(manifest, f, indent=2)
102
+
103
+ # Exclude local weight files from the Docker build context (not baked in)
104
+ ignore_lines = [w["dest"] for w in cfg.weights] + ["__pycache__", "*.pyc"]
105
+ with open(os.path.join(motion_dir, "Dockerfile.dockerignore"), "w") as f:
106
+ f.write("\n".join(ignore_lines) + "\n")
107
+
108
+ names = ", ".join(w["dest"] for w in cfg.weights)
109
+ action = "declared (dry-run)" if args.dry_run else "uploaded"
110
+ print(f" ✓ weights {len(cfg.weights)} {action} — fetched at container startup: {names}")
111
+
112
+ # 4. Generate .motion/Dockerfile
113
+ dockerfile = generate_dockerfile(cfg)
114
+ df_path = os.path.join(motion_dir, "Dockerfile")
115
+ with open(df_path, "w") as f:
116
+ f.write(dockerfile)
117
+ print(" ✓ generated .motion/Dockerfile")
118
+
119
+ if args.dry_run:
120
+ print(_c("\n--- Dockerfile (dry run, not built) ---", "33"))
121
+ print(dockerfile)
122
+ return 0
123
+
124
+ # 5. docker build — always target linux/amd64 so the image runs on AWS Batch
125
+ # (x86_64 instances). BuildKit / Rosetta handles the cross-compilation
126
+ # transparently on Apple Silicon.
127
+ tag = args.tag or cfg.image_tag
128
+ print(_c(f"\n▶ docker build --platform linux/amd64 -t {tag}"))
129
+ proc = subprocess.run([
130
+ "docker", "build",
131
+ "--platform", "linux/amd64",
132
+ "-f", df_path, "-t", tag, project_dir,
133
+ ])
134
+ if proc.returncode != 0:
135
+ print(_c("✗ docker build failed", "31"), file=sys.stderr)
136
+ return proc.returncode
137
+
138
+ print(_c(f"\n✓ built image: {tag}", "32"))
139
+ print(f" test locally: docker run --rm -p 8000:8000 {tag}")
140
+ print(" next step: motion deploy (push to ECR + create endpoint)")
141
+ return 0
142
+
143
+
144
+ def cmd_deploy(args):
145
+ import base64
146
+ import requests
147
+
148
+ project_dir = os.path.abspath(args.path)
149
+ print(_c(f"▶ motion deploy ({project_dir})"))
150
+
151
+ # 1. Parse motion.yml
152
+ cfg = MotionConfig.load(project_dir)
153
+ cfg.validate_files()
154
+ cat_label = ""
155
+ if cfg.category_id is not None:
156
+ cat_label = f" category_id={cfg.category_id}"
157
+ elif cfg.category:
158
+ cat_label = f" category={cfg.category}"
159
+ print(f" ✓ {_c('motion.yml')} name={cfg.name} gpu={cfg.build.gpu}{cat_label}")
160
+
161
+ api_key = os.environ.get("MOTION_API_KEY", "")
162
+ if not api_key:
163
+ print(_c(" ✗ MOTION_API_KEY not set.", "31"))
164
+ print(" export MOTION_API_KEY=<your_key>")
165
+ return 1
166
+
167
+ local_tag = args.tag or cfg.image_tag
168
+
169
+ # 2. Confirm image exists locally
170
+ result = subprocess.run(
171
+ ["docker", "image", "inspect", local_tag],
172
+ capture_output=True,
173
+ )
174
+ if result.returncode != 0:
175
+ print(_c(f" ✗ image '{local_tag}' not found locally. Run `motion build` first.", "31"))
176
+ return 1
177
+ print(f" ✓ image {local_tag} found")
178
+
179
+ # 3. Profile compute (docker stats) before push — optional
180
+ compute_profile = resolve_compute_profile(
181
+ image=local_tag,
182
+ project_dir=project_dir,
183
+ is_gpu=cfg.build.gpu,
184
+ declared_compute=cfg.compute.as_dict() if cfg.compute else None,
185
+ sample_hint=cfg.sample,
186
+ sample_override=args.sample,
187
+ no_profile=args.no_profile,
188
+ timeout=args.profile_timeout,
189
+ log=lambda m: print(f" ▸ {m}"),
190
+ log_warn=lambda m: print(_c(f" ⚠ {m}", "33")),
191
+ )
192
+
193
+ # 4. Get ECR push info (tag + login token) from backend — no AWS creds needed
194
+ print(" → requesting ECR push credentials …")
195
+ try:
196
+ r = requests.post(
197
+ f"{_api_url()}/api/v1/motion/deploy-info",
198
+ json={"model_name": cfg.name},
199
+ headers={"X-API-Key": api_key},
200
+ timeout=30,
201
+ )
202
+ r.raise_for_status()
203
+ except Exception as e:
204
+ print(_c(f" ✗ could not get deploy info: {e}", "31"))
205
+ return 1
206
+
207
+ info = r.json()
208
+ ecr_registry: str = info["ecr_registry"]
209
+ ecr_image: str = info["ecr_image"]
210
+ ecr_token: str = info["ecr_token"]
211
+ user_id: int = info["user_id"]
212
+ namespace: str = info["namespace"] # e.g. "jay"
213
+ model_code: str = f"{namespace}/{cfg.name}" # e.g. "jay/pose-estimation"
214
+ model_display: str = f"{namespace}/{_slug_to_title(cfg.name)}"
215
+ print(f" ✓ ECR target {ecr_image}")
216
+ print(f" ✓ model_code {model_code}")
217
+
218
+ # 5. docker login to ECR using the backend-issued token
219
+ decoded = base64.b64decode(ecr_token).decode()
220
+ _, ecr_password = decoded.split(":", 1)
221
+ login = subprocess.run(
222
+ ["docker", "login", "--username", "AWS", "--password-stdin", ecr_registry],
223
+ input=ecr_password.encode(),
224
+ capture_output=True,
225
+ )
226
+ if login.returncode != 0:
227
+ print(_c(f" ✗ docker login failed: {login.stderr.decode()}", "31"))
228
+ return 1
229
+ print(" ✓ docker login ECR OK")
230
+
231
+ # 6. Push to ECR — cross-build if local image is arm64
232
+ # On Apple Silicon `docker build --platform linux/amd64` still stores the
233
+ # image locally as arm64 in the default builder. Pushing that directly to
234
+ # ECR would give Batch a CannotPullContainerError on amd64 instances.
235
+ # When we detect arm64, we use `docker buildx build --platform linux/amd64
236
+ # --push` (container-driver buildx) to build a fresh amd64 image and push
237
+ # it straight to ECR — no local load required.
238
+ local_arch = subprocess.run(
239
+ ["docker", "image", "inspect", local_tag, "--format", "{{.Architecture}}"],
240
+ capture_output=True, text=True,
241
+ ).stdout.strip()
242
+
243
+ df_path = os.path.join(project_dir, ".motion", "Dockerfile")
244
+ need_cross_build = (local_arch == "arm64") and os.path.exists(df_path)
245
+
246
+ if need_cross_build:
247
+ print(f" ⚠ local image is arm64 — cross-building linux/amd64 with buildx")
248
+ # Ensure a container-driver builder exists
249
+ subprocess.run(
250
+ ["docker", "buildx", "create", "--name", "cross-builder",
251
+ "--driver", "docker-container", "--use"],
252
+ capture_output=True,
253
+ )
254
+ print(_c(f"\n▶ docker buildx build --platform linux/amd64 --push → {ecr_image}"))
255
+ push = subprocess.run([
256
+ "docker", "buildx", "build",
257
+ "--platform", "linux/amd64",
258
+ "--builder", "cross-builder",
259
+ "-f", df_path,
260
+ "-t", ecr_image,
261
+ "--push",
262
+ project_dir,
263
+ ])
264
+ else:
265
+ subprocess.run(["docker", "tag", local_tag, ecr_image], check=True)
266
+ print(_c(f"\n▶ docker push {ecr_image}"))
267
+ push = subprocess.run(["docker", "push", ecr_image])
268
+
269
+ if push.returncode != 0:
270
+ print(_c(" ✗ docker push failed", "31"), file=sys.stderr)
271
+ return push.returncode
272
+ print(_c(f"\n ✓ pushed {ecr_image}", "32"))
273
+
274
+ # 7. Parse predict.py Input fields → params list
275
+ params = _parse_predict_inputs(project_dir, cfg)
276
+ print(f" ✓ parsed {len(params)} input param(s) from {cfg.predict_file}")
277
+
278
+ try:
279
+ resolved_category_id = resolve_category_id(
280
+ category_id=cfg.category_id,
281
+ category_code=cfg.category,
282
+ )
283
+ except ValueError as e:
284
+ print(_c(f" ✗ {e}", "31"))
285
+ return 1
286
+
287
+ # 8. Register model in backend DB
288
+ print(" → registering model in Motion platform …")
289
+ register_payload = {
290
+ "model_name": model_display,
291
+ "model_code": model_code,
292
+ "description": cfg.description or "",
293
+ "gpu": compute_profile.get("gpu", cfg.build.gpu),
294
+ "input_type": cfg.input_type,
295
+ "output_type": cfg.output_type,
296
+ "ecr_image": ecr_image,
297
+ "params": params,
298
+ "inference_model_slug": cfg.inference_model_slug,
299
+ "cpu_vcpus": compute_profile.get("cpu_vcpus"),
300
+ "memory_mib": compute_profile.get("memory_mib"),
301
+ "compute_source": compute_profile.get("compute_source"),
302
+ }
303
+ if resolved_category_id is not None:
304
+ register_payload["category_id"] = resolved_category_id
305
+ elif cfg.category:
306
+ register_payload["category_code"] = cfg.category
307
+ if getattr(args, "service_type", None):
308
+ register_payload["service_type"] = args.service_type
309
+
310
+ try:
311
+ reg = requests.post(
312
+ f"{_api_url()}/api/v1/motion/models/register",
313
+ json=register_payload,
314
+ headers={"X-API-Key": api_key},
315
+ timeout=30,
316
+ )
317
+ reg.raise_for_status()
318
+ except Exception as e:
319
+ detail = ""
320
+ if hasattr(e, "response") and e.response is not None:
321
+ try:
322
+ detail = e.response.json().get("detail", "")
323
+ except Exception:
324
+ detail = e.response.text[:300]
325
+ msg = f"{e}" + (f" — {detail}" if detail else "")
326
+ print(_c(f" ✗ model registration failed: {msg}", "31"))
327
+ return 1
328
+
329
+ reg_data = reg.json()
330
+ action = "registered" if reg_data["created"] else "updated"
331
+ print(_c(f"\n✓ model {action}: {model_code} (id={reg_data['model_type_id']})", "32"))
332
+ print(f" ECR image: {reg_data['ecr_image']}")
333
+ print(f" model_code: {reg_data['model_code']}")
334
+ if resolved_category_id is not None:
335
+ print(f" category: id={resolved_category_id}")
336
+ elif cfg.category:
337
+ print(f" category: {cfg.category}")
338
+ if reg_data.get("cpu_vcpus") or reg_data.get("memory_mib"):
339
+ print(
340
+ f" compute: cpu={reg_data.get('cpu_vcpus')} "
341
+ f"mem={reg_data.get('memory_mib')} MiB "
342
+ f"gpu={reg_data.get('gpu')} "
343
+ f"source={reg_data.get('compute_source')}"
344
+ )
345
+ if reg_data.get("service_type"):
346
+ print(f" runs on: {reg_data['service_type']}")
347
+ print(f"\n Submit a job: POST /api/v1/motion/submit model_name={model_code}")
348
+ return 0
349
+
350
+
351
+ def _slug_to_title(slug: str) -> str:
352
+ """Convert 'pose-estimation' → 'Pose Estimation'."""
353
+ return " ".join(w.capitalize() for w in slug.replace("-", " ").replace("_", " ").split())
354
+
355
+
356
+ def _parse_predict_inputs(project_dir: str, cfg: "MotionConfig") -> list:
357
+ """Parse the predict method signature in predict.py and extract Input() fields.
358
+
359
+ Returns a list of param dicts compatible with RegisterModelRequest.params.
360
+ Each Input() annotation in the predict() method signature becomes one param.
361
+
362
+ Replicate-style:
363
+ def predict(self, video: Path = Input(description="…"), steps: int = Input(default=20)):
364
+ """
365
+ import ast
366
+
367
+ predict_path = os.path.join(project_dir, cfg.predict_file)
368
+ try:
369
+ with open(predict_path) as f:
370
+ tree = ast.parse(f.read())
371
+ except Exception:
372
+ return []
373
+
374
+ params = []
375
+ for node in ast.walk(tree):
376
+ if not isinstance(node, ast.ClassDef):
377
+ continue
378
+ if node.name != cfg.predict_class:
379
+ continue
380
+ for item in node.body:
381
+ if not isinstance(item, ast.FunctionDef) or item.name != "predict":
382
+ continue
383
+ for i, arg in enumerate(item.args.args):
384
+ if arg.arg == "self":
385
+ continue
386
+ # Determine type annotation
387
+ annotation = item.args.annotations.get(arg.arg) if hasattr(item.args, "annotations") else None
388
+ type_name = ""
389
+ if arg.annotation:
390
+ if isinstance(arg.annotation, ast.Name):
391
+ type_name = arg.annotation.id
392
+ elif isinstance(arg.annotation, ast.Attribute):
393
+ type_name = arg.annotation.attr
394
+
395
+ # Map Python type to field_type
396
+ field_type = _python_type_to_field(type_name)
397
+
398
+ # Parse Input() default for description / default_value / constraints
399
+ default_info = _extract_input_default(item, arg.arg)
400
+
401
+ params.append({
402
+ "param_key": arg.arg,
403
+ "label": arg.arg.replace("_", " ").capitalize(),
404
+ "field_type": field_type,
405
+ "default_value": default_info.get("default"),
406
+ "description": default_info.get("description"),
407
+ "min_value": default_info.get("ge") or default_info.get("min_value"),
408
+ "max_value": default_info.get("le") or default_info.get("max_value"),
409
+ "step": default_info.get("step"),
410
+ "is_required": default_info.get("default") is None,
411
+ "display_order": i,
412
+ })
413
+ return params
414
+
415
+
416
+ def _python_type_to_field(type_name: str) -> str:
417
+ return {
418
+ "int": "number",
419
+ "float": "range",
420
+ "str": "text",
421
+ "bool": "toggle",
422
+ "Path": "text",
423
+ "": "text",
424
+ }.get(type_name, "text")
425
+
426
+
427
+ def _extract_input_default(func_node, arg_name: str) -> dict:
428
+ """Walk defaults in the function AST to find Input(description=…, default=…, ge=…, le=…)."""
429
+ import ast
430
+
431
+ args = func_node.args.args
432
+ # Align defaults (they're right-aligned in args.defaults)
433
+ offset = len(args) - len(func_node.args.defaults)
434
+ for idx, a in enumerate(args):
435
+ if a.arg != arg_name:
436
+ continue
437
+ def_idx = idx - offset
438
+ if def_idx < 0 or def_idx >= len(func_node.args.defaults):
439
+ break
440
+ default_node = func_node.args.defaults[def_idx]
441
+ if not isinstance(default_node, ast.Call):
442
+ break
443
+ info = {}
444
+ for kw in default_node.keywords:
445
+ val = kw.value
446
+ if isinstance(val, ast.Constant):
447
+ info[kw.arg] = val.value
448
+ elif isinstance(val, ast.UnaryOp) and isinstance(val.op, ast.USub):
449
+ if isinstance(val.operand, ast.Constant):
450
+ info[kw.arg] = -val.operand.value
451
+ # Normalise key names
452
+ if "default" in info:
453
+ info["default"] = str(info["default"])
454
+ return info
455
+ return {}
456
+
457
+
458
+ def main(argv=None):
459
+ p = argparse.ArgumentParser(prog="motion", description="Build & deploy models for Motion inference")
460
+ sub = p.add_subparsers(dest="command", required=True)
461
+
462
+ b = sub.add_parser("build", help="upload weights to S3 + build model Docker image")
463
+ b.add_argument("path", nargs="?", default=".", help="path to model folder (has motion.yml)")
464
+ b.add_argument("--tag", help="image tag (default motion-<name>:latest)")
465
+ b.add_argument("--dry-run", action="store_true", help="print the Dockerfile, skip upload and build")
466
+ b.set_defaults(func=cmd_build)
467
+
468
+ d = sub.add_parser(
469
+ "deploy",
470
+ help="profile compute, push image to ECR, register model in platform",
471
+ )
472
+ d.add_argument("path", nargs="?", default=".")
473
+ d.add_argument("--tag", help="local image tag to push (default motion-<name>:latest)")
474
+ d.add_argument(
475
+ "--no-profile",
476
+ action="store_true",
477
+ help="skip docker stats profiling; only send gpu flag from motion.yml",
478
+ )
479
+ d.add_argument(
480
+ "--profile-timeout",
481
+ type=int,
482
+ default=120,
483
+ help="max seconds to run the container during profiling (default 120)",
484
+ )
485
+ d.add_argument(
486
+ "--sample",
487
+ help="sample input file for profiling (overrides motion.yml `sample:`)",
488
+ )
489
+ d.add_argument(
490
+ "--service-type",
491
+ choices=["batch", "inference_instance", "dedicated_instance"],
492
+ help="how this model's jobs run: batch (default), inference_instance "
493
+ "(warm host), or dedicated_instance (disabled for now). "
494
+ "Omit to keep the default/existing value.",
495
+ )
496
+ d.set_defaults(func=cmd_deploy)
497
+
498
+ args = p.parse_args(argv)
499
+ return args.func(args)
500
+
501
+
502
+ if __name__ == "__main__":
503
+ sys.exit(main())
motion/config.py ADDED
@@ -0,0 +1,148 @@
1
+ """Parse and validate motion.yml into a typed config object."""
2
+ import os
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Optional
5
+
6
+ try:
7
+ import yaml
8
+ except ImportError: # pragma: no cover
9
+ raise SystemExit("PyYAML is required: pip install pyyaml")
10
+
11
+
12
+ @dataclass
13
+ class BuildSpec:
14
+ gpu: bool = False
15
+ cuda: str = "11.8"
16
+ python_version: str = "3.10"
17
+ system_packages: List[str] = field(default_factory=list)
18
+ python_packages: List[str] = field(default_factory=list)
19
+ # extra `pip install -f <url>` wheel indexes (e.g. OpenMMLab CPU wheels)
20
+ python_find_links: List[str] = field(default_factory=list)
21
+ # replace the default PyPI index (e.g. https://download.pytorch.org/whl/cpu)
22
+ python_index_url: str = ""
23
+
24
+
25
+ @dataclass
26
+ class ComputeSpec:
27
+ cpu: Optional[int] = None
28
+ memory_mib: Optional[int] = None
29
+ gpu: Optional[bool] = None
30
+
31
+ def as_dict(self) -> dict:
32
+ out = {}
33
+ if self.cpu is not None:
34
+ out["cpu"] = self.cpu
35
+ if self.memory_mib is not None:
36
+ out["memory_mib"] = self.memory_mib
37
+ if self.gpu is not None:
38
+ out["gpu"] = self.gpu
39
+ return out
40
+
41
+
42
+ @dataclass
43
+ class MotionConfig:
44
+ name: str
45
+ predict: str # "predict.py:ClassName"
46
+ description: str = ""
47
+ category: str = "" # category_code (mapped to id locally)
48
+ category_id: Optional[int] = None # explicit id; wins over category code
49
+ inference_model: str = "" # inference_model slug; defaults to `name`
50
+ sample: str = "" # sample input used by deploy profiling
51
+ input_type: str = "video" # video | image | text
52
+ output_type: str = "mp4" # mp4 | glb | ...
53
+ build: BuildSpec = field(default_factory=BuildSpec)
54
+ compute: Optional[ComputeSpec] = None
55
+ # large weights pulled at startup (NOT baked into the image). Each item:
56
+ # {"url": "s3://... or https://...", "dest": "relative/path/in/src"}
57
+ weights: List[dict] = field(default_factory=list)
58
+ project_dir: str = "." # absolute dir containing motion.yml
59
+
60
+ @property
61
+ def predict_file(self) -> str:
62
+ return self.predict.split(":", 1)[0]
63
+
64
+ @property
65
+ def predict_class(self) -> str:
66
+ return self.predict.split(":", 1)[1]
67
+
68
+ @property
69
+ def image_tag(self) -> str:
70
+ return f"motion-{self.name}:latest"
71
+
72
+ @property
73
+ def inference_model_slug(self) -> str:
74
+ return self.inference_model or self.name
75
+
76
+ @classmethod
77
+ def load(cls, project_dir: str) -> "MotionConfig":
78
+ path = os.path.join(project_dir, "motion.yml")
79
+ if not os.path.exists(path):
80
+ raise FileNotFoundError(f"No motion.yml found in {project_dir}")
81
+ with open(path) as f:
82
+ raw = yaml.safe_load(f) or {}
83
+
84
+ errors = []
85
+ if not raw.get("name"):
86
+ errors.append("`name` is required")
87
+ predict = raw.get("predict")
88
+ if not predict or ":" not in str(predict):
89
+ errors.append("`predict` is required and must be 'file.py:ClassName'")
90
+ weights = []
91
+ for w in (raw.get("weights", []) or []):
92
+ if not w.get("url") or not w.get("dest"):
93
+ errors.append("each `weights` entry needs both `url` and `dest`")
94
+ else:
95
+ # `path` = local source to upload at build time (defaults to dest)
96
+ weights.append({
97
+ "url": str(w["url"]),
98
+ "dest": str(w["dest"]),
99
+ "path": str(w.get("path", w["dest"])),
100
+ })
101
+ if errors:
102
+ raise ValueError("Invalid motion.yml:\n - " + "\n - ".join(errors))
103
+
104
+ b = raw.get("build", {}) or {}
105
+ build = BuildSpec(
106
+ gpu=bool(b.get("gpu", False)),
107
+ cuda=str(b.get("cuda", "11.8")),
108
+ python_version=str(b.get("python_version", "3.10")),
109
+ system_packages=list(b.get("system_packages", []) or []),
110
+ python_packages=list(b.get("python_packages", []) or []),
111
+ python_find_links=list(b.get("python_find_links", []) or []),
112
+ python_index_url=str(b.get("python_index_url", "") or ""),
113
+ )
114
+
115
+ compute = None
116
+ c = raw.get("compute")
117
+ if isinstance(c, dict) and c:
118
+ cpu = c.get("cpu") if c.get("cpu") is not None else c.get("cpu_vcpus")
119
+ mem = c.get("memory_mib") if c.get("memory_mib") is not None else c.get("memory")
120
+ gpu = c.get("gpu")
121
+ compute = ComputeSpec(
122
+ cpu=int(cpu) if cpu is not None else None,
123
+ memory_mib=int(mem) if mem is not None else None,
124
+ gpu=bool(gpu) if gpu is not None else None,
125
+ )
126
+
127
+ return cls(
128
+ name=raw["name"],
129
+ predict=predict,
130
+ description=raw.get("description", ""),
131
+ category=str(raw.get("category", "") or "").strip(),
132
+ category_id=int(raw["category_id"]) if raw.get("category_id") is not None else None,
133
+ inference_model=str(raw.get("inference_model", "") or "").strip(),
134
+ sample=str(raw.get("sample", "") or "").strip(),
135
+ input_type=str(raw.get("input_type", "video") or "video"),
136
+ output_type=str(raw.get("output_type", "mp4") or "mp4"),
137
+ build=build,
138
+ compute=compute,
139
+ weights=weights,
140
+ project_dir=os.path.abspath(project_dir),
141
+ )
142
+
143
+ def validate_files(self) -> None:
144
+ pf = os.path.join(self.project_dir, self.predict_file)
145
+ if not os.path.exists(pf):
146
+ raise FileNotFoundError(
147
+ f"predict file '{self.predict_file}' not found in {self.project_dir}"
148
+ )