HiAPI
Blog

Tutorial

Veo 3.1 Lite Image-to-Video on HiAPI: curl and Python Tutorial

Nov 3, 2026 10 min read
午夜蓝底上铜色精细平行刻线由密到疏流动,抽象呼应图像转为视频的教程封面图。

What veo-3.1-lite/image-to-video does

veo-3.1-lite/image-to-video is a first-frame-to-video model. You hand it a single still image, and it animates that frame into a short video clip. The image you supply is not a style reference or a loose inspiration — it is literally the opening frame of the output, so composition, subject placement, and framing carry straight through into the first seconds of motion.

That design choice shapes how you think about the input. A well-lit, sharp, correctly cropped image produces a clip that starts exactly where you expect. A blurry or oddly framed one produces a clip that starts blurry and oddly framed, then drifts further as the model generates motion. Most of the craft in using this model well happens before you write any code.

On the output side, the model returns a video with native audio. This is the part that separates it from older image-to-video pipelines, where you generated silent footage and then bolted on a separate audio track. Here the audio is generated as part of the same job, so ambient sound and the visual scene stay coherent — no manual muxing step, no timing mismatch between a sound effect and the motion it belongs to.

Generation is asynchronous. You submit a job, get an identifier back, and poll until the job finishes. That is a deliberate architecture choice: video generation takes far longer than a typical HTTP request timeout, so the API decouples submission from completion. Every example in this tutorial follows that submit-then-poll shape.

In short: one image in, one video with audio out, delivered through an async job. Keep that mental model and the rest of the API surface is straightforward.

Before you send the first request

Three things need to be in place before your first call will succeed.

An API key

Every request to HiAPI.ai authenticates with an API key. Create one in your account dashboard and load it from an environment variable rather than pasting it into source files:

export HIAPI_API_KEY="your_api_key_here"

If a key has ever been committed to a repository, rotate it. Keys are cheap to replace and expensive to leak.

A reachable first-frame image

The model needs to fetch the image itself, so the image must be reachable by URL — a public object-storage link, a CDN path, or any HTTP endpoint that does not require interactive authentication. Signed URLs with long expiry work fine; URLs gated behind a login session do not. Use a common format such as PNG or JPEG, and prefer a sensible resolution: a sharp, moderately sized image gives the model clean detail to work with without wasting bandwidth.

If your source images live behind an internal service, upload them to object storage first and pass the resulting URL. This is usually the single most common cause of a failed first attempt.

The endpoint path

The model is addressed as veo-3.1-lite/image-to-video. Treat that string as the model identifier you send with the job rather than something to hard-code in a dozen places — put it in a constant, because you will likely swap between model variants later.

With a key exported, a fetchable image URL, and the model identifier in hand, you are ready to submit.

A working curl request

The submission call creates a generation job. A complete, runnable example looks like this:

curl -X POST "https://api.hiapi.ai/v1/video/generations" \
  -H "Authorization: Bearer $HIAPI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "veo-3.1-lite/image-to-video",
    "image": "https://your-bucket.example.com/first-frame.jpg",
    "prompt": "Slow push-in as morning light shifts across the scene",
    "duration": 5
  }'

Walking through it:

  • The URL is the generation endpoint on HiAPI. All model traffic goes through this one interface, so switching models later means changing the model field, not the endpoint.
  • The Authorization header carries your bearer token. The $HIAPI_API_KEY form reads it from the environment so the secret never appears in shell history.
  • model identifies veo-3.1-lite/image-to-video.
  • image is the first-frame URL. This is the field that makes the call image-to-video rather than text-to-video.
  • prompt is optional but strongly recommended. It describes the motion you want — camera movement, lighting change, subject action — not a description of the still, which the model can already see.
  • duration requests the clip length in seconds, subject to what the model supports.

The response is a job object containing an id. Save it:

{
  "id": "job_abc123",
  "status": "queued",
  "model": "veo-3.1-lite/image-to-video"
}

A queued or processing status is the expected outcome — you have not received a video yet, only a promise of one. The next section covers turning that promise into a file.

The same call in Python

The curl call translates directly into Python using requests. This version puts the submission in a small function so it can be reused inside a pipeline:

import os
import requests

API_KEY = os.environ["HIAPI_API_KEY"]
BASE_URL = "https://api.hiapi.ai/v1"
HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}

def submit_video_job(image_url: str, prompt: str, duration: int = 5) -> str:
    payload = {
        "model": "veo-3.1-lite/image-to-video",
        "image": image_url,
        "prompt": prompt,
        "duration": duration,
    }
    response = requests.post(
        f"{BASE_URL}/video/generations",
        headers=HEADERS,
        json=payload,
        timeout=30,
    )
    response.raise_for_status()
    data = response.json()
    print(f"Submitted job {data['id']} ({data['status']})")
    return data["id"]

if __name__ == "__main__":
    job_id = submit_video_job(
        image_url="https://your-bucket.example.com/first-frame.jpg",
        prompt="Slow push-in as morning light shifts across the scene",
    )

A few details worth keeping:

  • raise_for_status() turns non-2xx responses into exceptions early. Without it, an authentication or validation error looks like a successful call that returns an unexpected body.
  • The 30-second timeout applies to submission only. Submission should return quickly; the long work happens server-side. If submission itself times out, something is wrong with connectivity or payload size, not with generation speed.
  • Returning the job id rather than the whole response keeps the function composable — the caller decides what to do with the job.

From here, the function drops into a queue worker, a Flask endpoint, or a notebook cell without modification.

Polling the async job

Video generation takes minutes, not milliseconds. Poll the job status endpoint at a human-paced interval and stop as soon as the job reaches a terminal state:

import time
import requests

def wait_for_video(job_id: str, timeout: int = 600, interval: int = 10) -> dict:
    deadline = time.time() + timeout
    url = f"{BASE_URL}/video/generations/{job_id}"
    while time.time() < deadline:
        response = requests.get(url, headers=HEADERS, timeout=30)
        response.raise_for_status()
        data = response.json()
        status = data["status"]
        print(f"Job {job_id}: {status}")
        if status == "completed":
            return data
        if status == "failed":
            raise RuntimeError(f"Generation failed: {data}")
        time.sleep(interval)
    raise TimeoutError(f"Job {job_id} did not finish within {timeout}s")

Three practices make this reliable in production:

  1. Exponential backoff. Start with a short interval and grow it, capped at something reasonable. The first poll often returns queued anyway, so hammering the endpoint in the first few seconds buys nothing.
  2. A hard timeout. Always bound the total wait. A job stuck in processing should surface as an error you can alert on, not an infinite loop.
  3. Persist the job id. Write it to your database before you start polling. If the process crashes mid-wait, you can resume polling after restart instead of re-submitting and paying for the work twice.

Treat completed and failed as the only states that end the loop. Everything else — queued, processing — means keep waiting.

One more caution: if you run this on a web server, do not poll inside the request handler. Submit, return the job id to the client, and let a background worker finish the wait. Otherwise a slow generation ties up a connection for minutes.

Downloading the output video

When the job completes, the status response includes a URL for the finished video. Download it as binary data and write it to disk with an appropriate extension:

def download_video(job: dict, path: str = "output.mp4") -> str:
    video_url = job["output"]["url"]
    response = requests.get(video_url, headers=HEADERS, timeout=120, stream=True)
    response.raise_for_status()
    with open(path, "wb") as f:
        for chunk in response.iter_content(chunk_size=1 << 20):
            f.write(chunk)
    print(f"Saved {path}")
    return path

A few things to get right:

  • Write in binary mode. Opening with "w" instead of "wb" corrupts the file silently; the download succeeds but the video will not play.
  • Stream large files. The stream=True plus chunked copy above keeps memory flat regardless of clip size.
  • Calculate checksums if you are archiving outputs at scale. It costs one line and catches truncated downloads before they reach a user.

About audio: the returned video already contains its native audio track. There is no separate audio file to fetch and no muxing step. If you remux the file downstream — for example, to change the container — carry the audio stream across. The common mistake is running a conversion that strips audio by default, then wondering where it went.

Finally, the output URL is typically time-limited. Download and store the file in your own bucket promptly rather than saving the URL and fetching it days later; the link may have expired by then.

Common failure points

A short list of the errors that account for most troubleshooting time with image-to-video calls:

The image URL is not reachable

This is the most common failure by a wide margin. The model fetches your image server-side, so a URL that works in your logged-in browser tells you nothing. Test it with a plain unauthenticated request before submitting a job. Signed URLs should have an expiry comfortably longer than the generation time.

Auth errors that look like model errors

A missing or malformed Authorization header usually returns a clear 401, but occasionally developers wrap the setup in code that swallows the status code and then report a “model failure.” Check the raw status before debugging the payload.

Polling too aggressively or not at all

Polling every 100 milliseconds wastes quota and can trigger rate limiting. Polling once, seeing processing, and giving up means the job succeeded with nobody watching. Follow the backoff-and-timeout pattern above.

Treating submission as completion

A successful submission returns a job, not a video. Code that reads a url field off the submission response finds nothing. Only the completed status response carries the output.

Prompts that describe the image

Prompts like “a cat sitting on a windowsill” restate what the model can already see. Prompts that describe motion — “camera slowly pulls back as rain begins” — produce better results. Describe change, not content.

Losing the job id on crash

If a process dies between submission and completion, an unpersisted job id means the work is unrecoverable. Persist it and poll asynchronously from a worker. Small design choices prevent the longest debugging sessions.

Related articles

View all articles →

Start building with persistent AI artifacts

Get your API key and call image, video and audio models through one task-based API.

Read the API docs