Dramatis Studio turns one image, or a prompt alone, into a short video. This guide takes you from a first frame to a saved clip: how to make the task, how to wait for it, what the result holds, how long it is kept, what it costs and why it can fail.
What video is
Video is image to video. You give a first frame, a prompt and, if you like, a length, and you get a clip of that first frame set in motion. Some models also make a clip from the prompt alone: their limits.video.first_frame is optional. On the others it is required, and a task without a first frame is refused.
It uses the same call as an image, POST /v1/tasks, and the same keys, balance, idempotency and webhooks. The differences are these:
- A clip takes minutes, not seconds. Do not hold the connection open for it; see Wait for the clip .
- The result is
output.video, notoutput.images. - It is paid by the second, not by the image.
- It has running limits of its own, apart from the image limits.
To see the video models your key may use, ask the model list for the task image_to_video. The call needs no key, and a key shows the models open to its account.
The host in this sample is not open yet.
curl "https://api.studio.dramatis.ai/v1/models?task=image_to_video" |
The video models are open to every account. A model your account may not use is 403 model_not_allowed. Each model carries labels in the list: status, adult_capable and may_refuse (Model labels says what they mean). Dramatis Studio does not check the first frame or the result for content; what you send and make is yours to answer for, as it is for images.
Make a clip
The sample uses a placeholder model id, family/variant. Put the id of a video model from the model list in its place, and give the lengths that model offers. The key is read from OPSIS_API_KEY, as in the quick start.
The sample creates the task, reads it again until it has finished, and downloads the clip with a plain request. It does not pass wait.
Request
# Needs curl and jq. The key is read from the environment.
|
TASK=$(curl -s 'https://api.studio.dramatis.ai/v1/tasks' \
|
-H "Authorization: Bearer $OPSIS_API_KEY" \
|
-H "Content-Type: application/json" \
|
-H "Idempotency-Key: sample-$(date +%s)-$RANDOM" \
|
-d '{
|
"model": "family/variant",
|
"input": {
|
"image": "https://example.com/first-frame.png",
|
"prompt": "A slow push-in on a quiet harbour at dawn",
|
"duration": 5
|
}
|
}')
|
|
ID=$(echo "$TASK" | jq -r '.id')
|
# Give up a few seconds after deadline_at.
|
STOP=$(echo "$TASK" | jq -r '(.deadline_at | fromdateiso8601) + 5')
|
DELAY=2
|
|
# Read the task again, no faster than every 2 seconds, backing off to 10, until it has finished.
|
while true; do
|
STATUS=$(echo "$TASK" | jq -r '.status')
|
if [ "$STATUS" = "SUCCEEDED" ] || [ "$STATUS" = "FAILED" ]; then break; fi
|
if [ "$(date +%s)" -gt "$STOP" ]; then echo "The task did not finish by its deadline." >&2; exit 1; fi
|
sleep "$DELAY"
|
DELAY=$(( DELAY < 10 ? DELAY + 2 : 10 ))
|
TASK=$(curl -s "https://api.studio.dramatis.ai/v1/tasks/$ID" \
|
-H "Authorization: Bearer $OPSIS_API_KEY")
|
done
|
|
if [ "$STATUS" = "FAILED" ]; then
|
echo "$TASK" | jq -r '.error.code + ": " + .error.message' >&2
|
exit 1
|
fi
|
|
# The link is signed, so the download needs no key.
|
URL=$(echo "$TASK" | jq -r '.output.video.url')
|
curl -s -o 'clip.mp4' "$URL"
|
|
The cURL form needs jq. The create call answers 202 with the task, at once. The finished task has status set to SUCCEEDED and the clip in output.video.
The input
A video task takes prompt, takes image where the model needs a first frame, and may take last_frame, duration, aspect_ratio, seed and negative_prompt. A model takes the ones it lists in its input_schema and refuses the others.
Without a first frame, the clip is made from the prompt alone. On a model that keeps the shape of the first frame (it lists no aspect ratio), such a clip is wide, 16:9, and an aspect_ratio you send is still ignored. Such a clip has no poster: output.video.poster is null, since the poster is the first frame.
A last frame
Some models also take a last frame: the clip starts on the first frame in image and ends on the picture in last_frame. The model list says which: limits.video.last_frame is true on them. Today they are Veo 3.1 Lite and Hailuo 3.
- Send
last_frametogether withimage. A last frame on its own is refused. - A last frame does not change the price. A quote with one is the same as without it.
- It is checked, copied and downloaded as the first frame is, and held to the same limits.
- On a model that takes no last frame,
last_frameis refused before anything is charged.
No resolution, output_format, num_images, loras, translate_prompt, reference_images or background field exists for video. The size and the frame rate are fixed by the model.
A link to the first frame is confirmed to exist when the task is created and downloaded once, when the task starts. The checks of an image are the checks of a first frame, and Tasks lists them. What each model takes:
A model's entry in the model list, and its model page where the site shows one, hold the same facts, with the exact sizes for each aspect ratio in limits.video.sizes.
Hailuo 3 lists no aspect ratio and no size: the clip keeps the shape of the first frame. An aspect_ratio sent to it is accepted and ignored, and does not change the price.
Wait for the clip
A clip takes minutes. How long a task may wait for its turn and then run is in the model's limits.queue_seconds and limits.run_seconds, and deadline_at on the task is the outer limit of both.
Do not use wait for video. It holds the connection for at most 60 seconds, which is shorter than a clip takes, and the call then answers 202 anyway. Create the task and either poll it or take a webhook.
Poll
Read GET /v1/tasks/{id} no faster than every 2 seconds. Start at 2 seconds and back off to 10, and stop a few seconds after deadline_at: by then the task is final. The sample above does exactly this. Test for a final status, SUCCEEDED or FAILED, so that a status added later does not break your code.
Webhook
A webhook pushes the task to your server when it ends, so you do not poll. Give the task its own webhook_url, or register an address in the console. The event is task.succeeded or task.failed, and its data is the task with fresh links, output.video included.
Request
curl 'https://api.studio.dramatis.ai/v1/tasks' \
|
-X POST \
|
-H "Authorization: Bearer $OPSIS_API_KEY" \
|
-H "Content-Type: application/json" \
|
-H "Idempotency-Key: sample-$(date +%s)-$RANDOM" \
|
-d '{
|
"model": "family/variant",
|
"input": {
|
"image": "https://example.com/first-frame.png",
|
"prompt": "A slow push-in on a quiet harbour at dawn",
|
"duration": 5
|
},
|
"webhook_url": "https://example.com/hooks/opsis"
|
}' |
The receiver checks the signature with the SDK's own check, on the body exactly as it came, answers with a 2xx at once and then downloads the file with a plain request. It is for JavaScript and Python only: cURL cannot check a signature.
Request
import { createServer } from "node:http"
|
import { writeFile } from "node:fs/promises"
|
import { verifyWebhook } from "opsis-sdk"
|
|
const secret = process.env.OPSIS_WEBHOOK_SECRET
|
if (!secret) throw new Error("Set OPSIS_WEBHOOK_SECRET to the signing secret from the console.")
|
|
// Put this behind HTTPS on port 443: Dramatis Studio sends events to nothing else.
|
createServer(async (request, response) => {
|
const chunks: Buffer[] = []
|
for await (const chunk of request) chunks.push(chunk as Buffer)
|
const rawBody = Buffer.concat(chunks)
|
|
let event
|
try {
|
// The check needs the body exactly as it came, before any JSON parsing.
|
event = verifyWebhook({ secret, headers: request.headers, body: rawBody })
|
} catch {
|
response.writeHead(400).end()
|
return
|
}
|
// Answer with a 2xx within 30 seconds, then do the slow work.
|
response.writeHead(204).end()
|
|
const output = event.data.output
|
if (event.type !== "task.succeeded" || !output || !("video" in output)) return
|
// The link is signed, so the download is a plain request with no key.
|
const download = await fetch(output.video.url)
|
if (!download.ok) return
|
await writeFile("clip.mp4", Buffer.from(await download.arrayBuffer()))
|
}).listen(8080)
|
|
Order and duplicates are as for every event: deduplicate on webhook-id, and you may always read the task again.
The result
A finished video task has one result, output.video:
{
|
"id": "task_01k3m8x9q2z4v7n5p6r8s0t1w3",
|
"status": "SUCCEEDED",
|
"output": {
|
"video": {
|
"url": "https://…signed link…",
|
"expires_at": "2026-11-02T10:12:31Z",
|
"mime": "video/mp4",
|
"width": 768,
|
"height": 768,
|
"duration_seconds": 5.04,
|
"fps": 24,
|
"bytes": 2483920,
|
"audio": true,
|
"seed": 4311,
|
"poster": { "url": "https://…signed link…", "expires_at": "2026-11-02T10:12:31Z", "mime": "image/webp", "width": 768, "height": 768 }
|
}
|
}
|
} |
urlis the clip, anmp4file.posteris its first frame as awebppicture, and isnullif it could not be made.duration_seconds,fps,width,height,bytesandaudioare measured from the file, not copied from your request.audioistrueonly for a model whose clips have sound.seedis the seed used, andnullon a model that takes none.
The links are signed and last one hour; every read of the task returns fresh ones. Keep the task id, not the link. The file at the link is plain: fetching it needs no key. In a program, tell the two kinds of result apart by the key: "video" in task.output in JavaScript, and task["output"].get("video") in Python.
Retention
The clip and its poster are kept with the task's other content and removed together with it. The default is 24 hours after the task finishes. Retention says how to change it, from 10 minutes to 14 days or Never, for the account or for one task with retention_seconds. When the time runs out, content.state is expired and output is null. The same happens at DELETE /v1/tasks/{id}/content.
Download the clip within its retention. A link works for one hour, and a read of the task gives a new one for as long as the content exists.
Price and billing
A video is paid by the second. A task costs the price of one second, after your discount, times its length. The model's pricing.unit is second, and the price of a second is in pricing.prices; a model page, where the site shows one, shows the total for each length. This guide gives no price, since the list is the record.
- Ask for the price first with a quote. A quote runs the same checks and the same pricing as the task. It has one line,
second of video, and its quantity is the length. - The price is taken when the task is created. Without enough balance the create call answers
402 insufficient_balance, and its message names the price of the task. - A failed task is refunded in full: a refusal, a time-out and a task that never started included. A refusal is refunded too, unless the model's
pricing.refusals_chargedin the model list istrue.
Request
curl 'https://api.studio.dramatis.ai/v1/quotes' \
|
-X POST \
|
-H "Authorization: Bearer $OPSIS_API_KEY" \
|
-H "Content-Type: application/json" \
|
-d '{
|
"model": "family/variant",
|
"input": {
|
"image": "https://example.com/first-frame.png",
|
"prompt": "A slow push-in on a quiet harbour at dawn",
|
"duration": 5
|
}
|
}' |
When a clip fails
Most failures are refused before the task exists, so nothing is charged. Errors lists every code; the video cases are:
400 unsupported_combination: a length the model does not offer, an aspect ratio it does not offer, or a field it does not list, such as aseedon a model that takes none. A task with no first frame on a model that needs one is refused the same way, withparamset toinput.image. Alast_frameon a model that takes none, or alast_framewithoutimage, is refused the same way, withparamset toinput.last_frame.400 invalid_request: a first frame that is not a valid HTTPS link, or adata:image or upload of the wrong type or size.paramnames the field.403 model_not_allowed: the model is not open to your account.429 concurrency_limit: your account already has the most unfinished tasks it may have.
A task that was accepted and then failed has the status FAILED, and is refunded:
invalid_asset: the first frame could not be downloaded, was too large or was not an image, or an upload it used was gone.content_rejected: the model refused the request. The message reads "The engine refused this request. Change the prompt." Change the prompt or the first frame.generation_failed: the model could not produce a video. The message reads "The engine could not produce a video." Retry.timeout: the attempt ran out of time.backend_unavailable: no backend took the task in time. Retry either.
Some models are run by outside providers, with filters of their own, and may_refuse is true on them. Until Dramatis Studio has recorded the texts those providers use, a refusal can end a task as generation_failed instead of content_rejected. Treat the two alike: both are refunded, and a retry with the same prompt and first frame will be refused again.
As for any task, a clip that has started cannot be canceled: POST /v1/tasks/{id}/cancel answers 409 task_not_cancelable from STARTING on.
Limits
- Lengths, ratios and sizes are the model's, in the table above and in
limits.video. - The first frame has at least the pixels and at most the pixels and bytes in the table. A larger upload is refused when the task is created.
- Running video tasks. A video counts toward its own running limit, for the account and optionally for one
namespace, and toward no other. A video task over the limit waits asPENDINGand starts when one of your videos ends. It is not an error. The image limits are not used up by video, and a video does not wait for them. Limits has the numbers. - Unfinished tasks. Images and videos count together toward the unfinished tasks of the account. Beyond them, create answers
429 concurrency_limit. - The result file has a size limit, in
limits.video.max_bytes. A larger file fails the task, and it is refunded.
That is a clip. Next:
- Read how webhooks are signed and retried.
- Open the API reference for every field of the call and of
output.video.