Image to video pipeline
Generate a few stills, pick the best one, animate it into a 5 second clip, and download it, in Python or with the CLI.
This recipe chains two capabilities: image.generate makes candidate stills, you pick one, and video.generate animates it. The still’s asset id goes straight into the video job’s start_frame slot, so nothing is re-uploaded between steps.
You pay for three 1k stills and one 480p clip. The stills are cheap; the clip costs far more, so the script estimates it and asks before submitting. Current prices are in GET /v1/generation/capabilities.
Prerequisites
- An API key exported as
WINDPAINT_API_KEY(API keys). - Python 3.10+ with
httpx(pip install httpx), or thewindpaintCLI andjq.
How it fits together
POST /v1/generation/capabilities/image.generatethree times with different seeds,aspect_ratio: "16:9". Each returns202with arequest_id.- Poll
GET /v1/generation/requests/{id}/statusuntil each iscompleted. Each output is an asset with anid. - Download the stills, look at them, pick one.
POST /v1/generation/estimatefor the clip, thenPOST /v1/generation/capabilities/video.generatewithinputs: {"start_frame": [<picked id>]}.- Poll until
completed(a few minutes), then download the MP4.
Keep the still at the same aspect ratio as the clip (16:9 here) so nothing gets cropped. The live video model, wan-2.2-i2v, needs exactly one start frame and makes 5 second clips at 480p.
Python
Write the script
import os
import sys
import time
from decimal import Decimal
from pathlib import Path
import httpx
API = os.environ.get("WINDPAINT_API_URL", "https://api.windpaint.ai").rstrip("/") + "/v1"
TERMINAL = {"completed", "failed", "nsfw", "canceled"}
api = httpx.Client(
base_url=API,
headers={"Authorization": f"Bearer {os.environ['WINDPAINT_API_KEY']}"},
timeout=60,
)
out_dir = Path("out")
out_dir.mkdir(exist_ok=True)
def check(response: httpx.Response) -> dict:
"""Raise with the API's error code and message on any non-2xx response."""
if response.is_success:
return response.json()
try:
error = response.json()["error"]
except (ValueError, KeyError):
error = response.text
if isinstance(error, dict):
sys.exit(f"{response.status_code} {error['code']}: {error['message']} (request_id {error.get('request_id')})")
sys.exit(f"{response.status_code}: {error}")
def submit(capability: str, body: dict) -> str:
job = check(api.post(f"/generation/capabilities/{capability}", json=body))
print(f"submitted {capability} {job['request_id']} ({job['credits_estimate']} credits held)")
return job["request_id"]
def wait(request_id: str) -> dict:
delay = 1.5
while True:
status = check(api.get(f"/generation/requests/{request_id}/status"))
if status["status"] in TERMINAL:
if status["status"] != "completed":
sys.exit(f"{request_id} ended {status['status']}: {status['error']}")
return status
time.sleep(delay)
delay = min(delay * 1.5, 15)
def download(asset_id: str, path: Path) -> Path:
# A signed URL, valid 15 minutes. Fetch it without the Authorization header.
url = check(api.get(f"/generation/assets/{asset_id}/url"))["data"]["url"]
with httpx.stream("GET", url, timeout=300) as response:
response.raise_for_status()
with path.open("wb") as fh:
for chunk in response.iter_bytes():
fh.write(chunk)
return path
# 1. Three candidate stills, submitted together, then waited on.
prompt = "a lighthouse on a rocky point at dusk, long exposure, waves, cinematic"
seeds = [11, 22, 33]
jobs = [
submit("image.generate", {"prompt": prompt, "aspect_ratio": "16:9", "resolution": "1k", "seed": seed})
for seed in seeds
]
stills = []
for seed, request_id in zip(seeds, jobs):
asset = wait(request_id)["outputs"][0]
path = download(asset["id"], out_dir / f"still-seed{seed}.png")
stills.append(asset["id"])
print(f"[{len(stills)}] {path} ({asset['width']}x{asset['height']})")
# 2. Pick one.
choice = int(input(f"Animate which still? [1-{len(stills)}] ")) - 1
start_frame = stills[choice]
# 3. Estimate the clip and confirm before spending.
video = {
"prompt": "slow push-in toward the lighthouse, waves breaking on the rocks, the beam starts to turn",
"inputs": {"start_frame": [start_frame]},
"aspect_ratio": "16:9",
"resolution": "480p",
"duration": 5,
}
estimate = check(api.post("/generation/estimate", json={"capability": "video.generate", **video}))["data"]
balance = check(api.get("/billing/balance"))["data"]
if estimate["credits"] is None:
sys.exit(f"{estimate['model']} has no price for this tier")
print(f"clip: {estimate['model']} {estimate['width']}x{estimate['height']}, "
f"{estimate['credits']} credits ({balance['available']} available)")
if Decimal(estimate["credits"]) > Decimal(balance["available"]):
sys.exit("not enough credits; top up in Settings -> Billing")
if input("Submit? [y/N] ").lower() != "y":
sys.exit("skipped")
# 4. Animate and download.
clip = wait(submit("video.generate", video))
path = download(clip["video"]["id"], out_dir / "clip.mp4")
print(f"saved {path}, charged {clip['credits']['actual']} credits")
Run it
export WINDPAINT_API_KEY=aak_...
python pipeline.py
submitted image.generate 3f0d2c1e-... (0.08 credits held)
submitted image.generate 9b51a7d4-... (0.08 credits held)
submitted image.generate c2e4f816-... (0.08 credits held)
[1] out/still-seed11.png (1024x576)
[2] out/still-seed22.png (1024x576)
[3] out/still-seed33.png (1024x576)
Animate which still? [1-3] 2
clip: wan-2.2-i2v 848x480, 4 credits (31.76 available)
Submit? [y/N] y
submitted video.generate 5a8e0b93-... (4 credits held)
saved out/clip.mp4, charged 4 credits
The stills take a few seconds. The clip takes a few minutes; the script polls with backoff up to 15 seconds between checks.
balance["available"] already excludes credits held by running jobs. The /billing/balance call needs a key that can read billing; drop that check if yours can’t, and the submit will still be refused with 402 billing.insufficient_credits if the balance is short.
CLI
The CLI does the upload, polling and download for you. With --json, stdout is the API’s payload, so jq can pull out asset ids.
Generate three stills
mkdir -p out
prompt="a lighthouse on a rocky point at dusk, long exposure, waves, cinematic"
for seed in 11 22 33; do
windpaint run image.generate "$prompt" -a 16:9 -r 1k --seed "$seed" \
-o "out/still-seed$seed.png" --json | jq -r '.outputs[0].id' > "out/still-seed$seed.id"
done
Each run waits for its job and saves the file. The .id files hold the asset ids.
Pick one and estimate the clip
Open the PNGs, then:
frame=$(cat out/still-seed22.id)
windpaint estimate video.generate -p "slow push-in, waves breaking on the rocks" \
-i start_frame="$frame" -a 16:9 -r 480p -d 5
This prints the model, credits, output size and your balance, without running anything.
Animate it
windpaint run video.generate -p "slow push-in, waves breaking on the rocks" \
-i start_frame="$frame" -a 16:9 -r 480p -d 5 -o out/clip.mp4
run waits up to 15 minutes by default (--timeout). If you’d rather not block, add --no-wait, keep the printed request id, and later run windpaint status <request-id> --wait -o out/clip.mp4.
Exit code 9 means the job ended failed, nsfw or canceled; 10 means it was still running when the timeout passed (the job keeps running, so wait on it with status --wait rather than running it again). See Scripting and agents.
Variations
- Skip the picking step: the text-to-clip product does still, clip and cover frame in one call.
- Use your own still: upload it with
POST /v1/generation/uploads(multipart fielddata) and pass the returned asset id as thestart_frame. With the CLI,-i start_frame=./photo.jpguploads the file for you. - Get notified instead of polling: add
webhook_urlto the video submit. See Webhook receiver.