DEV Community

Arib Khan
Arib Khan

Posted on Originally published at revana.hashnode.dev

Turn a folder of product photos into videos with an image-to-video API (Python and Node)

Disclosure: I work on Revana, a pay-as-you-go API for image and video models. The scripts use its SDK (pip install revana / npm install revana, both MIT and open source), but the structure is what any batch job against a paid generation API needs.

A common job for an image-to-video API: you have a folder of product shots (shoe-red.jpg, shoe-blue.jpg, ... 40 of them) and you want a short clip of each for ads or a product page. The first version is always a for loop around one API call. It works, and then:

  • you only find out what the batch cost when the invoice arrives,
  • the script crashes on photo 27, you re-run it, and photos 1 to 26 are generated (and billed) again,
  • one call at a time takes an hour.

This post builds a script that fixes those: it uploads the photos, quotes every clip and prints the total before spending anything, asks you to confirm, generates several in parallel, and can be re-run any number of times without paying for the same clip twice.

The models and what one clip costs

All three models below take a start frame (image_url) and keep it as the first frame of the clip. Quotes are for a 5-second, 720p, 9:16 clip with audio, with a real image as the first frame, from Revana's quote endpoint on November 5, 2026:

Model Durations Quote for 5 s 40 photos
seedance-2.5 4 to 30 s $1.25 $50.00
kling-4.0 3 to 30 s $0.68 $27.20
wan-3.0 2 to 30 s $0.35 $14.00

Look at Seedance 2.5. Its listed 720p rate is $0.21 a second, but with a first frame the quote is $0.25 a second. If you multiply the rate card by the number of photos, you'll be 16% low on that model. That's the main reason the script quotes the exact request instead of computing prices itself.

Which model is "best" for your products is a judgment call I'm not going to make for you; run three or four photos through each and compare. The script makes that cheap to do.

Setup

pip install revana httpx
export REVANA_API_KEY=sr_live_...   # from revana.si/dashboard
Enter fullscreen mode Exit fullscreen mode

The SDK covers uploads, creating, waiting and downloading. It has no quote helper yet, so the script calls POST /v1/generations/quote with httpx directly. Quoting is free: nothing is created, held or charged.

The script

# photos_to_videos.py: python photos_to_videos.py ./photos --model kling-4.0
import argparse, hashlib, json, os, sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import httpx
from revana import Revana, GenerationFailedError, RevanaError, WaitTimeoutError

API = "https://api.revana.si"
PROMPT = "slow camera orbit around the product, soft studio light, clean background"

p = argparse.ArgumentParser()
p.add_argument("folder", type=Path)
p.add_argument("--model", default="wan-3.0")
p.add_argument("--duration", type=int, default=5)
p.add_argument("--resolution", default="720p")
p.add_argument("--aspect", default="9:16")
p.add_argument("--budget", type=float, default=20.0, help="stop if the batch quotes above this")
p.add_argument("--workers", type=int, default=4)
p.add_argument("--yes", action="store_true", help="don't ask before spending")
args = p.parse_args()

out = args.folder / "videos"
out.mkdir(exist_ok=True)
state_file = out / "state.json"   # upload links and job ids survive a crash
state = json.loads(state_file.read_text()) if state_file.exists() else {}

def save():
    state_file.write_text(json.dumps(state, indent=2))

revana = Revana()
http = httpx.Client(base_url=API, timeout=60,
                    headers={"Authorization": f"Bearer {os.environ['REVANA_API_KEY']}"})

photos = sorted(f for f in args.folder.iterdir() if f.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"})

def request_for(photo: Path) -> dict:
    s = state.setdefault(photo.name, {})
    if "url" not in s:  # upload once; the link works for 14 days
        s["url"] = revana.uploads.upload(str(photo))["url"]
        save()
    return {
        "model": args.model,
        "input": {"prompt": PROMPT, "image_url": s["url"], "duration": args.duration,
                  "resolution": args.resolution, "aspect_ratio": args.aspect, "audio": True},
    }

# 1. Upload and quote everything first.
jobs = []
for photo in photos:
    if (out / f"{photo.stem}.mp4").exists():
        continue  # done on an earlier run
    body = request_for(photo)
    q = http.post("/v1/generations/quote", json=body)
    if q.status_code != 200:
        sys.exit(f"{photo.name}: {q.json()['error']['message']}")
    jobs.append((photo, body, q.json()["quoted_usd"]))

total = round(sum(usd for _, _, usd in jobs), 2)
print(f"{len(jobs)} clips on {args.model}: ${total:.2f}")
if total > args.budget:
    sys.exit(f"over the ${args.budget:.2f} budget; raise --budget or pick a cheaper model")
if jobs and not args.yes and input("Generate? [y/N] ").strip().lower() != "y":
    sys.exit("nothing spent")

# 2. Generate in parallel, each with a key that is stable across re-runs.
def run(job):
    photo, body, usd = job
    digest = hashlib.sha256(photo.read_bytes()).hexdigest()[:16]
    key = f"photos/{photo.name}/{digest}/{args.model}/{args.duration}s-{args.resolution}-{args.aspect}"
    try:
        video = revana.generate(**body, max_cost_usd=usd, idempotency_key=key, timeout=1800)
    except GenerationFailedError as e:
        return f"{photo.name}: failed ({e.code}, retryable={e.retryable}), charged $0"
    except WaitTimeoutError:
        return f"{photo.name}: still running after 30 min; re-run later to pick it up (same key, no new charge)"
    except RevanaError as e:
        return f"{photo.name}: refused ({e.status} {e.code}): {e.message}"
    (out / f"{photo.stem}.mp4").write_bytes(revana.generations.download(video["id"]))
    state[photo.name].update(id=video["id"], cost_usd=video["cost_usd"])
    return f"{photo.name}: ${video['cost_usd']:.2f}"

with ThreadPoolExecutor(max_workers=args.workers) as pool:
    for line in pool.map(run, jobs):
        print(line)
save()
spent = sum(s.get("cost_usd") or 0 for s in state.values())
print(f"total charged for this folder so far: ${spent:.2f}")
Enter fullscreen mode Exit fullscreen mode

Run it on a few photos first. With 12 photos on Wan 3.0 at $0.35 a clip, the first lines look like this:

$ python photos_to_videos.py ./photos --model wan-3.0 --budget 5
12 clips on wan-3.0: $4.20
Generate? [y/N]
Enter fullscreen mode Exit fullscreen mode

Why each piece is there

Quote, then confirm. The quote takes the exact body you'll send, image included, so the total is what the batch will normally cost. Each clip is then sent with max_cost_usd equal to its own quote. If the first provider fails and only a dearer backup is left, that clip fails (and costs $0) instead of quietly costing more. If you'd rather have the clip, pass the quote's reserved_usd (the most a backup could charge) or a looser cap. You're only ever charged for the route that delivers.

Upload once, keep the link. References must be public https:// URLs, so a local file goes through POST /v1/uploads first. revana.uploads.upload() does both steps: it gets a presigned link, PUTs the bytes, and returns a url that works for 14 days. Uploads are free (images up to 30 MB). The script saves that URL in state.json, and that matters for the next point.

An idempotency key per clip, not per run. The key is built from the photo's name, a hash of its bytes, the model and the settings. Re-run the script after a crash and every clip that was already submitted sends the same key with the same body, so the API returns the original generation instead of starting a new paid one. That only works if the body is byte-for-byte the same, which is why the upload URL is cached: re-uploading would produce a new URL, a different body, and a 409 idempotency_conflict instead of a replay. Replace a photo with a new version and its hash changes, so it gets a new key and a new clip, which is what you want.

Skip what's done. Finished clips are on disk as videos/<name>.mp4, and the loop skips them before quoting, so a re-run after a crash only quotes and generates what's missing.

Parallel, but not unlimited. Four workers is a reasonable start. If the account has too many generations running, the create call answers 429 concurrency_limit. The SDK retries 429 and 5xx responses with backoff (honoring Retry-After), and because every create carries an idempotency key, those retries can't double-submit.

Failures are free and labelled. A failed generation has cost_usd: 0 and an error.code. retryable tells you whether the same request might work later (model_busy, timeout) or needs to change (content_policy, invalid_input). The script prints it and moves on, and a re-run picks it up again.

The same thing in Node

The Node SDK has the same calls (uploads.upload, generate, generations.download). The core of the loop:

// photos-to-videos.mjs: node photos-to-videos.mjs ./photos
import Revana, { GenerationFailedError } from "revana";
import { readFile, readdir, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join, parse } from "node:path";

const dir = process.argv[2];
const model = "wan-3.0";
const revana = new Revana(); // reads REVANA_API_KEY
const auth = { authorization: `Bearer ${process.env.REVANA_API_KEY}`, "content-type": "application/json" };

const jobs = [];
for (const name of (await readdir(dir)).filter((f) => /\.(jpe?g|png|webp)$/i.test(f))) {
  const bytes = await readFile(join(dir, name));
  const type = /\.png$/i.test(name) ? "image/png" : /\.webp$/i.test(name) ? "image/webp" : "image/jpeg";
  const { url } = await revana.uploads.upload(bytes, type, name); // cache this, as in the Python version
  const body = {
    model,
    input: { prompt: "slow camera orbit around the product, soft studio light", image_url: url,
             duration: 5, resolution: "720p", aspect_ratio: "9:16", audio: true },
  };
  const q = await fetch("https://api.revana.si/v1/generations/quote", { method: "POST", headers: auth, body: JSON.stringify(body) }).then((r) => r.json());
  if (!q.quoted_usd) throw new Error(`${name}: ${q.error?.message}`);
  const key = `photos/${name}/${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}/${model}/5s-720p-9:16`;
  jobs.push({ name, body: { ...body, max_cost_usd: q.quoted_usd }, key, usd: q.quoted_usd });
}

console.log(`${jobs.length} clips: $${jobs.reduce((s, j) => s + j.usd, 0).toFixed(2)}`);
// ...confirm here before spending...

const results = await Promise.allSettled(jobs.map(async (j) => {
  const video = await revana.generate(j.body, { idempotencyKey: j.key });
  await writeFile(join(dir, `${parse(j.name).name}.mp4`), Buffer.from(await revana.generations.download(video.id)));
  return `${j.name}: $${video.cost_usd}`;
}));
for (const r of results) console.log(r.status === "fulfilled" ? r.value : r.reason instanceof GenerationFailedError ? `failed, $0: ${r.reason.message}` : r.reason);
Enter fullscreen mode Exit fullscreen mode

Promise.allSettled over every job starts them all at once. For more than a handful of photos, add a small concurrency limit (a pool of four, like the Python version) so you don't spend the run waiting on 429 concurrency_limit retries.

Variations

  • First and last frame. Seedance 2.5, Kling 4.0, Wan 3.0 and Veo 3.1 also take end_image_url (it needs image_url). Two shots of the same product from different angles give you a controlled transition.
  • Reference images instead of a start frame. Seedance 2.5 takes up to 30 image_urls and Kling 4.0 and Wan 3.0 up to 10, for "this product, in a new scene" rather than "animate this exact photo". On Seedance, image_url and reference media can't be combined in one request.
  • A free dry run. minimax-h3-free takes a start frame too (5 to 15 s, sound always on) and quotes $0.00. The account needs one top-up first (the smallest pack is $10), but free jobs never spend it. Use it to test the whole pipeline before you point it at a paid model.

Prices for every model are at revana.si/pricing, and each model's options are in GET https://api.revana.si/v1/models (no key needed). The SDKs: revana-python and revana-js. API reference: revana.si/docs.

Quotes in this post are from Revana's live API on November 5, 2026.

Top comments (0)