Search

Search HiAPI models, tools, and resources.

Back to blog
Tutorial

PixVerse V6 Image to Video API: a Working curl & Python Example

hiapipixverse-v6Video APITutorialTask API

Animating a still image with pixverse-v6/image-to-video on hiapi is one POST /v1/tasks call plus a poll — the same async task pattern every generation model on the platform uses. The difference from the text-to-video endpoint is the input: you hand it a starting frame (image_url) and a prompt describing how it should move. This guide is a copy-paste curl and Python example, the exact input fields the endpoint validates, and the one surprise that trips people coming from the text-to-video call.

What you need

  • A hiapi API key. Create one in the dashboard and send it as Authorization: Bearer sk-<your-key>. See Authentication for the header format.
  • The model id is pixverse-v6/image-to-video (keep the slash — it is part of the id, not a URL path). This is a different model id from pixverse-v6/text-to-video.
  • A publicly reachable image URL for the first frame. The platform fetches the image server-side, so a localhost or private-bucket link will not work — host the frame somewhere the API can GET it.
  • Nothing to install for curl. For the Python example, only the standard library plus requests.

PixVerse V6 image-to-video takes your still as the opening frame and animates it into a 1–15 second clip. It runs through the unified task API — you submit a job, poll for the result, then download the output URL before it expires.

The minimal working request (curl)

Submit the job to POST /v1/tasks. Both image_url and prompt are required:

curl -s -X POST https://api.hiapi.ai/v1/tasks \
  -H "Authorization: Bearer sk-<your-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "pixverse-v6/image-to-video",
    "input": {
      "image_url": "https://cdn.example.com/your-first-frame.jpg",
      "prompt": "Slow push-in; the subject turns toward the camera and smiles, soft breeze in the hair",
      "duration": 5,
      "resolution": "720p"
    }
  }'

The response hands you a task id:

{ "code": 200, "data": { "taskId": "a1b2c3d4-...." } }

Then poll GET /v1/tasks/<taskId> until data.status is success:

curl -s https://api.hiapi.ai/v1/tasks/a1b2c3d4-.... \
  -H "Authorization: Bearer sk-<your-key>"

On success the finished video URL is at data.output[0].url:

{
  "code": 200,
  "data": {
    "status": "success",
    "output": [{ "url": "https://temp.hiapi.ai/....mp4", "expireAt": 1760000000 }]
  }
}

That output URL is temporary — it carries an expireAt timestamp. Download the .mp4 and store it on your own bucket right away; do not hotlink it.

The same flow in Python

import time, requests

BASE = "https://api.hiapi.ai/v1/tasks"
HEADERS = {"Authorization": "Bearer sk-<your-key>", "Content-Type": "application/json"}

def create_task():
    payload = {
        "model": "pixverse-v6/image-to-video",
        "input": {
            "image_url": "https://cdn.example.com/your-first-frame.jpg",
            "prompt": "Slow push-in; the subject turns toward the camera and smiles",
            "duration": 5,
            "resolution": "720p",
        },
    }
    r = requests.post(BASE, json=payload, headers=HEADERS, timeout=60)
    r.raise_for_status()
    data = r.json()
    task_id = (data.get("data") or {}).get("taskId")
    if not task_id:
        raise RuntimeError(f"create failed: {data}")  # 400s echo the exact bad field
    return task_id

def wait_for(task_id, timeout_s=600):
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        try:
            r = requests.get(f"{BASE}/{task_id}", headers=HEADERS, timeout=30)
            task = (r.json().get("data") or {})
        except requests.RequestException:
            time.sleep(5)          # one transient network blip should not abort the whole poll
            continue
        status = task.get("status")
        if status == "success":
            return task["output"][0]["url"]
        if status == "fail":
            raise RuntimeError(f"task failed: {task.get('error')}")
        time.sleep(5)              # still pending — keep the SAME task_id, re-poll
    raise TimeoutError(task_id)

task_id = create_task()
url = wait_for(task_id)
video = requests.get(url, timeout=120).content
open("out.mp4", "wb").write(video)
print("saved out.mp4")

The poll loop checks for success and fail explicitly and keeps waiting on anything else, so it is robust to whatever intermediate status label the job reports while rendering. If a single poll request errors out, sleep and re-poll the same saved task_id rather than resubmitting — resubmitting bills a second render.

Parameters pixverse-v6/image-to-video actually validates

These are the fields the live endpoint accepts. I confirmed each by sending an invalid value and reading the validation error, then cross-checked against the model docs:

FieldTypeValues / rangeNotes
image_urlstringa public image URLRequired. The first frame. A single URL string — not an array.
promptstring—Required. Describes the motion applied to the frame.
durationinteger1–15, default 5Seconds. Must be an integer, not a string.
resolutionstring360p, 540p, 720p, 1080pOutput resolution.
negative_promptstring—Optional; what to steer away from.
seedinteger—Optional; reuse for reproducibility.
stylestringanime, 3d_animation, clay, comic, cyberpunkOptional preset look.
generate_audiobooleantrue / false, default falseOptional; toggles a generated soundtrack/ambience.

There is no aspect_ratio field here. That is the one real difference from the request you may have seen for text-to-video, and it is worth internalizing (see below).

Production notes

  • No aspect_ratio — the output geometry follows your input image. The text-to-video endpoint takes an aspect_ratio enum; this one rejects it. Because you supply the first frame, the clip inherits that frame's dimensions. If you need a 9:16 vertical clip, crop or pad your image_url to 9:16 before you submit — there is no ratio knob to override it.
  • The schema is closed. Any field not in the table above is rejected with invalid input: <root>: additional properties '<field>' not allowed. A typo'd or guessed field fails loudly at submit time instead of being silently ignored. Fields like first_frame, image, input_image, motion_mode, and camera_movement are all rejected — the starting image goes in image_url, and camera movement goes in the prompt text.
  • duration is an integer, unlike some other video models on the platform that take a string ("6"). Sending "5" returns invalid input: duration: got string, want integer. Out-of-range values report the bound directly, e.g. duration: maximum: got 999, want 15.
  • Errors come back one field at a time, in a flat envelope: {"code":400,"error_code":"INVALID_REQUEST","message":"invalid input: image_url: missing required field \"image_url\""}. Fix the reported field and resubmit; the next invalid field (if any) surfaces on the following call.
  • A failed job still returns HTTP 200. The failure lives in the body as data.status: "fail" — check the status field, not just the HTTP code. An image URL the platform cannot fetch is a common cause, so verify your frame is public first.
  • Pricing is per second and scales with resolution and duration, so a 1080p/15s clip costs much more than a 540p/3s one. Check the current rate on the pricing page before you batch-generate.

What I verified (and what I didn't)

I mapped the input contract by sending deliberately invalid values to the live /v1/tasks endpoint for pixverse-v6/image-to-video and reading the API's own validation responses — a read-only schema probe, no callback attached, no render billed. Concrete findings worth keeping:

  • image_url is the required first-frame field, and it is a single string, not an array. Sending a number returns image_url: got number, want string; omitting it returns missing required field "image_url". The guessable alternatives (first_frame, image, input_image) are all rejected as unknown properties.
  • aspect_ratio is rejected on this endpoint (additional properties 'aspect_ratio' not allowed), even though it is valid on pixverse-v6/text-to-video. This is the concrete reason the image-to-video request looks different from the text-to-video one: the output shape is determined by your frame, not by a parameter.
  • generate_audio is a real boolean toggle (default false) — sending a string returns generate_audio: got string, want boolean. If you want sound, set it to true; it is not forced on or off.

I did not render a clip as part of this probe, so this guide makes no claim about motion quality or how faithfully the model holds your input frame — it documents the exact request contract the endpoint enforces.

FAQ

Why is there no aspect_ratio for image-to-video? Because the clip opens on your image_url frame and inherits its dimensions. To control the output ratio, resize or crop the input image before you submit — the endpoint rejects aspect_ratio as an unknown field.

Does image_url accept more than one image? No. On this endpoint it is a single URL string; passing an array fails validation. It sets the starting frame only.

Is duration in seconds or frames? Seconds — any integer from 1 to 15, default 5. It must be an integer; the string "5" is rejected.

How is this different from the text-to-video endpoint? pixverse-v6/image-to-video animates a frame you provide (image_url + prompt, no aspect_ratio). pixverse-v6/text-to-video is a separate model id that generates from a prompt alone and does take aspect_ratio. Use image-to-video when you already have the exact opening still you want to move.

Why did my request fail with "additional properties not allowed"? You sent a field that is not in the schema. The input accepts only image_url, prompt, duration, resolution, negative_prompt, seed, style, and generate_audio.

Generate it with HiAPI

Choose a model, enter your prompt, and see the result.

HiAPI Blog

Related articles

HiAPI

Generate it with HiAPI

Start generating