myosdk 0.1.0__tar.gz
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.
- myosdk-0.1.0/.gitignore +42 -0
- myosdk-0.1.0/PKG-INFO +61 -0
- myosdk-0.1.0/README.md +49 -0
- myosdk-0.1.0/myosdk/__init__.py +25 -0
- myosdk-0.1.0/myosdk/assets.py +350 -0
- myosdk-0.1.0/myosdk/characters.py +53 -0
- myosdk-0.1.0/myosdk/client.py +55 -0
- myosdk-0.1.0/myosdk/exceptions.py +68 -0
- myosdk-0.1.0/myosdk/http.py +283 -0
- myosdk-0.1.0/myosdk/jobs.py +241 -0
- myosdk-0.1.0/pyproject.toml +27 -0
- myosdk-0.1.0/pytest.ini +8 -0
- myosdk-0.1.0/tests/__init__.py +1 -0
- myosdk-0.1.0/tests/notebook.ipynb +426 -0
- myosdk-0.1.0/tests/test_client.py +45 -0
- myosdk-0.1.0/tests/test_download_dict.py +106 -0
- myosdk-0.1.0/tests/test_integration.py +364 -0
- myosdk-0.1.0/tests/test_jobs_characters.py +73 -0
- myosdk-0.1.0/tests/test_upload_file.py +157 -0
- myosdk-0.1.0/uv.lock +274 -0
myosdk-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
|
|
12
|
+
tmp
|
|
13
|
+
*.zip
|
|
14
|
+
*.tar.gz
|
|
15
|
+
|
|
16
|
+
.env
|
|
17
|
+
|
|
18
|
+
.clinerules
|
|
19
|
+
.kiro
|
|
20
|
+
memory-bank
|
|
21
|
+
tmp-assets
|
|
22
|
+
_backoffice
|
|
23
|
+
|
|
24
|
+
# Terraform
|
|
25
|
+
*.tfstate
|
|
26
|
+
*.tfstate.*
|
|
27
|
+
.terraform/
|
|
28
|
+
.terraform.lock.hcl
|
|
29
|
+
terraform.tfvars
|
|
30
|
+
*.auto.tfvars
|
|
31
|
+
infra/terraform/envs/*.tfvars
|
|
32
|
+
infra/.ecr_url
|
|
33
|
+
infra/deployment_outputs.env
|
|
34
|
+
infra/sagemaker_vpc_config.txt
|
|
35
|
+
infra/terraform/keys/
|
|
36
|
+
|
|
37
|
+
# Docs
|
|
38
|
+
site/
|
|
39
|
+
|
|
40
|
+
vendored/
|
|
41
|
+
|
|
42
|
+
.cursor/
|
myosdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: myosdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for MyoSapiens API
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.28.1
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest-mock>=3.12.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# MyoSapiens Python SDK (MyoSDK)
|
|
14
|
+
|
|
15
|
+
A lightweight Python client for the MyoSapiens API.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install myosdk
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
### Prerequisites
|
|
26
|
+
Before installing the SDK, ensure you have:
|
|
27
|
+
|
|
28
|
+
- **Python 3.10+**
|
|
29
|
+
- **A MyoSapiens API key**, available at [dev.myolab.ai](dev.myolab.ai)
|
|
30
|
+
|
|
31
|
+
### Basic Usage
|
|
32
|
+
``` python
|
|
33
|
+
from myosdk import Client
|
|
34
|
+
|
|
35
|
+
client = Client(api_key="ak_live_xxx")
|
|
36
|
+
|
|
37
|
+
# Upload a C3D motion capture file
|
|
38
|
+
c3d_asset = client.assets.upload_file(<c3d_path>)
|
|
39
|
+
|
|
40
|
+
# Upload markerset XML file
|
|
41
|
+
markerset_asset = client.assets.upload_file(<markerset_path>)
|
|
42
|
+
|
|
43
|
+
# Run tracking
|
|
44
|
+
job = client.jobs.start_retarget(
|
|
45
|
+
tracker_asset_id=c3d_asset["asset_id"],
|
|
46
|
+
markerset_asset_id=markerset_asset["asset_id"],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Wait for completion
|
|
50
|
+
result = client.jobs.wait(job["job_id"])
|
|
51
|
+
|
|
52
|
+
# Download the resulting joint angles and joint names(.npz)
|
|
53
|
+
joint_angle_asset_id = result["output"]["retarget_output_asset_id"]
|
|
54
|
+
client.assets.download(joint_angle_asset_id, <output_npy_path>)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Examples and Tutorials
|
|
58
|
+
See examples and tutorials at [https://github.com/myolab/myosdk_tutorials](https://github.com/myolab/myosdk_tutorials)
|
|
59
|
+
|
|
60
|
+
## SDK Documentation
|
|
61
|
+
Full SDK and API documentation is available at [docs.myolab.ai](docs.myolab.ai).
|
myosdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# MyoSapiens Python SDK (MyoSDK)
|
|
2
|
+
|
|
3
|
+
A lightweight Python client for the MyoSapiens API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install myosdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### Prerequisites
|
|
14
|
+
Before installing the SDK, ensure you have:
|
|
15
|
+
|
|
16
|
+
- **Python 3.10+**
|
|
17
|
+
- **A MyoSapiens API key**, available at [dev.myolab.ai](dev.myolab.ai)
|
|
18
|
+
|
|
19
|
+
### Basic Usage
|
|
20
|
+
``` python
|
|
21
|
+
from myosdk import Client
|
|
22
|
+
|
|
23
|
+
client = Client(api_key="ak_live_xxx")
|
|
24
|
+
|
|
25
|
+
# Upload a C3D motion capture file
|
|
26
|
+
c3d_asset = client.assets.upload_file(<c3d_path>)
|
|
27
|
+
|
|
28
|
+
# Upload markerset XML file
|
|
29
|
+
markerset_asset = client.assets.upload_file(<markerset_path>)
|
|
30
|
+
|
|
31
|
+
# Run tracking
|
|
32
|
+
job = client.jobs.start_retarget(
|
|
33
|
+
tracker_asset_id=c3d_asset["asset_id"],
|
|
34
|
+
markerset_asset_id=markerset_asset["asset_id"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Wait for completion
|
|
38
|
+
result = client.jobs.wait(job["job_id"])
|
|
39
|
+
|
|
40
|
+
# Download the resulting joint angles and joint names(.npz)
|
|
41
|
+
joint_angle_asset_id = result["output"]["retarget_output_asset_id"]
|
|
42
|
+
client.assets.download(joint_angle_asset_id, <output_npy_path>)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Examples and Tutorials
|
|
46
|
+
See examples and tutorials at [https://github.com/myolab/myosdk_tutorials](https://github.com/myolab/myosdk_tutorials)
|
|
47
|
+
|
|
48
|
+
## SDK Documentation
|
|
49
|
+
Full SDK and API documentation is available at [docs.myolab.ai](docs.myolab.ai).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""MyoSapiens Python SDK."""
|
|
2
|
+
|
|
3
|
+
from myosdk.characters import Characters
|
|
4
|
+
from myosdk.client import Client
|
|
5
|
+
from myosdk.exceptions import (
|
|
6
|
+
APIError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
ServerError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Client",
|
|
16
|
+
"APIError",
|
|
17
|
+
"AuthenticationError",
|
|
18
|
+
"NotFoundError",
|
|
19
|
+
"RateLimitError",
|
|
20
|
+
"ServerError",
|
|
21
|
+
"ValidationError",
|
|
22
|
+
"Characters",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Assets resource for managing file uploads and downloads."""
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
import time
|
|
5
|
+
from io import BufferedReader
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, BinaryIO
|
|
8
|
+
|
|
9
|
+
from myosdk.http import HTTPClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Assets:
|
|
13
|
+
"""Assets resource for managing file uploads and downloads."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, http_client: HTTPClient):
|
|
16
|
+
"""Initialize Assets resource.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
http_client: HTTP client instance
|
|
20
|
+
"""
|
|
21
|
+
self._http = http_client
|
|
22
|
+
|
|
23
|
+
def initiate(
|
|
24
|
+
self,
|
|
25
|
+
purpose: str,
|
|
26
|
+
filename: str,
|
|
27
|
+
content_type: str = "application/octet-stream",
|
|
28
|
+
expected_size_bytes: int | None = None,
|
|
29
|
+
metadata: dict | None = None,
|
|
30
|
+
) -> dict:
|
|
31
|
+
"""Initiate an asset upload.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
purpose: Asset purpose (e.g., "video", "trackers")
|
|
35
|
+
filename: Original filename
|
|
36
|
+
content_type: MIME type of the file
|
|
37
|
+
expected_size_bytes: Optional expected file size
|
|
38
|
+
metadata: Optional metadata dict
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Dict with asset_id, upload_url, fields, expires_at
|
|
42
|
+
"""
|
|
43
|
+
payload = {
|
|
44
|
+
"purpose": purpose,
|
|
45
|
+
"filename": filename,
|
|
46
|
+
"content_type": content_type,
|
|
47
|
+
}
|
|
48
|
+
if expected_size_bytes is not None:
|
|
49
|
+
payload["expected_size_bytes"] = expected_size_bytes
|
|
50
|
+
if metadata is not None:
|
|
51
|
+
payload["metadata"] = metadata
|
|
52
|
+
|
|
53
|
+
return self._http.post("/v1/assets/initiate", json=payload)
|
|
54
|
+
|
|
55
|
+
def upload(
|
|
56
|
+
self, asset: dict | str, file_like: BinaryIO | BufferedReader | Path | str
|
|
57
|
+
) -> dict:
|
|
58
|
+
"""Upload a file to a presigned URL.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
asset: Asset dict from initiate() (must contain upload_url and fields)
|
|
62
|
+
file_like: File-like object, Path, or file path string
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Dict with upload_time_seconds
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If asset dict is missing required fields
|
|
69
|
+
"""
|
|
70
|
+
# Asset dict from initiate() should have upload_url and fields directly
|
|
71
|
+
if isinstance(asset, str):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"upload() requires asset dict from initiate(), not asset_id. Use initiate() first."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
upload_url = asset.get("upload_url")
|
|
77
|
+
fields = asset.get("fields", {})
|
|
78
|
+
|
|
79
|
+
if not upload_url:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"Asset dict missing upload_url. Make sure to use the dict returned by initiate()."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Handle file input
|
|
85
|
+
if isinstance(file_like, str | Path):
|
|
86
|
+
file_path = Path(file_like)
|
|
87
|
+
file_obj = open(file_path, "rb")
|
|
88
|
+
filename = file_path.name
|
|
89
|
+
should_close = True
|
|
90
|
+
else:
|
|
91
|
+
file_obj = file_like
|
|
92
|
+
filename = getattr(file_obj, "name", "file")
|
|
93
|
+
should_close = False
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
# Upload to presigned POST URL
|
|
97
|
+
# For presigned POST, we need to send fields + file as multipart/form-data
|
|
98
|
+
# httpx accepts files in format: {"file": (filename, file_obj, content_type)} or {"file": (filename, file_obj)}
|
|
99
|
+
content_type = (
|
|
100
|
+
getattr(file_obj, "content_type", None) or "application/octet-stream"
|
|
101
|
+
)
|
|
102
|
+
files = {"file": (filename, file_obj, content_type)}
|
|
103
|
+
|
|
104
|
+
start_time = time.perf_counter()
|
|
105
|
+
response = self._http.post_multipart(upload_url, data=fields, files=files)
|
|
106
|
+
upload_time = time.perf_counter() - start_time
|
|
107
|
+
|
|
108
|
+
# Presigned POST typically returns 204 or 201, but we don't need to parse JSON
|
|
109
|
+
# Just verify it was successful
|
|
110
|
+
if response.status_code not in (200, 201, 204):
|
|
111
|
+
raise ValueError(f"Upload failed with status {response.status_code}")
|
|
112
|
+
|
|
113
|
+
return {"upload_time_seconds": upload_time}
|
|
114
|
+
finally:
|
|
115
|
+
if should_close:
|
|
116
|
+
file_obj.close()
|
|
117
|
+
|
|
118
|
+
def complete(self, asset_id: str) -> dict:
|
|
119
|
+
"""Complete an asset upload.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
asset_id: Asset identifier
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Dict with asset_id, verified, size_bytes, checksum_sha256, message
|
|
126
|
+
"""
|
|
127
|
+
return self._http.post(f"/v1/assets/{asset_id}/complete")
|
|
128
|
+
|
|
129
|
+
def get(self, asset_id: str) -> dict:
|
|
130
|
+
"""Get asset details.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
asset_id: Asset identifier
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Dict with asset details including download_url
|
|
137
|
+
"""
|
|
138
|
+
return self._http.get(f"/v1/assets/{asset_id}")
|
|
139
|
+
|
|
140
|
+
def download(self, asset: str | dict, destination: str | Path) -> dict:
|
|
141
|
+
"""Download an asset to a local file.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
asset: Asset identifier (string) or asset dict from get()
|
|
145
|
+
destination: Destination file path
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Dict with download_time_seconds and size_bytes
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ValueError: If asset doesn't have a download_url
|
|
152
|
+
"""
|
|
153
|
+
# Accept either asset_id string or asset dict for convenience
|
|
154
|
+
if isinstance(asset, dict):
|
|
155
|
+
asset_data = asset
|
|
156
|
+
asset_id = asset.get("asset_id")
|
|
157
|
+
else:
|
|
158
|
+
asset_id = asset
|
|
159
|
+
asset_data = self.get(asset_id)
|
|
160
|
+
|
|
161
|
+
download_url = asset_data.get("download_url")
|
|
162
|
+
|
|
163
|
+
if not download_url:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"Asset {asset_id} does not have a download_url (might not be ready yet)"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Download from presigned URL
|
|
169
|
+
start_time = time.perf_counter()
|
|
170
|
+
response = self._http.get_raw(download_url)
|
|
171
|
+
response.raise_for_status()
|
|
172
|
+
|
|
173
|
+
# Write to file
|
|
174
|
+
destination_path = Path(destination)
|
|
175
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
with open(destination_path, "wb") as f:
|
|
177
|
+
f.write(response.content)
|
|
178
|
+
|
|
179
|
+
download_time = time.perf_counter() - start_time
|
|
180
|
+
size_bytes = len(response.content)
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
"download_time_seconds": download_time,
|
|
184
|
+
"size_bytes": size_bytes,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
def list(
|
|
188
|
+
self,
|
|
189
|
+
purpose: str | None = None,
|
|
190
|
+
reference_count: int | None = None,
|
|
191
|
+
limit: int = 50,
|
|
192
|
+
offset: int = 0,
|
|
193
|
+
) -> dict:
|
|
194
|
+
"""List assets.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
purpose: Filter by purpose
|
|
198
|
+
reference_count: Filter by reference count
|
|
199
|
+
limit: Items per page
|
|
200
|
+
offset: Items to skip
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Dict with assets list and pagination info
|
|
204
|
+
"""
|
|
205
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
206
|
+
if purpose is not None:
|
|
207
|
+
params["purpose"] = purpose
|
|
208
|
+
if reference_count is not None:
|
|
209
|
+
params["reference_count"] = reference_count
|
|
210
|
+
|
|
211
|
+
return self._http.get("/v1/assets", params=params)
|
|
212
|
+
|
|
213
|
+
def delete(self, asset_id: str) -> None:
|
|
214
|
+
"""Delete an asset.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
asset_id: Asset identifier
|
|
218
|
+
"""
|
|
219
|
+
self._http.delete(f"/v1/assets/{asset_id}")
|
|
220
|
+
|
|
221
|
+
def upload_file(
|
|
222
|
+
self,
|
|
223
|
+
file_path: str | Path,
|
|
224
|
+
purpose: str | None = None,
|
|
225
|
+
metadata: dict[str, Any] | None = None,
|
|
226
|
+
) -> dict:
|
|
227
|
+
"""Upload a file in one step (convenience method).
|
|
228
|
+
|
|
229
|
+
This is a high-level method that handles initiate → upload → complete automatically.
|
|
230
|
+
It auto-detects filename, content_type, and purpose from the file.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
file_path: Path to the file to upload
|
|
234
|
+
purpose: Asset purpose (auto-detected if not provided: video files → "video", .pkl → "trackers")
|
|
235
|
+
metadata: Optional metadata dict
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Dict with completed asset details including asset_id, verified, size_bytes, checksum_sha256,
|
|
239
|
+
and timings dict with initiate_seconds, upload_seconds, complete_seconds, total_seconds
|
|
240
|
+
|
|
241
|
+
Example:
|
|
242
|
+
>>> asset = client.assets.upload_file("walk.mp4")
|
|
243
|
+
>>> print(asset["asset_id"])
|
|
244
|
+
>>> print(asset["timings"]["total_seconds"])
|
|
245
|
+
"""
|
|
246
|
+
path = Path(file_path)
|
|
247
|
+
if not path.exists():
|
|
248
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
249
|
+
|
|
250
|
+
# Auto-detect purpose from file extension if not provided
|
|
251
|
+
if purpose is None:
|
|
252
|
+
purpose = self._detect_purpose(path)
|
|
253
|
+
|
|
254
|
+
# Auto-detect content type from file extension
|
|
255
|
+
content_type = self._detect_content_type(path)
|
|
256
|
+
|
|
257
|
+
# Get file size for validation
|
|
258
|
+
file_size = path.stat().st_size
|
|
259
|
+
|
|
260
|
+
# Step 1: Initiate upload
|
|
261
|
+
initiate_start = time.perf_counter()
|
|
262
|
+
asset = self.initiate(
|
|
263
|
+
purpose=purpose,
|
|
264
|
+
filename=path.name,
|
|
265
|
+
content_type=content_type,
|
|
266
|
+
expected_size_bytes=file_size,
|
|
267
|
+
metadata=metadata,
|
|
268
|
+
)
|
|
269
|
+
initiate_time = time.perf_counter() - initiate_start
|
|
270
|
+
|
|
271
|
+
# Step 2: Upload file
|
|
272
|
+
with open(path, "rb") as f:
|
|
273
|
+
upload_result = self.upload(asset, f)
|
|
274
|
+
upload_time = upload_result["upload_time_seconds"]
|
|
275
|
+
|
|
276
|
+
# Step 3: Complete upload
|
|
277
|
+
complete_start = time.perf_counter()
|
|
278
|
+
result = self.complete(asset["asset_id"])
|
|
279
|
+
complete_time = time.perf_counter() - complete_start
|
|
280
|
+
|
|
281
|
+
# Add timing breakdown to result
|
|
282
|
+
total_time = initiate_time + upload_time + complete_time
|
|
283
|
+
result["timings"] = {
|
|
284
|
+
"initiate_seconds": initiate_time,
|
|
285
|
+
"upload_seconds": upload_time,
|
|
286
|
+
"complete_seconds": complete_time,
|
|
287
|
+
"total_seconds": total_time,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
def _detect_purpose(self, path: Path) -> str:
|
|
293
|
+
"""Detect asset purpose from file extension.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
path: File path
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Purpose string ("video", "trackers", or "retarget")
|
|
300
|
+
"""
|
|
301
|
+
extension = path.suffix.lower()
|
|
302
|
+
|
|
303
|
+
# Video extensions
|
|
304
|
+
video_extensions = {".mp4", ".mov", ".avi", ".webm", ".mkv", ".flv", ".wmv"}
|
|
305
|
+
if extension in video_extensions:
|
|
306
|
+
return "video"
|
|
307
|
+
|
|
308
|
+
# Tracker/pickle files
|
|
309
|
+
tracker_extensions = {".pkl", ".pickle"}
|
|
310
|
+
if extension in tracker_extensions:
|
|
311
|
+
return "trackers"
|
|
312
|
+
|
|
313
|
+
# Retarget input files (C3D/TRC motion capture, markerset XML, parquet trackers)
|
|
314
|
+
retarget_extensions = {".c3d", ".trc", ".xml", ".parquet"}
|
|
315
|
+
if extension in retarget_extensions:
|
|
316
|
+
return "retarget"
|
|
317
|
+
|
|
318
|
+
# Default to video for unknown types (conservative choice)
|
|
319
|
+
return "video"
|
|
320
|
+
|
|
321
|
+
def _detect_content_type(self, path: Path) -> str:
|
|
322
|
+
"""Detect MIME type from file extension.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
path: File path
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
MIME type string
|
|
329
|
+
"""
|
|
330
|
+
# Try to guess from extension
|
|
331
|
+
content_type, _ = mimetypes.guess_type(str(path))
|
|
332
|
+
|
|
333
|
+
if content_type:
|
|
334
|
+
return content_type
|
|
335
|
+
|
|
336
|
+
# Fallback for common cases
|
|
337
|
+
extension = path.suffix.lower()
|
|
338
|
+
fallback_types = {
|
|
339
|
+
".mp4": "video/mp4",
|
|
340
|
+
".mov": "video/quicktime",
|
|
341
|
+
".avi": "video/x-msvideo",
|
|
342
|
+
".webm": "video/webm",
|
|
343
|
+
".pkl": "application/octet-stream",
|
|
344
|
+
".pickle": "application/octet-stream",
|
|
345
|
+
".parquet": "application/parquet",
|
|
346
|
+
".c3d": "application/octet-stream",
|
|
347
|
+
".trc": "text/plain",
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return fallback_types.get(extension, "application/octet-stream")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Characters resource for browsing available characters and versions."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from myosdk.http import HTTPClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Characters:
|
|
9
|
+
"""Read-only character catalog."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, http_client: HTTPClient):
|
|
12
|
+
"""Initialize Characters resource."""
|
|
13
|
+
self._http = http_client
|
|
14
|
+
|
|
15
|
+
def list(
|
|
16
|
+
self,
|
|
17
|
+
name_contains: str | None = None,
|
|
18
|
+
has_ready_versions: bool | None = None,
|
|
19
|
+
limit: int = 50,
|
|
20
|
+
offset: int = 0,
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
"""List available characters with optional filtering.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
name_contains: Filter by substring match on name
|
|
26
|
+
has_ready_versions: Only include characters that have READY versions
|
|
27
|
+
limit: Items per page
|
|
28
|
+
offset: Items to skip
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Dict containing character list and pagination info
|
|
32
|
+
"""
|
|
33
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
34
|
+
if name_contains:
|
|
35
|
+
params["name_contains"] = name_contains
|
|
36
|
+
if has_ready_versions is not None:
|
|
37
|
+
params["has_ready_versions"] = has_ready_versions
|
|
38
|
+
|
|
39
|
+
return self._http.get("/v1/characters", params=params)
|
|
40
|
+
|
|
41
|
+
def get(self, character_id: str) -> dict[str, Any]:
|
|
42
|
+
"""Get details for a single character."""
|
|
43
|
+
return self._http.get(f"/v1/characters/{character_id}")
|
|
44
|
+
|
|
45
|
+
def validate_manifest(self, character_id: str, version: str) -> dict[str, Any]:
|
|
46
|
+
"""Validate that a character manifest exists in storage."""
|
|
47
|
+
if not version:
|
|
48
|
+
raise ValueError("version is required")
|
|
49
|
+
|
|
50
|
+
return self._http.post(
|
|
51
|
+
f"/v1/characters/{character_id}/validate-manifest",
|
|
52
|
+
params={"version": version},
|
|
53
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Main client class for the SDK."""
|
|
2
|
+
|
|
3
|
+
from myosdk.assets import Assets
|
|
4
|
+
from myosdk.characters import Characters
|
|
5
|
+
from myosdk.http import HTTPClient
|
|
6
|
+
from myosdk.jobs import Jobs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Client:
|
|
10
|
+
"""Main client for interacting with the MyoSapiens API."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
api_key: str,
|
|
15
|
+
base_url: str = "https://v2m-alb-us-east-1.myolab.ai",
|
|
16
|
+
timeout: float = 30.0,
|
|
17
|
+
):
|
|
18
|
+
"""Initialize the client.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
api_key: API key for authentication
|
|
22
|
+
base_url: Base URL of the API (default: https://v2m-alb-us-east-1.myolab.ai)
|
|
23
|
+
timeout: Request timeout in seconds (default: 30.0)
|
|
24
|
+
"""
|
|
25
|
+
self._http = HTTPClient(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
26
|
+
self._assets = Assets(self._http)
|
|
27
|
+
self._jobs = Jobs(self._http)
|
|
28
|
+
self._characters = Characters(self._http)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def assets(self) -> Assets:
|
|
32
|
+
"""Access the assets resource."""
|
|
33
|
+
return self._assets
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def jobs(self) -> Jobs:
|
|
37
|
+
"""Access the jobs resource."""
|
|
38
|
+
return self._jobs
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def characters(self) -> Characters:
|
|
42
|
+
"""Access the characters catalog."""
|
|
43
|
+
return self._characters
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
"""Close the HTTP client and release resources."""
|
|
47
|
+
self._http.close()
|
|
48
|
+
|
|
49
|
+
def __enter__(self):
|
|
50
|
+
"""Context manager entry."""
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
54
|
+
"""Context manager exit."""
|
|
55
|
+
self.close()
|