Video to motion
Convert 2D video files into high-quality 3D character animations. Video-to-motion is perfect for extracting motion from existing video content and applying it to your 3D characters.
Learn more about Video to motion.
Overview
Video-to-motion processes video files asynchronously. Unlike text-to-motion, video-to-motion returns a job that you'll need to poll to check when processing is complete.
Duplicate uploads: If you upload the same video file while a job for that file is already running, or if it was successfully processed in the last 24 hours or failed in the last hour, the API returns the existing job instead of throwing an error. Use the returned job ID to poll for status as usual.
Available models: If multiple models are available, pass an optional model parameter. The default model is video-to-motion-v2.
video-to-motion-v2– Uthana's flagship video-to-motion model.video-to-motion-v1is deprecated and will be unavailable after 30 Apr 2026. Migrate tovideo-to-motion-v2.
Step-by-step tutorial
Step 1: Set up the client
Install your client library and authenticate using your API key. See the quickstart for setup instructions for each language.
Step 2: Prepare your video file
Choose a video of you or someone else performing the motion you want to create. The video should be a single, continuous shot, with movement clearly visible and centered in the frame.
Video requirements:
- Supported formats:
.mp4,.mov,.avi - Video parameters: 2-60 seconds, 24-120 fps, 300px to 4096px resolution
- Camera: Use a stable, flat surface or tripod
- Framing: Keep your whole body in view
- Start position: Begin standing with both feet on the ground
- Subject: Ensure only one person is in the video
- Clothing: Wear well-fitting, distinct colors
- Background: Plain, contrasting, and well-lit
- Lighting: Avoid direct sunlight or harsh shadows
Step 3: Upload video and create motion
- Shell
- Python
- TypeScript
- React
- C#
curl -X POST https://uthana.com/graphql \
-u $API_KEY: \
-F 'operations={
"query": "mutation ($file: Upload!, $motion_name: String!) { create_video_to_motion(file: $file, motion_name: $motion_name) { job { id status } } }",
"variables": { "file": null, "motion_name": "My Video Motion" }
}' \
-F 'map={ "0": ["variables.file"] }' \
-F '0=@'"$VIDEO_FILE"
async def main():
job = await client.vtm.create("/path/to/your/video.mp4", motion_name="My Video Motion")
print(f"Job ID: {job['id']}, Status: {job['status']}")
asyncio.run(main())
// Browser: pass a File object from a file picker
const videoInput = document.querySelector<HTMLInputElement>("#videoFile");
const videoFile = videoInput?.files?.[0];
if (!videoFile) throw new Error("Select a video file before uploading.");
const job = await client.vtm.create(videoFile, { motion_name: "My Video Motion" });
console.log(`Job ID: ${job.id}, Status: ${job.status}`);
import { useUthanaVtm } from "@uthana/react";
function UploadVideo() {
const vtm = useUthanaVtm();
return (
<input
type="file"
accept="video/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) vtm.mutate({ file, motion_name: "My Video Motion" });
}}
/>
);
}
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
public async Task<Job> CreateVideoToMotionAsync(string videoFilePath, string motionName)
{
var operations = new
{
query = @"
mutation ($file: Upload!, $motion_name: String!) {
create_video_to_motion(file: $file, motion_name: $motion_name) {
job {
id
status
}
}
}",
variables = new
{
file = (object)null,
motion_name = motionName
}
};
var map = new { ["0"] = new[] { "variables.file" } };
using var content = new MultipartFormDataContent();
content.Add(new StringContent(JsonSerializer.Serialize(operations), Encoding.UTF8, "application/json"), "operations");
content.Add(new StringContent(JsonSerializer.Serialize(map), Encoding.UTF8, "application/json"), "map");
var fileBytes = await File.ReadAllBytesAsync(videoFilePath);
content.Add(new ByteArrayContent(fileBytes), "0", Path.GetFileName(videoFilePath));
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();
var result = JsonSerializer.Deserialize<GraphQLResponse<CreateVideoToMotionData>>(responseJson);
return result.Data.CreateVideoToMotion.Job;
}
Step 4: Poll for job completion
Video-to-motion processing happens asynchronously. Poll the job status until it's complete.
- Shell
- Python
- TypeScript
- React
- C#
JOB_ID="your-job-id"
# Poll until job is complete
while true; do
RESPONSE=$(curl -s https://uthana.com/graphql \
-u $API_KEY: \
-H "Content-Type: application/json" \
-d "{\"query\": \"query { job(job_id: \\\"$JOB_ID\\\") { id status result } }\"}")
STATUS=$(echo "$RESPONSE" | jq -r '.data.job.status')
echo "Job status: $STATUS"
if [ "$STATUS" = "FINISHED" ] || [ "$STATUS" = "FAILED" ]; then
break
fi
sleep 5 # Wait 5 seconds before checking again
done
# Get the final result
echo "$RESPONSE" | jq '.data.job'
import asyncio
async def main():
job = await client.vtm.create("/path/to/your/video.mp4", motion_name="My Video Motion")
while job["status"] not in ("FINISHED", "FAILED"):
await asyncio.sleep(5)
job = await client.jobs.get(job["id"])
print(f"Job status: {job['status']}")
if job["status"] == "FINISHED":
motion_id = job["result"]["result"]["id"]
print(f"Motion created: {motion_id}")
asyncio.run(main())
// The JS client has a built-in wait helper that polls automatically
const finished = await client.jobs.wait(job.id!, {
intervalMs: 5000,
timeoutMs: 120_000,
});
if (finished.status === "FINISHED") {
const motionId = finished.result?.result?.id;
console.log(`Motion created: ${motionId}`);
}
import { useUthanaVtm, useUthanaJob } from "@uthana/react";
function VideoUploadWithStatus() {
const vtm = useUthanaVtm();
const jobId = vtm.data?.id;
// useUthanaJob polls automatically when a job ID is provided
const { job } = useUthanaJob(jobId);
return (
<div>
<input
type="file"
accept="video/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) vtm.mutate({ file, motion_name: "My Video Motion" });
}}
/>
{job && <p>Status: {job.status}</p>}
{job?.status === "FINISHED" && <p>Motion ID: {job.result?.result?.id}</p>}
</div>
);
}
public async Task<Job> PollJobStatusAsync(string jobId)
{
var query = @"
query GetJob($job_id: String!) {
job(job_id: $job_id) {
id
status
result
}
}";
while (true)
{
var request = new
{
query = query,
variables = new { job_id = jobId }
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(ApiUrl, content);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<GraphQLResponse<JobData>>(responseJson);
var job = result.Data.Job;
Console.WriteLine($"Job status: {job.Status}");
if (job.Status == "FINISHED" || job.Status == "FAILED")
{
return job;
}
await Task.Delay(5000); // Wait 5 seconds
}
}
// Usage — replace with job ID from Step 3
var finalJob = await PollJobStatusAsync("<job-id-from-step-3>");
if (finalJob.Status == "FINISHED")
{
var motionId = finalJob.Result["result"]["id"].ToString();
Console.WriteLine($"Motion created: {motionId}");
}
Step 5: Download your motion
Once the job is finished, extract the motion ID from the result and download it.
- Shell
- Python
- TypeScript
- React
- C#
CHARACTER_ID="cXi2eAP19XwQ" # Default character, or use your own
MOTION_ID=$(echo "$RESPONSE" | jq -r '.data.job.result.result.id')
# Download as FBX (filename is customizable)
curl -L "https://uthana.com/motion/file/motion_viewer/$CHARACTER_ID/$MOTION_ID/fbx/motion.fbx" \
-u $API_KEY: \
-o motion.fbx
from uthana import UthanaCharacters
async def main():
job = await client.vtm.create("/path/to/your/video.mp4", motion_name="My Video Motion")
while job["status"] not in ("FINISHED", "FAILED"):
await asyncio.sleep(5)
job = await client.jobs.get(job["id"])
motion_id = job["result"]["result"]["id"]
data = await client.motions.download(
UthanaCharacters.tar, # or your own character ID
motion_id,
output_format="glb",
fps=30,
)
with open("dance.glb", "wb") as f:
f.write(data)
asyncio.run(main())
import { UthanaCharacters } from "@uthana/client";
const motionId = finished.result?.result?.id;
const buffer = await client.motions.download(
UthanaCharacters.tar, // or your own character ID
motionId,
{ output_format: "glb", fps: 30 },
);
// Node.js: write to disk
import { writeFileSync } from "fs";
writeFileSync("dance.glb", Buffer.from(buffer));
import { useUthanaClient } from "@uthana/react";
function DownloadMotion({ characterId, motionId }: { characterId: string; motionId: string }) {
const client = useUthanaClient();
async function download() {
const buffer = await client.motions.download(characterId, motionId, {
output_format: "glb",
fps: 30,
});
const url = URL.createObjectURL(new Blob([buffer], { type: "model/gltf-binary" }));
const a = document.createElement("a");
a.href = url;
a.download = "motion.glb";
a.click();
}
return <button onClick={download}>Download GLB</button>;
}
private const string CHARACTER_ID = "cXi2eAP19XwQ"; // Default character, or use your own
var motionId = "<motion-id-from-step-4>";
var downloadUrl = $"https://uthana.com/motion/file/motion_viewer/{CHARACTER_ID}/{motionId}/fbx/motion.fbx";
var downloadResponse = await _httpClient.GetAsync(downloadUrl);
downloadResponse.EnsureSuccessStatusCode();
var motionData = await downloadResponse.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync("motion.fbx", motionData);
Job statuses
RESERVED: Job has been created and is waiting to startREADY: Job is ready to be processedFINISHED: Job completed successfullyFAILED: Job failed (check theresultfield for error details)
Error handling
- Shell
- Python
- TypeScript
- React
- C#
if [ "$STATUS" = "FAILED" ]; then
echo "Job failed:"
echo "$RESPONSE" | jq '.data.job.result'
fi
async def main():
# ... (create job and poll as shown above)
if job["status"] == "FAILED":
print(f"Job failed: {job.get('result', {})}")
asyncio.run(main())
if (finished.status === "FAILED") {
console.error("Job failed:", finished.result);
}
{job?.status === "FAILED" && <p>Job failed: {JSON.stringify(job.result)}</p>}
// After polling (see Step 4), finalJob.Status may be "FAILED":
if (finalJob.Status == "FAILED")
{
Console.WriteLine($"Job failed: {JsonSerializer.Serialize(finalJob.Result)}");
}
Next steps
- Learn about Text to motion for generating animations from text
- Learn about Locomotion for predictable, looptable movement with explicit direction, speed, and style
- Explore Retargeting to apply motions to custom characters
- Check the API reference for complete schema documentation