Download ComfyUI_INSTARAW/nodes/api_nodes/creative_api.py from MarkLilly/mis-custom-nodes: direct link, hf CLI and curl.
- Browser
- Download file 45 kB
-
https://huggingface.co/MarkLilly/mis-custom-nodes/resolve/main/ComfyUI_INSTARAW/nodes/api_nodes/creative_api.py
- Command line
-
hf download hf://MarkLilly/mis-custom-nodes/ComfyUI_INSTARAW/nodes/api_nodes/creative_api.py
-
curl -L -o creative_api.py https://huggingface.co/MarkLilly/mis-custom-nodes/resolve/main/ComfyUI_INSTARAW/nodes/api_nodes/creative_api.py
45 kB
| # --- | |
| # Filename: ../combined_test_codex/ComfyUI_INSTARAW/nodes/api_nodes/creative_api.py | |
| # --- | |
| # --- | |
| # Filename: ../ComfyUI_INSTARAW/creative_api.py | |
| # Creative Prompt Generation API - Gemini & Grok Integration | |
| # --- | |
| """ | |
| Backend API endpoint for creative prompt generation using Gemini and Grok APIs. | |
| Supports both inspiration-based generation and character-consistent generation. | |
| """ | |
| import os | |
| import json | |
| import hashlib | |
| import aiohttp | |
| from aiohttp import web | |
| from server import PromptServer | |
| CORS_HEADERS = { | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Methods": "POST, OPTIONS", | |
| "Access-Control-Allow-Headers": "Content-Type", | |
| } | |
| # === Gemini Integration === | |
| async def generate_with_gemini(system_prompt, user_prompt, model="gemini-2.5-pro", api_key=None, temperature=0.9, top_p=0.9, images=None): | |
| """ | |
| Generate creative prompts using Google Gemini API. | |
| Returns a list of {positive, negative, tags} dictionaries. | |
| Uses the NEW Google Genai SDK pattern (matching gemini_native.py). | |
| Args: | |
| images: List of base64-encoded image strings (for vision/img2img mode) | |
| """ | |
| try: | |
| from google import genai | |
| from google.genai import types | |
| import base64 | |
| except ImportError: | |
| raise ImportError("The 'google-genai' library is required. Run: pip install -U google-genai") | |
| # Use provided API key or fall back to environment variable | |
| if not api_key or api_key.strip() == "": | |
| api_key = os.environ.get("GEMINI_API_KEY") | |
| if not api_key or api_key.strip() == "": | |
| raise ValueError("Gemini API Key is missing. Provide it in the node or set GEMINI_API_KEY env var.") | |
| try: | |
| # NEW SDK pattern: use Client (matching gemini_native.py) | |
| client = genai.Client(api_key=api_key) | |
| # Build parts list (text + optional images) | |
| parts = [types.Part.from_text(text=f"{system_prompt}\n\n{user_prompt}")] | |
| # Add images if provided | |
| if images and len(images) > 0: | |
| for img_base64 in images: | |
| try: | |
| # Decode base64 to bytes | |
| image_bytes = base64.b64decode(img_base64) | |
| parts.append(types.Part.from_bytes(data=image_bytes, mime_type='image/png')) | |
| print(f"[RPG Creative API] Added image to Gemini vision request (base64 length: {len(img_base64)})") | |
| except Exception as img_error: | |
| print(f"[RPG Creative API] Failed to decode image: {img_error}") | |
| contents = [types.Content(role="user", parts=parts)] | |
| # Safety settings | |
| safety_settings = [ | |
| types.SafetySetting(category=cat, threshold="BLOCK_NONE") | |
| for cat in ["HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_DANGEROUS_CONTENT", | |
| "HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_SEXUALLY_EXPLICIT"] | |
| ] | |
| # Generation config (matching gemini_native.py pattern) | |
| config = types.GenerateContentConfig( | |
| temperature=temperature, | |
| top_p=top_p, | |
| candidate_count=1, | |
| safety_settings=safety_settings, | |
| response_mime_type="application/json" | |
| ) | |
| # Call API (synchronous call, but wrapped in async function) | |
| response = client.models.generate_content( | |
| model=f"models/{model}", | |
| contents=contents, | |
| config=config | |
| ) | |
| if not response.candidates: | |
| raise Exception("Gemini returned no candidates (likely blocked by safety filters)") | |
| # Parse JSON response | |
| result_text = response.text | |
| return parse_prompt_json(result_text) | |
| except Exception as e: | |
| print(f"[RPG Creative API] Gemini error: {e}") | |
| raise | |
| # === Grok Integration === | |
| async def generate_with_grok(system_prompt, user_prompt, model="grok-4", api_key=None, temperature=0.9, top_p=0.9, images=None): | |
| """ | |
| Generate creative prompts using xAI Grok API. | |
| Returns a list of {positive, negative, tags} dictionaries. | |
| Args: | |
| images: List of base64-encoded image strings (for vision/img2img mode) | |
| """ | |
| # Use provided API key or fall back to environment variable | |
| if not api_key or api_key.strip() == "": | |
| api_key = os.environ.get("XAI_API_KEY") | |
| if not api_key or api_key.strip() == "": | |
| raise ValueError("Grok API Key is missing. Provide it in the node or set XAI_API_KEY env var.") | |
| try: | |
| base_url = os.environ.get("XAI_API_BASE", "https://api.x.ai") | |
| url = f"{base_url.rstrip('/')}/v1/chat/completions" | |
| # Build user message content (text + optional images) | |
| if images and len(images) > 0: | |
| # Vision mode: multimodal content array | |
| user_content = [ | |
| {"type": "text", "text": user_prompt} | |
| ] | |
| for img_base64 in images: | |
| try: | |
| # Grok vision API expects base64 images in this format | |
| user_content.append({ | |
| "type": "image_url", | |
| "image_url": { | |
| "url": f"data:image/png;base64,{img_base64}" | |
| } | |
| }) | |
| print(f"[RPG Creative API] Added image to Grok vision request (base64 length: {len(img_base64)})") | |
| except Exception as img_error: | |
| print(f"[RPG Creative API] Failed to add image to Grok request: {img_error}") | |
| else: | |
| # Text-only mode | |
| user_content = user_prompt | |
| payload = { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_content}, | |
| ], | |
| "temperature": temperature, | |
| "top_p": top_p, | |
| "response_format": {"type": "json_object"}, | |
| } | |
| headers = { | |
| "Authorization": f"Bearer {api_key.strip()}", | |
| "Content-Type": "application/json", | |
| } | |
| timeout = aiohttp.ClientTimeout(total=300) | |
| async with aiohttp.ClientSession(timeout=timeout) as session: | |
| async with session.post(url, json=payload, headers=headers) as resp: | |
| body = await resp.text() | |
| if resp.status >= 400: | |
| raise RuntimeError(f"Grok API error {resp.status}: {body}") | |
| try: | |
| data = json.loads(body) | |
| except json.JSONDecodeError as e: | |
| raise RuntimeError(f"Grok API returned invalid JSON: {e}") | |
| choices = data.get("choices") or [] | |
| if not choices: | |
| raise RuntimeError("Grok API returned no choices") | |
| first_choice = choices[0] | |
| message = first_choice.get("message") or {} | |
| content = message.get("content") or first_choice.get("text", "") | |
| if not content: | |
| raise RuntimeError("Grok API returned empty content") | |
| return parse_prompt_json(content) | |
| except Exception as e: | |
| print(f"[RPG Creative API] Grok error: {e}") | |
| raise | |
| # === Helper Functions === | |
| def parse_prompt_json(text): | |
| """ | |
| Parse JSON array of prompts from API response. | |
| Handles markdown code blocks and extracts JSON. | |
| """ | |
| # Try to find JSON in markdown code blocks | |
| if "```json" in text: | |
| start = text.find("```json") + 7 | |
| end = text.find("```", start) | |
| text = text[start:end].strip() | |
| elif "```" in text: | |
| start = text.find("```") + 3 | |
| end = text.find("```", start) | |
| text = text[start:end].strip() | |
| try: | |
| data = json.loads(text) | |
| if isinstance(data, list): | |
| return data | |
| elif isinstance(data, dict) and "prompts" in data: | |
| return data["prompts"] | |
| else: | |
| return [data] | |
| except json.JSONDecodeError: | |
| # If JSON parsing fails, create a single entry from the text | |
| return [{ | |
| "positive": text.strip(), | |
| "negative": "", | |
| "tags": [] | |
| }] | |
| def build_system_prompt(is_sdxl=False, character_description="", generation_mode="img2img", affect_elements=None, user_text_input="", random_inspiration_prompts=None, generation_style="reality"): | |
| """ | |
| Build system prompt for creative generation. | |
| Args: | |
| is_sdxl: Whether to optimize for SDXL | |
| character_description: Detailed character description for consistency | |
| generation_mode: "img2img" or "txt2img" | |
| affect_elements: List of elements to modify (img2img only): ["background", "outfit", "pose", "lighting"] | |
| user_text_input: User's custom input (txt2img only) | |
| random_inspiration_prompts: List of random prompts for inspiration (txt2img only) | |
| generation_style: "reality" (strict adherence) or "creative" (flexible inspiration) | |
| """ | |
| base_prompt = """You are an expert AI prompt engineer specializing in creating high-quality, detailed prompts for REALISTIC photographic image generation models. | |
| IMPORTANT: All images are REALISTIC photography. Do not include artistic styles, illustrations, or non-photographic elements.""" | |
| if is_sdxl: | |
| base_prompt += "\n\nIMPORTANT: Generate prompts optimized for SDXL (Stable Diffusion XL). Use SDXL-specific quality tags like 'masterpiece', 'best quality', 'photorealistic', 'high resolution'." | |
| # Character consistency | |
| if character_description and character_description.strip(): | |
| base_prompt += f"\n\nCHARACTER CONSISTENCY: All prompts must accurately describe this character with exact physical features:\n{character_description}" | |
| # Mode-specific instructions | |
| if generation_mode == "img2img": | |
| base_prompt += "\n\nMODE: Image-to-Image Generation" | |
| if affect_elements and len(affect_elements) > 0: | |
| # Inclusion mode - only modify checked elements | |
| elements_str = ", ".join(affect_elements) | |
| base_prompt += f"\n\nIMPORTANT INSTRUCTION: You are modifying ONLY these elements: {elements_str}" | |
| base_prompt += "\n\nFor all OTHER elements NOT in this list, describe them EXACTLY as they appear in the input image. Do not change or modify them." | |
| base_prompt += f"\n\nElements to MODIFY creatively: {elements_str}" | |
| base_prompt += "\nElements to KEEP as-is: Everything else (describe exactly as shown)" | |
| else: | |
| # No checkboxes - describe as-is | |
| base_prompt += "\n\nIMPORTANT INSTRUCTION: Describe the input images EXACTLY as they appear. Create detailed, accurate descriptions that capture all visual elements faithfully." | |
| elif generation_mode == "txt2img": | |
| base_prompt += "\n\nMODE: Text-to-Image Generation (creating new prompts from scratch)" | |
| if random_inspiration_prompts and len(random_inspiration_prompts) > 0: | |
| if generation_style == "reality": | |
| # Reality Mode: Strict adherence to library prompts | |
| base_prompt += "\n\n🎯 REALITY MODE: You must ONLY use elements, words, and concepts from these reference prompts. Stay precise and constrained to what's provided:" | |
| for i, prompt in enumerate(random_inspiration_prompts[:5]): # Limit to 5 for context | |
| pos = prompt.get("prompt", {}).get("positive", "") | |
| tags = ", ".join(prompt.get("tags", [])[:5]) | |
| base_prompt += f"\n\nReference {i+1}:" | |
| base_prompt += f"\nPrompt: {pos[:200]}..." # Truncate long prompts | |
| base_prompt += f"\nTags: {tags}" | |
| base_prompt += "\n\nIMPORTANT: Your generated prompts should ONLY combine and rearrange elements from these references. Do not introduce new concepts or elements not present in these prompts." | |
| else: | |
| # Creative Mode: Flexible inspiration | |
| base_prompt += "\n\n✨ CREATIVE MODE: Use these prompts as creative inspiration for generating diverse, high-quality variations. Feel free to be flexible and creative:" | |
| for i, prompt in enumerate(random_inspiration_prompts[:5]): # Limit to 5 for context | |
| pos = prompt.get("prompt", {}).get("positive", "") | |
| tags = ", ".join(prompt.get("tags", [])[:5]) | |
| base_prompt += f"\n\nInspiration {i+1}:" | |
| base_prompt += f"\nPrompt: {pos[:200]}..." # Truncate long prompts | |
| base_prompt += f"\nTags: {tags}" | |
| base_prompt += "\n\nYou can be creative and add new elements while maintaining the overall style and quality of the inspiration prompts." | |
| if user_text_input and user_text_input.strip(): | |
| base_prompt += f"\n\nUSER INPUT: Incorporate this user guidance into your prompts:\n{user_text_input}" | |
| base_prompt += """\n\nOUTPUT FORMAT: You MUST return a valid JSON array of prompt objects. Do not include any other text or markdown. Each object must have these keys: | |
| - "positive": A string containing the detailed positive prompt for realistic photography | |
| - "negative": A string containing the negative prompt (avoid: unrealistic, illustration, painting, drawing, art, artistic, low quality, deformed, etc.) | |
| - "tags": An array of strings representing relevant tags | |
| Example: | |
| [ | |
| { | |
| "positive": "masterpiece, best quality, photorealistic, high resolution, professional photography, 1girl, blonde hair, blue eyes, smiling, park background, natural sunlight, bokeh, sharp focus", | |
| "negative": "unrealistic, illustration, painting, drawing, art, artistic, low quality, deformed, bad anatomy, blurry, amateur", | |
| "tags": ["portrait", "photorealistic", "outdoor", "natural_lighting"] | |
| } | |
| ] | |
| """ | |
| return base_prompt | |
| def build_user_prompt(generation_count, generation_mode="img2img", images_data=None): | |
| """ | |
| Build user prompt for creative generation. | |
| Args: | |
| generation_count: Number of prompts to generate | |
| generation_mode: "img2img" or "txt2img" | |
| images_data: For img2img, information about the images (optional) | |
| """ | |
| if generation_mode == "img2img": | |
| if images_data and len(images_data) > 0: | |
| return f"""Generate {generation_count} detailed, accurate prompts for the {len(images_data)} input image(s) provided. | |
| Follow the instructions in the system prompt regarding which elements to modify and which to keep as-is. | |
| Each prompt should: | |
| 1. Accurately describe all visual elements as specified | |
| 2. Maintain photorealistic quality | |
| 3. Be detailed and professional | |
| 4. Follow the modification rules (if any elements are specified to be changed) | |
| Generate {generation_count} unique prompt(s).""" | |
| else: | |
| return f"""Generate {generation_count} high-quality prompts for realistic photographic image generation based on the input images. | |
| Each prompt should be detailed, professional, and capture all visual elements accurately. | |
| Generate {generation_count} unique prompt(s).""" | |
| else: # txt2img | |
| return f"""Generate {generation_count} high-quality, creative prompts for realistic photographic image generation. | |
| Using the inspiration prompts and user input provided (if any), create diverse, professional prompts that: | |
| 1. Are detailed and vivid | |
| 2. Focus on realistic photographic elements | |
| 3. Include appropriate quality tags | |
| 4. Specify lighting, composition, and technical details | |
| 5. Are varied and creative while maintaining high quality | |
| Generate {generation_count} unique prompts.""" | |
| # === API Endpoint === | |
| async def _generate_creative_prompts(request): | |
| """ | |
| POST /instaraw/generate_creative_prompts | |
| Body: | |
| { | |
| // Legacy parameters (still supported) | |
| "source_prompts": [{id, prompt: {positive, negative}}, ...], | |
| "inspiration_count": 3, | |
| "character_reference": "", | |
| // New unified parameters | |
| "generation_count": 5, | |
| "is_sdxl": false, | |
| "model": "gemini-2.5-pro", | |
| "gemini_api_key": "", | |
| "grok_api_key": "", | |
| "temperature": 0.9, | |
| "top_p": 0.9, | |
| "force_regenerate": false, | |
| // Character likeness (NEW) | |
| "character_description": "Detailed character description...", | |
| "use_character_likeness": true, | |
| // Generation mode (NEW) | |
| "generation_mode": "img2img", // or "txt2img" | |
| // img2img parameters (NEW) | |
| "images": ["base64_image_data..."], // Images for vision models | |
| "affect_elements": ["background", "outfit"], // Which elements to modify | |
| // txt2img parameters (NEW) | |
| "random_inspiration_prompts": [{prompt, tags}, ...], // Random library prompts | |
| "user_text_input": "Custom user guidance..." | |
| } | |
| Returns: | |
| { | |
| "success": true, | |
| "prompts": [{positive, negative, tags}, ...] | |
| } | |
| """ | |
| try: | |
| data = await request.json() | |
| # Core parameters | |
| generation_count = int(data.get("generation_count", 5)) | |
| is_sdxl = bool(data.get("is_sdxl", False)) | |
| model = data.get("model", "gemini-2.5-pro") | |
| gemini_api_key = data.get("gemini_api_key", "") | |
| grok_api_key = data.get("grok_api_key", "") | |
| temperature = float(data.get("temperature", 0.9)) | |
| top_p = float(data.get("top_p", 0.9)) | |
| temperature = max(0.0, min(2.0, temperature)) | |
| top_p = max(0.0, min(1.0, top_p)) | |
| force_regenerate = bool(data.get("force_regenerate", False)) | |
| # NEW: Character likeness | |
| character_description = data.get("character_description", "") | |
| use_character_likeness = bool(data.get("use_character_likeness", False)) | |
| if not use_character_likeness: | |
| character_description = "" | |
| # NEW: Generation mode | |
| generation_mode = data.get("generation_mode", "img2img") | |
| # NEW: img2img parameters | |
| images = data.get("images", []) | |
| multi_images = data.get("multi_images", []) # New format: [{label, base64}, ...] | |
| affect_elements = data.get("affect_elements", []) | |
| # If multi_images provided, convert to images array for backward compatibility | |
| # Also build image labels description for the prompt | |
| image_labels_context = "" | |
| if multi_images and len(multi_images) > 0: | |
| images = [img.get("base64") for img in multi_images if img.get("base64")] | |
| labels = [img.get("label", f"image_{i+1}") for i, img in enumerate(multi_images)] | |
| if len(labels) > 1: | |
| image_labels_context = f"\n\nYou are provided with {len(labels)} reference images: {', '.join(labels)}. Consider all images when generating the prompt." | |
| print(f"[RPG Creative API] Multi-image mode: {len(multi_images)} images with labels: {labels}") | |
| # NEW: txt2img parameters | |
| random_inspiration_prompts = data.get("random_inspiration_prompts", []) | |
| user_text_input = data.get("user_text_input", "") | |
| # NEW: Generation style (Reality vs Creative) | |
| generation_style = data.get("generation_style", "reality") | |
| # Legacy support: convert old parameters to new format | |
| source_prompts = data.get("source_prompts", []) | |
| inspiration_count = int(data.get("inspiration_count", 0)) | |
| character_reference = data.get("character_reference", "") | |
| if character_reference and not character_description: | |
| character_description = character_reference | |
| use_character_likeness = True | |
| if source_prompts and len(source_prompts) > 0 and not random_inspiration_prompts: | |
| random_inspiration_prompts = source_prompts[:inspiration_count] if inspiration_count > 0 else [] | |
| # Custom system prompt override | |
| custom_system_prompt = (data.get("system_prompt") or "").strip() | |
| # Build prompts | |
| if custom_system_prompt: | |
| system_prompt = custom_system_prompt | |
| else: | |
| system_prompt = build_system_prompt( | |
| is_sdxl=is_sdxl, | |
| character_description=character_description, | |
| generation_mode=generation_mode, | |
| affect_elements=affect_elements, | |
| user_text_input=user_text_input, | |
| random_inspiration_prompts=random_inspiration_prompts, | |
| generation_style=generation_style | |
| ) | |
| user_prompt = build_user_prompt( | |
| generation_count=generation_count, | |
| generation_mode=generation_mode, | |
| images_data=images | |
| ) | |
| # Append multi-image context if available | |
| if image_labels_context: | |
| user_prompt = user_prompt + image_labels_context | |
| # Check cache (skip if force_regenerate is True) | |
| cache_key = hashlib.sha256( | |
| f"{system_prompt}_{user_prompt}_{model}_{temperature}_{top_p}".encode("utf-8") | |
| ).hexdigest() | |
| cache_dir = os.path.join(os.path.dirname(__file__), "..", "..", "cache") | |
| os.makedirs(cache_dir, exist_ok=True) | |
| cache_file = os.path.join(cache_dir, f"{cache_key}_creative.json") | |
| if not force_regenerate and os.path.exists(cache_file): | |
| print(f"[RPG Creative API] Using cached result: {cache_key[:8]}") | |
| with open(cache_file, 'r', encoding='utf-8') as f: | |
| prompts = json.load(f) | |
| return web.json_response({"success": True, "prompts": prompts}, headers=CORS_HEADERS) | |
| if force_regenerate: | |
| print(f"[RPG Creative API] Force regenerate enabled - bypassing cache for {cache_key[:8]}") | |
| # Generate with appropriate API | |
| print(f"[RPG Creative API] Generating {generation_count} prompts - Mode: {generation_mode}, Model: {model}") | |
| if use_character_likeness: | |
| print(f"[RPG Creative API] Using character likeness (description length: {len(character_description)} chars)") | |
| if generation_mode == "img2img" and affect_elements: | |
| print(f"[RPG Creative API] Affecting elements: {affect_elements}") | |
| if generation_mode == "txt2img": | |
| print(f"[RPG Creative API] Inspiration prompts: {len(random_inspiration_prompts)}, User input: {len(user_text_input)} chars") | |
| if images and len(images) > 0: | |
| if len(images) > 1: | |
| print(f"[RPG Creative API] 🖼️ Multi-image vision mode: {len(images)} images for combined prompt generation") | |
| else: | |
| print(f"[RPG Creative API] 🖼️ Processing {len(images)} image(s) for vision mode - First image base64 length: {len(images[0])} chars") | |
| if model.startswith("gemini"): | |
| prompts = await generate_with_gemini(system_prompt, user_prompt, model, gemini_api_key, temperature, top_p, images=images) | |
| elif model.startswith("grok"): | |
| prompts = await generate_with_grok(system_prompt, user_prompt, model, grok_api_key, temperature, top_p, images=images) | |
| else: | |
| raise ValueError(f"Unsupported model: {model}") | |
| # Cache result | |
| with open(cache_file, 'w', encoding='utf-8') as f: | |
| json.dump(prompts, f, indent=2) | |
| print(f"[RPG Creative API] Generated {len(prompts)} prompts with {model}") | |
| return web.json_response({"success": True, "prompts": prompts}, headers=CORS_HEADERS) | |
| except Exception as e: | |
| print(f"[RPG Creative API] Error: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return web.json_response({ | |
| "success": False, | |
| "error": str(e) | |
| }, status=500, headers=CORS_HEADERS) | |
| async def generate_creative_prompts_endpoint(request): | |
| return await _generate_creative_prompts(request) | |
| async def generate_creative_prompts_endpoint_slash(request): | |
| return await _generate_creative_prompts(request) | |
| async def generate_creative_prompts_options(request): | |
| return web.Response(headers=CORS_HEADERS) | |
| # === Character Description Generation === | |
| # In-memory cache for character descriptions | |
| CHARACTER_DESCRIPTION_CACHE = {} | |
| def get_character_system_prompt(complexity="balanced"): | |
| """ | |
| Generate system prompt based on complexity level. | |
| Complexity levels: concise (50-75 words), balanced (100-150 words), detailed (200-250 words) | |
| """ | |
| base_instruction = """You are an expert at analyzing images and generating character descriptions for image generation prompts. | |
| Generate a character description focusing on PERMANENT physical features: | |
| - Facial features (face shape, eyes, nose, lips, skin tone) | |
| - Hair (color, length, style, texture) | |
| - Body type and build | |
| - Age and ethnicity | |
| - Distinctive features (scars, tattoos, piercings, etc.) | |
| DO NOT include clothing, background, pose, or temporary features. | |
| DO NOT use tags like "1girl, solo" or similar categorization prefixes.""" | |
| if complexity == "concise": | |
| length_instruction = "\nOUTPUT: A concise description (50-75 words) focusing only on the most essential and distinctive physical features." | |
| elif complexity == "detailed": | |
| length_instruction = "\nOUTPUT: A comprehensive, detailed description (200-250 words) covering all physical aspects with nuanced detail and specific characteristics." | |
| else: # balanced | |
| length_instruction = "\nOUTPUT: A balanced description (100-150 words) covering key physical features in natural language." | |
| return base_instruction + length_instruction | |
| async def generate_character_description_with_gemini(user_prompt, model="gemini-2.5-pro", api_key=None, character_image=None, complexity="balanced", custom_system_prompt=None, temperature=0.7, top_p=0.9): | |
| """ | |
| Generate plain text character description using Gemini. | |
| Returns raw text, not JSON. | |
| Uses the NEW Google Genai SDK pattern (matching gemini_native.py) | |
| """ | |
| try: | |
| from google import genai | |
| from google.genai import types | |
| import base64 | |
| except ImportError: | |
| raise ImportError("The 'google-genai' library is required. Run: pip install -U google-genai") | |
| if not api_key or api_key.strip() == "": | |
| api_key = os.environ.get("GEMINI_API_KEY") | |
| if not api_key or api_key.strip() == "": | |
| raise ValueError("Gemini API Key is missing") | |
| try: | |
| # NEW SDK pattern: use Client instead of configure() | |
| client = genai.Client(api_key=api_key) | |
| # Use custom system prompt if provided, otherwise generate based on complexity | |
| if custom_system_prompt: | |
| system_instruction = custom_system_prompt | |
| else: | |
| system_instruction = get_character_system_prompt(complexity) | |
| # Build parts list (text + optional image) | |
| parts = [types.Part.from_text(text=f"{system_instruction}\n\n{user_prompt}")] | |
| # Add image if provided (matching gemini_native.py pattern) | |
| if character_image: | |
| # Extract base64 data (remove data URL prefix if present) | |
| if character_image.startswith("data:"): | |
| base64_data = character_image.split(",", 1)[1] | |
| else: | |
| base64_data = character_image | |
| # Decode base64 to bytes | |
| image_bytes = base64.b64decode(base64_data) | |
| parts.append(types.Part.from_bytes(data=image_bytes, mime_type='image/png')) | |
| contents = [types.Content(role="user", parts=parts)] | |
| # Safety settings | |
| safety_settings = [ | |
| types.SafetySetting(category=cat, threshold="BLOCK_NONE") | |
| for cat in ["HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_DANGEROUS_CONTENT", | |
| "HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_SEXUALLY_EXPLICIT"] | |
| ] | |
| # Generation config with thinking support | |
| # Gemini 3.0 uses thinking_level (HIGH/LOW), Gemini 2.5 uses thinking_budget | |
| if model == "gemini-3-pro-preview": | |
| # Gemini 3.0 format | |
| thinking_config = types.ThinkingConfig(thinking_level="HIGH") | |
| elif model == "gemini-2.5-pro": | |
| # Gemini 2.5 format (unlimited thinking) | |
| thinking_config = types.ThinkingConfig(thinking_budget=-1) | |
| else: | |
| # No thinking for other models | |
| thinking_config = None | |
| config_params = { | |
| "temperature": temperature, | |
| "top_p": top_p, | |
| "candidate_count": 1, | |
| "safety_settings": safety_settings | |
| } | |
| # Only add thinking_config if it's not None | |
| if thinking_config: | |
| config_params["thinking_config"] = thinking_config | |
| config = types.GenerateContentConfig(**config_params) | |
| # NEW SDK pattern: use client.models.generate_content() | |
| response = client.models.generate_content( | |
| model=f"models/{model}", | |
| contents=contents, | |
| config=config | |
| ) | |
| if not response.candidates: | |
| raise Exception("Gemini returned no candidates (likely blocked by safety filters)") | |
| # Return raw text, not JSON | |
| return response.text.strip() | |
| except Exception as e: | |
| print(f"[RPG Character API] Gemini error: {e}") | |
| raise | |
| async def generate_character_description_with_grok(user_prompt, model="grok-4-fast-reasoning", api_key=None, character_image=None, complexity="balanced", custom_system_prompt=None, temperature=0.7, top_p=0.9): | |
| """ | |
| Generate plain text character description using Grok. | |
| Supports vision like grok_native.py | |
| Returns raw text, not JSON. | |
| """ | |
| if not api_key or api_key.strip() == "": | |
| api_key = os.environ.get("XAI_API_KEY") | |
| if not api_key or api_key.strip() == "": | |
| raise ValueError("Grok API Key is missing") | |
| try: | |
| base_url = os.environ.get("XAI_API_BASE", "https://api.x.ai").rstrip("/") | |
| url = f"{base_url}/v1/chat/completions" | |
| # Use custom system prompt if provided, otherwise generate based on complexity | |
| if custom_system_prompt: | |
| system_instruction = custom_system_prompt | |
| else: | |
| system_instruction = get_character_system_prompt(complexity) | |
| # Build messages array (like grok_native.py) | |
| messages = [ | |
| {"role": "system", "content": system_instruction} | |
| ] | |
| # Build user content (text + image if provided) | |
| user_content = [ | |
| {"type": "text", "text": user_prompt} | |
| ] | |
| # Add image if provided (same format as grok_native.py line 127-132) | |
| if character_image: | |
| # character_image is already base64 from JavaScript | |
| # Extract just the base64 part (remove "data:image/png;base64," prefix if present) | |
| if character_image.startswith("data:"): | |
| base64_data = character_image.split(",", 1)[1] | |
| else: | |
| base64_data = character_image | |
| user_content.append({ | |
| "type": "image_url", | |
| "image_url": { | |
| "url": f"data:image/png;base64,{base64_data}", | |
| "detail": "high" | |
| } | |
| }) | |
| messages.append({"role": "user", "content": user_content}) | |
| payload = { | |
| "model": model, | |
| "messages": messages, | |
| "temperature": temperature, | |
| "top_p": top_p, | |
| } | |
| headers = { | |
| "Authorization": f"Bearer {api_key}", | |
| "Content-Type": "application/json", | |
| } | |
| timeout = aiohttp.ClientTimeout(total=300) | |
| async with aiohttp.ClientSession(timeout=timeout) as session: | |
| async with session.post(url, json=payload, headers=headers) as resp: | |
| body = await resp.text() | |
| if resp.status >= 400: | |
| raise RuntimeError(f"Grok API error {resp.status}: {body}") | |
| try: | |
| data = json.loads(body) | |
| except json.JSONDecodeError as e: | |
| raise RuntimeError(f"Grok API returned invalid JSON: {e}") | |
| choices = data.get("choices") or [] | |
| if not choices: | |
| raise RuntimeError("Grok API returned no choices") | |
| first_choice = choices[0] | |
| message = first_choice.get("message") or {} | |
| content = message.get("content") | |
| # Handle different response formats | |
| if isinstance(content, list): | |
| text = "".join( | |
| part.get("text", "") if isinstance(part, dict) else str(part) | |
| for part in content | |
| ).strip() | |
| if text: | |
| return text | |
| elif isinstance(content, str) and content.strip(): | |
| return content.strip() | |
| # Fallback | |
| legacy = first_choice.get("text") | |
| if legacy: | |
| return legacy.strip() | |
| raise RuntimeError("Unable to extract text from Grok response") | |
| except Exception as e: | |
| print(f"[RPG Character API] Grok error: {e}") | |
| raise | |
| def build_character_description_prompt(): | |
| """Build system prompt for character description generation.""" | |
| return """You are an expert at analyzing images and generating detailed character descriptions for image generation prompts. | |
| Your task is to generate a comprehensive, detailed character description that can be used consistently across multiple image generation prompts. | |
| IMPORTANT: Focus on PERMANENT, CONSISTENT physical features that define the character: | |
| - Facial features (face shape, eyes, nose, lips, skin tone) | |
| - Hair (color, length, style, texture) | |
| - Body type and build | |
| - Age and ethnicity | |
| - Distinctive features (scars, tattoos, piercings, etc.) | |
| DO NOT include: | |
| - Clothing or outfit (this varies per image) | |
| - Background or setting | |
| - Pose or action | |
| - Temporary features (makeup, accessories) | |
| OUTPUT FORMAT: Return a single detailed paragraph (100-150 words) describing the character's permanent physical features. This description will be inserted into other prompts to ensure character consistency. | |
| Example output: | |
| "A young woman in her mid-20s with an athletic build and olive skin tone. She has striking almond-shaped green eyes, high cheekbones, and full lips. Her dark brown hair falls in natural waves to shoulder length with subtle copper highlights. She has a defined jawline, straight nose, and arched eyebrows. Her features suggest Mediterranean heritage. She stands approximately 5'7" with a toned, balanced physique and confident posture." | |
| """ | |
| async def _generate_character_description(request): | |
| """ | |
| POST /instaraw/generate_character_description | |
| Body: | |
| { | |
| "character_image": "base64_string_or_null", | |
| "character_text": "manual_description_or_null", | |
| "model": "gemini-2.5-pro", | |
| "temperature": 0.7, | |
| "top_p": 0.9, | |
| "gemini_api_key": "", | |
| "grok_api_key": "", | |
| "force_regenerate": false | |
| } | |
| Returns: | |
| { | |
| "success": true, | |
| "description": "Detailed character description...", | |
| "cached": false, | |
| "cache_key": "hash_of_inputs" | |
| } | |
| """ | |
| try: | |
| data = await request.json() | |
| character_image = data.get("character_image") | |
| character_text = data.get("character_text") | |
| model = data.get("model", "gemini-2.5-pro") | |
| temperature = float(data.get("temperature", 0.7)) | |
| top_p = float(data.get("top_p", 0.9)) | |
| gemini_api_key = data.get("gemini_api_key", "") | |
| grok_api_key = data.get("grok_api_key", "") | |
| force_regenerate = bool(data.get("force_regenerate", False)) | |
| complexity = data.get("complexity", "balanced") | |
| custom_system_prompt = data.get("custom_system_prompt", "") | |
| # Validation | |
| if not character_image and not character_text: | |
| return web.json_response({ | |
| "success": False, | |
| "error": "Either character_image or character_text must be provided" | |
| }, status=400, headers=CORS_HEADERS) | |
| # Generate cache key (include complexity and custom prompt in key) | |
| cache_input = character_image if character_image else character_text | |
| cache_key = hashlib.sha256(f"{cache_input}_{model}_{complexity}_{custom_system_prompt}".encode("utf-8")).hexdigest() | |
| # Check cache | |
| if not force_regenerate and cache_key in CHARACTER_DESCRIPTION_CACHE: | |
| print(f"[RPG Character API] Using cached character description: {cache_key[:8]}") | |
| return web.json_response({ | |
| "success": True, | |
| "description": CHARACTER_DESCRIPTION_CACHE[cache_key], | |
| "cached": True, | |
| "cache_key": cache_key | |
| }, headers=CORS_HEADERS) | |
| # Build user prompt | |
| if character_image: | |
| user_prompt = "Analyze this image and generate a detailed character description following the instructions." | |
| else: | |
| user_prompt = f"Enhance this character description into a detailed, structured format suitable for image generation:\n\n{character_text}" | |
| # Generate description using dedicated function | |
| print(f"[RPG Character API] Generating character description with {model} (complexity: {complexity})...") | |
| if model.startswith("gemini"): | |
| description = await generate_character_description_with_gemini( | |
| user_prompt, | |
| model=model, | |
| api_key=gemini_api_key, | |
| character_image=character_image, | |
| complexity=complexity, | |
| custom_system_prompt=custom_system_prompt if custom_system_prompt else None, | |
| temperature=temperature, | |
| top_p=top_p | |
| ) | |
| elif model.startswith("grok"): | |
| description = await generate_character_description_with_grok( | |
| user_prompt, | |
| model=model, | |
| api_key=grok_api_key, | |
| character_image=character_image, | |
| complexity=complexity, | |
| custom_system_prompt=custom_system_prompt if custom_system_prompt else None, | |
| temperature=temperature, | |
| top_p=top_p | |
| ) | |
| else: | |
| raise ValueError(f"Unsupported model: {model}") | |
| if not description or description.strip() == "": | |
| raise ValueError("Generated description is empty. API may have failed.") | |
| # Cache the description | |
| CHARACTER_DESCRIPTION_CACHE[cache_key] = description | |
| print(f"[RPG Character API] ✅ Generated character description ({len(description)} chars)") | |
| return web.json_response({ | |
| "success": True, | |
| "description": description, | |
| "cached": False, | |
| "cache_key": cache_key | |
| }, headers=CORS_HEADERS) | |
| except Exception as e: | |
| print(f"[RPG Character API] Error: {e}") | |
| return web.json_response({ | |
| "success": False, | |
| "error": str(e) | |
| }, status=500, headers=CORS_HEADERS) | |
| # === Random Prompt Selection === | |
| PROMPTS_DB_CACHE = None | |
| PROMPTS_DB_URL = "https://instara.s3.us-east-1.amazonaws.com/prompts.db.json" | |
| async def load_prompts_database(): | |
| """Load and cache the prompts database from remote URL.""" | |
| global PROMPTS_DB_CACHE | |
| if PROMPTS_DB_CACHE is not None: | |
| return PROMPTS_DB_CACHE | |
| print("[RPG Prompts API] Loading prompts database from remote URL...") | |
| try: | |
| timeout = aiohttp.ClientTimeout(total=60) | |
| async with aiohttp.ClientSession(timeout=timeout) as session: | |
| async with session.get(PROMPTS_DB_URL) as resp: | |
| if resp.status >= 400: | |
| raise RuntimeError(f"Failed to load prompts database: HTTP {resp.status}") | |
| data = await resp.json() | |
| PROMPTS_DB_CACHE = data | |
| print(f"[RPG Prompts API] Loaded {len(data)} prompts from database") | |
| return data | |
| except Exception as e: | |
| print(f"[RPG Prompts API] Error loading database: {e}") | |
| raise | |
| async def _get_random_prompts(request): | |
| """ | |
| POST /instaraw/get_random_prompts | |
| Body: | |
| { | |
| "count": 5, | |
| "filters": { | |
| "content_type": "person", // optional | |
| "safety_level": "sfw", // optional | |
| "shot_type": "portrait" // optional | |
| } | |
| } | |
| Returns: | |
| { | |
| "success": true, | |
| "prompts": [ | |
| { | |
| "id": "...", | |
| "tags": ["..."], | |
| "prompt": { | |
| "positive": "...", | |
| "negative": "..." | |
| }, | |
| "classification": {...} | |
| } | |
| ] | |
| } | |
| """ | |
| try: | |
| import random | |
| data = await request.json() | |
| count = int(data.get("count", 5)) | |
| filters = data.get("filters", {}) | |
| # Load database | |
| prompts_db = await load_prompts_database() | |
| # Apply filters | |
| filtered_prompts = prompts_db | |
| if "content_type" in filters and filters["content_type"]: | |
| content_type = filters["content_type"] | |
| filtered_prompts = [ | |
| p for p in filtered_prompts | |
| if p.get("classification", {}).get("content_type") == content_type | |
| ] | |
| if "safety_level" in filters and filters["safety_level"]: | |
| safety_level = filters["safety_level"] | |
| filtered_prompts = [ | |
| p for p in filtered_prompts | |
| if p.get("classification", {}).get("safety_level") == safety_level | |
| ] | |
| if "shot_type" in filters and filters["shot_type"]: | |
| shot_type = filters["shot_type"] | |
| filtered_prompts = [ | |
| p for p in filtered_prompts | |
| if p.get("classification", {}).get("shot_type") == shot_type | |
| ] | |
| # Random selection | |
| if len(filtered_prompts) == 0: | |
| return web.json_response({ | |
| "success": False, | |
| "error": "No prompts match the specified filters" | |
| }, status=400, headers=CORS_HEADERS) | |
| selected_count = min(count, len(filtered_prompts)) | |
| selected_prompts = random.sample(filtered_prompts, selected_count) | |
| print(f"[RPG Prompts API] Selected {selected_count} random prompts from {len(filtered_prompts)} filtered") | |
| return web.json_response({ | |
| "success": True, | |
| "prompts": selected_prompts | |
| }, headers=CORS_HEADERS) | |
| except Exception as e: | |
| print(f"[RPG Prompts API] Error: {e}") | |
| return web.json_response({ | |
| "success": False, | |
| "error": str(e) | |
| }, status=500, headers=CORS_HEADERS) | |
| # === Register New Endpoints === | |
| async def generate_character_description_endpoint(request): | |
| return await _generate_character_description(request) | |
| async def generate_character_description_endpoint_slash(request): | |
| return await _generate_character_description(request) | |
| async def generate_character_description_options(request): | |
| return web.Response(headers=CORS_HEADERS) | |
| async def get_random_prompts_endpoint(request): | |
| return await _get_random_prompts(request) | |
| async def get_random_prompts_endpoint_slash(request): | |
| return await _get_random_prompts(request) | |
| async def get_random_prompts_options(request): | |
| return web.Response(headers=CORS_HEADERS) | |
| print("[RPG Creative API] Endpoint registered: POST /instaraw/generate_creative_prompts") | |
| print("[RPG Character API] Endpoint registered: POST /instaraw/generate_character_description") | |
| print("[RPG Prompts API] Endpoint registered: POST /instaraw/get_random_prompts") | |
| # Add these lines to the end of the file | |
| NODE_CLASS_MAPPINGS = {} | |
| NODE_DISPLAY_NAME_MAPPINGS = {} |