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.

Official clients

The official Python (uthana) and JavaScript (@uthana/client) clients support stitching and looping. The raw GraphQL examples below are also available.

Learn more about Stitching and Looping.

stitch joins exactly two existing motions on one character. Each step is a flat object with a required motion_id. The service samples, retargets, and aligns the sources; no local pose sampler is required.

Simple join​

{"character_id": "CHARACTER_ID", "steps": [{"motion_id": "FIRST_MOTION_ID"}, {"motion_id": "SECOND_MOTION_ID"}]}

Trims and placement​

Transition fields belong to the outgoing first motion. This example trims both sources and places the second motion one meter forward, half a meter right, and facing 45 degrees right relative to the first motion’s ending facing.

{
  "character_id": "CHARACTER_ID",
  "steps": [
    {
      "motion_id": "FIRST_MOTION_ID",
      "trim_start_frame": 12,
      "trim_end_frame": 60,
      "transition_duration_seconds": 1.5,
      "transition_forward_meters": 1.0,
      "transition_right_meters": 0.5,
      "transition_facing_degrees": 45.0
    },
    {
      "motion_id": "SECOND_MOTION_ID",
      "trim_start_seconds": 0.5,
      "trim_end_seconds": 3.0
    }
  ]
}

Use trim_start_frame/trim_end_frame or trim_start_seconds/trim_end_seconds per step, never both units on one step. Frames are zero-based native source samples, with inclusive endpoints. Seconds snap inward to native samples. Start defaults to zero; omitting end reserves the required model context. Invalid or unusable explicit intervals are rejected. Select more than 0.15 seconds and leave the first motion’s required endpoint context.

Omitting transition_duration_seconds defaults to one second. Durations must be 0.1–3 seconds and are rounded to 20 Hz model frames. Omit all three placement fields for automatic continuation of recent travel and aligned facing. Supplying any placement field requests explicit placement; omitted components become zero. Forward/right distances are in meters, limited to -100 through 100; facing is in degrees, limited to -180 through 180, with positive values turning right. Placement is relative, not absolute.

Omit unspecified fields rather than supplying null. Transition fields on the final motion receive scalar/type checks but are otherwise ignored: they do not affect execution, billing, or request identity. SDK and MCP inputs reject numeric strings, booleans, and nonfinite values. Direct GraphQL calls use ordinary String, Int, and Float coercion before semantic validation; frame inputs use the GraphQL signed 32-bit integer range.

Result and recovery​

The result contains motion and two flat resolved_steps. Both steps report motion_id, trim_start_frame, trim_end_frame, trim_start_seconds, and trim_end_seconds. The first also reports the rounded transition_duration_seconds. Explicit placement returns all three normalized placement fields on the first step; automatic continuation omits them. Raw poses and model parameters remain internal.

Stitching is synchronous and billed at $0.01 per second of output motion. Allow at least 420 seconds in an MCP host, inspect resolved trims, and review playback before accepting the animation. Never automatically retry an uncertain paid operation. MCP calls additionally require a durable request_id; use get_request_status to recover results before resubmitting. Historical receipts remain readable.

Existing sampled-pose stitching methods and looping remain available separately.

GraphQL​

mutation Stitch($input: StitchInput!) {
  stitch(input: $input) {
    motion { id name }
    resolved_steps
  }
}

The JSON request above is the input variable. GraphQL uses a step discriminator; the service validates fields for each step type. See legacy sampled-pose stitching only when maintaining an existing integration.

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.

Looping is billed like stitching: $0.01 per second of output motion on pay-as-you-go, whether you call create_looped_motion below or use Trim & loop in the web app. Accounts without enough credit are blocked before the job runs. See Pricing.

Loop modes: progressive vs. cyclical​

  • Cyclical (closed) 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 (open) 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. With looping, your goal is specifically to connect a motion back to itself (or to a compatible "restart" pose) so it can repeat cleanly.

What you need for looping​

  • Pick joint points carefully: choose trim_start_pct (near the start) and trim_end_pct (near the end) where the poses are compatible (similar contact, similar facing). Avoid sampling exactly at clip boundaries.
  • 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 pose at trim_end_pct is positioned/oriented to match the pose at trim_start_pct 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 zone_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 the root's world position 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.

Required fields:

  • character_id
  • motion_id
  • loop_mode:
    • closed (default) - a cyclical loop: the character will end in the same global position as the start frame
    • open - a progressive loop: the character will translate to the specified zone_end_position at the end of the animation

Optional fields:

  • trim_start_pct: 0.0 (default) - the starting percent at which to trim the original motion to be looped
  • trim_end_pct: 1.0 (default) - the ending percent at which to trim the original motion to be looped
  • zone_duration: 2.0 (default) - duration of the editable zone of the loop in seconds
  • zone_mode:
    • modify (default) - preserves the length of the animation by modifying existing keyframes
    • extend - increases the length of the animation by adding new keyframes
  • zone_end_position: null (default) - the goal position of the character at the end of the loop, applicable for open loops only. If not specified for open loop, it is calculated as continuing along average velocity of animation.
    • The ZoneEndPositionInput requires x, y, and facing_angle. Both x and y are a target planar position along the given axis, with facing_angle representing the target facing angle in radians.

Step-by-step tutorial​

Step 1: Authenticate your request​

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

Step 2: Call the mutation​

CHARACTER_ID="cXi2eAP19XwQ"
MOTION_ID="your_motion_id"
TRIM_START_PCT=0.0
TRIM_END_PCT=1.0
ZONE_DURATION=2.0
LOOP_MODE="closed"
ZONE_MODE="modify"
ZONE_END_X=0.0
ZONE_END_Y=0.0
FACING_ANGLE=0.0

curl -X POST "$API_URL" \
  -u "$API_KEY": \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation CreateLoopedMotion($character_id: String!, $motion_id: String!, $trim_start_pct: Float, $trim_end_pct: Float, $zone_duration: Float, $loop_mode: String!, $zone_mode: String, $zone_end_position: ZoneEndPositionInput) { create_looped_motion(character_id: $character_id, motion_id: $motion_id, trim_start_pct: $trim_start_pct, trim_end_pct: $trim_end_pct, zone_duration: $zone_duration, loop_mode: $loop_mode, zone_mode: $zone_mode, zone_end_position: $zone_end_position) { motion { id name } } }",
    "variables": {
      "character_id": "'"$CHARACTER_ID"'",
      "motion_id": "'"$MOTION_ID"'",
      "trim_start_pct": '"$TRIM_START_PCT"',
      "trim_end_pct": '"$TRIM_END_PCT"',
      "zone_duration": '"$ZONE_DURATION"',
      "loop_mode": "'"$LOOP_MODE"'",
      "zone_mode": "'"$ZONE_MODE"'",
      "zone_end_position": {"x": '"$ZONE_END_X"', "y": '"$ZONE_END_Y"', "facing_angle": '"$FACING_ANGLE"'}
    }
  }'

Step 3: Handle the response​

The mutation returns the new motion ID immediately:

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

Next steps​