Video to motion | Uthana API Docs

On this page

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: Pass an optional model parameter to select the model. The default is video-to-motion-2.0.

Model removed

video-to-motion-v1 is no longer available. The deprecation deadline of 30 Apr 2026 has passed. Use video-to-motion-2.0 instead.

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:

Step 3: Upload video and create motion

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.

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.

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

Error handling

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