| |
| |
| |
| |
| |
| |
| |
|
|
| import requests |
| import base64 |
| import io |
| import time |
| from PIL import Image |
| import numpy as np |
| import torch |
| import hashlib |
| import os |
| import json |
|
|
| |
| |
| |
|
|
|
|
| def build_seedream_fal_payload(api_base, **kwargs): |
| payload = { |
| "prompt": kwargs.get("prompt"), |
| "image_size": {"width": kwargs.get("width"), "height": kwargs.get("height")}, |
| "enable_safety_checker": kwargs.get("enable_safety_checker"), |
| } |
| if (seed := kwargs.get("seed", -1)) >= 0: |
| payload["seed"] = seed |
| |
| |
| images_b64 = [ |
| api_base.image_to_base64(img, max_size_mb=7) |
| for img in [kwargs.get(f"image_{i}") for i in range(1, 5)] |
| if img is not None |
| ] |
| if images_b64: |
| payload["image_urls"] = images_b64 |
| |
| return payload |
|
|
|
|
| def build_seedream_wavespeed_payload(api_base, **kwargs): |
| width = kwargs.get('width', 1024) |
| height = kwargs.get('height', 1024) |
|
|
| |
| MIN_PIXELS_SEEDREAM_V45 = 3686400 |
| current_pixels = width * height |
|
|
| if current_pixels < MIN_PIXELS_SEEDREAM_V45: |
| |
| scale_factor = (MIN_PIXELS_SEEDREAM_V45 / current_pixels) ** 0.5 |
| width = int(width * scale_factor) + 1 |
| height = int(height * scale_factor) + 1 |
| print(f"⬆️ Scaling output size from {kwargs.get('width')}x{kwargs.get('height')} to {width}x{height} to meet API minimum ({MIN_PIXELS_SEEDREAM_V45:,} px)") |
|
|
| payload = { |
| "prompt": kwargs.get("prompt"), |
| "size": f"{width}*{height}", |
| } |
|
|
| |
| images_b64 = [ |
| api_base.image_to_base64(img, include_prefix=False, max_size_mb=7) |
| for img in [kwargs.get(f"image_{i}") for i in range(1, 5)] |
| if img is not None |
| ] |
| if images_b64: |
| payload["images"] = images_b64 |
|
|
| return payload |
|
|
|
|
| def build_nanobanana_fal_payload(api_base, **kwargs): |
| payload = {"prompt": kwargs.get("prompt")} |
| |
| |
| images_b64 = [ |
| api_base.image_to_base64(img, max_size_mb=7) |
| for img in [kwargs.get(f"image_{i}") for i in range(1, 5)] |
| if img is not None |
| ] |
| if images_b64: |
| payload["image_urls"] = images_b64 |
| elif "aspect_ratio" in kwargs: |
| payload["aspect_ratio"] = kwargs.get("aspect_ratio") |
| |
| return payload |
|
|
|
|
| def build_nanobanana_wavespeed_payload(api_base, **kwargs): |
| payload = {"prompt": kwargs.get("prompt")} |
|
|
| |
| num_images = kwargs.get("num_images", 1) |
| if num_images > 1: |
| payload["num_images"] = num_images |
|
|
| |
| images_b64 = [ |
| api_base.image_to_base64(img, include_prefix=False, max_size_mb=7) |
| for img in [kwargs.get(f"image_{i}") for i in range(1, 5)] |
| if img is not None |
| ] |
| if images_b64: |
| payload["images"] = images_b64 |
| elif "aspect_ratio" in kwargs: |
| payload["aspect_ratio"] = kwargs.get("aspect_ratio") |
|
|
| return payload |
|
|
|
|
| def build_nanobanana_pro_fal_payload(api_base, **kwargs): |
| """fal.ai payload for Nano Banana Pro (Gemini 3.0 Pro Image)""" |
| payload = { |
| "prompt": kwargs.get("prompt"), |
| "num_images": 1, |
| "output_format": "png", |
| } |
|
|
| |
| images_b64 = [ |
| api_base.image_to_base64(img, max_size_mb=7) |
| for img in [kwargs.get(f"image_{i}") for i in range(1, 5)] |
| if img is not None |
| ] |
|
|
| if images_b64: |
| |
| payload["image_urls"] = images_b64 |
| payload["aspect_ratio"] = kwargs.get("aspect_ratio", "auto") |
| if kwargs.get("resolution"): |
| payload["resolution"] = kwargs.get("resolution") |
| else: |
| |
| if kwargs.get("aspect_ratio"): |
| payload["aspect_ratio"] = kwargs.get("aspect_ratio") |
| if kwargs.get("resolution"): |
| payload["resolution"] = kwargs.get("resolution") |
|
|
| return payload |
|
|
|
|
| def build_nanobanana_pro_wavespeed_payload(api_base, **kwargs): |
| """WaveSpeed payload for Nano Banana Pro (Gemini 3.0 Pro Image)""" |
| payload = { |
| "prompt": kwargs.get("prompt"), |
| "output_format": "jpeg", |
| } |
|
|
| |
| num_images = kwargs.get("num_images", 1) |
| if num_images > 1: |
| payload["num_images"] = num_images |
|
|
| |
| images_b64 = [ |
| api_base.image_to_base64(img, include_prefix=False, max_size_mb=7) |
| for img in [kwargs.get(f"image_{i}") for i in range(1, 5)] |
| if img is not None |
| ] |
|
|
| if images_b64: |
| |
| payload["images"] = images_b64 |
| if kwargs.get("aspect_ratio"): |
| payload["aspect_ratio"] = kwargs.get("aspect_ratio") |
| if kwargs.get("resolution"): |
| |
| payload["resolution"] = kwargs.get("resolution", "1k").lower() |
| else: |
| |
| if kwargs.get("aspect_ratio"): |
| payload["aspect_ratio"] = kwargs.get("aspect_ratio") |
| if kwargs.get("resolution"): |
| |
| payload["resolution"] = kwargs.get("resolution", "1k").lower() |
|
|
| return payload |
|
|
|
|
| |
| |
| |
|
|
| MODEL_CONFIG = { |
| "SeeDream v4.5": { |
| "providers": { |
| "fal.ai": { |
| "t2i_endpoint": "fal-ai/bytedance/seedream/v4.5/text-to-image", |
| "i2i_endpoint": "fal-ai/bytedance/seedream/v4.5/edit", |
| "build_payload": build_seedream_fal_payload, |
| }, |
| "wavespeed.ai": { |
| "t2i_endpoint": "bytedance/seedream-v4.5", |
| "i2i_endpoint": "bytedance/seedream-v4.5/edit", |
| "build_payload": build_seedream_wavespeed_payload, |
| }, |
| } |
| }, |
| "SeeDream v4": { |
| "providers": { |
| "fal.ai": { |
| "t2i_endpoint": "fal-ai/bytedance/seedream/v4/text-to-image", |
| "i2i_endpoint": "fal-ai/bytedance/seedream/v4/edit", |
| "build_payload": build_seedream_fal_payload, |
| }, |
| "wavespeed.ai": { |
| "t2i_endpoint": "bytedance/seedream-v4", |
| "i2i_endpoint": "bytedance/seedream-v4/edit", |
| "build_payload": build_seedream_wavespeed_payload, |
| }, |
| } |
| }, |
| "Nano Banana": { |
| "providers": { |
| "fal.ai": { |
| "t2i_endpoint": "fal-ai/nano-banana", |
| "i2i_endpoint": "fal-ai/nano-banana/edit", |
| "build_payload": build_nanobanana_fal_payload, |
| }, |
| "wavespeed.ai": { |
| "t2i_endpoint": "google/nano-banana/text-to-image", |
| "i2i_endpoint": "google/nano-banana/edit", |
| "build_payload": build_nanobanana_wavespeed_payload, |
| }, |
| } |
| }, |
| "Nano Banana Pro": { |
| "providers": { |
| "fal.ai": { |
| "t2i_endpoint": "fal-ai/nano-banana-pro", |
| "i2i_endpoint": "fal-ai/nano-banana-pro/edit", |
| "build_payload": build_nanobanana_pro_fal_payload, |
| }, |
| "wavespeed.ai": { |
| "t2i_endpoint": "google/nano-banana-pro/text-to-image", |
| "i2i_endpoint": "google/nano-banana-pro/edit", |
| "build_payload": build_nanobanana_pro_wavespeed_payload, |
| }, |
| } |
| }, |
| } |
|
|
| |
| |
| |
|
|
|
|
| class INSTARAW_GenerativeAPIBase: |
| def __init__(self): |
| self.api_key = None |
|
|
| def set_api_key(self, api_key): |
| self.api_key = api_key |
|
|
| def _log_and_update_hash(self, hasher, key, value): |
| if value is None: |
| return |
| byte_value = b"" |
| if isinstance(value, str): |
| byte_value = value.encode("utf-8") |
| elif isinstance(value, (int, float, bool)): |
| byte_value = str(value).encode("utf-8") |
| elif isinstance(value, torch.Tensor): |
| byte_value = value.cpu().numpy().tobytes() |
| else: |
| return |
| hasher.update(byte_value) |
|
|
| def image_to_base64(self, image_tensor, include_prefix=True, max_size_mb=7, min_pixels=None): |
| if image_tensor is None: |
| return None |
|
|
| image_np = image_tensor.cpu().numpy() |
| |
| if image_np.ndim == 4: |
| image_np = image_np[0] |
| if image_np.max() <= 1.0: |
| image_np = (image_np * 255).astype(np.uint8) |
|
|
| |
| image_pil = Image.fromarray(image_np).convert("RGB") |
|
|
| |
| if min_pixels is not None: |
| current_pixels = image_pil.width * image_pil.height |
| if current_pixels < min_pixels: |
| |
| scale_factor = (min_pixels / current_pixels) ** 0.5 |
| new_width = int(image_pil.width * scale_factor) + 1 |
| new_height = int(image_pil.height * scale_factor) + 1 |
| print(f"⬆️ Upscaling image from {image_pil.width}x{image_pil.height} ({current_pixels:,} px) to {new_width}x{new_height} ({new_width * new_height:,} px) to meet API minimum ({min_pixels:,} px)") |
| image_pil = image_pil.resize((new_width, new_height), Image.Resampling.LANCZOS) |
|
|
| |
| quality_levels = [95, 90, 85, 80, 75] |
| max_bytes = max_size_mb * 1024 * 1024 |
|
|
| buffer = io.BytesIO() |
| for quality in quality_levels: |
| buffer.seek(0) |
| buffer.truncate(0) |
| image_pil.save(buffer, format="JPEG", quality=quality) |
|
|
| |
| if buffer.tell() <= max_bytes: |
| print(f"✅ Image compressed to JPEG (quality={quality}) to fit API limits. Size: {buffer.tell() / 1024:.2f} KB") |
| base64_str = base64.b64encode(buffer.getvalue()).decode() |
| mime_type = "image/jpeg" |
| return f"data:{mime_type};base64,{base64_str}" if include_prefix else base64_str |
|
|
| |
| final_size_kb = buffer.tell() / 1024 |
| raise Exception(f"Image is too large to send to the API. Even after compressing to lowest quality JPEG, size is {final_size_kb:.2f} KB (limit is {max_size_mb * 1024:.2f} KB). Please use a smaller input image.") |
|
|
| def _submit_fal(self, endpoint, payload): |
| url = f"https://fal.run/{endpoint}" |
| headers = { |
| "Authorization": f"Key {self.api_key}", |
| "Content-Type": "application/json", |
| } |
| print(f"🚀 Submitting SYNC request to fal.ai: {url}") |
| response = requests.post(url, json=payload, headers=headers, timeout=180) |
| if not response.ok: |
| if response.status_code == 422: |
| try: |
| error_data = response.json() |
| error_msg = error_data.get("detail", [{}])[0].get( |
| "msg", "Unknown validation error." |
| ) |
| if "Gemini" in error_msg: |
| raise Exception( |
| f"API Error (422): The prompt was rejected by the provider's safety filter (fal.ai/Gemini). Try rephrasing sensitive terms or switch to a different provider." |
| ) |
| else: |
| raise Exception( |
| f"API Error (422): Unprocessable Entity. Details: {error_msg}" |
| ) |
| except (json.JSONDecodeError, IndexError): |
| raise Exception( |
| f"API request failed with 422 (Unprocessable Entity), but the error response was not valid JSON." |
| ) |
| else: |
| raise Exception( |
| f"API request failed: {response.status_code} - {response.text}" |
| ) |
| result = response.json() |
| print("📦 fal.ai sync response received.") |
| if "images" in result and len(result["images"]) > 0: |
| return result["images"][0]["url"] |
| raise Exception( |
| f"API response did not contain an image URL. Full response: {result}" |
| ) |
|
|
| def _submit_wavespeed(self, e, p, return_all_outputs=False): |
| """ |
| Submit request to Wavespeed.ai API. |
| |
| Args: |
| e: Endpoint |
| p: Payload |
| return_all_outputs: If True, return list of all output URLs (for multi-image mode) |
| """ |
| base, headers = "https://api.wavespeed.ai/api/v3", { |
| "Authorization": f"Bearer {self.api_key}", |
| "Content-Type": "application/json", |
| } |
| num_images = p.get("num_images", 1) |
| print(f"🚀 Submitting ASYNC task to wavespeed.ai: {base}/{e} (num_images={num_images})") |
| r = requests.post(f"{base}/{e}", json=p, headers=headers, timeout=30) |
| if not r.ok: |
| |
| error_text = r.text[:200] |
| if "content" in error_text.lower() or "policy" in error_text.lower() or "422" in str(r.status_code): |
| raise Exception(f"Content policy violation: {error_text}") |
| raise Exception(f"API task submission failed: {r.status_code} - {error_text}") |
| req_id = r.json()["data"]["id"] |
| print(f"✅ Task submitted (ID: {req_id[:8]}...). Polling for result...") |
| poll_url, start_time, timeout = ( |
| f"{base}/predictions/{req_id}/result", |
| time.time(), |
| 300, |
| ) |
| poll_count = 0 |
| while time.time() - start_time < timeout: |
| pr = requests.get(poll_url, headers=headers, timeout=30) |
| if not pr.ok: |
| raise Exception(f"API polling failed: {pr.status_code} - {pr.text}") |
| d = pr.json()["data"] |
| status = d.get("status") |
| if status == "completed": |
| elapsed = time.time() - start_time |
| outputs = d.get("outputs", []) |
| print(f"✅ Task completed in {elapsed:.1f}s ({len(outputs)} image(s))") |
| if return_all_outputs: |
| return outputs |
| return outputs[0] if outputs else None |
| if status == "failed": |
| error_msg = d.get('error', 'Unknown error') |
| |
| if any(x in str(error_msg).lower() for x in ['content', 'policy', 'safety', 'moderation']): |
| raise Exception(f"Content policy violation: {error_msg}") |
| raise Exception(f"API task failed: {error_msg}") |
| |
| poll_count += 1 |
| if poll_count % 5 == 1: |
| elapsed = time.time() - start_time |
| print(f" ⏳ Waiting for result... ({elapsed:.0f}s elapsed, status: {status})") |
| time.sleep(2) |
| raise Exception("API task timed out after 5 minutes.") |
|
|
| def submit_request(self, provider, endpoint, payload): |
| if not self.api_key: |
| raise ValueError(f"API key for {provider} not set.") |
| if provider == "fal.ai": |
| return self._submit_fal(endpoint, payload) |
| if provider == "wavespeed.ai": |
| return self._submit_wavespeed(endpoint, payload) |
| raise ValueError(f"Unknown provider: {provider}") |
|
|
| def execute_generation(self, is_i2i, cache_filename, **kwargs): |
| provider, model = kwargs.get("provider"), kwargs.get("model") |
| if not model: |
| raise ValueError("Model not provided.") |
| if not provider: |
| raise ValueError("Provider not provided.") |
| model_conf = MODEL_CONFIG.get(model) |
| if not model_conf: |
| raise ValueError(f"Invalid model selected: {model}") |
| provider_conf = model_conf["providers"].get(provider) |
| if not provider_conf: |
| raise ValueError( |
| f"Provider '{provider}' is not supported for model '{model}'" |
| ) |
| endpoint = ( |
| provider_conf["i2i_endpoint"] if is_i2i else provider_conf["t2i_endpoint"] |
| ) |
| build_payload_func = provider_conf["build_payload"] |
| self.set_api_key(kwargs.get("api_key")) |
| payload = build_payload_func(self, **kwargs) |
| image_url = self.submit_request(provider, endpoint, payload) |
| image_response = requests.get(image_url) |
| image_response.raise_for_status() |
| image_pil = Image.open(io.BytesIO(image_response.content)).convert("RGB") |
| print(f"💾 Saving generated image to cache: {cache_filename}") |
| image_pil.save(cache_filename, "PNG") |
| image_np = np.array(image_pil).astype(np.float32) / 255.0 |
| return (torch.from_numpy(image_np).unsqueeze(0),) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class INSTARAW_APITextToImage(INSTARAW_GenerativeAPIBase): |
| @classmethod |
| def INPUT_TYPES(cls): |
| return { |
| "required": { |
| "api_key": ("STRING", {"forceInput": True}), |
| "provider": ("STRING", {"forceInput": True}), |
| "model": ("STRING", {"forceInput": True}), |
| "prompt": ( |
| "STRING", |
| {"multiline": True, "default": "A beautiful landscape"}, |
| ), |
| "width": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 64}), |
| "height": ( |
| "INT", |
| {"default": 1024, "min": 64, "max": 4096, "step": 64}, |
| ), |
| "aspect_ratio": ( |
| [ |
| "1:1", |
| "4:3", |
| "3:2", |
| "2:3", |
| "5:4", |
| "4:5", |
| "3:4", |
| "16:9", |
| "9:16", |
| "21:9", |
| ], |
| {"default": "1:1"}, |
| ), |
| "enable_safety_checker": ("BOOLEAN", {"default": True}), |
| }, |
| "optional": { |
| "seed": ("INT", {"default": -1, "min": -1, "max": 2147483647}), |
| "aspect_ratio_override": ( |
| "STRING", |
| { |
| "forceInput": True, |
| "tooltip": "Override aspect ratio from external node (e.g., Nano Banana Aspect Ratio)", |
| }, |
| ), |
| "resolution": ( |
| "STRING", |
| { |
| "forceInput": True, |
| "tooltip": "Resolution tier (1K, 2K, 4K) for Nano Banana Pro", |
| }, |
| ), |
| }, |
| } |
|
|
| RETURN_TYPES, FUNCTION, CATEGORY = ("IMAGE",), "generate", "INSTARAW/API" |
|
|
| def generate(self, **kwargs): |
| |
| if kwargs.get("aspect_ratio_override"): |
| kwargs["aspect_ratio"] = kwargs["aspect_ratio_override"] |
|
|
| cache_dir = os.path.join(os.path.dirname(__file__), "..", "..", "cache") |
| os.makedirs(cache_dir, exist_ok=True) |
| hasher = hashlib.sha256() |
| for key in sorted(kwargs.keys()): |
| self._log_and_update_hash(hasher, key, kwargs[key]) |
| cache_filepath = os.path.join(cache_dir, f"{hasher.hexdigest()}_api_t2i.png") |
| if os.path.exists(cache_filepath): |
| print(f"✅ API T2I Cache Hit! Loading image from {cache_filepath}") |
| return ( |
| torch.from_numpy( |
| np.array(Image.open(cache_filepath)).astype(np.float32) / 255.0 |
| ).unsqueeze(0), |
| ) |
| print("💨 API T2I Cache Miss. Proceeding with API call...") |
| return self.execute_generation( |
| is_i2i=False, cache_filename=cache_filepath, **kwargs |
| ) |
|
|
|
|
| class INSTARAW_APIImageToImage(INSTARAW_GenerativeAPIBase): |
| @classmethod |
| def INPUT_TYPES(cls): |
| return { |
| "required": { |
| "api_key": ("STRING", {"forceInput": True}), |
| "provider": ("STRING", {"forceInput": True}), |
| "model": ("STRING", {"forceInput": True}), |
| "prompt": ( |
| "STRING", |
| {"multiline": True, "default": "Transform image 1"}, |
| ), |
| "enable_safety_checker": ("BOOLEAN", {"default": True}), |
| }, |
| "optional": { |
| "width": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 64}), |
| "height": ( |
| "INT", |
| {"default": 1024, "min": 64, "max": 4096, "step": 64}, |
| ), |
| "image_1": ("IMAGE",), |
| "image_2": ("IMAGE",), |
| "image_3": ("IMAGE",), |
| "image_4": ("IMAGE",), |
| "seed": ("INT", {"default": -1, "min": -1, "max": 2147483647}), |
| "aspect_ratio": ( |
| "STRING", |
| { |
| "forceInput": True, |
| "tooltip": "Aspect ratio from external node (e.g., Nano Banana Aspect Ratio)", |
| }, |
| ), |
| "resolution": ( |
| "STRING", |
| { |
| "forceInput": True, |
| "tooltip": "Resolution tier (1K, 2K, 4K) for Nano Banana Pro", |
| }, |
| ), |
| }, |
| } |
|
|
| RETURN_TYPES, FUNCTION, CATEGORY = ("IMAGE",), "generate", "INSTARAW/API" |
|
|
| def generate( |
| self, |
| api_key, |
| provider, |
| model, |
| prompt, |
| enable_safety_checker, |
| width=1024, |
| height=1024, |
| image_1=None, |
| image_2=None, |
| image_3=None, |
| image_4=None, |
| seed=-1, |
| aspect_ratio=None, |
| resolution=None, |
| ): |
| |
| if all(img is None for img in [image_1, image_2, image_3, image_4]): |
| raise ValueError("INSTARAW API I2I node requires at least one image input to be provided.") |
|
|
| all_args = { |
| "api_key": api_key, |
| "provider": provider, |
| "model": model, |
| "image_1": image_1, |
| "prompt": prompt, |
| "enable_safety_checker": enable_safety_checker, |
| "width": width, |
| "height": height, |
| "image_2": image_2, |
| "image_3": image_3, |
| "image_4": image_4, |
| "seed": seed, |
| } |
|
|
| |
| if aspect_ratio: |
| all_args["aspect_ratio"] = aspect_ratio |
| if resolution: |
| all_args["resolution"] = resolution |
| cache_dir = os.path.join(os.path.dirname(__file__), "..", "..", "cache") |
| os.makedirs(cache_dir, exist_ok=True) |
| hasher = hashlib.sha256() |
| hasher.update(b"01KDDP892EY2XSEMMWYBDDD5RE") |
| for key in sorted(all_args.keys()): |
| self._log_and_update_hash(hasher, key, all_args[key]) |
| cache_filepath = os.path.join(cache_dir, f"{hasher.hexdigest()}_api_i2i.png") |
| if os.path.exists(cache_filepath): |
| print(f"✅ API I2I Cache Hit! Loading image from {cache_filepath}") |
| return ( |
| torch.from_numpy( |
| np.array(Image.open(cache_filepath)).astype(np.float32) / 255.0 |
| ).unsqueeze(0), |
| ) |
| print("💨 API I2I Cache Miss. Proceeding with API call...") |
| return self.execute_generation( |
| is_i2i=True, cache_filename=cache_filepath, **all_args |
| ) |
|
|
|
|
| |
| |
| |
|
|
| NODE_CLASS_MAPPINGS = { |
| "INSTARAW_APITextToImage": INSTARAW_APITextToImage, |
| "INSTARAW_APIImageToImage": INSTARAW_APIImageToImage, |
| } |
| NODE_DISPLAY_NAME_MAPPINGS = { |
| "INSTARAW_APITextToImage": "🎨 INSTARAW API T2I", |
| "INSTARAW_APIImageToImage": "🎨 INSTARAW API I2I", |
| } |