Files
1038lab-ComfyUI-Pollinations/pollinations.py
T
2025-07-06 22:36:53 -07:00

350 lines
13 KiB
Python

import os
import sys
import json
import numpy as np
import torch
import torchaudio
from PIL import Image
import requests
import tempfile
import time
from urllib.parse import quote, unquote
from pydub import AudioSegment
import io
import folder_paths
DEFAULT_IMAGE_MODELS = ["flux", "turbo"]
TEXT_GENERATION_MODELS = [
"openai", # OpenAI GPT-4o Mini
"mistral", # Mistral Small 3.1 24B
"llamascout", # Llama 4 Scout 17B
"openai-fast", # OpenAI GPT-4.1 Nano
"openai-reasoning", # OpenAI O3
"phi", # Phi-4 Mini Instruct
"qwen-coder", # Qwen 2.5 Coder 32B
"bidara", # NASA BIDARA
"midijourney" # MIDIjourney
]
SEARCH_MODELS = [
"searchgpt", # OpenAI GPT-4o Mini Search Preview
"elixposearch" # Elixpo Search
]
TEXT_TO_SPEECH_MODELS = [
"openai-audio", # OpenAI GPT-4o Mini Audio Preview
"hypnosis-tracy" # Hypnosis Tracy
]
# Updated voice list from API
AVAILABLE_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer", "coral", "verse", "ballad", "ash", "sage", "amuch", "dan"]
class PollinationsImageGen:
@classmethod
def INPUT_TYPES(cls):
# Use fixed model list
return {
"required": {
"prompt": ("STRING", {"multiline": True, "placeholder": "Enter a description of the image you want..."}),
"model": (DEFAULT_IMAGE_MODELS, {"default": "flux"}),
"width": ("INT", {"default": 1024, "min": 512, "max": 4096, "step": 8}),
"height": ("INT", {"default": 1024, "min": 512, "max": 4096, "step": 8}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 4, "step": 1}),
},
"optional": {
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"enhance": ("BOOLEAN", {"default": True}),
"nologo": ("BOOLEAN", {"default": True}),
"private": ("BOOLEAN", {"default": True}),
"safe": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("IMAGE", "STRING", "STRING")
RETURN_NAMES = ("images", "image_urls", "prompts")
OUTPUT_IS_LIST = (True, True, False)
FUNCTION = "generate"
CATEGORY = "🧪AILab/🌸Pollinations"
def generate(self, prompt, model, width, height, batch_size=1, seed=0,
enhance=True, nologo=True, private=True, safe=False):
"""Generate multiple images"""
# Use fixed model list
images = []
urls = []
prompts = []
for i in range(batch_size):
current_seed = seed + i if seed != 0 else 0
try:
image, url, final_prompt = self._generate_single(
prompt, model, width, height,
current_seed, enhance, nologo, private, safe
)
images.append(image)
urls.append(url)
prompts.append(final_prompt)
except Exception as e:
images.append(torch.zeros(1, 512, 512, 3))
urls.append(f"Error: {str(e)}")
prompts.append(prompt)
return (images, urls, prompts)
def _generate_single(self, prompt, model, width, height, seed=0,
enhance=True, nologo=True, private=True, safe=False):
"""Generate a single image"""
try:
base_url = "https://image.pollinations.ai/prompt/"
encoded_prompt = quote(prompt)
params = {
"model": model,
"width": width,
"height": height,
}
if seed and seed != 0:
params["seed"] = seed
if nologo:
params["nologo"] = "true"
if private:
params["private"] = "true"
if enhance:
params["enhance"] = "true"
if safe:
params["safe"] = "true"
param_str = "&".join([f"{k}={v}" for k, v in params.items()])
url = f"{base_url}{encoded_prompt}?{param_str}"
response = requests.get(url, stream=True)
response.raise_for_status()
final_prompt = prompt
try:
image_url = response.url
if "/prompt/" in image_url:
encoded_part = image_url.split("/prompt/")[1].split("?")[0]
extracted_prompt = unquote(encoded_part)
if extracted_prompt != prompt and enhance:
final_prompt = extracted_prompt
except Exception:
pass
temp_dir = tempfile.gettempdir()
filename = f"pollinations_{int(time.time())}.png"
image_path = os.path.join(temp_dir, filename)
with open(image_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
image = Image.open(image_path)
image_tensor = torch.from_numpy(np.array(image).astype(np.float32) / 255.0)[None,]
return (image_tensor, url, final_prompt)
except Exception as e:
error_msg = f"Pollinations API error: {str(e)}"
empty_image = torch.zeros(1, 512, 512, 3)
return (empty_image, error_msg, prompt)
@classmethod
def IS_CHANGED(cls, **kwargs):
return time.time()
class PollinationsTextGen:
@classmethod
def INPUT_TYPES(cls):
# Use fixed model list
return {
"required": {
"prompt": ("STRING", {"multiline": True, "placeholder": "Enter your text prompt..."}),
"model": (TEXT_GENERATION_MODELS, {"default": "openai"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
"optional": {
"private": ("BOOLEAN", {"default": True, "tooltip": "Keep the generation private"})
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("generated_text",)
FUNCTION = "generate_text"
CATEGORY = "🧪AILab/🌸Pollinations"
def generate_text(self, prompt, model, seed, private=True):
try:
# Use model directly from fixed list
model_name = model if model in TEXT_GENERATION_MODELS else "openai"
params = {
"model": model_name,
"seed": seed,
"private": str(private).lower()
}
param_str = "&".join([f"{k}={v}" for k, v in params.items()])
url = f"https://text.pollinations.ai/{quote(prompt)}?{param_str}"
response = requests.get(url)
if response.status_code == 200:
return (response.text,)
else:
return (f"Error: {response.status_code}",)
except Exception as e:
return (f"Text generation failed: {str(e)}",)
# Adding Search node
class PollinationsSearch:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"query": ("STRING", {"multiline": True, "placeholder": "Enter your search query..."}),
"model": (SEARCH_MODELS, {"default": "searchgpt"}),
},
"optional": {
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"private": ("BOOLEAN", {"default": True, "tooltip": "Keep the search private"})
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("search_results",)
FUNCTION = "search"
CATEGORY = "🧪AILab/🌸Pollinations"
def search(self, query, model, seed=0, private=True):
try:
params = {
"model": model,
"seed": seed,
"private": str(private).lower()
}
param_str = "&".join([f"{k}={v}" for k, v in params.items()])
url = f"https://text.pollinations.ai/{quote(query)}?{param_str}"
response = requests.get(url, timeout=30)
if response.status_code == 200:
return (response.text,)
else:
return (f"Search error: {response.status_code}",)
except Exception as e:
return (f"Search failed: {str(e)}",)
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN")
# Adding Text-to-Speech node
class PollinationsTextToSpeech:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "placeholder": "Enter text to convert to speech..."}),
"model": (TEXT_TO_SPEECH_MODELS, {"default": "openai-audio"}),
"voice": (AVAILABLE_VOICES, {"default": "nova"}),
},
"optional": {
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "tooltip": "Random seed (requires authentication)"})
}
}
RETURN_TYPES = ("AUDIO", "STRING",)
RETURN_NAMES = ("audio", "audio_path",)
FUNCTION = "generate_speech"
CATEGORY = "🧪AILab/🌸Pollinations"
def generate_speech(self, text, model, voice, seed=None):
try:
# Use selected model, fallback to openai-audio if not in list
model_name = model if model in TEXT_TO_SPEECH_MODELS else "openai-audio"
params = {
"model": model_name,
"voice": voice
}
# Only add seed if provided and not 0 (requires authentication)
if seed is not None and seed != 0:
params["seed"] = seed
param_str = "&".join([f"{k}={v}" for k, v in params.items()])
url = f"https://text.pollinations.ai/{quote(text)}?{param_str}"
response = requests.get(url, stream=True)
if response.status_code == 200:
# Get ComfyUI's temp directory
temp_dir = os.path.join(folder_paths.get_output_directory(), "pollinations_temp")
os.makedirs(temp_dir, exist_ok=True)
# Generate unique filename
timestamp = int(time.time())
mp3_filename = f"pollinations_speech_{timestamp}.mp3"
mp3_path = os.path.join(temp_dir, mp3_filename)
# Save MP3 file
with open(mp3_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
# Load and process audio
waveform, sample_rate = torchaudio.load(mp3_path)
# Ensure mono audio (take mean if stereo)
if waveform.shape[0] > 1:
waveform = waveform.mean(dim=0, keepdim=True)
# Add batch dimension if needed
if waveform.dim() == 2:
waveform = waveform.unsqueeze(0)
# Normalize audio
if waveform.numel() > 0:
max_val = waveform.abs().max()
if max_val > 0:
waveform = waveform / max_val
# Return audio in ComfyUI format
audio_dict = {
"waveform": waveform,
"sample_rate": sample_rate
}
return (audio_dict, mp3_path)
elif response.status_code == 402:
# Payment required - authentication needed (usually when using seed)
error_msg = "Text-to-Speech with seed parameter requires authentication. Remove seed or visit https://auth.pollinations.ai to get authentication."
print(f"[PollinationsTextToSpeech] {error_msg}")
return ({"waveform": torch.zeros(1, 1, 16000), "sample_rate": 16000}, "")
else:
print(f"[PollinationsTextToSpeech] Error: HTTP {response.status_code} - {response.text[:200]}")
return ({"waveform": torch.zeros(1, 1, 16000), "sample_rate": 16000}, "")
except Exception as e:
print(f"[PollinationsTextToSpeech] Exception: {str(e)}")
return ({"waveform": torch.zeros(1, 1, 16000), "sample_rate": 16000}, "")
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN")
NODE_CLASS_MAPPINGS = {
"PollinationsImageGen": PollinationsImageGen,
"PollinationsTextGen": PollinationsTextGen,
"PollinationsSearch": PollinationsSearch,
"PollinationsTextToSpeech": PollinationsTextToSpeech,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PollinationsImageGen": "Image Gen 🖼️ (Pollinations)",
"PollinationsTextGen": "Text Gen 📝 (Pollinations)",
"PollinationsSearch": "Search 🔍 (Pollinations)",
"PollinationsTextToSpeech": "Text To Speech Chat 🔊 (Pollinations)",
}