Windpaint
Recipes

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 the windpaint CLI and jq.

How it fits together

  1. POST /v1/generation/capabilities/image.generate three times with different seeds, aspect_ratio: "16:9". Each returns 202 with a request_id.
  2. Poll GET /v1/generation/requests/{id}/status until each is completed. Each output is an asset with an id.
  3. Download the stills, look at them, pick one.
  4. POST /v1/generation/estimate for the clip, then POST /v1/generation/capabilities/video.generate with inputs: {"start_frame": [<picked id>]}.
  5. 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

pipeline.py
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 field data) and pass the returned asset id as the start_frame. With the CLI, -i start_frame=./photo.jpg uploads the file for you.
  • Get notified instead of polling: add webhook_url to the video submit. See Webhook receiver.

Next steps