#!/usr/bin/env python3 """Animate stills into short clips with Veo (image-to-video), one clip per still. python3 animate.py motion.json --stills stills/ --out clips/ [--model veo-3.1-fast-generate-preview] [--seconds 8] [--resolution 1080p] [--parallel 2] [names...] motion.json: {"name": {"file": "name.jpg", "kind": "nature|archival|product", "motion": "what moves", "inset": 0.0, "negative": "optional, not supported by Lite"}} Each still is cropped to 16:9 (plus `inset` on every edge, for film borders), resized to 1920x1080 and sent with a prompt built from `motion` plus a fixed tail that keeps the look of the image and forbids film damage effects (add grain in post instead; asking Veo for scratches makes it draw rings). Existing clips are skipped. Needs GEMINI_API_KEY (or GOOGLE_API_KEY) and Pillow. Models (pick the cheapest that looks good; costs scale with seconds): veo-3.1-lite-generate-preview cheapest; good for nature footage; no negativePrompt veo-3.1-fast-generate-preview middle; has the strictest rate limit after a burst veo-3.1-generate-preview best, several times the price of fast; use only when asked 1080p needs 8-second clips (4 s is 720p only). Only ~1 s of each clip usually ends up in the cut. """ import argparse, base64, concurrent.futures, io, json, os, time from PIL import Image from common import key, http_json, http_bytes ap = argparse.ArgumentParser() ap.add_argument("motion"); ap.add_argument("names", nargs="*") ap.add_argument("--stills", default="stills"); ap.add_argument("--out", default="clips") ap.add_argument("--model", default="veo-3.1-fast-generate-preview") ap.add_argument("--seconds", type=int, default=8); ap.add_argument("--resolution", default="1080p") ap.add_argument("--parallel", type=int, default=2); ap.add_argument("--retries", type=int, default=60) a = ap.parse_args() K = key("GEMINI_API_KEY", "GOOGLE_API_KEY") BASE = "https://generativelanguage.googleapis.com/v1beta" os.makedirs(a.out, exist_ok=True) spec = json.load(open(a.motion)) TAIL = {"nature": " It should look like real nature documentary footage.", "archival": " It should look like real 1960s documentary film footage.", "product": " It should look like a real product film."} CLEAN = (" Motion starts right away and is clearly visible within the first second. Keep the colors, lighting and look" " of the original image. Steady, clean footage: no added scratches, rings, flashes, light leaks or film damage" " effects. No text, captions, logos or watermarks.") def prep(path, inset): im = Image.open(path).convert("RGB"); w, h = im.size if inset: dx, dy = int(w * inset), int(h * inset); im = im.crop((dx, dy, w - dx, h - dy)); w, h = im.size th = round(w * 9 / 16) if th <= h: im = im.crop((0, (h - th) // 2, w, (h - th) // 2 + th)) else: tw = round(h * 16 / 9); im = im.crop(((w - tw) // 2, 0, (w - tw) // 2 + tw, h)) b = io.BytesIO(); im.resize((1920, 1080), Image.LANCZOS).save(b, "JPEG", quality=92) return base64.b64encode(b.getvalue()).decode() def run(name): s = spec[name]; out = f"{a.out}/{name}.mp4" if os.path.exists(out): return f"{name}: exists" params = {"aspectRatio": "16:9", "durationSeconds": a.seconds, "resolution": a.resolution, "personGeneration": "allow_adult"} if s.get("negative") and "lite" not in a.model: params["negativePrompt"] = s["negative"] body = {"instances": [{"prompt": s["motion"] + TAIL.get(s.get("kind", "nature"), "") + CLEAN, "image": {"bytesBase64Encoded": prep(f"{a.stills}/{s['file']}", s.get("inset", 0)), "mimeType": "image/jpeg"}}], "parameters": params} try: # Veo answers 429 for a while after a burst; keep retrying slowly rather than failing the batch op = http_json(f"{BASE}/models/{a.model}:predictLongRunning", body, {"x-goog-api-key": K}, retries=a.retries, wait=45) except RuntimeError as e: return f"{name}: {e}" t0 = time.time() while not op.get("done"): time.sleep(10) try: op = http_json(f"{BASE}/{op['name']}", None, {"x-goog-api-key": K}, retries=3, wait=10) except RuntimeError as e: print(f"{name}: poll {e}", flush=True) if time.time() - t0 > 900: return f"{name}: timed out" if "error" in op: return f"{name}: {json.dumps(op['error'])[:400]}" uri = op["response"]["generateVideoResponse"]["generatedSamples"][0]["video"]["uri"] data = http_bytes(uri, None, {"x-goog-api-key": K}) open(out, "wb").write(data) return f"{name}: ok {len(data) // 1024} KB in {int(time.time() - t0)}s" names = [n for n in spec if not a.names or n in a.names] with concurrent.futures.ThreadPoolExecutor(a.parallel) as ex: for r in ex.map(run, names): print(r, flush=True)