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.
@@ -0,0 +1,324 @@
1
+ """Upload model weights to S3 via Motion backend presigned URLs.
2
+
3
+ Developers only need MOTION_API_KEY — never AWS credentials on their machine.
4
+ Small files use a single presigned PUT; large files use S3 multipart upload
5
+ (init → per-part presigned PUT → complete) so multi-GB checkpoints can resume
6
+ per-part instead of restarting the whole file.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import mimetypes
11
+ import os
12
+ import time
13
+ from typing import Callable, Optional
14
+
15
+ # Use multipart for files >= 64 MiB (S3 minimum part size is 5 MiB).
16
+ MULTIPART_THRESHOLD_BYTES = 64 * 1024 * 1024
17
+ DEFAULT_PART_SIZE_BYTES = 64 * 1024 * 1024
18
+ MAX_PART_RETRIES = 5
19
+ API_TIMEOUT = (30, 120)
20
+
21
+
22
+ def upload_weight_via_backend(
23
+ local_path: str,
24
+ model_name: str,
25
+ api_key: str,
26
+ *,
27
+ api_url: str,
28
+ log_ok: Callable[[str], str],
29
+ log_warn: Callable[[str], str],
30
+ log_err: Callable[[str], str],
31
+ ) -> Optional[str]:
32
+ """Upload a local weight file; return final s3:// URL or None on failure."""
33
+ size_bytes = os.path.getsize(local_path)
34
+ if size_bytes >= MULTIPART_THRESHOLD_BYTES:
35
+ return _upload_multipart(
36
+ local_path,
37
+ model_name,
38
+ api_key,
39
+ api_url=api_url,
40
+ log_ok=log_ok,
41
+ log_warn=log_warn,
42
+ log_err=log_err,
43
+ )
44
+ return _upload_single_put(
45
+ local_path,
46
+ model_name,
47
+ api_key,
48
+ api_url=api_url,
49
+ log_ok=log_ok,
50
+ log_warn=log_warn,
51
+ log_err=log_err,
52
+ )
53
+
54
+
55
+ def _api_post(session, api_url: str, path: str, api_key: str, payload: dict) -> dict:
56
+ r = session.post(
57
+ f"{api_url}{path}",
58
+ json=payload,
59
+ headers={"X-API-Key": api_key},
60
+ timeout=API_TIMEOUT,
61
+ )
62
+ r.raise_for_status()
63
+ return r.json()
64
+
65
+
66
+ def _api_post_with_retries(
67
+ session,
68
+ api_url: str,
69
+ path: str,
70
+ api_key: str,
71
+ payload: dict,
72
+ *,
73
+ log_warn: Callable[[str], str],
74
+ max_attempts: int = 5,
75
+ ) -> Optional[dict]:
76
+ for attempt in range(1, max_attempts + 1):
77
+ try:
78
+ return _api_post(session, api_url, path, api_key, payload)
79
+ except Exception as e:
80
+ if attempt < max_attempts:
81
+ wait = min(30, 2 ** attempt)
82
+ print(log_warn(f" ⚠ API {path} failed (attempt {attempt}): {e}"))
83
+ print(f" retrying in {wait}s …")
84
+ time.sleep(wait)
85
+ else:
86
+ print(log_err(f" ✗ API {path} failed after {max_attempts} attempts: {e}"))
87
+ return None
88
+ return None
89
+
90
+
91
+ def _upload_single_put(
92
+ local_path: str,
93
+ model_name: str,
94
+ api_key: str,
95
+ *,
96
+ api_url: str,
97
+ log_ok: Callable[[str], str],
98
+ log_warn: Callable[[str], str],
99
+ log_err: Callable[[str], str],
100
+ ) -> Optional[str]:
101
+ import requests
102
+
103
+ filename = os.path.basename(local_path)
104
+ size_bytes = os.path.getsize(local_path)
105
+ size_mb = size_bytes / 1e6
106
+ content_type = mimetypes.guess_type(local_path)[0] or "application/octet-stream"
107
+ read_timeout = max(3600, int(size_bytes / (512 * 1024)) + 300)
108
+
109
+ session = requests.Session()
110
+ print(f" ↑ {filename} ({size_mb:.1f} MB) — single PUT upload …")
111
+
112
+ for attempt in range(1, MAX_PART_RETRIES + 1):
113
+ data = _api_post_with_retries(
114
+ session,
115
+ api_url,
116
+ "/api/v1/motion/weights/upload-url",
117
+ api_key,
118
+ {
119
+ "filename": filename,
120
+ "model_name": model_name,
121
+ "content_type": content_type,
122
+ },
123
+ log_warn=log_warn,
124
+ )
125
+ if data is None:
126
+ return None
127
+
128
+ s3_url = data["s3_url"]
129
+ label = "uploading" if attempt == 1 else f"retry {attempt}/{MAX_PART_RETRIES}"
130
+ print(f" ↑ {label} → {s3_url}")
131
+
132
+ try:
133
+ with open(local_path, "rb") as f:
134
+ put = session.put(
135
+ data["upload_url"],
136
+ data=f,
137
+ headers={"Content-Type": content_type},
138
+ timeout=(30, read_timeout),
139
+ )
140
+ put.raise_for_status()
141
+ print(log_ok(f" ✓ uploaded {filename} → {s3_url}"))
142
+ return s3_url
143
+ except Exception as e:
144
+ if attempt < MAX_PART_RETRIES:
145
+ wait = min(30, 2 ** attempt)
146
+ print(log_warn(f" ⚠ upload failed (attempt {attempt}): {e}"))
147
+ print(f" retrying in {wait}s …")
148
+ time.sleep(wait)
149
+ else:
150
+ print(log_err(f" ✗ upload failed after {MAX_PART_RETRIES} attempts: {e}"))
151
+ return None
152
+ return None
153
+
154
+
155
+ def _upload_multipart(
156
+ local_path: str,
157
+ model_name: str,
158
+ api_key: str,
159
+ *,
160
+ api_url: str,
161
+ log_ok: Callable[[str], str],
162
+ log_warn: Callable[[str], str],
163
+ log_err: Callable[[str], str],
164
+ ) -> Optional[str]:
165
+ import math
166
+
167
+ import requests
168
+
169
+ filename = os.path.basename(local_path)
170
+ size_bytes = os.path.getsize(local_path)
171
+ size_mb = size_bytes / 1e6
172
+ content_type = mimetypes.guess_type(local_path)[0] or "application/octet-stream"
173
+ part_size = DEFAULT_PART_SIZE_BYTES
174
+ part_count = max(1, math.ceil(size_bytes / part_size))
175
+
176
+ session = requests.Session()
177
+ print(
178
+ f" ↑ {filename} ({size_mb:.1f} MB) — multipart upload "
179
+ f"({part_count} parts × {part_size / (1024 * 1024):.0f} MiB) …"
180
+ )
181
+
182
+ init = _api_post_with_retries(
183
+ session,
184
+ api_url,
185
+ "/api/v1/motion/weights/multipart/init",
186
+ api_key,
187
+ {
188
+ "filename": filename,
189
+ "model_name": model_name,
190
+ "content_type": content_type,
191
+ "file_size": size_bytes,
192
+ "part_size": part_size,
193
+ },
194
+ log_warn=log_warn,
195
+ )
196
+ if init is None:
197
+ return None
198
+
199
+ upload_id = init["upload_id"]
200
+ s3_key = init["s3_key"]
201
+ s3_url = init["s3_url"]
202
+ print(f" ↑ destination → {s3_url}")
203
+
204
+ completed_parts: list[dict] = []
205
+ upload_failed = False
206
+
207
+ try:
208
+ with open(local_path, "rb") as f:
209
+ for part_number in range(1, part_count + 1):
210
+ chunk = f.read(part_size)
211
+ if not chunk:
212
+ break
213
+
214
+ uploaded = False
215
+ for attempt in range(1, MAX_PART_RETRIES + 1):
216
+ part_meta = _api_post_with_retries(
217
+ session,
218
+ api_url,
219
+ "/api/v1/motion/weights/multipart/part-url",
220
+ api_key,
221
+ {
222
+ "s3_key": s3_key,
223
+ "upload_id": upload_id,
224
+ "part_number": part_number,
225
+ },
226
+ log_warn=log_warn,
227
+ max_attempts=3,
228
+ )
229
+ if part_meta is None:
230
+ upload_failed = True
231
+ break
232
+
233
+ try:
234
+ put = session.put(
235
+ part_meta["upload_url"],
236
+ data=chunk,
237
+ timeout=(30, 600),
238
+ )
239
+ put.raise_for_status()
240
+ etag = put.headers.get("ETag", "").strip('"')
241
+ if not etag:
242
+ raise RuntimeError(f"missing ETag for part {part_number}")
243
+ completed_parts.append(
244
+ {"part_number": part_number, "etag": etag}
245
+ )
246
+ uploaded = True
247
+ pct = 100.0 * part_number / part_count
248
+ print(
249
+ f" part {part_number}/{part_count} "
250
+ f"({pct:.0f}%, {len(chunk) / (1024 * 1024):.1f} MiB)"
251
+ )
252
+ break
253
+ except Exception as e:
254
+ if attempt < MAX_PART_RETRIES:
255
+ wait = min(15, 2 ** attempt)
256
+ print(
257
+ log_warn(
258
+ f" ⚠ part {part_number} failed "
259
+ f"(attempt {attempt}): {e}"
260
+ )
261
+ )
262
+ time.sleep(wait)
263
+ else:
264
+ print(
265
+ log_err(
266
+ f" ✗ part {part_number} failed after "
267
+ f"{MAX_PART_RETRIES} attempts: {e}"
268
+ )
269
+ )
270
+ upload_failed = True
271
+ break
272
+
273
+ if not uploaded:
274
+ upload_failed = True
275
+ break
276
+
277
+ if upload_failed:
278
+ _abort_multipart(session, api_url, api_key, s3_key, upload_id, log_warn)
279
+ return None
280
+
281
+ complete = _api_post_with_retries(
282
+ session,
283
+ api_url,
284
+ "/api/v1/motion/weights/multipart/complete",
285
+ api_key,
286
+ {
287
+ "s3_key": s3_key,
288
+ "upload_id": upload_id,
289
+ "parts": completed_parts,
290
+ },
291
+ log_warn=log_warn,
292
+ )
293
+ if complete is None:
294
+ _abort_multipart(session, api_url, api_key, s3_key, upload_id, log_warn)
295
+ return None
296
+
297
+ final_url = complete.get("s3_url", s3_url)
298
+ print(log_ok(f" ✓ uploaded {filename} → {final_url}"))
299
+ return final_url
300
+
301
+ except Exception as e:
302
+ print(log_err(f" ✗ multipart upload failed: {e}"))
303
+ _abort_multipart(session, api_url, api_key, s3_key, upload_id, log_warn)
304
+ return None
305
+
306
+
307
+ def _abort_multipart(
308
+ session,
309
+ api_url: str,
310
+ api_key: str,
311
+ s3_key: str,
312
+ upload_id: str,
313
+ log_warn: Callable[[str], str],
314
+ ) -> None:
315
+ try:
316
+ _api_post(
317
+ session,
318
+ api_url,
319
+ "/api/v1/motion/weights/multipart/abort",
320
+ api_key,
321
+ {"s3_key": s3_key, "upload_id": upload_id},
322
+ )
323
+ except Exception as e:
324
+ print(log_warn(f" ⚠ could not abort incomplete multipart upload: {e}"))
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: motion-intelligence
3
+ Version: 0.2.0
4
+ Summary: Author, build & deploy Python models for the Motion inference platform
5
+ Project-URL: Homepage, https://github.com/devathub9/Motion-packages
6
+ Project-URL: Repository, https://github.com/devathub9/Motion-packages
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: requests>=2.28
11
+ Requires-Dist: boto3>=1.26
12
+
13
+ # motion-cli (Python)
14
+
15
+ Build and deploy Docker images for models on the Motion inference platform.
16
+
17
+ ```bash
18
+ pip install -e /path/to/cli-py # gives the `motion` command
19
+ export MOTION_API_KEY=motion_live_<your_key>
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ A model is a folder with `motion.yml` + `predict.py`:
25
+
26
+ ```python
27
+ # predict.py
28
+ from motion import BasePredictor, Input, Path
29
+
30
+ class MyPredictor(BasePredictor):
31
+ def setup(self):
32
+ self.model = load_model(...)
33
+
34
+ def predict(self, video: Path = Input(description="Input clip")) -> Path:
35
+ out = "/tmp/out.mp4"
36
+ self.model.run(video, out)
37
+ return Path(out)
38
+ ```
39
+
40
+ ```yaml
41
+ # motion.yml
42
+ name: lingbot-map
43
+ predict: predict.py:MyPredictor
44
+ description: Streaming 3D reconstruction
45
+
46
+ # Where the model appears in GET /api/v1/motion/models
47
+ category: reconstruction # required for catalog listing
48
+ inference_model: lingbot-map # optional; defaults to `name`
49
+ input_type: video # video | image | text
50
+ output_type: mp4
51
+
52
+ # Used by `motion deploy` to measure CPU/RAM via docker stats
53
+ sample: demo_files/input.mp4
54
+
55
+ build:
56
+ gpu: true
57
+ cuda: "11.8"
58
+ python_version: "3.11"
59
+ system_packages: [ffmpeg]
60
+ python_packages: ["torch>=2.4.0"]
61
+
62
+ # Optional — overrides auto-profiling
63
+ compute:
64
+ cpu: 4
65
+ memory_mib: 16384
66
+ gpu: true
67
+
68
+ weights:
69
+ - path: weights/model.pt
70
+ url: https://example.com/model.pt
71
+ dest: weights/model.pt
72
+ ```
73
+
74
+ ## Commands
75
+
76
+ ### `motion build`
77
+
78
+ Uploads local weights to S3 (via `MOTION_API_KEY`, no AWS creds on your machine),
79
+ generates `.motion/Dockerfile`, and builds `motion-<name>:latest`.
80
+
81
+ ```bash
82
+ motion build . # build image
83
+ motion build . --dry-run # print Dockerfile only
84
+ motion build . --tag myimg:dev # custom tag
85
+ ```
86
+
87
+ **Requires:** `MOTION_API_KEY` when `weights:` has local files to upload.
88
+
89
+ Large weights (≥64 MiB) use multipart S3 upload automatically.
90
+
91
+ ### `motion deploy`
92
+
93
+ Profiles the local image, pushes to ECR, and registers the model in the platform.
94
+
95
+ ```bash
96
+ motion deploy .
97
+ motion deploy . --no-profile # skip docker stats measurement
98
+ motion deploy . --sample tests/clip.mp4 # override profiling input
99
+ motion deploy . --profile-timeout 300 # longer profiling window
100
+ ```
101
+
102
+ **Flow:**
103
+ 1. Parse `motion.yml` (including `category`, `sample`, `compute`)
104
+ 2. **Profile** — run the container locally, poll `docker stats`, derive `cpu_vcpus` + `memory_mib`
105
+ - Skipped if `compute:` is set in motion.yml (`compute_source=declared`)
106
+ - Skipped with `--no-profile`
107
+ - Skipped if no sample file is found
108
+ 3. Push image to ECR (backend issues a short-lived docker login token)
109
+ 4. **Register** — `POST /api/v1/motion/models/register` with:
110
+ - `model_code` = `<namespace>/<name>` (e.g. `saurav/lingbot-map`)
111
+ - `category_code`, `inference_model_slug`, compute profile, predict params
112
+
113
+ **Requires:** `MOTION_API_KEY`, local image from `motion build`.
114
+
115
+ **Category codes:** list with `GET /api/v1/motion/models` — e.g. `reconstruction`, `hand-tracking`, `mesh`, `3d-object`.
116
+
117
+ After deploy, submit jobs with:
118
+
119
+ ```bash
120
+ POST /api/v1/motion/submit model_name=<namespace>/<name>
121
+ ```
122
+
123
+ ## Environment
124
+
125
+ | Variable | Purpose |
126
+ |----------|---------|
127
+ | `MOTION_API_KEY` | Auth for build (weight upload) and deploy (ECR + register) |
128
+ | `MOTION_API_URL` | Backend base URL (default: Motion App Runner prod) |
129
+
130
+ ## Image contract
131
+
132
+ Every built image exposes: `GET /health`, `GET /schema`, `POST /predict`.
133
+
134
+ Weights declared in `motion.yml` are fetched from S3 at container startup — not baked into the image.
@@ -0,0 +1,15 @@
1
+ motion/__init__.py,sha256=wntmmPl2ftB947kd3FcEtEF7uX_W5VGyye9ZLDF-JWc,292
2
+ motion/batch.py,sha256=nI_vP5Jy-IjXKV3e1vDK_XvSrAtSuMSaft-66Dvi4P8,5152
3
+ motion/categories.py,sha256=nfJpsBQsiTr8RvpKIR4UEh7wBmmOlt9ccKGlPKiTbUE,1235
4
+ motion/cli.py,sha256=PRoXW3oaHBcZ9RQjiNrJAgELIixJNgZgXtAJ-4JwJ6w,19761
5
+ motion/config.py,sha256=H093udCVsQkywVeEh5o3tYFU5NdToZ4BwdFcn0rmixA,5748
6
+ motion/dockerfile.py,sha256=yTpxRrRAjqlE8TZNsiM20oRPfuu9Nm38sc5Gsz1sZZo,5023
7
+ motion/predictor.py,sha256=hVGaE_0p5ohrx7QJW-fhzkl83oV9cVo42YwGtKInJ84,2211
8
+ motion/profile.py,sha256=5nx0OYox_btGx1DNg-tiq0EOoRVl37emcUp8eZfehSA,9735
9
+ motion/server.py,sha256=3v4I-78mEgcEjJ8Ler-miAscFi5qOCOCGshIgNjCs4g,5066
10
+ motion/weight_upload.py,sha256=J4EZKQjFZUFQ-ZU3UWsb1QvLDcqg0WQkLxOYvfqQSdI,10416
11
+ motion_intelligence-0.2.0.dist-info/METADATA,sha256=qsicn3hYj_WLtHrEx8ZjG7cWGXKURselGGky0kOa0OA,3991
12
+ motion_intelligence-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ motion_intelligence-0.2.0.dist-info/entry_points.txt,sha256=VNMZnZpeWzX2w7CtKSbalOs3mkKSYkeNTudgCkxnQnE,43
14
+ motion_intelligence-0.2.0.dist-info/top_level.txt,sha256=E9RTvbgvBIgO3RWcgGtgIHlM3v-RG0fqt-WzobhO1c0,7
15
+ motion_intelligence-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ motion = motion.cli:main
@@ -0,0 +1 @@
1
+ motion