Skip to main content

Stitch & loop motions

Preview API

This API is a preview and is unstable—it is expected to change in future releases. Reach out via support@uthana.com, Slack, or Discord to share your feedback.

Stitch allows you to join two motion clips into a single animation. Using a leading prefix, a trailing suffix, and spacial data at the join points, the stitch model generates a smooth transition between the two motions.

Overview

You provide pose samples for each clip (pelvis/root position + rotation, plus forward-facing yaw) at the required time points. The stitch uses the prefix pose at at_upper_trim_time and the suffix pose at at_lower_trim_time as the connection points, aligns the motions in world space, and returns a new motion id synchronously.

You can use the Uthana web app Stitch UI to play around with stitch and better understand how to position characters for prefix/suffix alignment.

Key concepts and terminology

Before diving into the API, it helps to understand the core concepts and how they map to the stitch parameters.

Coordinate system and axes

Uthana uses a right-handed, Y-up coordinate system consistent with Three.js, GLM, and

glTF

:

AxisDirectionMeaning
XRightPositive X points to the character's right when facing +Z
YUp

Positive Y points upward; the floor is at y = 0

ZForwardPositive Z is "forward" when the character has zero yaw
Right-handed Y-up coordinate system with character
Right-handed Y-up axes

Pose properties and how they map to the API

The stitch model needs to know where each character is and how it's oriented at key moments. It uses this pose data to align the prefix and suffix spatially and generate a smooth transition. Positions are {x, y, z} in meters—e.g. {x: 0.1, y: 0.95, z: 0.5} means 0.1m to the right, pelvis height 0.95m above the floor, 0.5m forward. These properties are required for both the leading (prefix) and trailing (suffix) motion—each motion gets its own StitchParamsInput with full pose data.

You should get these values from your motion loader by evaluating the motion at each required time: seek to the frame, update the animation, and read the world-space transforms from your 3D engine (e.g. getWorldPosition, getWorldQuaternion in Three.js). Pelvis position and rotation come directly from the motion; hips_forward_facing_world_yaw is computed from the left and right hip bone positions (see How to obtain these values).

Suppose you want to space two motions 1 meter apart in a straight line. You would set the prefix pelvis at the last frame (at_upper_trim_time) to {x: 0, y: 0.95, z: 0} and the suffix pelvis at the first frame (at_lower_trim_time) to {x: 0, y: 0.95, z: 1}. Without this spatial data, the stitch model has no way to know where each motion sits in the world, and the transition will look wrong.

What each property is
PropertyTypeWhat it represents
root_node_world_pos{x, y, z} (meters)Character root position in world space. x and z are horizontal; y is height above the floor.
root_node_world_rot{x, y, z, w} (quaternion)Character root orientation in world space. Identity (no rotation) is {x: 0, y: 0, z: 0, w: 1}.
pelvis_world_pos{x, y, z} (meters)Pelvis bone position in world space. Used in each of at_zero_time, at_lower_trim_time, at_upper_trim_time.
pelvis_world_rot{x, y, z, w} (quaternion)Pelvis bone orientation in world space.
hips_forward_facing_world_yawradiansAngle around the Y axis for hips facing. Computed as atan2(forward.x, forward.z). Examples:
  • 0 = facing +Z
  • π/2 = facing -X
  • π = facing -Z
  • -π/2 = facing +X

Quaternions use {x, y, z, w} with w as the scalar component. This matches Three.js, GLM, and common game engines. See

Three.js Quaternion

and

Quaternions and spatial rotation

for more on this format. For the coordinate system and glTF reference, see above.

What they're used for

The stitch model uses root and pelvis transforms to:

  • Align the two motions spatially so the character doesn't pop or drift when transitioning.
  • Match facing direction so the blend looks natural.
  • Preserve floor contact so the character doesn't float or sink.

Without accurate pose data, the transition can look jarring. For production, always sample from your motion loader.

Example: a single pelvis state

Each of at_zero_time, at_lower_trim_time, and at_upper_trim_time is a PelvisStateInput:

{
  "pelvis_world_pos": { "x": 0.12, "y": 0.95, "z": 0.0 },
  "pelvis_world_rot": { "x": 0, "y": 0, "z": 0, "w": 1 },
  "hips_forward_facing_world_yaw": 0
}

Root node vs. pelvis

  • Root node (root_node_world_pos, root_node_world_rot): The character’s root transform in world space—typically the top-level node that contains the whole skeleton.
  • Pelvis: A specific bone (the hip/pelvis joint) that defines the character’s center of mass and facing direction. The stitch model uses pelvis data to align the two motions spatially.

The root is the hierarchy root; the pelvis is a child bone. Their positions and rotations can differ when the character is posed.

Trim times and the three sample points

Each motion is sampled at three time points:

SampleWhenPurpose
at_zero_timet = 0Start of the motion clip
at_lower_trim_timeStart of the trimmed segmentWhere the “useful” part of the clip begins
at_upper_trim_timeEnd of the trimmed segmentWhere the “useful” part ends
  • Prefix motion: The stitch uses the pose at at_upper_trim_time (the last frame of the prefix) to connect to the suffix.
  • Suffix motion: The stitch uses the pose at at_lower_trim_time (the first frame of the suffix) as the connection point.

For the prefix, keep at_upper_trim_time slightly before the clip end (e.g. motion_duration - 0.051) to avoid sampling exactly at the boundary.

How to obtain these values

Motion loader required

You will need a motion loader (e.g. GLTF + Three.js, or your engine's equivalent) that can evaluate poses at specific times.

  • Lower and upper trim times. You first need to decide lower_trim and upper_trim—the start and end of the segment you want to use. In the web app, the trim handles on each motion's timeline define these: drag the handles to mark the segment; the times at those positions are your lower_trim and upper_trim.

    Stitch UI showing prefix (leading) and suffix (trailing) motion cards with trim handles

    Trim handles on the timeline define the segment used for stitching—their positions give you lower_trim and upper_trim.

  • Pose data at each time. Once you have those times, sample poses at 0, lower_trim, and upper_trim from your motion loader. The pose data comes from pelvis and root rotation and positioning in world space—pelvis_world_pos, pelvis_world_rot, and the root transforms—plus the hips forward yaw. The web app lets you preview and adjust how the prefix and suffix characters are positioned and oriented relative to each other; the 3D view shows how pelvis rotation and positioning affect spatial alignment.

    Stitch UI 3D view with prefix and suffix characters showing pelvis rotation and positioning

    Pelvis rotation and positioning in the 3D view—this is how you get the pose data for alignment.

  • Pelvis positioning and duration. The spatial gap between the prefix and suffix connection poses, combined with stitch_duration, can make or break a stitch. If the two poses are far apart in world space but you use a short duration, the model has to compress a large motion into a small window—the result can look rushed or unnatural. Conversely, if the poses are very close and you use a long duration, the transition may feel sluggish. Match the duration to the distance: larger gaps generally need longer durations for a smooth, believable transition. For example:

    • Walking (~1.2 m/s): For a 1 m gap, use stitch_duration in the 0.8–1.2 s range; for 2 m, use 1.5–2.0 s.
    • Jogging (~2.5 m/s): For a 1 m gap, use 0.4–0.6 s; for 2 m, use 0.8–1.2 s.

Summary. For programmatic use, the sampling process is:

  1. Load the motion (e.g. GLTF with animation) and the character skeleton.
  2. Seek to each time (0, lower_trim, upper_trim) and update the animation.
  3. Read world-space transforms for the pelvis and root from your 3D engine (e.g. getWorldPosition, getWorldQuaternion in Three.js).
  4. Compute hips_forward_facing_world_yaw: Get world positions of left and right hip bones. The "hips forward" direction is perpendicular to the hip line in the horizontal plane: forward = normalize(cross(left_hip - right_hip, up)). Then yaw = atan2(forward.x, forward.z).

Looping

Looping means generating a motion that can repeat seamlessly: the last frame transitions back into the first frame without a visible “pop” in pose, facing, or root motion.

Progressive vs. cyclical loops

  • Cyclical (in-place) loops: The character returns to the same world-space root position and facing each cycle (e.g. idle, breathing, in-place jog). These are easiest to loop because the start and end are naturally compatible.
  • Progressive (moving) loops: The character keeps translating/rotating each cycle (e.g. walk-forward with root motion). A good loop still has a seamless join, but the character’s root position will advance every repetition.

How looping differs from regular stitching

Where regular stitching connects the end of one motion to the start of another motion, looping connects the end of one motion to the start of the same motion. Looping uses the same connection mechanism—prefix at_upper_trim_time → suffix at_lower_trim_time—but your goal is specifically to connect a motion back to itself (or to a compatible “restart” pose) so it can repeat cleanly. The key difference is that instead of passing a different motion ID for leading (prefix) and trailing (suffix), you pass the same motion ID for both.

Loop-specific toggle

Set stitch_loop: true when creating a loop. See also recommended defaults.

What you need to adjust for looping

  • Pick join points carefully: choose motion_upper_trim_time (near the end) and motion_lower_trim_time (near the start) where the poses are compatible (similar contact, similar facing). Avoid sampling exactly at clip boundaries (see the trim guidance in Trim times and the three sample points).
  • Suffix vs prefix setup:
    • For looping a single clip, you’ll typically set the prefix and suffix to the same motion_id, with prefix trims near the end and suffix trims near the start.
    • For stitching two different clips into a loop, you still set stitch_loop: true, but the join points must be mutually compatible (end pose of A matches start pose of B, and B’s end matches A’s start if you plan to repeat).
  • Root/pelvis alignment matters more:
    • Cyclical loops: you can often use sampled world-space transforms directly if the clip was authored to start/end in the same place.
    • Progressive loops: you usually need to ensure the suffix connection pose is positioned/oriented to match the prefix end pose in world space (same facing at the join, and positions aligned so the transition doesn’t jump). This keeps the join seamless while allowing motion to continue advancing each repetition.
  • Tune stitch_duration: increase it when the start/end poses are similar but not identical; decrease it when the clip is already very close to loopable and you want a tighter join.

If you’re unsure whether your loop is cyclical or progressive, inspect the root node translation over time: if root_node_world_pos drifts each cycle, you’re in progressive territory and should treat the join as a “continue moving” connection rather than a “return to origin” connection.

Prerequisites

Before calling the stitch API, you need:

  • API key: For authentication (see GraphQL API)
  • Character ID: The character both motions are retargeted to (e.g. cXi2eAP19XwQ for Uthana's default character)
  • Prefix motion ID: The leading motion (plays first)
  • Suffix motion ID: The trailing motion (plays second)
  • Pose data: Pelvis and root-node transforms sampled at time 0, lower trim, and upper trim for each motion

See How to obtain these values for the sampling process. The Uthana web app performs this automatically when you use the Stitch UI.

Step-by-step tutorial

Step 1: Authenticate your request

API_KEY="{{apiKey}}"
API_URL="https://uthana.com/graphql"

Step 2: Build the stitch input

The MotionStitchInput requires character_id, prefix, and suffix. Each of prefix and suffix is a StitchParamsInput with motion metadata and pose samples at three time points.

If you don't yet have a motion loader, you can use these placeholder values for the pose fields to satisfy the schema and test the API flow. They are structurally valid but will not yield good stitch quality—for production, always sample from your motion data.

PropertyPlaceholder valueNotes
root_node_world_pos{x: 0, y: 0, z: 0}Origin. Replace with sampled data for real stitches.
root_node_world_rot{x: 0, y: 0, z: 0, w: 1}Identity (no rotation).
pelvis_world_pos (in each sample){x: 0, y: 0, z: 0}Origin.
pelvis_world_rot (in each sample){x: 0, y: 0, z: 0, w: 1}Identity.
hips_forward_facing_world_yaw0Facing +Z.
Recommended defaults

For stitch_duration, stitch_loop, prompt, and other optional fields, see Recommended defaults.

Required fields (must be derived from your motion data):

  • motion_id, motion_duration, motion_lower_trim_time, motion_upper_trim_time, motion_lower_trim_fraction, motion_upper_trim_fraction
  • root_node_world_pos, root_node_world_rot (character root at trim points)
  • at_zero_time, at_lower_trim_time, at_upper_trim_time (pelvis position, rotation, and hips_forward_facing_world_yaw in radians)

For prefix motions, keep the upper trim time slightly before the clip end (e.g. duration - 0.051) to avoid sampling at the exact boundary, which improves stitch quality.

# Build stitch_input JSON (replace placeholders with your motion data)
# See Recommended defaults and API reference for full structure
CHARACTER_ID="cXi2eAP19XwQ"
PREFIX_MOTION_ID="your_prefix_motion_id"
SUFFIX_MOTION_ID="your_suffix_motion_id"
STITCH_DURATION=2.0

# Helper: build a PelvisStateInput (pelvis_world_pos, pelvis_world_rot, hips_forward_facing_world_yaw)
# You must sample these from your motion at t=0, lower_trim, upper_trim

Step 3: Call the mutation

curl -X POST "$API_URL" \
  -u "$API_KEY": \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation CreateEnhancedStitchedMotion($stitch_input: MotionStitchInput!) { create_enhanced_stitched_motion(stitch_input: $stitch_input) { motion { id } } }",
    "variables": {
      "stitch_input": {
        "character_id": "'"$CHARACTER_ID"'",
        "prefix": { "prompt": "", "motion_id": "'"$PREFIX_MOTION_ID"'", "stitch_loop": false, "stitch_duration": 2.0, "motion_duration": 5.0, "motion_lower_trim_time": 0, "motion_upper_trim_time": 4.949, "motion_lower_trim_fraction": 0, "motion_upper_trim_fraction": 0.9898, "root_node_world_pos": {"x":0,"y":0,"z":0}, "root_node_world_rot": {"x":0,"y":0,"z":0,"w":1}, "at_zero_time": {"pelvis_world_pos":{"x":0,"y":0,"z":0},"pelvis_world_rot":{"x":0,"y":0,"z":0,"w":1},"hips_forward_facing_world_yaw":0}, "at_lower_trim_time": {"pelvis_world_pos":{"x":0,"y":0,"z":0},"pelvis_world_rot":{"x":0,"y":0,"z":0,"w":1},"hips_forward_facing_world_yaw":0}, "at_upper_trim_time": {"pelvis_world_pos":{"x":0,"y":0,"z":0},"pelvis_world_rot":{"x":0,"y":0,"z":0,"w":1},"hips_forward_facing_world_yaw":0} },
        "suffix": { "prompt": "", "motion_id": "'"$SUFFIX_MOTION_ID"'", "stitch_loop": false, "stitch_duration": 2.0, "motion_duration": 5.0, "motion_lower_trim_time": 0.05, "motion_upper_trim_time": 5.0, "motion_lower_trim_fraction": 0.01, "motion_upper_trim_fraction": 1.0, "root_node_world_pos": {"x":0,"y":0,"z":0}, "root_node_world_rot": {"x":0,"y":0,"z":0,"w":1}, "at_zero_time": {"pelvis_world_pos":{"x":0,"y":0,"z":0},"pelvis_world_rot":{"x":0,"y":0,"z":0,"w":1},"hips_forward_facing_world_yaw":0}, "at_lower_trim_time": {"pelvis_world_pos":{"x":0,"y":0,"z":0},"pelvis_world_rot":{"x":0,"y":0,"z":0,"w":1},"hips_forward_facing_world_yaw":0}, "at_upper_trim_time": {"pelvis_world_pos":{"x":0,"y":0,"z":0},"pelvis_world_rot":{"x":0,"y":0,"z":0,"w":1},"hips_forward_facing_world_yaw":0} }
      }
    }
  }'

Note: Replace the placeholder pose values (zeros) with data sampled from your motions. The example structure is valid but will produce better results with real pose data.

Step 4: Handle the response

The mutation returns the new motion ID immediately:

{
  "data": {
    "create_enhanced_stitched_motion": {
      "motion": {
        "id": "{new_motion_id}"
      }
    }
  }
}

Use these values when building your stitch payload:

FieldValueRationale
stitch_duration2.0Default transition length. Range 0.1–3.0 seconds. Shorter = snappier, longer = smoother.
stitch_loopfalseSet this value to true to create a looped motion.
prompt""Empty string for deterministic behavior. The UI default "A person " is filtered out and has no semantic effect.
Prefix upper trimmotion_duration - 0.051Slightly before clip end improves stitch quality (avoids exact-boundary sampling).
Quaternion identity{x:0,y:0,z:0,w:1}Use only when your coordinate system matches; otherwise sample from motion.

All position and rotation fields must come from sampling your motion at the trim points. Use a GLTF/Three.js–style loader to evaluate the character pose at t=0, lower_trim, and upper_trim, then extract pelvis world position, pelvis world rotation (quaternion), and hips forward-facing yaw (e.g. Math.atan2(forward.x, forward.z)).

How to tune

  • Duration: Increase stitch_duration (up to 3.0 s) for smoother transitions; decrease for quicker cuts.
  • Trim windows: Narrower trim ranges focus the stitch on a smaller segment; ensure prefix upper and suffix lower are temporally compatible.
  • Prompt: Optional. When provided (non-empty, not "A person"), can influence transition style. Empty is recommended for reproducibility.
  • Character positioning: Root and pelvis transforms define spatial alignment. Ensure prefix end pose and suffix start pose are reasonably aligned for best results.

Error handling

Common errors and how to resolve them:

ErrorCauseResolution
User does not have access to prefix motionMotion not in your org or invalid IDVerify motion IDs and org membership
User does not have access to suffix motionSame as aboveSame as above
Invalid/malformed inputMissing required fields or wrong typesCheck GraphQL reference for schema
Poor stitch qualityIncorrect or placeholder pose dataSample real pose data from your motion at the trim points
if echo "$RESPONSE" | jq -e '.errors' > /dev/null; then
    echo "Error occurred:"
    echo "$RESPONSE" | jq '.errors'
fi

Legacy endpoint

The create_stitched_motion mutation (with leading_motion_id, trailing_motion_id, duration) is deprecated. Migrate to create_enhanced_stitched_motion for better quality and control. See the GraphQL reference for the legacy schema.

Next steps