Files
2026-04-24 18:34:18 +02:00

181 lines
6.0 KiB
Python

"""Shared helpers for Pruna API nodes."""
import os
import time
from io import BytesIO
import numpy as np
import requests
import torch
from PIL import Image
PRUNA_API_BASE = "https://api.pruna.ai"
PREDICTIONS_URL = f"{PRUNA_API_BASE}/v1/predictions"
FILES_URL = f"{PRUNA_API_BASE}/v1/files"
ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]
VIDEO_ASPECT_RATIOS = ["16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "1:1"]
POLL_INTERVAL = 3 # seconds between status checks
POLL_TIMEOUT = 300 # seconds before giving up
def resolve_api_key(api_key: str) -> str:
"""Return the api_key field value, falling back to the PRUNA_API_KEY env var."""
key = api_key.strip() if api_key else ""
if not key:
key = os.environ.get("PRUNA_API_KEY", "")
if not key:
raise RuntimeError(
"No Pruna API key provided. Set the api_key input or the "
"PRUNA_API_KEY environment variable."
)
return key
def tensor_to_pil(tensor: torch.Tensor) -> Image.Image:
"""Convert a ComfyUI IMAGE tensor [1, H, W, C] or [H, W, C] to a PIL Image."""
if tensor.ndim == 4:
tensor = tensor[0]
arr = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
if arr.shape[-1] == 4:
return Image.fromarray(arr, mode="RGBA").convert("RGB")
return Image.fromarray(arr, mode="RGB")
def upload_image(tensor: torch.Tensor, api_key: str) -> str:
"""Upload a ComfyUI IMAGE tensor to the Pruna files API and return the file URL."""
pil_image = tensor_to_pil(tensor)
buf = BytesIO()
pil_image.save(buf, format="PNG")
buf.seek(0)
response = requests.post(
FILES_URL,
headers={"apikey": api_key},
files={"content": ("image.png", buf, "image/png")},
timeout=60,
)
if response.status_code not in (200, 201):
raise RuntimeError(
f"Pruna file upload failed {response.status_code}: {response.text}"
)
data = response.json()
return data["urls"]["get"]
def call_api_sync(model: str, payload: dict, api_key: str) -> dict:
"""POST a prediction and return the JSON response (synchronous, Try-Sync: true)."""
headers = {
"Content-Type": "application/json",
"apikey": api_key,
"Model": model,
"Try-Sync": "true",
}
response = requests.post(PREDICTIONS_URL, json=payload, headers=headers, timeout=120)
if response.status_code not in (200, 201):
raise RuntimeError(
f"Pruna API error {response.status_code}: {response.text}"
)
return response.json()
def _poll_until_complete(get_url: str, api_key: str) -> dict:
"""Poll a submitted Pruna job by its get_url until it completes or times out."""
start = time.time()
while time.time() - start < POLL_TIMEOUT:
time.sleep(POLL_INTERVAL)
status_response = requests.get(
get_url,
headers={"apikey": api_key},
timeout=30,
)
if status_response.status_code != 200:
raise RuntimeError(
f"Pruna status check failed {status_response.status_code}: "
f"{status_response.text}"
)
status = status_response.json()
state = status.get("status", "")
if state == "succeeded" or status.get("generation_url"):
return status
if state in ("failed", "canceled"):
raise RuntimeError(f"Pruna job {state}: {status}")
raise RuntimeError(
f"Pruna job did not complete within {POLL_TIMEOUT}s. "
f"Check the Pruna developer portal for job status."
)
def call_api_async(model: str, payload: dict, api_key: str) -> dict:
"""POST a prediction and poll until complete. Returns the final status dict."""
headers = {
"Content-Type": "application/json",
"apikey": api_key,
"Model": model,
}
response = requests.post(PREDICTIONS_URL, json=payload, headers=headers, timeout=60)
if response.status_code not in (200, 201):
raise RuntimeError(
f"Pruna API error {response.status_code}: {response.text}"
)
job = response.json()
if job.get("status") == "succeeded" or job.get("generation_url"):
return job
get_url = job.get("get_url")
if not get_url:
raise RuntimeError(f"Pruna API returned no get_url for polling. Response: {job}")
return _poll_until_complete(get_url, api_key)
def call_api_with_fallback(model: str, payload: dict, api_key: str) -> dict:
"""Try sync prediction first; if no generation_url yet, poll the existing job."""
data = call_api_sync(model, payload, api_key)
if data.get("generation_url"):
return data
get_url = data.get("get_url")
if not get_url:
raise RuntimeError(
f"Pruna API returned no generation_url and no get_url. Response: {data}"
)
return _poll_until_complete(get_url, api_key)
def add_hf_token(payload: dict, hf_api_token: str) -> None:
"""Attach a non-empty HuggingFace token to payload['input']."""
token = hf_api_token.strip()
if token:
payload["input"]["hf_api_token"] = token
def resolve_generation_url(url: str) -> str:
"""Normalise a generation_url that may be a relative path."""
if url.startswith("/"):
return f"{PRUNA_API_BASE}{url}"
return url
def generation_url_to_tensor(data: dict) -> torch.Tensor:
"""Download generation_url from a Pruna API response and return an IMAGE tensor."""
generation_url = data.get("generation_url")
if not generation_url:
raise RuntimeError(f"Pruna API returned no generation_url. Response: {data}")
generation_url = resolve_generation_url(generation_url)
img_response = requests.get(generation_url, timeout=60)
if img_response.status_code != 200:
raise RuntimeError(
f"Failed to download image from {generation_url}: "
f"{img_response.status_code}"
)
image = Image.open(BytesIO(img_response.content)).convert("RGB")
image_np = np.array(image).astype(np.float32) / 255.0
return torch.from_numpy(image_np).unsqueeze(0) # [1, H, W, C]