Skip to content

MMCP 1.4: joints that move, curves keyed where they bend (0.12.0) - #13

Merged
PPierzc merged 6 commits into
mainfrom
feat/mmcp-1.4
Oct 13, 2026
Merged

PPierzc merged 6 commits into
mainfrom
feat/mmcp-1.4

Conversation

@PPierzc

@PPierzc PPierzc commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Releases feat/joint-translation on top of 1.3 as MMCP 1.4 / motionmcp-sdk 0.12.0. pfx-motion has been running that branch from a vendored wheel (0.10.0 plus these features, minus 0.11.0). The Animatica API, on PyPI 0.11.0, rejects the capabilities those backbones publish (extra_forbidden), so their models drop out of the registry.

What's in 1.4

All three are opt-in on the model's side:

  • supports_joint_translation + MotionResult.translations (B, T, J, 3): a glTF translation channel for each joint that leaves its rest offset, giving its offset from the parent. This is for rigs that deform by sliding joints (face, fin membrane, tongue). The server drops the channels for a model that doesn't advertise the capability.
  • MotionResult.keyframes: channels keyed only on the frames the model names, each sampler with its own input accessor. Anything left out stays dense.
  • supports_keyframe_reduction + options.keyframe_budget (float > 0): the caller asks for thinned curves. A model that doesn't advertise reduction refuses the option with invalid_options.

Added on top of the branch

  • Client parser fix. parse_gltf took the frame count from the first rotation track, so a document with sparse keys crashed the SDK's own reader. It also read any translation channel as a world position. Now every track is resampled onto the sample's timeline (slerp for rotations, held outside the keyed range). Per-joint offsets come back as a new local_translations, and posed_joints stays world positions (the root's).
  • Tests. tests/test_joint_translation.py covers all three features through the server and back through the parser. Two of its tests fail against the 0.11.0 parser. The branch commits had no tests for translations or keyframes.
  • Release. Version 0.12.0 / protocol 1.4, plus CHANGELOG and docs (versioning, capabilities, generate-request options, response channels, MotionResult, client parsing).
  • ts_acc in the encoder was assigned for its side effect only; it's now a plain call with a comment.

Verified

  • 398 passed (pytest, with the [server] extras).
  • Against the built 0.12.0 wheel, the Animatica business API's suite passes, a Vincent-shaped capabilities snapshot loads and is served with both flags, and options.keyframe_budget passes from_wire.

After merge: tag v0.12.0 on main, and release.yml publishes to PyPI.

🤖 Generated with Claude Code

PPierzc and others added 6 commits October 13, 2026 16:09
A rig whose joints are turned is carried perfectly by rotations and a root.
A rig that DEFORMS by moving joints is not: a face that opens by sliding its
lips, a fin membrane, a tongue. Measured on one such rig against the pose its
own pipeline renders, rotations alone leave the membrane 38.8 units out of a
223-unit shoulder and the mouth shut.

The container was never the limit -- glTF has had a translation channel per
node all along, and this writes one for any joint that does not simply sit at
its rest offset. On a 357-joint rig that is a handful of them, so the file
grows by what is used and no more. `MotionResult.translations` is (B, T, J, 3),
a joint's offset from its parent in the parent's frame, which is what glTF
means by a node's translation and what `rest_translation` is when nothing has
moved; for the root it is the world position, which is the same thing.

Opt-in at both ends. A model advertises `supports_joint_translation` and the
server drops the channels for one that does not, so a backbone cannot ship
them to clients that were never told to expect them. A reader that ignores
them still gets a correct skeleton -- turned right, placed right -- and gets a
stiffer one.

Rebuilt the way a reader rebuilds it, the same take that was 38.8 units out on
the membrane and 13.5 inside the mouth comes back at 0.0001, which is float32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A key on every frame is exact and is what a take being retargeted wants. It is
not what an animator wants: a curve keyed everywhere can be rebaked and nothing
else. So `MotionResult.keyframes` lets a model name the frames each channel is
worth keying, and the encoder gives those samplers their own input accessors.

The frames are the model's to choose. Only the model knows what straightening
its own curve costs -- an encoder sees quaternions and cannot tell which of them
a long chain is about to multiply into a visible error. A model that says
nothing stays dense, so this is additive: the same request against the same
backbone returns the same document as before unless the backbone fills it in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A key on every frame is exact, and exact is what a take being RETARGETED
wants -- at one-frame spacing the tangents have nothing left to argue about. A
thinned curve is what someone who has to OPEN the result wants: one keyed
everywhere can be rebaked and nothing else.

Those are opposite, the server cannot see which caller it has, and a model that
picks for itself picks wrong half the time. So `options.keyframe_budget` asks:
a tolerance in the model's own world units, omitted meaning key every frame.
Omitted is the default because dense is the answer that loses nothing.

A model advertises `supports_keyframe_reduction` and the server refuses the
option where it is not advertised, rather than ignoring it -- a caller that
sent a number and got a dense answer back has no way to tell that the number
went nowhere.

test_capabilities_unchanged_for_an_old_backbone pops the added capabilities
instead of carrying them into the golden, which is what was already done for
supports_motion_reference_mixed. The golden keeps saying what 0.9 said, and the
list in the test is the whole of what has been added since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ts_acc` was assigned and never read: the call is there so the dense
timeline is the document's first accessor, used or not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parse_gltf took the frame count from the first rotation track and copied
every track into an array of that length. A channel keyed where it bends
(MotionResult.keyframes) is shorter than that, so a document the server
now writes crashed the SDK's own reader. And a translation channel was
read as a world position wherever it appeared, which on any joint but the
root it is not.

Every track is now put on the sample's timeline -- the extension's fps and
num_frames, or the longest input for a document without them -- the way
a glTF reader plays it: slerp along the shorter arc for rotations, a line
for translations, held outside the keyed range. Dense documents come out
as before.

Per-joint translations arrive as `local_translations` [T, J, 3]: the
channel where there is one, the rest offset where there is not, the
root's world position for the root. `posed_joints` keeps meaning world
positions and so stays the root's.

tests/test_joint_translation.py covers the three 1.4 additions through
the server and back through this parser; two of its tests fail against
the 0.11.0 parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… bend (0.12.0)

Releases what pfx-motion has been running from a vendored build of
feat/joint-translation, now on top of 1.3:

- supports_joint_translation + MotionResult.translations: a translation
  channel for each joint that leaves its rest offset, the offset from
  its parent. The server drops them for a model that does not advertise
  the capability.
- MotionResult.keyframes: channels keyed on the frames the model names,
  each sampler with its own input.
- supports_keyframe_reduction + options.keyframe_budget: the caller asks
  for thinned curves; refused with invalid_options where not advertised.

All three are opt-in on the model's side. A 1.3 client against a model
that advertises none of them gets the same motion as before.

Docs: versioning, capabilities, generate-request options, response
channels, MotionResult fields, and what the client parser returns.
test_ground_height stops pinning the protocol it shipped in; the smoke
tests pin the current one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PPierzc
PPierzc merged commit df90bb5 into main Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant