motionmcp-sdk 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.
- motionmcp/__init__.py +76 -0
- motionmcp/backbone.py +179 -0
- motionmcp/errors.py +122 -0
- motionmcp/gltf.py +378 -0
- motionmcp/null_backbone.py +118 -0
- motionmcp/protocol.py +25 -0
- motionmcp/schemas.py +218 -0
- motionmcp/server.py +335 -0
- motionmcp_sdk-0.1.0.dist-info/METADATA +168 -0
- motionmcp_sdk-0.1.0.dist-info/RECORD +12 -0
- motionmcp_sdk-0.1.0.dist-info/WHEEL +4 -0
- motionmcp_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
motionmcp/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""motionmcp — Python SDK for the Motion Model Context Protocol (MMCP).
|
|
2
|
+
|
|
3
|
+
Build a vendor-neutral motion-generation HTTP server in ~30 lines:
|
|
4
|
+
|
|
5
|
+
from motionmcp import Backbone, ModelSpec, GenerateRequest, MotionResult, serve
|
|
6
|
+
|
|
7
|
+
class MyBackbone(Backbone):
|
|
8
|
+
def capabilities(self) -> ModelSpec:
|
|
9
|
+
return ModelSpec(
|
|
10
|
+
id="my-model",
|
|
11
|
+
fps=30.0,
|
|
12
|
+
supports_retargeting=False,
|
|
13
|
+
supported_constraints=["pose_keyframe"],
|
|
14
|
+
canonical_skeleton=load_skeleton(),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
async def generate(self, req: GenerateRequest) -> MotionResult:
|
|
18
|
+
...
|
|
19
|
+
return MotionResult(rotations=..., root_translations=...)
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
serve(MyBackbone())
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .backbone import Backbone, ModelSpec, MotionResult
|
|
26
|
+
from .errors import ProtocolError
|
|
27
|
+
from .protocol import (
|
|
28
|
+
PROTOCOL_VERSION,
|
|
29
|
+
SUPPORTED_CONSTRAINTS,
|
|
30
|
+
SUPPORTED_GUIDANCE_TYPES,
|
|
31
|
+
DEFAULT_LIMITS,
|
|
32
|
+
)
|
|
33
|
+
from .schemas import (
|
|
34
|
+
Constraint,
|
|
35
|
+
EffectorTargetConstraint,
|
|
36
|
+
GenerateRequest,
|
|
37
|
+
Guidance,
|
|
38
|
+
Joint,
|
|
39
|
+
Options,
|
|
40
|
+
PoseKeyframeConstraint,
|
|
41
|
+
RootPathConstraint,
|
|
42
|
+
Segment,
|
|
43
|
+
Skeleton,
|
|
44
|
+
TextSegment,
|
|
45
|
+
Timing,
|
|
46
|
+
UnconditionedSegment,
|
|
47
|
+
)
|
|
48
|
+
from .server import build_app, serve
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"Backbone",
|
|
52
|
+
"Constraint",
|
|
53
|
+
"DEFAULT_LIMITS",
|
|
54
|
+
"EffectorTargetConstraint",
|
|
55
|
+
"GenerateRequest",
|
|
56
|
+
"Guidance",
|
|
57
|
+
"Joint",
|
|
58
|
+
"ModelSpec",
|
|
59
|
+
"MotionResult",
|
|
60
|
+
"Options",
|
|
61
|
+
"PROTOCOL_VERSION",
|
|
62
|
+
"PoseKeyframeConstraint",
|
|
63
|
+
"ProtocolError",
|
|
64
|
+
"RootPathConstraint",
|
|
65
|
+
"SUPPORTED_CONSTRAINTS",
|
|
66
|
+
"SUPPORTED_GUIDANCE_TYPES",
|
|
67
|
+
"Segment",
|
|
68
|
+
"Skeleton",
|
|
69
|
+
"TextSegment",
|
|
70
|
+
"Timing",
|
|
71
|
+
"UnconditionedSegment",
|
|
72
|
+
"build_app",
|
|
73
|
+
"serve",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
__version__ = "0.1.0"
|
motionmcp/backbone.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Backbone abstraction — what users implement to plug their model into MMCP.
|
|
3
|
+
|
|
4
|
+
Two pieces:
|
|
5
|
+
|
|
6
|
+
* :class:`ModelSpec` — the per-model metadata served at ``GET /capabilities``.
|
|
7
|
+
* :class:`Backbone` — the ABC users subclass; defines :meth:`Backbone.capabilities`
|
|
8
|
+
and :meth:`Backbone.generate`.
|
|
9
|
+
|
|
10
|
+
A :class:`MotionResult` is what :meth:`Backbone.generate` returns: per-sample
|
|
11
|
+
quaternion rotations, root translations, and optional foot-contact masks. The
|
|
12
|
+
SDK encodes this to glTF for you.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import abc
|
|
18
|
+
import inspect
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Awaitable, Sequence
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
24
|
+
|
|
25
|
+
from .protocol import (
|
|
26
|
+
DEFAULT_LIMITS,
|
|
27
|
+
SUPPORTED_GUIDANCE_TYPES,
|
|
28
|
+
)
|
|
29
|
+
from .schemas import GenerateRequest, Skeleton
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---- ModelSpec — published at /capabilities --------------------------------
|
|
33
|
+
|
|
34
|
+
class Limits(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
max_duration_seconds: float = float(DEFAULT_LIMITS["max_duration_seconds"])
|
|
37
|
+
max_num_samples: int = int(DEFAULT_LIMITS["max_num_samples"])
|
|
38
|
+
max_constraints_per_request: int = int(DEFAULT_LIMITS["max_constraints_per_request"])
|
|
39
|
+
max_prompt_length: int = int(DEFAULT_LIMITS["max_prompt_length"])
|
|
40
|
+
max_request_bytes: int = int(DEFAULT_LIMITS["max_request_bytes"])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ModelSpec(BaseModel):
|
|
44
|
+
"""Per-model entry under ``/capabilities.models[]``.
|
|
45
|
+
|
|
46
|
+
All fields except ``id``, ``fps``, and ``canonical_skeleton`` have spec
|
|
47
|
+
defaults — you only have to fill in what differs from the conservative
|
|
48
|
+
baseline.
|
|
49
|
+
"""
|
|
50
|
+
model_config = ConfigDict(extra="forbid")
|
|
51
|
+
|
|
52
|
+
id: str = Field(..., min_length=1)
|
|
53
|
+
fps: float = Field(..., gt=0)
|
|
54
|
+
canonical_skeleton: Skeleton
|
|
55
|
+
|
|
56
|
+
supports_retargeting: bool = False
|
|
57
|
+
supports_async: bool = False
|
|
58
|
+
|
|
59
|
+
supported_constraints: list[str] = Field(default_factory=list)
|
|
60
|
+
supported_guidance_types: list[str] = Field(
|
|
61
|
+
default_factory=lambda: list(SUPPORTED_GUIDANCE_TYPES)
|
|
62
|
+
)
|
|
63
|
+
predicted_contact_joints: list[str] = Field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
native_clip_seconds: float = 10.0
|
|
66
|
+
chunking: str = Field("none", pattern=r"^(none|stitched)$")
|
|
67
|
+
recommended_max_duration_seconds: float = 12.0
|
|
68
|
+
|
|
69
|
+
limits: Limits = Field(default_factory=Limits)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---- MotionResult — what generate() returns --------------------------------
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class MotionResult:
|
|
76
|
+
"""The output of :meth:`Backbone.generate`.
|
|
77
|
+
|
|
78
|
+
Shape conventions (B = num_samples, T = num_frames, J = num_joints):
|
|
79
|
+
|
|
80
|
+
* ``rotations``: ``(B, T, J, 4)`` quaternions ``(x, y, z, w)``, local-to-parent.
|
|
81
|
+
* ``root_translations``: ``(B, T, 3)`` world-space root positions.
|
|
82
|
+
* ``joint_names``: optional ``(J,)`` list. Defaults to the request skeleton's
|
|
83
|
+
joint order. Provide this only if your model retargeted internally and
|
|
84
|
+
you need to declare a different output order — the SDK will validate
|
|
85
|
+
that ``joint_names`` matches the request skeleton (or your declared
|
|
86
|
+
``canonical_to_request`` map).
|
|
87
|
+
* ``foot_contacts``: optional ``{joint_name: (B, T) bool array}``. Joint
|
|
88
|
+
names must exist in the request skeleton.
|
|
89
|
+
* ``chunk_boundaries``: optional per-sample list of frame indices where
|
|
90
|
+
the server stitched internal chunks. Empty / None means no stitching.
|
|
91
|
+
* ``canonical_to_request``: optional ``{canonical_joint: request_joint}``
|
|
92
|
+
map echoed in the response extension. Only populate when the server
|
|
93
|
+
actually retargeted.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
rotations: np.ndarray
|
|
97
|
+
root_translations: np.ndarray
|
|
98
|
+
joint_names: Sequence[str] | None = None
|
|
99
|
+
foot_contacts: dict[str, np.ndarray] = field(default_factory=dict)
|
|
100
|
+
chunk_boundaries: Sequence[Sequence[int]] | None = None
|
|
101
|
+
canonical_to_request: dict[str, str] | None = None
|
|
102
|
+
|
|
103
|
+
def __post_init__(self) -> None:
|
|
104
|
+
r = self.rotations
|
|
105
|
+
if r.ndim != 4 or r.shape[-1] != 4:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"rotations must be (B, T, J, 4); got {r.shape}"
|
|
108
|
+
)
|
|
109
|
+
B, T, J, _ = r.shape
|
|
110
|
+
t = self.root_translations
|
|
111
|
+
if t.shape != (B, T, 3):
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"root_translations must be ({B}, {T}, 3); got {t.shape}"
|
|
114
|
+
)
|
|
115
|
+
for joint, mask in self.foot_contacts.items():
|
|
116
|
+
if mask.shape != (B, T):
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"foot_contacts[{joint!r}] must be ({B}, {T}); "
|
|
119
|
+
f"got {mask.shape}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def num_samples(self) -> int:
|
|
124
|
+
return int(self.rotations.shape[0])
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def num_frames(self) -> int:
|
|
128
|
+
return int(self.rotations.shape[1])
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def num_joints(self) -> int:
|
|
132
|
+
return int(self.rotations.shape[2])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---- Backbone ABC ----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
class Backbone(abc.ABC):
|
|
138
|
+
"""The contract a user implements to plug their motion model into MMCP.
|
|
139
|
+
|
|
140
|
+
Subclasses MUST implement:
|
|
141
|
+
|
|
142
|
+
* :meth:`capabilities` — returns a :class:`ModelSpec` describing the model.
|
|
143
|
+
* :meth:`generate` — runs the model on a validated :class:`GenerateRequest`
|
|
144
|
+
and returns a :class:`MotionResult`.
|
|
145
|
+
|
|
146
|
+
:meth:`generate` may be ``async def`` or a regular function — the SDK
|
|
147
|
+
handles both. Async is preferred when the underlying model has any I/O or
|
|
148
|
+
benefits from yielding (queueing on a GPU worker, awaiting a remote call).
|
|
149
|
+
|
|
150
|
+
Raise :class:`~motionmcp.errors.ProtocolError` (or one of its convenience
|
|
151
|
+
constructors in :mod:`motionmcp.errors`) for protocol-level failures —
|
|
152
|
+
the SDK converts them to the standard error envelope. Any other exception
|
|
153
|
+
becomes a generic ``500 internal_error``.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
@abc.abstractmethod
|
|
157
|
+
def capabilities(self) -> ModelSpec:
|
|
158
|
+
"""Return the per-model entry that goes under ``/capabilities.models[]``."""
|
|
159
|
+
|
|
160
|
+
@abc.abstractmethod
|
|
161
|
+
def generate(self, request: GenerateRequest) -> MotionResult | Awaitable[MotionResult]:
|
|
162
|
+
"""Run motion generation for one validated request.
|
|
163
|
+
|
|
164
|
+
May be implemented as either ``def`` or ``async def``.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
# Optional hook: called once at server startup. Use it to load model
|
|
168
|
+
# weights, allocate GPU buffers, warm caches.
|
|
169
|
+
def setup(self) -> None: # pragma: no cover - default no-op
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
# Optional hook: called once at server shutdown.
|
|
173
|
+
def teardown(self) -> None: # pragma: no cover - default no-op
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def is_async_generate(backbone: Backbone) -> bool:
|
|
178
|
+
"""Return True if the backbone's ``generate`` is an async coroutine function."""
|
|
179
|
+
return inspect.iscoroutinefunction(backbone.generate)
|
motionmcp/errors.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""MMCP error envelope and protocol-level exceptions.
|
|
3
|
+
|
|
4
|
+
The wire format is a single shape::
|
|
5
|
+
|
|
6
|
+
{"error": {"code": "...", "message": "...", "details": {...}}}
|
|
7
|
+
|
|
8
|
+
Backbone code raises :class:`ProtocolError` (or one of the typed subclasses)
|
|
9
|
+
to signal a failure; the SDK's FastAPI layer maps it to the right HTTP status
|
|
10
|
+
via :data:`STATUS_FOR` and serialises the envelope.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Code → HTTP status. The set is closed at v1.0; new codes go in minor versions.
|
|
19
|
+
STATUS_FOR: dict[str, int] = {
|
|
20
|
+
"schema_validation": 422,
|
|
21
|
+
"unknown_model": 400,
|
|
22
|
+
"retargeting_unsupported": 400,
|
|
23
|
+
"unsupported_constraint": 400,
|
|
24
|
+
"unknown_joint": 400,
|
|
25
|
+
"invalid_skeleton": 400,
|
|
26
|
+
"frame_out_of_range": 400,
|
|
27
|
+
"invalid_options": 400,
|
|
28
|
+
"version_unsupported": 400,
|
|
29
|
+
"unauthorized": 401,
|
|
30
|
+
"forbidden": 403,
|
|
31
|
+
"payload_too_large": 413,
|
|
32
|
+
"rate_limited": 429,
|
|
33
|
+
"internal_error": 500,
|
|
34
|
+
"resource_exhausted": 503,
|
|
35
|
+
"model_unavailable": 503,
|
|
36
|
+
"timeout": 504,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProtocolError(Exception):
|
|
41
|
+
"""Raised by handlers to signal a protocol-level failure.
|
|
42
|
+
|
|
43
|
+
The FastAPI app catches this and serialises it to the MMCP error envelope.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
code: str,
|
|
49
|
+
message: str,
|
|
50
|
+
details: dict[str, Any] | None = None,
|
|
51
|
+
):
|
|
52
|
+
if code not in STATUS_FOR:
|
|
53
|
+
raise ValueError(f"unknown protocol error code: {code!r}")
|
|
54
|
+
super().__init__(f"{code}: {message}")
|
|
55
|
+
self.code = code
|
|
56
|
+
self.message = message
|
|
57
|
+
self.details = details or {}
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def status_code(self) -> int:
|
|
61
|
+
return STATUS_FOR[self.code]
|
|
62
|
+
|
|
63
|
+
def to_envelope(self) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"error": {
|
|
66
|
+
"code": self.code,
|
|
67
|
+
"message": self.message,
|
|
68
|
+
"details": self.details,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---- Convenience constructors for common cases ---------------------------
|
|
74
|
+
|
|
75
|
+
def unknown_model(model_id: str, available: list[str]) -> ProtocolError:
|
|
76
|
+
return ProtocolError(
|
|
77
|
+
"unknown_model",
|
|
78
|
+
f"model {model_id!r} is not registered with this server",
|
|
79
|
+
details={"available_models": available},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def unsupported_constraint(
|
|
84
|
+
constraint_type: str, supported: list[str]
|
|
85
|
+
) -> ProtocolError:
|
|
86
|
+
return ProtocolError(
|
|
87
|
+
"unsupported_constraint",
|
|
88
|
+
f"constraint type {constraint_type!r} is not supported by this model",
|
|
89
|
+
details={"supported_constraints": supported},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def unknown_joint(joint: str, skeleton_joints: list[str]) -> ProtocolError:
|
|
94
|
+
return ProtocolError(
|
|
95
|
+
"unknown_joint",
|
|
96
|
+
f"joint {joint!r} is not in the request skeleton",
|
|
97
|
+
details={"skeleton_joints": skeleton_joints},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def frame_out_of_range(frame: int, total_frames: int) -> ProtocolError:
|
|
102
|
+
return ProtocolError(
|
|
103
|
+
"frame_out_of_range",
|
|
104
|
+
f"frame {frame} is outside [0, {total_frames - 1}]",
|
|
105
|
+
details={"frame": frame, "total_frames": total_frames},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def retargeting_unsupported() -> ProtocolError:
|
|
110
|
+
return ProtocolError(
|
|
111
|
+
"retargeting_unsupported",
|
|
112
|
+
"this model does not support retargeting; send the canonical "
|
|
113
|
+
"skeleton verbatim from /capabilities",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def version_unsupported(requested: str, supported_majors: list[str]) -> ProtocolError:
|
|
118
|
+
return ProtocolError(
|
|
119
|
+
"version_unsupported",
|
|
120
|
+
f"protocol_version {requested!r} is not supported by this server",
|
|
121
|
+
details={"supported_majors": supported_majors},
|
|
122
|
+
)
|