What you'll build
One reference-to-video call on hiapi's async /v1/tasks API that blends several reference stills — and, optionally, a voice clip — into a single 3–16 second take at up to 4K. You submit one POST, poll (or get a callback) until the task finishes, then download the MP4.
Grab a key from the hiapi dashboard first; every request below uses Authorization: Bearer sk-<your-key>.
Reference-to-video vs image-to-video. This endpoint blends 1–15 stills — hand it an actor still, a location still, a prop still, and it composes them into one shot. If you only have a single first frame to animate, use vidu-q4-preview/image-to-video instead; it takes a single
image_urland rejectsreference_image_urls.
Minimal runnable request (curl)
curl -X POST https://api.hiapi.ai/v1/tasks \
-H "Authorization: Bearer sk-<your-key>" \
-H "Content-Type: application/json" \
-d '{
"model": "vidu-q4-preview/reference-to-video",
"input": {
"prompt": "The person from reference image 1 walks through the cafe from reference image 2, warm morning light, slow dolly-in",
"reference_image_urls": [
"https://your-cdn.com/person.jpg",
"https://your-cdn.com/cafe.jpg"
],
"resolution": "1080p",
"duration": 5,
"aspect_ratio": "16:9"
}
}'
The POST returns a task id, not the video:
{ "code": 200, "data": { "taskId": "task_abc123" } }
Poll GET /v1/tasks/<taskId> until data.status is success, then read the MP4 from data.output[0].url:
curl https://api.hiapi.ai/v1/tasks/task_abc123 \
-H "Authorization: Bearer sk-<your-key>"
{
"code": 200,
"data": {
"status": "success",
"output": [
{ "url": "https://temp.hiapi.ai/....mp4", "expireAt": 1760000000 }
]
}
}
The output URL is temporary — it carries an expireAt (a few days out) — so download the bytes right away instead of hotlinking.
Python: submit → poll → download
import time
import requests
BASE = "https://api.hiapi.ai/v1/tasks"
HEADERS = {
"Authorization": "Bearer sk-<your-key>",
"Content-Type": "application/json",
}
def submit() -> str:
payload = {
"model": "vidu-q4-preview/reference-to-video",
"input": {
"prompt": (
"The person from reference image 1 walks through the cafe "
"from reference image 2, warm morning light, slow dolly-in"
),
"reference_image_urls": [
"https://your-cdn.com/person.jpg",
"https://your-cdn.com/cafe.jpg",
],
"resolution": "1080p",
"duration": 5,
"aspect_ratio": "16:9",
},
}
r = requests.post(BASE, json=payload, headers=HEADERS, timeout=60)
r.raise_for_status()
body = r.json()
task_id = (body.get("data") or {}).get("taskId")
if not task_id:
raise RuntimeError(f"submit rejected: {body}") # 400s land here
return task_id
def wait(task_id: str, timeout_s: int = 600, interval: int = 5) -> str:
deadline = time.time() + timeout_s
while time.time() < deadline:
r = requests.get(f"{BASE}/{task_id}", headers=HEADERS, timeout=40)
task = r.json().get("data") or {}
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(interval)
raise TimeoutError(task_id)
def download(url: str, path: str) -> None:
with requests.get(url, stream=True, timeout=120) as r:
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_content(1 << 16):
f.write(chunk)
task_id = submit()
url = wait(task_id)
download(url, "out.mp4")
One thing to internalize: a render that fails still comes back as HTTP 200, with the reason under data.status: "fail" and data.error.code / data.error.message. A failed generation is not an HTTP error, so branch on the body, not the status code.
Adding a voice reference
The voice field is reference_audio_urls — an array of up to 3 clips. The audio sets the timbre; your prompt still carries the words and drives lip-sync.
{
"model": "vidu-q4-preview/reference-to-video",
"input": {
"prompt": "The presenter from reference image 1 speaks in the voice of reference audio 1: 'Welcome back to the channel.'",
"reference_image_urls": ["https://your-cdn.com/presenter.jpg"],
"reference_audio_urls": ["https://your-cdn.com/voice.mp3"],
"resolution": "1080p",
"duration": 8
}
}
Field-name gotcha: the intuitive voice_reference_urls is rejected (the validator only allows reference_audio_urls). See the probe table below.
Production: callbacks and idempotency
For a server workflow you usually don't want to hold a polling loop open. Attach a top-level callback (sibling of input, not inside it) and hiapi will POST the finished task to your URL:
{
"model": "vidu-q4-preview/reference-to-video",
"input": { "prompt": "...", "reference_image_urls": ["..."] },
"callback": { "url": "https://your-app.com/hooks/vidu", "when": "final" }
}
whenonly supports"final"— you're notified once, on the terminal state (success or fail), never on intermediate progress.- The callback body is the same payload you'd get from
GET /v1/tasks/<id>. - Delivery retries a few times and can duplicate, so dedupe on
taskIdand make the handler idempotent. - Keep polling as a fallback for the window where your endpoint was briefly unreachable.
Save the taskId the moment you get it. If your poller hits a transient network error, re-poll the saved id — never resubmit, or you pay for a second render.
What I verified (schema contract)
I probed the live endpoint with deliberately invalid values (free — rejected requests don't bill, and an invalid value never creates a task). The validator is strict and returns a flat { "code": 400, "error_code": "INVALID_REQUEST", "message": ... }:
| What you send | What comes back |
|---|---|
resolution: "9999p" | resolution: value must be one of '540p', '720p', '1080p', '2k', '4k' |
duration: 999 | duration: maximum: got 999, want 16 |
aspect_ratio: "banana" | aspect_ratio: value must be one of '16:9', '9:16', '1:1', '4:3', '3:4' |
voice_reference_urls: [...] | additional properties 'voice_reference_urls' not allowed |
four reference_audio_urls | reference_audio_urls: maxItems: got 4, want 3 |
omit reference_image_urls | missing required field "reference_image_urls" |
So the confirmed input shape is:
| Field | Required | Values |
|---|---|---|
prompt | yes | text |
reference_image_urls | yes | 1–15 image URLs |
reference_audio_urls | no | up to 3 audio URLs (the voice field) |
resolution | no | 540p / 720p / 1080p / 2k / 4k |
duration | no | integer 3–16 (seconds) |
aspect_ratio | no | 16:9 / 9:16 / 1:1 / 4:3 / 3:4 |
duration is an integer (5), not a string — unlike some other video models on hiapi that expect "6". Passing extra keys is a hard error (additional properties ... not allowed), so don't copy fields over from the image-to-video schema.
Output-side notes before you wire up downloads
- Every clip carries an AAC audio track even when you send no
reference_audio_urls. Sound words in the prompt drive an ambient track, so don't assume "no voice reference = silent file." - 4K output from Vidu Q4 Preview is delivered as HEVC/H.265. It plays in most native players, but many browsers won't inline an H.265 MP4 inside a
<video>tag — re-encode to H.264 if you're embedding on the web. The lower tiers come back as standard H.264.
Preview model — treat the enums as a snapshot
vidu-q4-preview/reference-to-video is a preview model: it's callable today but isn't listed in /v1/models, and a preview schema can shift. Before a large batch, re-run one invalid-value probe (like the table above) to confirm the enums still hold. The authoritative spec is what the validator enforces plus the model page.
Related
- vidu-q4-preview/reference-to-video model page — live spec and per-request cost.
- Vidu Q4 Preview reference-to-video prompt recipes — five copy-paste prompts with the real clips they produced, for ideas on what to put in
prompt(this guide covers the call; that page covers the prompt). - vidu-q4-preview/image-to-video — the single-frame animation endpoint; reach for it when you have one still, not a set to blend.
- hiapi pricing — reference-to-video bills per second and scales with resolution.
FAQ
Is reference-to-video the same as image-to-video?
No. Reference-to-video blends 1–15 stills (optionally plus up to 3 voice clips) into one composed shot; image-to-video animates a single first frame. They're separate endpoints with different inputs — reference-to-video even rejects the image-to-video image_url field and vice versa.
How do I add a voice?
Attach reference_audio_urls (up to 3 clips) and name it in the prompt ("…in the voice of reference audio 1"). The field is exactly reference_audio_urls; voice_reference_urls and other names are rejected.
What's the longest clip I can request?
16 seconds. duration is an integer from 3 to 16.
Do I have to poll?
No — attach callback: { "url": ..., "when": "final" } to be notified once on the terminal state. Dedupe on taskId and keep polling as a fallback.
How much does a 4K clip cost?
It's billed per second and the rate rises with resolution, with 4K the top tier — see hiapi pricing. A practical workflow is to iterate at 720p and re-render only the keeper at 4k.




