This commit is contained in:
pk
2025-05-19 12:37:10 +02:00
commit ae094ea5d1
6 changed files with 764 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
from .nodes.pulid.AQ_MasksAndImagesAsList import AQ_MasksAndImagesAsList
from .nodes.transformers.AQ_Qwen import AQ_Qwen
from .nodes.transformers.AQ_Qwen import AQ_QwenLoader
from .nodes.pulid.AQ_multiface_ApplyPulidFlux import AQ_multiface_ApplyPulidFlux
from .nodes.api.AQ_Gemini import AQ_Gemini
NODE_CLASS_MAPPINGS = {
"AQ_MasksAndImagesAsList": AQ_MasksAndImagesAsList,
"AQ_multiface_ApplyPulidFlux": AQ_multiface_ApplyPulidFlux,
"AQ_Qwen": AQ_Qwen,
"AQ_QwenLoader": AQ_QwenLoader,
"AQ_Gemini": AQ_Gemini
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AQ_MasksAndImagesAsList": "AQ_MasksAndImagesAsList",
"AQ_multiface_ApplyPulidFlux": "AQ_multiface_ApplyPulidFlux",
"AQ_Qwen": "AQ_Qwen",
"AQ_QwenLoader": "AQ_QwenLoader",
"AQ_Gemini": "AQ_Gemini"
}
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+276
View File
@@ -0,0 +1,276 @@
import requests
import base64
from PIL import Image
import io
import json
import numpy as np
import torch
from google import genai
from google.genai import types
class AQ_Gemini:
@classmethod
def INPUT_TYPES(s):
default_schema = '''{
"type": "object",
"required": ["description", "style"],
"properties": {
"description": {
"type": "string"
},
"style": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
}
}
}'''
models = [
"gemma-3-12b-it", # default
"gemma-3-1b-it",
"gemma-3-4b-it",
"gemma-3-27b-it",
"gemini-2.5-flash-preview-04-17",
"gemini-2.5-pro-preview-05-06",
"gemini-2.0-flash",
"gemini-2.0-flash-preview-image-generation",
"gemini-2.0-flash-lite",
"gemini-1.5-flash",
"gemini-1.5-flash-8b",
"gemini-1.5-pro",
"gemini-embedding-exp",
"imagen-3.0-generate-002",
"veo-2.0-generate-001",
"gemini-2.0-flash-live-001",
"custom" # Option for custom model
]
return {
"required": {
"gemini_api_key": ("STRING", {"default": ""}),
"model_selection": (models, {"default": "gemma-3-12b-it"}),
"custom_model": ("STRING", {"default": "", "multiline": False}),
"prompt": ("STRING", {"default": "", "multiline": True}),
"system_message": ("STRING", {"default": "You are a helpful assistant.", "multiline": True}),
"temperature": ("FLOAT", {"default": 1, "min": 0.0, "max": 2.0, "step": 0.05}),
"top_k": ("INT", {"default": 64, "min": 0, "max": 100}),
"top_p": ("FLOAT", {"default": 0.95, "min": 0.0, "max": 1.0, "step": 0.01}),
"enable_json": ("BOOLEAN", {"default": False}),
"json_schema": ("STRING", {"default": default_schema, "multiline": True}),
"result_template": ("STRING", {"default": "", "multiline": True}),
},
"optional": {
"image": ("IMAGE",),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING",)
RETURN_NAMES = ("response", "formatted_response", "instruction")
FUNCTION = "generate"
CATEGORY = "Aquasite/LLM"
def generate(self, gemini_api_key, model_selection, custom_model, prompt, system_message, temperature=0.8,
top_k=64, top_p=0.95, enable_json=False, json_schema="", result_template="", image=None):
instruction = """
How to use AQ_Gemini node:
1. Provide your Gemini API key in the 'gemini_api_key' field
2. Select a model from the dropdown or choose 'custom' and enter your model name
3. Enter your prompt in the 'prompt' field
4. Customize the system message to control the assistant's behavior
5. Optionally provide an image for image-based prompts
6. For JSON output:
- Enable 'enable_json'
- Provide JSON schema in 'json_schema'
- Use 'result_template' to format the response
Available Models:
- Gemma Models: gemma-3-{1b,4b,12b,27b}-it
- Gemini 2.5: flash-preview, pro-preview
- Gemini 2.0: flash, flash-preview-image-generation, flash-lite, flash-live
- Gemini 1.5: flash, flash-8b, pro
- Special Models: gemini-embedding-exp, imagen-3.0, veo-2.0
Note: If any error occurs, empty strings will be returned instead of raising an error.
"""
try:
if not gemini_api_key:
return ("", "", instruction)
# Initialize Gemini client
client = genai.Client(api_key=gemini_api_key)
# Determine which model to use
model_name = custom_model if model_selection == "custom" and custom_model else model_selection
# Prepare contents list
contents = []
# Add system message if provided
if system_message:
contents.append(
types.Content(
role="user",
parts=[types.Part.from_text(text=system_message)]
)
)
# Handle image if provided
if image is not None:
# Convert tensor to numpy array
if isinstance(image, torch.Tensor):
image = image.cpu().numpy()
# Handle ComfyUI image format (B, H, W, C)
if len(image.shape) == 4:
image = image[0]
# Ensure proper type and range for PIL
if image.dtype != np.uint8:
if image.max() <= 1:
image = (image * 255).astype(np.uint8)
else:
image = image.astype(np.uint8)
# Convert numpy array to PIL Image and then to base64
img = Image.fromarray(image)
img_byte_arr = io.BytesIO()
img.save(img_byte_arr, format="PNG")
img_byte_arr.seek(0)
img_base64 = base64.b64encode(img_byte_arr.getvalue()).decode('utf-8')
# Create content with image and prompt
contents.append(
types.Content(
role="user",
parts=[{
"inline_data": {
"mime_type": "image/png",
"data": img_base64
}
},
{
"text": prompt
}]
)
)
else:
# Text-only content
contents.append(
types.Content(
role="user",
parts=[types.Part.from_text(text=prompt)]
)
)
# Configure generation settings
generate_config = types.GenerateContentConfig(
temperature=temperature,
top_k=top_k,
top_p=top_p
)
# Add JSON schema if enabled and not a Gemma-3 model
is_gemma_model = model_name.startswith("gemma-3")
if enable_json and json_schema and not is_gemma_model:
try:
schema_dict = json.loads(json_schema)
generate_config.response_mime_type = "application/json"
generate_config.response_schema = self._convert_schema_dict_to_genai(schema_dict)
except json.JSONDecodeError:
print("Invalid JSON schema provided")
# Generate content
response = ""
for chunk in client.models.generate_content_stream(
model=model_name,
contents=contents,
config=generate_config,
):
if chunk.text:
response += chunk.text
formatted_response = response
# Handle template formatting if result_template is provided and response might be JSON
if result_template and enable_json:
success, json_response = self.try_parse_json(response)
if success:
try:
formatted_response = result_template.format(json=json_response)
except KeyError as e:
print(f"Template formatting error: {str(e)}")
formatted_response = response
else:
print("Response is not valid JSON")
formatted_response = response
return (response, formatted_response, instruction)
except Exception as e:
print(f"Error in Gemini API: {str(e)}")
return ("", "", instruction)
def _convert_schema_dict_to_genai(self, schema_dict):
"""Convert a JSON schema dictionary to genai Schema object."""
schema_type = schema_dict.get("type", "object")
if schema_type == "object":
properties = {}
for prop_name, prop_schema in schema_dict.get("properties", {}).items():
properties[prop_name] = self._convert_schema_dict_to_genai(prop_schema)
return genai.types.Schema(
type=genai.types.Type.OBJECT,
required=schema_dict.get("required", []),
properties=properties
)
elif schema_type == "array":
items_schema = self._convert_schema_dict_to_genai(schema_dict.get("items", {}))
return genai.types.Schema(
type=genai.types.Type.ARRAY,
items=items_schema
)
elif schema_type == "string":
return genai.types.Schema(type=genai.types.Type.STRING)
elif schema_type == "number":
return genai.types.Schema(type=genai.types.Type.NUMBER)
elif schema_type == "integer":
return genai.types.Schema(type=genai.types.Type.INTEGER)
elif schema_type == "boolean":
return genai.types.Schema(type=genai.types.Type.BOOLEAN)
else:
return genai.types.Schema(type=genai.types.Type.STRING)
def prepare_json(self, text):
"""Clean up text that might contain markdown JSON code blocks."""
# Remove markdown JSON code block markers
text = text.replace("```json", "").replace("```", "").strip()
return text
def try_parse_json(self, text):
"""Try to parse JSON from text, handling markdown code blocks.
Returns tuple (success, result) where result is either parsed JSON or original text."""
try:
# First clean up any markdown formatting
cleaned_text = self.prepare_json(text)
# Try to parse as JSON
return True, json.loads(cleaned_text)
except json.JSONDecodeError as e:
print(f"JSON parsing error: {str(e)}")
return False, text
except Exception as e:
print(f"Unexpected error while parsing JSON: {str(e)}")
return False, text
+47
View File
@@ -0,0 +1,47 @@
import torch
class AQ_MasksAndImagesAsList:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"masks": ("MASK", {"forceInput": True}), # From SEGSToMaskList
"images": ("IMAGE", {"forceInput": True}), # From SEGSToImageList
}
}
RETURN_TYPES = ("IMAGE_LIST", "IMAGE_LIST", "STRING")
RETURN_NAMES = ("images", "masks", "count")
FUNCTION = "convert_to_lists"
CATEGORY = "AQ/Pulid"
INPUT_IS_LIST = True
def convert_to_lists(self, masks, images):
# Ensure we have the same number of masks and images
if len(masks) != len(images):
print(f"Warning: Number of masks ({len(masks)}) does not match number of images ({len(images)})")
# Use the shorter length to avoid index errors
length = min(len(masks), len(images))
masks = masks[:length]
images = images[:length]
# Process masks and images together to maintain order
processed_images = []
processed_masks = []
for mask, img in zip(masks, images):
# Process mask
if len(mask.shape) == 2:
mask = mask.unsqueeze(-1)
processed_masks.append(mask)
# Process image
processed_images.append(img)
# Create count string
count_str = f"{len(processed_images)}"
return (processed_images, processed_masks, count_str)
@@ -0,0 +1,95 @@
import torch
import sys
import os
import importlib
class AQ_multiface_ApplyPulidFlux:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL", ),
"pulid_flux": ("PULIDFLUX", ),
"eva_clip": ("EVA_CLIP", ),
"face_analysis": ("FACEANALYSIS", ),
"images": ("IMAGE_LIST", ),
"weight": ("FLOAT", {"default": 1.0, "min": -1.0, "max": 5.0, "step": 0.05 }),
"start_at": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001 }),
"end_at": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001 }),
},
"optional": {
"attn_masks": ("IMAGE_LIST", ),
"options": ("OPTIONS",),
},
"hidden": {
"unique_id": "UNIQUE_ID"
},
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "apply_multi_pulid_flux"
CATEGORY = "AQ/Pulid"
def apply_multi_pulid_flux(self, model, pulid_flux, eva_clip, face_analysis, images, weight, start_at, end_at, attn_masks=None, options={}, unique_id=None):
# Rather than importing the class, we'll call the original node directly
# We know the original node is successfully registered in ComfyUI
from nodes import NODE_CLASS_MAPPINGS
if "ApplyPulidFlux" not in NODE_CLASS_MAPPINGS:
# This fallback will help with debugging if the node isn't found
print("Available nodes:", list(NODE_CLASS_MAPPINGS.keys()))
# Try an alternative approach - import from the PuLID_Flux module directly
try:
# Add the parent directory to sys.path to make absolute imports work
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
# Import the module (not relatively)
print(f"Trying to import from {parent_dir}")
pulidflux_module = importlib.import_module("ComfyUI_PuLID_Flux_ll.pulidflux")
ApplyPulidFlux = pulidflux_module.ApplyPulidFlux
apply_pulid = ApplyPulidFlux()
except Exception as e:
print(f"Error importing directly from module: {e}")
raise ValueError(f"Could not find or import ApplyPulidFlux. Is ComfyUI_PuLID_Flux_ll installed? Error: {e}")
else:
# Use the registered node class
ApplyPulidFlux = NODE_CLASS_MAPPINGS["ApplyPulidFlux"]
apply_pulid = ApplyPulidFlux()
# Work directly on the original model
current_model = model
# Get number of images in the list
num_images = len(images)
# Process each image-mask pair
for i in range(num_images):
# Get single image from the list
single_image = images[i].unsqueeze(0) if len(images[i].shape) == 3 else images[i]
# Extract corresponding mask if provided
attn_mask = None
if attn_masks is not None:
if i < len(attn_masks):
attn_mask = attn_masks[i].unsqueeze(0) if len(attn_masks[i].shape) == 3 else attn_masks[i]
# Apply PuLID Flux to this image-mask pair
print(f"Processing image {i+1}/{num_images}")
current_model, = apply_pulid.apply_pulid_flux(
model=current_model,
pulid_flux=pulid_flux,
eva_clip=eva_clip,
face_analysis=face_analysis,
image=single_image,
weight=weight,
start_at=start_at,
end_at=end_at,
attn_mask=attn_mask,
options=options,
unique_id=unique_id
)
return (current_model,)
+308
View File
@@ -0,0 +1,308 @@
import torch
import os
import folder_paths
import numpy as np
from PIL import Image
from transformers import (
Qwen2_5_VLForConditionalGeneration,
BitsAndBytesConfig,
AutoProcessor,
)
from pathlib import Path
import json
model_directory = os.path.join(folder_paths.models_dir, "VLM")
os.makedirs(model_directory, exist_ok=True)
class AQ_QwenLoader:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": (
[
"unsloth/Qwen2.5-VL-3B-Instruct-bnb-4bit",
"unsloth/Qwen2.5-VL-7B-Instruct-bnb-4bit",
"unsloth/Qwen2.5-VL-32B-Instruct-bnb-4bit",
],
{"default": "unsloth/Qwen2.5-VL-3B-Instruct-bnb-4bit"},
),
"quantization": (
["none", "4bit"],
{"default": "4bit"},
),
"attention": (
["flash_attention_2", "sdpa", "eager"],
{"default": "sdpa"},
),
"device": (
["auto", "cuda:0", "cuda:1"],
{"default": "auto"},
),
}
}
RETURN_TYPES = ("QWEN_MODEL",)
RETURN_NAMES = ("qwen_model",)
FUNCTION = "load_model"
CATEGORY = "AQ/LLM"
def load_model(self, model, quantization, attention, device):
qwen_model = {"model": None, "processor": None, "model_path": None}
# Setup model path
model_name = model.rsplit("/", 1)[-1]
model_path = os.path.join(model_directory, model_name)
# Download if needed
if not os.path.exists(model_path):
print(f"Downloading Qwen model to: {model_path}")
from huggingface_hub import snapshot_download
snapshot_download(
repo_id=model,
local_dir=model_path,
local_dir_use_symlinks=False
)
# Setup quantization
if quantization == "4bit":
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4"
)
elif quantization == "8bit":
quantization_config = BitsAndBytesConfig(load_in_8bit=True)
else:
quantization_config = None
# Load model and processor
qwen_model["model"] = Qwen2_5_VLForConditionalGeneration.from_pretrained(
model_path,
torch_dtype=torch.float16,
device_map=device,
attn_implementation=attention,
quantization_config=quantization_config,
trust_remote_code=True,
)
qwen_model["processor"] = AutoProcessor.from_pretrained(
model_path,
trust_remote_code=True,
use_fast=True
)
qwen_model["model_path"] = model_path
return (qwen_model,)
class AQ_Qwen:
@classmethod
def INPUT_TYPES(s):
default_format = '''{
"type": "object",
"properties": {
"user_idea": {
"type": "string"
},
"new_scene_description": {
"type": "string"
},
"new_image_type":{
"type": "string"
},
"new_style":{
"type": "string"
}
},
"required": [
"user_idea",
"new_scene_description",
"new_image_type",
"new_style"
]
}'''
return {
"required": {
"qwen_model": ("QWEN_MODEL",),
"prompt": ("STRING", {"default": "", "multiline": True}),
"system_message": ("STRING", {"default": "You are a helpful assistant.", "multiline": True}),
"temperature": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 2.0, "step": 0.05}),
"max_tokens": ("INT", {"default": 256, "min": 1, "max": 2048}),
"top_p": ("FLOAT", {"default": 0.001, "min": 0.0, "max": 1.0, "step": 0.001}),
"repetition_penalty": ("FLOAT", {"default": 1.05, "min": 1.0, "max": 2.0, "step": 0.01}),
"min_pixels": ("INT", {"default": 224, "min": 16, "max": 1280}),
"max_pixels": ("INT", {"default": 1280, "min": 224, "max": 2048}),
"enable_json": ("BOOLEAN", {"default": False}),
"json_format": ("STRING", {"default": default_format, "multiline": True}),
"result_template": ("STRING", {
"default": "{json[new_scene_description]} in style {json[new_style]}, {json[new_image_type]}",
"multiline": True
}),
},
"optional": {
"image": ("IMAGE",),
}
}
RETURN_TYPES = ("STRING", "STRING",)
RETURN_NAMES = ("response", "formatted_response")
FUNCTION = "generate"
CATEGORY = "AQ/LLM"
def format_response(self, response, enable_json, json_format, result_template):
if not enable_json:
return response, response
try:
# First try to parse the response as JSON
try:
json_response = json.loads(response)
except json.JSONDecodeError:
# If response is not JSON, it means the model didn't follow the format
print("Response is not valid JSON, returning as is")
return response, response
# If result_template is provided, format using it
if result_template:
try:
formatted_response = result_template.format(json=json_response)
return response, formatted_response
except KeyError as e:
error_msg = f"Error: Missing required field in JSON response: {str(e)}"
print(error_msg)
return response, error_msg
except Exception as e:
error_msg = f"Error formatting template: {str(e)}"
print(error_msg)
return response, error_msg
return response, response
except Exception as e:
print(f"Error in JSON formatting: {str(e)}")
return response, response
def generate(self, qwen_model, prompt, system_message, temperature=0.1,
max_tokens=256, top_p=0.001, repetition_penalty=1.05,
min_pixels=224, max_pixels=1280, enable_json=False,
json_format="", result_template="", image=None):
try:
min_pixels = min_pixels * min_pixels
max_pixels = max_pixels * max_pixels
content = []
# Handle system message
if system_message:
messages = [{"role": "system", "content": system_message}]
else:
messages = []
# Handle image if provided
user_content = []
if image is not None:
# Convert tensor to numpy array
if isinstance(image, torch.Tensor):
image = image.cpu().numpy()
# Handle ComfyUI image format (B, H, W, C)
if len(image.shape) == 4:
image = image[0] # Take first image from batch
# Ensure proper type and range for PIL
if image.dtype != np.uint8:
if image.max() <= 1:
image = (image * 255).astype(np.uint8)
else:
image = image.astype(np.uint8)
# Convert numpy array to PIL Image and save temporarily
img = Image.fromarray(image)
image_path = Path(folder_paths.temp_directory) / "temp_qwen_image.png"
img.save(str(image_path))
# Add image to content
user_content.append({
"type": "image",
"image": f"file://{image_path.resolve().as_posix()}",
"min_pixels": min_pixels,
"max_pixels": max_pixels,
})
# Modify prompt if JSON is enabled
if enable_json and json_format:
try:
schema = json.loads(json_format)
prompt = f"""Please provide your response in the following JSON format:
{json.dumps(schema, indent=2)}
{prompt}"""
except json.JSONDecodeError:
print("Invalid JSON format provided, using prompt as is")
# Add prompt
user_content.append({"type": "text", "text": prompt})
messages.append({"role": "user", "content": user_content})
# Process with Qwen's template
model_text = qwen_model["processor"].apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
# Process vision info
from qwen_vl_utils import process_vision_info
image_inputs, video_inputs, video_kwargs = process_vision_info(
messages,
return_video_kwargs=True
)
# Prepare inputs
inputs = qwen_model["processor"](
text=[model_text],
images=image_inputs,
padding=True,
return_tensors="pt",
**video_kwargs,
)
# Move to correct device
inputs = inputs.to(qwen_model["model"].device)
# Generate response
generated_ids = qwen_model["model"].generate(
**inputs,
max_new_tokens=max_tokens,
do_sample=True,
temperature=temperature,
top_p=top_p,
repetition_penalty=repetition_penalty,
pad_token_id=qwen_model["processor"].tokenizer.pad_token_id,
eos_token_id=qwen_model["processor"].tokenizer.eos_token_id,
)
# Process output
generated_ids_trimmed = [
out_ids[len(in_ids):]
for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
]
response = qwen_model["processor"].batch_decode(
generated_ids_trimmed,
skip_special_tokens=True,
clean_up_tokenization_spaces=False,
)[0]
# Format response if needed
return self.format_response(response, enable_json, json_format, result_template)
except Exception as e:
print(f"Error in Qwen generation: {str(e)}")
return ("")
finally:
# Clean up temp files
if image is not None and 'image_path' in locals() and image_path.exists():
image_path.unlink()
+14
View File
@@ -0,0 +1,14 @@
huggingface_hub
accelerate
insightface
onnxruntime-gpu
facexlib
segment-anything
dill
qwen-vl-utils
qwen-vl-utils[decord]
# for gemini nodes
google-generativeai>=0.3.2
google-genai
requests>=2.31.0