Backbone
Backbone is the only thing you implement. It has two required methods —
capabilities() and generate() — plus two optional lifecycle hooks.
from motionmcp import Backbone, ModelSpec, GenerateRequest, MotionResult
class MyBackbone(Backbone):
def setup(self) -> None: ... # optional
def teardown(self) -> None: ... # optional
def capabilities(self) -> ModelSpec: ...
async def generate(self, req: GenerateRequest) -> MotionResult: ...
capabilities()
Returns a ModelSpec describing the model. The SDK serialises it into
the per-model entry under /capabilities.models[].
ModelSpec is a Pydantic model with sensible defaults — only id,
fps, and canonical_skeleton are required.
from motionmcp import ModelSpec, Skeleton, Joint
ModelSpec(
id="my-model-v1",
fps=30.0,
canonical_skeleton=Skeleton(joints=[
Joint(name="Hips", parent=None,
rest_translation=(0.0, 0.95, 0.0),
rest_rotation=(0.0, 0.0, 0.0, 1.0)),
]),
# All fields below are optional with the defaults shown:
supports_retargeting=False,
supports_async=False,
supported_constraints=[], # empty = the model accepts no constraints
supported_guidance_types=["nocfg", "regular", "separated"],
predicted_contact_joints=[],
native_clip_seconds=10.0,
chunking="none", # or "stitched"
recommended_max_duration_seconds=12.0,
# limits=Limits(max_duration_seconds=30.0, max_num_samples=16, …)
)
See Capabilities reference → for what every field controls on the wire.
The SDK calls capabilities() on every /capabilities and /generate
request to look up the spec. Keep it cheap — return a cached object if
your spec depends on slow lookups.
generate(request)
Runs the model on a validated GenerateRequest and returns a
MotionResult. May be async def or sync — the SDK detects the form
and awaits when needed.
async def generate(self, req: GenerateRequest) -> MotionResult:
# req.skeleton, req.segments, req.constraints — parsed Pydantic models
# req.total_frames — int helper
# req.fps(spec.fps) — effective fps
# req.options.num_samples — None-safe access via .options
rotations = run_my_model(req)
translations = run_my_root(req)
return MotionResult(
rotations=rotations,
root_translations=translations,
)
The shape of MotionResult is documented in
Results →. The SDK encodes it to glTF for you.
Sync vs async
Both work. Async is preferred when generation does I/O (queueing on a GPU worker, calling out to another service). Sync is fine for a self-contained model:
class FastBackbone(Backbone):
def generate(self, req: GenerateRequest) -> MotionResult:
...
The SDK uses inspect.iscoroutinefunction to pick the right path.
Raising errors
Raise motionmcp.ProtocolError (or one of the typed convenience
constructors) for protocol-level failures. The SDK converts them to the
standard error envelope with the right HTTP status:
from motionmcp.errors import unknown_joint, ProtocolError
if user_joint not in self.skeleton_names:
raise unknown_joint(user_joint, sorted(self.skeleton_names))
if not self.weights_loaded:
raise ProtocolError("model_unavailable", "weights still loading")
Anything that isn't a ProtocolError becomes 500 internal_error.
See Errors → for the full list.
Motion references (1.2)
A motion_reference
segment is a prompt given as a motion. To take one, list it and cap the
clip's length:
spec = ModelSpec(..., supported_segments=["text", "unconditioned", "motion_reference"])
spec.limits.max_reference_frames = 300
The SDK validates a reference with the text segment's rules (it has a
duration_frames and a seed, mixes with other segments, goes with loop,
constraints and guidance as text does) plus its payload: joints on its own
skeleton (or the request skeleton when it has none), the root joint
among them, finite numbers, the clip no longer than
max_reference_frames frames as sent. It reaches you as parsed, not
resampled or retargeted. A clip within max_reference_frames MUST be
accepted at any fps: if resampling it to your model's rate gives more
frames than your window, resample it to fit (or trim it), don't refuse it. Treat it as a text segment whose caption embedding comes from
the clip instead of the text encoder, and run everything else — lengths,
seeds, guidance defaults, transitions, loop, batching — through your text
path unchanged:
for seg in req.segments:
if seg.type == "motion_reference":
# seg.rotations (T_ref × J × 4), seg.root_positions (T_ref × 3),
# seg.joint_names, seg.fps; seg.skeleton (Skeleton | None): the
# clip's own rig, to retarget from when set
caption = embed_clip(seg) # clip -> the text encoder's kind of vector
elif seg.type == "text":
caption = encode_text(seg.prompt)
... # the same path for both
/capabilities advertises supports_motion_reference_mixed: true for such a
model and false for any other (a reference always mixes like text). If
your model can't take a non-identity rest_rotation (on the request
skeleton or a segment's), raise 400 invalid_skeleton for it rather than
ignoring it. What clients send — rotations
from rest with identity rest_rotation, the root's world position, any
rig's own skeleton — is in Building reference segments →.
Video references (1.2)
A video_reference
segment is a prompt given as a video of a person. To take one, list it and
cap the video:
spec = ModelSpec(..., supported_segments=["text", "unconditioned", "video_reference"])
spec.limits.max_video_bytes = 50_000_000 # decoded size of the video file
spec.limits.max_video_seconds = 20.0 # seconds of video to read
The SDK validates it with the text segment's rules plus its payload: one
source (an https video.url, or base64 video.data with a
media_type), 0 ≤ start_s < end_s, the inline data within
max_video_bytes (413 payload_too_large) and a trimmed video within
max_video_seconds (400 invalid_options). It never decodes the video;
the rest is yours, once you have it: fetch the URL, check an untrimmed
video's length, find the most prominent person (the largest, most visible
one) to follow, and raise invalid_request when you can't.
Fetching a URL safely. A video.url comes from the client, so
fetching it makes your server an SSRF target (its loopback, its cloud
metadata endpoint, its private network). A backbone that fetches one MUST:
- fetch
httpsonly, with the URL rule the schema applies (a host, nouser:pw@, no whitespace or control characters, a valid port); - resolve the host and refuse it if any address is loopback, private,
link-local (which covers
169.254.169.254), carrier-grade NAT, multicast, reserved or unspecified, then connect to the address it checked (so a second DNS answer can't swap it); - follow redirects by hand, a few at most, checking every hop the same way;
- stream the body and stop past
limits.max_video_bytes(413 payload_too_large), and bound the whole fetch with a timeout.
motionmcp.fetch_video_url(url, max_bytes=None, timeout=30.0) does all of
this with the standard library, raising ProtocolErrors you can let
propagate (invalid_request, payload_too_large, timeout). IPv6
addresses that reach an IPv4 one (mapped, NAT64, 6to4, Teredo) are judged
by that address, and timeout is one hard deadline over connect, TLS,
headers and body. max_bytes=None (your model advertises no
max_video_bytes) means motionmcp.fetch.DEFAULT_MAX_VIDEO_BYTES,
100 MiB. It is synchronous: from an async def generate, run it with
asyncio.to_thread so it doesn't block the event loop.
import asyncio
from motionmcp import ProtocolError, fetch_video_url
async def generate(self, req):
cap = self.capabilities().limits.max_video_bytes # None -> the 100 MiB default
for seg in req.segments:
if seg.type == "video_reference":
raw = (seg.video.decoded() if seg.video.data else
await asyncio.to_thread(fetch_video_url, seg.video.url, max_bytes=cap))
# seg.video.media_type (inline), seg.start_s / seg.end_s (seconds
# into the video, None = its start / end), seg.fps (a hint, or None)
track = track_most_prominent_person(raw, seg.start_s, seg.end_s)
if track is None:
raise ProtocolError("invalid_request", "no person to follow in the video",
details={"segment": req.segments.index(seg)})
caption = embed_track(track) # video -> the text encoder's kind of vector
... # then the text path, as for a motion reference
req.video_references lists them in order (req.video_reference is the
first). seg.video.num_bytes is the inline file's size, worked out from
the base64's length (the SDK checks the alphabet but doesn't decode it);
seg.video.decoded() decodes it. Keep limits.max_request_bytes above
max_video_bytes × 4/3 if you take inline video: the body carries it as
base64, and the SDK server refuses a body over max_request_bytes before
parsing it.
generate_batch(requests)
Optional. The SDK serves batch bodies for every backbone (and advertises
supports_batch); without this method it runs generate() on each item in
turn. Override it when the model can do better — one GPU pass over several
characters:
class MyBackbone(Backbone):
def generate_batch(self, requests):
# one entry per request, in order: a MotionResult, or the exception
# that item raised (a ProtocolError gives a clean error envelope)
return [self.generate(r) for r in requests]
Items arrive already validated, grouped by model. Sync or async, like
generate().
setup() and teardown()
Optional hooks called once at server startup and shutdown. Use them for model loading, GPU allocation, warm caches.
class MyBackbone(Backbone):
def setup(self) -> None:
self.model = torch.load("weights.pt").to("cuda").eval()
def teardown(self) -> None:
del self.model
torch.cuda.empty_cache()
Default implementations are no-ops; override only when needed.
Putting it together
A real backbone with a loaded model:
import numpy as np
import torch
from motionmcp import (
Backbone, ModelSpec, GenerateRequest, MotionResult,
Skeleton, serve,
)
from motionmcp.errors import unknown_joint
class SomaBackbone(Backbone):
def __init__(self, weights_path: str) -> None:
self.weights_path = weights_path
self.model = None
def setup(self) -> None:
self.model = torch.load(self.weights_path).to("cuda").eval()
self._spec = ModelSpec(
id="soma-v1",
fps=30.0,
canonical_skeleton=Skeleton.model_validate(
self.model.canonical_skeleton_dict
),
supports_retargeting=True,
supported_constraints=["root_path", "pose_keyframe"],
)
def capabilities(self) -> ModelSpec:
return self._spec
async def generate(self, req: GenerateRequest) -> MotionResult:
with torch.no_grad():
out = self.model.run(req) # your I/O here
return MotionResult(
rotations=out.rotations.cpu().numpy(),
root_translations=out.root_translations.cpu().numpy(),
)
if __name__ == "__main__":
serve(SomaBackbone("weights.pt"))