#!/usr/bin/env python3 """ Cloud I2V Batch Processor Uploads keyframe images to a cloud GPU (Vast.ai), runs Wan 2.1 I2V generation, and downloads results. This is 10-50x faster than local M4 Max for video generation. Requires: pip install vastai httpx """ import argparse import asyncio import json import os import re import subprocess import sys import time from dataclasses import dataclass from pathlib import Path from typing import Optional import httpx @dataclass class CloudInstance: id: str ip: str port: int ssh_port: int hourly_cost: float gpu: str def run_vastai(args: list[str], capture_json: bool = False) -> dict | str: """Run a vastai CLI command.""" cmd = ["vastai"] + args if capture_json: cmd.append("--raw") result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"vastai command failed: {result.stderr}") if capture_json: return json.loads(result.stdout) return result.stdout def search_offers(max_price: float = 0.50, min_gpu_ram: int = 24) -> list[dict]: """Search for available GPU instances using vastai CLI.""" query = f"gpu_ram >= {min_gpu_ram} num_gpus = 1 rentable = true dph < {max_price} reliability > 0.95" offers = run_vastai(["search", "offers", query, "-o", "dph+"], capture_json=True) # Filter for good GPUs (both underscore and space formats) good_gpus = ["RTX 4090", "RTX 5090", "RTX A6000", "A100", "H100", "A40", "RTX_4090", "RTX_5090", "RTX_A6000"] filtered = [o for o in offers if any(g in o.get("gpu_name", "") for g in good_gpus)] if not filtered: # If no exact matches, return all offers with 24GB+ VRAM return offers[:10] return filtered[:10] # Top 10 cheapest suitable options def create_instance(offer_id: int, image: str = "runpod/pytorch:2.1.0-py3.10-cuda11.8.0-devel-ubuntu22.04") -> int: """Create a new instance from an offer.""" # Use onstart script to install ComfyUI onstart = """#!/bin/bash set -e cd /root # Ensure pip is available apt-get update && apt-get install -y python3-pip || true pip3 install comfy-cli httpx || pip install comfy-cli httpx comfy --skip-prompt install comfy node install ComfyUI-GGUF ComfyUI-VideoHelperSuite # Download models mkdir -p /root/comfy/ComfyUI/models/diffusion_models mkdir -p /root/comfy/ComfyUI/models/text_encoders mkdir -p /root/comfy/ComfyUI/models/vae # Wan I2V model (only if not exists) if [ ! -f /root/comfy/ComfyUI/models/diffusion_models/wan2.1-i2v-14b-480p-Q5_K_M.gguf ]; then wget -q -O /root/comfy/ComfyUI/models/diffusion_models/wan2.1-i2v-14b-480p-Q5_K_M.gguf \ "https://huggingface.co/city96/Wan2.1-I2V-14B-480P-GGUF/resolve/main/wan2.1-i2v-14b-480p-Q5_K_M.gguf" fi # Text encoder if [ ! -f /root/comfy/ComfyUI/models/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors ]; then wget -q -O /root/comfy/ComfyUI/models/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors \ "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors" fi # VAE if [ ! -f /root/comfy/ComfyUI/models/vae/wan_2.1_vae.safetensors ]; then wget -q -O /root/comfy/ComfyUI/models/vae/wan_2.1_vae.safetensors \ "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/vae/wan_2.1_vae.safetensors" fi # Start ComfyUI cd /root/comfy/ComfyUI python main.py --listen 0.0.0.0 --port 8188 & """ # Write onstart script to temp file onstart_file = Path("/tmp/vastai_onstart.sh") onstart_file.write_text(onstart) result = run_vastai([ "create", "instance", str(offer_id), "--image", image, "--disk", "80", "--onstart-cmd", onstart, "--direct", ], capture_json=False) # CLI doesn't return JSON for create # Parse instance ID from success message like "Started. {'new_contract': 12345, ...}" # or "success: created instance 12345" match = re.search(r"new_contract['\"]?\s*:\s*(\d+)", result) if match: return int(match.group(1)) match = re.search(r"instance\s+(\d+)", result, re.IGNORECASE) if match: return int(match.group(1)) # Try to find any number that looks like an instance ID match = re.search(r"(\d{7,})", result) if match: return int(match.group(1)) raise RuntimeError(f"Failed to parse instance ID from: {result}") def get_instance_info(instance_id: int) -> Optional[dict]: """Get instance connection info.""" instances = run_vastai(["show", "instances"], capture_json=True) for inst in instances: if inst.get("id") == instance_id: return inst return None def wait_for_instance(instance_id: int, timeout: int = 900) -> dict: """Wait for instance to be ready.""" print(f"Waiting for instance {instance_id} to be ready...") start = time.time() last_status = None while time.time() - start < timeout: info = get_instance_info(instance_id) if info: status = info.get("actual_status", "") if status != last_status: print(f" Status: {status}") last_status = status if status == "running": return info # Don't wait here - we'll check ComfyUI separately time.sleep(10) raise TimeoutError(f"Instance {instance_id} did not become ready in {timeout}s") def destroy_instance(instance_id: int): """Destroy an instance.""" try: run_vastai(["destroy", "instance", str(instance_id)]) print(f"Destroyed instance {instance_id}") except Exception as e: print(f"Warning: Failed to destroy instance: {e}") def build_i2v_workflow(image_name: str, motion_prompt: str, num_frames: int = 33, steps: int = 6) -> dict: """Build ComfyUI workflow for Wan I2V.""" import random seed = random.randint(0, 2**32 - 1) return { "1": {"class_type": "LoadImage", "inputs": {"image": image_name}}, "2": {"class_type": "ImageScale", "inputs": { "image": ["1", 0], "width": 832, "height": 480, "upscale_method": "lanczos", "crop": "center" }}, "3": {"class_type": "UnetLoaderGGUF", "inputs": { "unet_name": "wan2.1-i2v-14b-480p-Q5_K_M.gguf" }}, "4": {"class_type": "CLIPLoader", "inputs": { "clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", "type": "wan" }}, "5": {"class_type": "VAELoader", "inputs": { "vae_name": "wan_2.1_vae.safetensors" }}, "6": {"class_type": "CLIPTextEncode", "inputs": { "text": motion_prompt, "clip": ["4", 0] }}, "7": {"class_type": "CLIPTextEncode", "inputs": { "text": "blurry, distorted, watermark, static", "clip": ["4", 0] }}, "8": {"class_type": "WanImageToVideo", "inputs": { "positive": ["6", 0], "negative": ["7", 0], "vae": ["5", 0], "width": 832, "height": 480, "length": num_frames, "batch_size": 1, "start_image": ["2", 0], }}, "9": {"class_type": "ModelSamplingSD3", "inputs": { "model": ["3", 0], "shift": 8.0 }}, "10": {"class_type": "KSampler", "inputs": { "model": ["9", 0], "positive": ["8", 0], "negative": ["8", 1], "latent_image": ["8", 2], "seed": seed, "steps": steps, "cfg": 5.0, "sampler_name": "uni_pc", "scheduler": "normal", "denoise": 1.0, }}, "11": {"class_type": "VAEDecode", "inputs": { "samples": ["10", 0], "vae": ["5", 0] }}, "12": {"class_type": "VHS_VideoCombine", "inputs": { "frame_rate": 16, "loop_count": 0, "filename_prefix": f"i2v_{Path(image_name).stem}", "format": "video/h264-mp4", "pingpong": False, "save_output": True, "images": ["11", 0], }}, } async def submit_workflow(base_url: str, workflow: dict) -> str: """Submit workflow to ComfyUI and return prompt_id.""" async with httpx.AsyncClient(timeout=60) as client: resp = await client.post(f"{base_url}/prompt", json={"prompt": workflow}) resp.raise_for_status() return resp.json()["prompt_id"] async def wait_for_completion(base_url: str, prompt_id: str, timeout: int = 600) -> Optional[str]: """Wait for workflow to complete.""" async with httpx.AsyncClient(timeout=30) as client: start = time.time() while time.time() - start < timeout: await asyncio.sleep(10) resp = await client.get(f"{base_url}/history/{prompt_id}") if resp.status_code == 200: history = resp.json() if prompt_id in history: entry = history[prompt_id] status = entry.get("status", {}).get("status_str", "") if status == "success": outputs = entry.get("outputs", {}) for node_id, output in outputs.items(): if "gifs" in output: return output["gifs"][0]["filename"] if "videos" in output: return output["videos"][0]["filename"] elif "error" in status.lower(): print(f" Workflow error: {entry}") return None print(f" Timeout waiting for {prompt_id}") return None async def run_batch( instance_info: dict, images_dir: Path, output_dir: Path, motion_prompt: str, steps: int = 6, frames: int = 33, ) -> list[Path]: """Run batch I2V on cloud instance.""" # Get connection info ssh_host = instance_info.get("ssh_host", "").split(":")[0] ssh_port = int(instance_info.get("ssh_port", 22)) public_ip = instance_info.get("public_ipaddr", ssh_host) # ComfyUI port (usually 8188, mapped to a high port) ports = instance_info.get("ports", {}) comfy_port = None for port_map in ports.values() if isinstance(ports, dict) else []: if "8188" in str(port_map): comfy_port = port_map.get("HostPort", 8188) break if not comfy_port: comfy_port = 8188 # Default base_url = f"http://{public_ip}:{comfy_port}" print(f"ComfyUI URL: {base_url}") # Find images images = list(images_dir.glob("*.png")) + list(images_dir.glob("*.jpg")) print(f"Found {len(images)} images to process") # Upload images via SSH print("Uploading images...") # Find SSH key for Vast.ai (check for dedicated key first, then default) ssh_key = Path.home() / ".ssh" / "id_ed25519_vastai" if not ssh_key.exists(): ssh_key = Path.home() / ".ssh" / "id_ed25519" if not ssh_key.exists(): ssh_key = Path.home() / ".ssh" / "id_rsa" ssh_opts = f"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {ssh_key}" scp_port = f"-P {ssh_port}" # SCP uses capital P for port for img in images: result = subprocess.run( f"scp {ssh_opts} {scp_port} {img} root@{ssh_host}:/root/comfy/ComfyUI/input/", shell=True, capture_output=True, text=True ) if result.returncode != 0: print(f" Warning: Failed to upload {img.name}: {result.stderr}") raise RuntimeError(f"SCP failed: {result.stderr}") # Process each image output_videos = [] for i, img in enumerate(images): print(f"Processing {i+1}/{len(images)}: {img.name}") workflow = build_i2v_workflow(img.name, motion_prompt, frames, steps) try: prompt_id = await submit_workflow(base_url, workflow) print(f" Submitted: {prompt_id}") video_file = await wait_for_completion(base_url, prompt_id, timeout=600) if video_file: output_videos.append(video_file) print(f" Complete: {video_file}") else: print(f" Failed or timed out") except Exception as e: print(f" Error: {e}") # Download results output_dir.mkdir(parents=True, exist_ok=True) print(f"\nDownloading {len(output_videos)} videos...") for video in output_videos: result = subprocess.run( f"scp {ssh_opts} {scp_port} root@{ssh_host}:/root/comfy/ComfyUI/output/{video} {output_dir}/", shell=True, capture_output=True, text=True ) if result.returncode != 0: print(f" Warning: Failed to download {video}: {result.stderr}") return list(output_dir.glob("*.mp4")) async def main(): parser = argparse.ArgumentParser(description="Cloud I2V Batch Processor") parser.add_argument("--images", type=Path, required=True, help="Input images directory") parser.add_argument("--output", type=Path, default=Path("i2v_output"), help="Output directory") parser.add_argument("--motion", default="subtle organic motion, gentle breathing, cinematic") parser.add_argument("--steps", type=int, default=6, help="Sampling steps (4-12)") parser.add_argument("--frames", type=int, default=33, help="Output frames (33=~2s at 16fps)") parser.add_argument("--max-price", type=float, default=0.50, help="Max $/hr") parser.add_argument("--dry-run", action="store_true", help="Show offers only") parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmations") parser.add_argument("--instance", type=int, help="Use existing instance ID") args = parser.parse_args() # Count images images = list(args.images.glob("*.png")) + list(args.images.glob("*.jpg")) if not images: print(f"No images found in {args.images}") sys.exit(1) print(f"Found {len(images)} images to process") # Search for offers print(f"\nSearching for GPU instances under ${args.max_price}/hr...") offers = search_offers(args.max_price) if not offers: print("No suitable GPU instances found. Try increasing --max-price") sys.exit(1) print(f"\nTop offers:") print(f"{'ID':<12} {'GPU':<20} {'RAM':<8} {'$/hr':<10} {'Location':<20}") print("-" * 75) for o in offers[:5]: print(f"{o['id']:<12} {o.get('gpu_name', 'N/A'):<20} {o.get('gpu_ram', 0):<8.0f} ${o.get('dph_total', 0):<9.4f} {o.get('geolocation', 'N/A'):<20}") # Cost estimate best = offers[0] est_time_min = len(images) * 3 # ~3 min per clip on fast GPU est_cost = (est_time_min / 60 + 0.5) * best['dph_total'] # +30 min for setup print(f"\nEstimated: {est_time_min} min processing + 30 min setup = ${est_cost:.2f}") if args.dry_run: print("\n(Dry run - no instance created)") return # Confirm if not args.yes: response = input("\nProceed with cheapest option? [y/N] ") if response.lower() != "y": print("Cancelled") return else: print("\n--yes flag: proceeding automatically") instance_id = args.instance instance_info = None try: if not instance_id: # Create instance print(f"\nCreating instance from offer {best['id']}...") instance_id = create_instance(best['id']) print(f"Created instance: {instance_id}") # Wait for ready instance_info = wait_for_instance(instance_id) else: instance_info = get_instance_info(instance_id) if not instance_info: print(f"Instance {instance_id} not found") sys.exit(1) ssh_host = instance_info.get("ssh_host", "").split(":")[0] ssh_port = int(instance_info.get("ssh_port", 22)) public_ip = instance_info.get("public_ipaddr", ssh_host) print(f"\nInstance ready!") print(f" SSH: ssh -p {ssh_port} root@{ssh_host}") # Find SSH key ssh_key = Path.home() / ".ssh" / "id_ed25519_vastai" if not ssh_key.exists(): ssh_key = Path.home() / ".ssh" / "id_ed25519" if not ssh_key.exists(): ssh_key = Path.home() / ".ssh" / "id_rsa" ssh_opts = f"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -i {ssh_key}" # Get ComfyUI port from port mappings ports = instance_info.get("ports", {}) comfy_port = 8188 for port_key, port_info in (ports.items() if isinstance(ports, dict) else []): if "8188" in str(port_key): if isinstance(port_info, list) and port_info: comfy_port = port_info[0].get("HostPort", 8188) break comfy_url = f"http://{public_ip}:{comfy_port}" # Wait for ComfyUI setup to complete (downloads ~18GB of models) # Check both SSH connectivity AND ComfyUI input directory exists print(f" Waiting for setup to complete (this can take 5-10 minutes for model downloads)...") comfy_ready = False for attempt in range(90): # Wait up to 15 minutes elapsed = attempt * 10 # First check if SSH works and input directory exists try: ssh_check = subprocess.run( f"ssh {ssh_opts} -p {ssh_port} root@{ssh_host} 'test -d /root/comfy/ComfyUI/input && echo OK'", shell=True, capture_output=True, text=True, timeout=20 ) except subprocess.TimeoutExpired: if attempt % 6 == 0: print(f" SSH connection timed out, retrying... ({elapsed}s)") await asyncio.sleep(10) continue if ssh_check.returncode == 0 and "OK" in ssh_check.stdout: # SSH works and directory exists, now check ComfyUI API try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get(f"{comfy_url}/system_stats") if resp.status_code == 200: print(f" ComfyUI ready! (took {elapsed}s)") comfy_ready = True break except Exception: pass if attempt % 3 == 0: print(f" ComfyUI directory exists, waiting for API... ({elapsed}s)") else: if attempt % 6 == 0: print(f" Setup in progress... ({elapsed}s)") await asyncio.sleep(10) if not comfy_ready: print(" Warning: ComfyUI setup may not be complete, but proceeding...") # Run batch downloaded = await run_batch( instance_info, args.images, args.output, args.motion, args.steps, args.frames, ) print(f"\nComplete! Downloaded {len(downloaded)} videos to {args.output}") finally: if instance_id and not args.instance: if not args.yes: response = input("\nDestroy instance? [Y/n] ") if response.lower() != "n": destroy_instance(instance_id) else: # Auto-destroy with --yes flag destroy_instance(instance_id) if __name__ == "__main__": asyncio.run(main())