Stitch & loop motions
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.
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) andtrim_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_pctis positioned/oriented to match the pose attrim_start_pctin 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_idmotion_idloop_mode:closed(default) - a cyclical loop: the character will end in the same global position as the start frameopen- a progressive loop: the character will translate to the specifiedzone_end_positionat 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 loopedtrim_end_pct:1.0(default) - the ending percent at which to trim the original motion to be loopedzone_duration:2.0(default) - duration of the editable zone of the loop in secondszone_mode:modify(default) - preserves the length of the animation by modifying existing keyframesextend- 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
ZoneEndPositionInputrequiresx,y, andfacing_angle. Bothxandyare a target planar position along the given axis, withfacing_anglerepresenting the target facing angle in radians.
- The
Step-by-step tutorial
Step 1: Authenticate your request
- Shell
- Python
- TypeScript
- C#
API_KEY="{{apiKey}}"
API_URL="https://uthana.com/graphql"
import requests
API_URL = "https://uthana.com/graphql"
API_KEY = "{{apiKey}}"
const API_URL = "https://uthana.com/graphql";
const API_KEY = "{{apiKey}}";
private const string ApiUrl = "https://uthana.com/graphql";
private readonly string _apiKey = "{{apiKey}}";
Step 2: Call the mutation
- Shell
- Python
- TypeScript
- C#
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"'}
}
}'
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" # or "open"
zone_mode = "modify" # or "extend"
zone_end_position = {"x": 0.0, "y": 0.0, "facing_angle": 0.0} # or None for open-loop default
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
}
}
}
"""
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": zone_end_position,
}
response = requests.post(
API_URL,
auth=(API_KEY, ""),
json={"query": query, "variables": variables},
)
result = response.json()
if "errors" in result:
print(f"Error: {result['errors']}")
else:
motion = result["data"]["create_looped_motion"]["motion"]
print(f"Created looped motion: {motion['id']}")
const variables = {
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" as const, // or "open"
zone_mode: "modify" as const, // or "extend"
zone_end_position: { x: 0.0, y: 0.0, facing_angle: 0.0 } as { x: number; y: number; facing_angle?: number } | null,
};
const 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
}
}
}
`;
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${btoa(`${API_KEY}:`)}`,
},
body: JSON.stringify({ query, variables }),
});
const json = await response.json();
if (json.errors) {
console.error("GraphQL errors:", json.errors);
} else {
const motion = json.data.create_looped_motion.motion;
console.log(`Created looped motion: ${motion.id}`);
}
var 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
}
}
}";
var request = new
{
query,
variables = new
{
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_position = new { x = 0.0, y = 0.0, facing_angle = 0.0 },
},
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_apiKey}:"));
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authValue);
var response = await _httpClient.PostAsync(ApiUrl, content);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(responseJson);
var motionId = doc.RootElement.GetProperty("data").GetProperty("create_looped_motion").GetProperty("motion").GetProperty("id").GetString();
Console.WriteLine($"Created looped motion: {motionId}");
Step 3: Handle the response
The mutation returns the new motion ID immediately:
{
"data": {
"create_looped_motion": {
"motion": {
"id": "{new_motion_id}"
}
}
}
}
Next steps
- Text to motion — Generate motions from text prompts
- Locomotion — Controllable, looptable travel in a chosen direction
- Video to motion — Convert video to motion
- Download a motion — Export FBX, GLB, BVH
- Unitree G1 — Unitree G1 robot (
.csv) download - GraphQL API reference — Full schema and
create_enhanced_stitched_motioncontract