Update Nano Banana workflows, remove V6 node, add FSLGeminiGenerateImageV8
@@ -6,6 +6,13 @@ Clone this repo into your comfyui folder.
|
||||
|
||||
## Projects Included
|
||||
|
||||
### Update notes
|
||||
|
||||
- **Node Updates**
|
||||
Gemini Generate Image Node v6 was replaced with Gemini Generate Image Node v8. The v8 version has removed models that do not return an image. The v8 version also has added the Gemini-3-Pro-Image_Preview model.
|
||||
- **Workflow Updates**
|
||||
All workflows have been updated to have the Gemini Generate Image Node v8.
|
||||
|
||||
### 🧩 Custom Nodes: Comfyui_FSL_Nodes
|
||||
- **manual_alpha_mask_painter.py**
|
||||
Custom node to manually convert black mask areas into alpha transparency. Useful with GRIPTAPE when inpainting and creating the mask by right clicking on the loaded image and select load mask editor. Griptape requires an alpha channel mask.
|
||||
@@ -15,7 +22,7 @@ Clone this repo into your comfyui folder.
|
||||
Designed to remove the Alpha Channel But Keep Transparent Parts as Background Color (not currently used)
|
||||
- **fsl_save_and_strip_alpha**
|
||||
Strips alpha channel from RGBA image. (not currently used)
|
||||
- **FSLGeminiGenerateImageV6.py**
|
||||
- **FSLGeminiGenerateImageV8.py**
|
||||
Allows for the use of Gemini API's to create image. This node also provide for whether or not the preceding image is used for iterative generation. The dimension fields are just to support Metadata. They do not effect the image.
|
||||
- **fsl_image_memory.py**
|
||||
Provides 4 nodes. Image Memory Store and Image Memory Recall stores an image to the key entered in the 'key' field. Image memory recall recalls the image for subsequent use. Image Memory Clear is used to delete the stored image based on the entered key. Image Memory Clear All clears all keys that have been entered.
|
||||
@@ -64,6 +71,14 @@ Located in the [`Comfyui_FSL_Nodes/`](https://github.com/fredlef/comfyui_project
|
||||
|
||||
---
|
||||
|
||||
### Clarification - Init Image vs Image
|
||||
- **Gemini Generate Image Node v8**
|
||||
This node has input sockets for both Init-Image and Images. This was primarily designed to support the iterative workflow. The Init-Image socket is specifically for the iterative workflow and should be set to "True" after the first image is created. For the first image both the Init-Image and Images socket should be set to "False".
|
||||
|
||||
If the iterative workflow has a front loader for the first image then for the first run the Images socket should be set to "True" and the Init-Image should be set to "False". After the first run the Images socket should be changed to "False" and the Init-images socket should be set to True.
|
||||
|
||||
I am using this node generally for all my nano banana workflows so if this is for a workflow other than the iterative workflow the Images socket should always be set to "True" and the Init-Image socket should always be set to false.
|
||||
|
||||
## Acknowledgements
|
||||
- **Thanks to Alex and ComfyuiStudio for allowing me to use his excellent upscaler**
|
||||
- **Mycroft Holmes - AKA ChatGPT for assistance and guidance in the creation of Custom Nodes**
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
|
||||
import os
|
||||
import time
|
||||
import base64
|
||||
import random
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
try:
|
||||
import torch
|
||||
except Exception:
|
||||
torch = None
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except Exception:
|
||||
Image = None
|
||||
|
||||
|
||||
# ---------------- Helpers ----------------
|
||||
|
||||
def _tensor_to_png_b64(t) -> str:
|
||||
"""Convert torch.Tensor or np.ndarray image to base64 PNG."""
|
||||
if torch is not None and isinstance(t, torch.Tensor):
|
||||
x = t.detach().cpu().float()
|
||||
if x.dim() == 4 and x.shape[0] > 0:
|
||||
x = x[0]
|
||||
if x.dim() == 3 and x.shape[0] in (1, 3, 4) and x.shape[-1] not in (3, 4):
|
||||
x = x.permute(1, 2, 0)
|
||||
arr = x.numpy()
|
||||
elif isinstance(t, np.ndarray):
|
||||
arr = t
|
||||
else:
|
||||
raise TypeError(f"Expected image as torch.Tensor or np.ndarray, got {type(t)}")
|
||||
|
||||
if arr.max() <= 1.5:
|
||||
arr = arr * 255.0
|
||||
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
||||
|
||||
if Image is None:
|
||||
raise RuntimeError("Pillow is required to encode PNG images.")
|
||||
im = Image.fromarray(arr)
|
||||
buf = BytesIO()
|
||||
im.save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
|
||||
|
||||
def _extract_base64_image(resp_json: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return first inline image payload from Gemini JSON response."""
|
||||
def _walk(node):
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
yield k, v
|
||||
yield from _walk(v)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
yield from _walk(item)
|
||||
|
||||
for k, v in _walk(resp_json):
|
||||
if k in ("inline_data", "inlineData") and isinstance(v, dict):
|
||||
data = v.get("data")
|
||||
mime = v.get("mime_type") or v.get("mimeType") or ""
|
||||
if data and ("image/" in mime or mime == "" or mime is None):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def _post_with_backoff(url: str, headers: Dict[str, str], params: Dict[str, Any], payload: Dict[str, Any]):
|
||||
delay = 1.0
|
||||
last = None
|
||||
for _ in range(4):
|
||||
r = requests.post(url, headers=headers, params=params, json=payload, timeout=60)
|
||||
last = r
|
||||
if r.status_code != 429:
|
||||
return r
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
return last
|
||||
|
||||
|
||||
MODEL_CHOICES = (
|
||||
"gemini-2.5-flash-image",
|
||||
"gemini-2.5-flash-image-preview",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-2.0-pro-exp",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.0-pro-vision",
|
||||
"gemini-2.5-flash-audio-preview",
|
||||
"gemini-2.5-vision-preview",
|
||||
)
|
||||
|
||||
|
||||
class FSLGeminiGenerateImageV6:
|
||||
"""Gemini image generator with iterative + external image toggles."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"model": (MODEL_CHOICES, {"default": "gemini-2.5-flash-image-preview"}),
|
||||
"width": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 64}),
|
||||
"height": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 64}),
|
||||
"temperature": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.1}),
|
||||
"seed": ("INT", {"default": -1, "min": -1, "max": 0x7FFFFFFF}),
|
||||
"use_init_image": ("BOOLEAN", {"default": True}),
|
||||
"use_images": ("BOOLEAN", {"default": False}),
|
||||
"refresh_models": ("BOOLEAN", {"default": False}),
|
||||
"output_prompt": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"init_image": ("IMAGE",),
|
||||
"images": ("IMAGE",),
|
||||
"api_key": ("STRING", {"default": "", "multiline": False, "placeholder": "Leave blank to use GEMINI_API_KEY env var"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "INT", "INT", "FLOAT", "INT", "STRING", "DICT")
|
||||
RETURN_NAMES = (
|
||||
"image",
|
||||
"prompt",
|
||||
"model",
|
||||
"width",
|
||||
"height",
|
||||
"temperature",
|
||||
"seed",
|
||||
"seed_mode",
|
||||
"metadata",
|
||||
)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "FSL"
|
||||
|
||||
# ---------- internals ----------
|
||||
|
||||
def _resolve_api_key(self, key_in: str) -> str:
|
||||
k = (key_in or "").strip()
|
||||
if not k:
|
||||
k = os.environ.get("GEMINI_API_KEY", "").strip()
|
||||
if not k:
|
||||
raise RuntimeError("Gemini API key missing. Provide it in the node or set GEMINI_API_KEY env var.")
|
||||
return k
|
||||
|
||||
def _append_tensor_as_part(self, img_like, out: List[Dict[str, Any]]):
|
||||
b64 = _tensor_to_png_b64(img_like)
|
||||
out.append({"inline_data": {"mime_type": "image/png", "data": b64}})
|
||||
|
||||
def _collect_reference_parts(
|
||||
self,
|
||||
use_init_image: bool,
|
||||
init_image,
|
||||
use_images: bool,
|
||||
images,
|
||||
) -> List[Dict[str, Any]]:
|
||||
parts: List[Dict[str, Any]] = []
|
||||
|
||||
# init first (if enabled)
|
||||
if use_init_image and init_image is not None:
|
||||
if isinstance(init_image, (list, tuple)):
|
||||
if len(init_image) > 0:
|
||||
self._append_tensor_as_part(init_image[0], parts)
|
||||
elif torch is not None and isinstance(init_image, torch.Tensor):
|
||||
if init_image.dim() == 4 and init_image.shape[0] > 0:
|
||||
self._append_tensor_as_part(init_image[0], parts)
|
||||
else:
|
||||
self._append_tensor_as_part(init_image, parts)
|
||||
elif isinstance(init_image, np.ndarray):
|
||||
self._append_tensor_as_part(init_image, parts)
|
||||
|
||||
# then all external images (if enabled)
|
||||
if use_images and images is not None:
|
||||
if isinstance(images, (list, tuple)):
|
||||
for t in images:
|
||||
self._append_tensor_as_part(t, parts)
|
||||
elif torch is not None and isinstance(images, torch.Tensor):
|
||||
if images.dim() == 4:
|
||||
for i in range(images.shape[0]):
|
||||
self._append_tensor_as_part(images[i], parts)
|
||||
else:
|
||||
self._append_tensor_as_part(images, parts)
|
||||
elif isinstance(images, np.ndarray):
|
||||
self._append_tensor_as_part(images, parts)
|
||||
|
||||
return parts
|
||||
|
||||
# ---------- main ----------
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
width: int,
|
||||
height: int,
|
||||
temperature: float,
|
||||
seed: int,
|
||||
use_init_image: bool,
|
||||
refresh_models: bool,
|
||||
output_prompt: bool,
|
||||
use_images: bool = False,
|
||||
init_image: Optional["torch.Tensor"] = None,
|
||||
images: Optional["torch.Tensor"] = None,
|
||||
api_key: str = "",
|
||||
) -> Tuple["torch.Tensor", str, str, int, int, float, int, str, Dict[str, Any]]:
|
||||
|
||||
seed_mode = "fixed"
|
||||
if seed == -1:
|
||||
seed = random.randint(0, 0x7FFFFFFF)
|
||||
seed_mode = "randomize"
|
||||
|
||||
api_key = self._resolve_api_key(api_key)
|
||||
endpoint = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
parts: List[Dict[str, Any]] = [{"text": prompt}]
|
||||
ref_parts = self._collect_reference_parts(use_init_image, init_image, use_images, images)
|
||||
parts.extend(ref_parts)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"contents": [{"role": "user", "parts": parts}],
|
||||
"generationConfig": {
|
||||
"temperature": float(temperature),
|
||||
"seed": int(seed),
|
||||
},
|
||||
}
|
||||
|
||||
_ = refresh_models # UI no-op
|
||||
|
||||
resp = _post_with_backoff(endpoint, headers=headers, params={"key": api_key}, payload=payload)
|
||||
|
||||
# Parse response
|
||||
try:
|
||||
resp_json = resp.json()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Gemini response was not JSON: {e}")
|
||||
|
||||
if isinstance(resp_json, dict) and "error" in resp_json:
|
||||
err = resp_json["error"]
|
||||
raise RuntimeError(f"Gemini error: {err.get('status')} - {err.get('message')}")
|
||||
|
||||
b64_img = _extract_base64_image(resp_json)
|
||||
if not b64_img:
|
||||
raise RuntimeError(f"Failed to find inline image in Gemini response. First 1k of JSON: {str(resp_json)[:1000]}")
|
||||
|
||||
try:
|
||||
img_bytes = base64.b64decode(b64_img)
|
||||
if Image is None:
|
||||
raise RuntimeError("Pillow is required to decode returned image.")
|
||||
pil = Image.open(BytesIO(img_bytes)).convert("RGBA")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to decode inline image: {e}")
|
||||
|
||||
np_img = np.array(pil).astype(np.float32) / 255.0
|
||||
if torch is None:
|
||||
raise RuntimeError("torch is required for IMAGE tensor output.")
|
||||
image_tensor = torch.from_numpy(np_img)[None, ...]
|
||||
|
||||
out_prompt = prompt if output_prompt else ""
|
||||
|
||||
metadata_output = {
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"temperature": temperature,
|
||||
"seed": seed,
|
||||
"seed_mode": seed_mode,
|
||||
"use_init_image": bool(use_init_image and init_image is not None),
|
||||
"use_images": bool(use_images and images is not None),
|
||||
"init_image_connected": bool(init_image is not None),
|
||||
"batch_images": (
|
||||
int(images.shape[0]) if (torch is not None and isinstance(images, torch.Tensor) and images.dim() == 4)
|
||||
else (len(images) if isinstance(images, (list, tuple)) else (1 if images is not None else 0))
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
image_tensor,
|
||||
out_prompt,
|
||||
model,
|
||||
width,
|
||||
height,
|
||||
temperature,
|
||||
seed,
|
||||
seed_mode,
|
||||
metadata_output,
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"FSLGeminiGenerateImageV6": FSLGeminiGenerateImageV6}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"FSLGeminiGenerateImageV6": "FSL Gemini Generate Image V6"}
|
||||
@@ -0,0 +1,386 @@
|
||||
# FSLGeminiGenerateImageV8.py
|
||||
import os, time, base64, random
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
try:
|
||||
import torch
|
||||
except Exception:
|
||||
torch = None
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageOps, ImageFilter
|
||||
except Exception:
|
||||
Image = None
|
||||
|
||||
|
||||
# -------- helpers --------
|
||||
def _tensor_like_to_numpy(img_like):
|
||||
"""Accept torch or np tensor/array and return uint8 ndarray in HxWxC or HxW."""
|
||||
if torch is not None and isinstance(img_like, torch.Tensor):
|
||||
t = img_like.detach().cpu().float()
|
||||
if t.dim() == 4:
|
||||
t = t[0]
|
||||
if t.dim() == 3 and t.shape[0] in (1, 3, 4) and t.shape[-1] not in (1, 3, 4):
|
||||
t = t.permute(1, 2, 0) # CHW -> HWC
|
||||
arr = t.numpy()
|
||||
elif isinstance(img_like, np.ndarray):
|
||||
arr = img_like
|
||||
else:
|
||||
raise TypeError(f"Expected torch.Tensor or np.ndarray, got {type(img_like)}")
|
||||
|
||||
# scale to 0..255
|
||||
if arr.dtype != np.uint8 or arr.max() <= 1.5:
|
||||
arr = np.clip(arr * (255.0 if arr.max() <= 1.5 else 1.0), 0, 255).astype(np.uint8)
|
||||
return arr
|
||||
|
||||
|
||||
def _to_pil(img_like) -> Image.Image:
|
||||
arr = _tensor_like_to_numpy(img_like)
|
||||
if Image is None:
|
||||
raise RuntimeError("Pillow is required.")
|
||||
if arr.ndim == 2:
|
||||
return Image.fromarray(arr, "L")
|
||||
c = arr.shape[2] if arr.ndim == 3 else 1
|
||||
if c == 1:
|
||||
return Image.fromarray(arr[:, :, 0], "L")
|
||||
if c == 3:
|
||||
return Image.fromarray(arr, "RGB")
|
||||
if c == 4:
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
return Image.fromarray(arr[:, :, :3], "RGB")
|
||||
|
||||
|
||||
def _tensor_to_png_b64(img_like) -> str:
|
||||
pil = _to_pil(img_like)
|
||||
buf = BytesIO()
|
||||
pil.save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
|
||||
|
||||
def _prepare_mask_png_b64(
|
||||
mask_like=None,
|
||||
mask_image_like=None,
|
||||
target_size=None,
|
||||
invert: bool = False,
|
||||
feather_radius: int = 0,
|
||||
strength: float = 1.0,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Build an 8-bit 'L' mask PNG b64.
|
||||
- White (255) = editable
|
||||
- Black (0) = preserved
|
||||
Optional transforms: invert, feather (GaussianBlur), strength (0..1 linear scale).
|
||||
"""
|
||||
if mask_like is None and mask_image_like is None:
|
||||
return None
|
||||
src = mask_like if mask_like is not None else mask_image_like
|
||||
m = _to_pil(src).convert("L")
|
||||
if target_size is not None and m.size != target_size:
|
||||
m = m.resize(target_size, resample=Image.NEAREST)
|
||||
if invert:
|
||||
m = ImageOps.invert(m)
|
||||
if feather_radius and feather_radius > 0:
|
||||
m = m.filter(ImageFilter.GaussianBlur(radius=int(feather_radius)))
|
||||
if strength is not None:
|
||||
s = max(0.0, min(1.0, float(strength)))
|
||||
if s < 1.0:
|
||||
# linear scale towards 0 (stronger protection)
|
||||
m = m.point(lambda px: int(px * s))
|
||||
buf = BytesIO()
|
||||
m.save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
|
||||
|
||||
def _extract_base64_image(resp_json: Dict[str, Any]) -> Optional[str]:
|
||||
def _walk(n):
|
||||
if isinstance(n, dict):
|
||||
for k, v in n.items():
|
||||
yield k, v
|
||||
yield from _walk(v)
|
||||
elif isinstance(n, list):
|
||||
for i in n:
|
||||
yield from _walk(i)
|
||||
for k, v in _walk(resp_json):
|
||||
if k in ("inline_data", "inlineData") and isinstance(v, dict):
|
||||
data = v.get("data")
|
||||
mime = v.get("mime_type") or v.get("mimeType") or ""
|
||||
if data and ("image/" in mime or mime in ("", None)):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def _post_with_backoff(url: str, headers: Dict[str, str], params: Dict[str, Any], payload: Dict[str, Any]):
|
||||
delay = 1.0
|
||||
last = None
|
||||
for _ in range(4):
|
||||
r = requests.post(url, headers=headers, params=params, json=payload, timeout=60)
|
||||
last = r
|
||||
if r.status_code != 429:
|
||||
return r
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
return last
|
||||
|
||||
|
||||
MODEL_CHOICES = (
|
||||
"gemini-3-pro-image-preview",
|
||||
"gemini-2.5-flash-image",
|
||||
"gemini-2.5-flash-image-preview",
|
||||
"gemini-2.0-flash-exp",
|
||||
)
|
||||
|
||||
|
||||
class FSLGeminiGenerateImageV8:
|
||||
"""Gemini image generator with inpainting support.
|
||||
Base selection: auto / init_image / images.
|
||||
Mask inputs: MASK or IMAGE, with invert/feather/strength options.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"model": (MODEL_CHOICES, {"default": "gemini-2.5-flash-image-preview"}),
|
||||
"width": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 64}),
|
||||
"height": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 64}),
|
||||
"temperature": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.1}),
|
||||
"seed": ("INT", {"default": -1, "min": -1, "max": 0x7FFFFFFF}),
|
||||
"use_init_image": ("BOOLEAN", {"default": True}),
|
||||
"use_images": ("BOOLEAN", {"default": False}),
|
||||
"use_mask": ("BOOLEAN", {"default": False}),
|
||||
"base_for_inpaint": (("auto", "init_image", "images"), {"default": "auto"}),
|
||||
"refresh_models": ("BOOLEAN", {"default": False}),
|
||||
"output_prompt": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"init_image": ("IMAGE",),
|
||||
"images": ("IMAGE",),
|
||||
"mask": ("MASK",), # true mask
|
||||
"mask_image": ("IMAGE",), # image as mask
|
||||
"invert_mask": ("BOOLEAN", {"default": False}),
|
||||
"feather_radius": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1}),
|
||||
"mask_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||
"api_key": ("STRING", {"default": "", "multiline": False, "placeholder": "Leave blank to use GEMINI_API_KEY env var"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "INT", "INT", "FLOAT", "INT", "STRING", "DICT")
|
||||
RETURN_NAMES = ("image", "prompt", "model", "width", "height", "temperature", "seed", "seed_mode", "metadata")
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "FSL"
|
||||
|
||||
# ---- internals ----
|
||||
def _resolve_api_key(self, k: str) -> str:
|
||||
k = (k or "").strip() or os.environ.get("GEMINI_API_KEY", "").strip()
|
||||
if not k:
|
||||
raise RuntimeError("Gemini API key missing. Provide it in the node or set GEMINI_API_KEY.")
|
||||
return k
|
||||
|
||||
def _append_tensor_as_part(self, img_like, out: List[Dict[str, Any]]):
|
||||
out.append({"inline_data": {"mime_type": "image/png", "data": _tensor_to_png_b64(img_like)}})
|
||||
|
||||
def _iter_imgs(self, imgs):
|
||||
if imgs is None:
|
||||
return []
|
||||
if isinstance(imgs, (list, tuple)):
|
||||
return list(imgs)
|
||||
if torch is not None and isinstance(imgs, torch.Tensor):
|
||||
if imgs.dim() == 4:
|
||||
return [imgs[i] for i in range(imgs.shape[0])]
|
||||
return [imgs]
|
||||
if isinstance(imgs, np.ndarray):
|
||||
return [imgs]
|
||||
return []
|
||||
|
||||
def _collect_parts_with_base_choice(
|
||||
self,
|
||||
base_choice: str,
|
||||
use_init_image: bool, init_image,
|
||||
use_images: bool, images,
|
||||
use_mask: bool, mask, mask_image,
|
||||
invert_mask: bool, feather_radius: int, mask_strength: float,
|
||||
) -> Tuple[List[Dict[str, Any]], Optional[Tuple[int, int]], str]:
|
||||
"""
|
||||
Returns (parts, base_size, base_source)
|
||||
base_source in {"init_image","images","none"}
|
||||
"""
|
||||
parts: List[Dict[str, Any]] = []
|
||||
base_size = None
|
||||
base_source = "none"
|
||||
|
||||
imgs_list = self._iter_imgs(images)
|
||||
|
||||
if base_choice == "init_image" and use_init_image and init_image is not None:
|
||||
# base = init_image
|
||||
if isinstance(init_image, (list, tuple)) and len(init_image) > 0:
|
||||
pil = _to_pil(init_image[0]); base_size = pil.size; self._append_tensor_as_part(init_image[0], parts)
|
||||
elif torch is not None and isinstance(init_image, torch.Tensor):
|
||||
t = init_image[0] if (init_image.dim() == 4 and init_image.shape[0] > 0) else init_image
|
||||
pil = _to_pil(t); base_size = pil.size; self._append_tensor_as_part(t, parts)
|
||||
elif isinstance(init_image, np.ndarray):
|
||||
pil = _to_pil(init_image); base_size = pil.size; self._append_tensor_as_part(init_image, parts)
|
||||
base_source = "init_image"
|
||||
|
||||
# mask after base
|
||||
if use_mask and (mask is not None or mask_image is not None):
|
||||
b64 = _prepare_mask_png_b64(mask, mask_image, base_size, invert_mask, feather_radius, mask_strength)
|
||||
if b64:
|
||||
parts.append({"inline_data": {"mime_type": "image/png", "data": b64}})
|
||||
|
||||
# remaining refs
|
||||
if use_images and imgs_list:
|
||||
for t in imgs_list:
|
||||
self._append_tensor_as_part(t, parts)
|
||||
return parts, base_size, base_source
|
||||
|
||||
if base_choice == "images" and use_images and imgs_list:
|
||||
# promote first image as base
|
||||
first = imgs_list[0]
|
||||
pil = _to_pil(first); base_size = pil.size; self._append_tensor_as_part(first, parts)
|
||||
base_source = "images"
|
||||
|
||||
# mask after base
|
||||
if use_mask and (mask is not None or mask_image is not None):
|
||||
b64 = _prepare_mask_png_b64(mask, mask_image, base_size, invert_mask, feather_radius, mask_strength)
|
||||
if b64:
|
||||
parts.append({"mime_type": "image/png", "inline_data": {"mime_type": "image/png", "data": b64}}) # keep schema consistent
|
||||
# Note: either {"inline_data":{...}} or the same dict; we use the same structure as other parts
|
||||
parts[-1] = {"inline_data": {"mime_type": "image/png", "data": b64}}
|
||||
|
||||
# rest as refs
|
||||
for t in imgs_list[1:]:
|
||||
self._append_tensor_as_part(t, parts)
|
||||
return parts, base_size, base_source
|
||||
|
||||
# no valid base selected — still push refs if present
|
||||
if use_images and imgs_list:
|
||||
for t in imgs_list:
|
||||
if base_size is None:
|
||||
base_size = _to_pil(t).size
|
||||
self._append_tensor_as_part(t, parts)
|
||||
|
||||
# If mask without a base, we can still send it (model may ignore)
|
||||
if use_mask and (mask is not None or mask_image is not None):
|
||||
b64 = _prepare_mask_png_b64(mask, mask_image, base_size, invert_mask, feather_radius, mask_strength)
|
||||
if b64:
|
||||
parts.append({"inline_data": {"mime_type": "image/png", "data": b64}})
|
||||
|
||||
return parts, base_size, base_source
|
||||
|
||||
# ---- main ----
|
||||
def generate(
|
||||
self,
|
||||
prompt: str, model: str, width: int, height: int, temperature: float, seed: int,
|
||||
use_init_image: bool, use_images: bool, use_mask: bool, base_for_inpaint: str,
|
||||
refresh_models: bool, output_prompt: bool,
|
||||
init_image: Optional["torch.Tensor"] = None, images: Optional["torch.Tensor"] = None,
|
||||
mask: Optional["torch.Tensor"] = None, mask_image: Optional["torch.Tensor"] = None,
|
||||
invert_mask: bool = False, feather_radius: int = 0, mask_strength: float = 1.0,
|
||||
api_key: str = "",
|
||||
):
|
||||
seed_mode = "fixed"
|
||||
if seed == -1:
|
||||
seed = random.randint(0, 0x7FFFFFFF)
|
||||
seed_mode = "randomize"
|
||||
|
||||
api_key = self._resolve_api_key(api_key)
|
||||
endpoint = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
# Resolve effective base with AUTO fallback
|
||||
requested = base_for_inpaint
|
||||
have_init = bool(use_init_image and (init_image is not None))
|
||||
have_images = bool(use_images and (images is not None))
|
||||
|
||||
if requested == "auto":
|
||||
base_choice = "init_image" if have_init else ("images" if have_images else "none")
|
||||
else:
|
||||
if requested == "init_image" and not have_init:
|
||||
base_choice = "images" if have_images else "none"
|
||||
elif requested == "images" and not have_images:
|
||||
base_choice = "init_image" if have_init else "none"
|
||||
else:
|
||||
base_choice = requested
|
||||
|
||||
# Build parts
|
||||
parts: List[Dict[str, Any]] = []
|
||||
if use_mask and (mask is not None or mask_image is not None):
|
||||
parts.append({"text":
|
||||
"INPAINT MODE: You are given a base image and a mask image. "
|
||||
"Modify ONLY pixels where the mask is white; keep black (unmasked) pixels exactly the same as the base image. "
|
||||
"Respect edges and composition."
|
||||
})
|
||||
parts.append({"text": prompt})
|
||||
|
||||
ref_parts, base_size, resolved_base = self._collect_parts_with_base_choice(
|
||||
base_choice=base_choice,
|
||||
use_init_image=use_init_image, init_image=init_image,
|
||||
use_images=use_images, images=images,
|
||||
use_mask=use_mask, mask=mask, mask_image=mask_image,
|
||||
invert_mask=invert_mask, feather_radius=feather_radius, mask_strength=mask_strength,
|
||||
)
|
||||
parts.extend(ref_parts)
|
||||
|
||||
if use_mask and (mask is not None or mask_image is not None) and resolved_base == "none":
|
||||
raise RuntimeError("Inpaint requires a base image. Enable and connect either init_image or images.")
|
||||
|
||||
payload = {
|
||||
"contents": [{"role": "user", "parts": parts}],
|
||||
"generationConfig": {"temperature": float(temperature), "seed": int(seed)},
|
||||
}
|
||||
_ = refresh_models # UI no-op
|
||||
|
||||
resp = _post_with_backoff(endpoint, headers=headers, params={"key": api_key}, payload=payload)
|
||||
|
||||
# Parse response
|
||||
try:
|
||||
resp_json = resp.json()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Gemini response was not JSON: {e}")
|
||||
|
||||
if isinstance(resp_json, dict) and "error" in resp_json:
|
||||
err = resp_json["error"]
|
||||
raise RuntimeError(f"Gemini error: {err.get('status')} - {err.get('message')}")
|
||||
|
||||
b64_img = _extract_base64_image(resp_json)
|
||||
if not b64_img:
|
||||
raise RuntimeError(f"Failed to find inline image in Gemini response. First 1k of JSON: {str(resp_json)[:1000]}")
|
||||
|
||||
try:
|
||||
img_bytes = base64.b64decode(b64_img)
|
||||
if Image is None:
|
||||
raise RuntimeError("Pillow is required to decode returned image.")
|
||||
pil = Image.open(BytesIO(img_bytes)).convert("RGBA")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to decode inline image: {e}")
|
||||
|
||||
np_img = np.array(pil).astype(np.float32) / 255.0
|
||||
if torch is None:
|
||||
raise RuntimeError("torch is required for IMAGE tensor output.")
|
||||
image_tensor = torch.from_numpy(np_img)[None, ...]
|
||||
|
||||
out_prompt = prompt if output_prompt else ""
|
||||
metadata = {
|
||||
"prompt": prompt, "model": model, "width": width, "height": height,
|
||||
"temperature": temperature, "seed": seed, "seed_mode": seed_mode,
|
||||
"use_init_image": bool(use_init_image and init_image is not None),
|
||||
"use_images": bool(use_images and images is not None),
|
||||
"use_mask": bool(use_mask and (mask is not None or mask_image is not None)),
|
||||
"mask_source": "MASK" if mask is not None else ("IMAGE" if mask_image is not None else "none"),
|
||||
"requested_base_for_inpaint": base_for_inpaint,
|
||||
"effective_base_for_inpaint": base_choice,
|
||||
"resolved_base_source": resolved_base,
|
||||
"invert_mask": bool(invert_mask),
|
||||
"feather_radius": int(feather_radius),
|
||||
"mask_strength": float(mask_strength),
|
||||
}
|
||||
return (image_tensor, out_prompt, model, width, height, temperature, seed, seed_mode, metadata)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"FSLGeminiGenerateImageV8": FSLGeminiGenerateImageV8}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"FSLGeminiGenerateImageV8": "FSL Gemini Generate Image V8"}
|
||||
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 804 KiB After Width: | Height: | Size: 819 KiB |
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 609 KiB After Width: | Height: | Size: 637 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 2.4 MiB |