import logging import time import traceback from pathlib import Path from typing import Optional, Tuple, Dict, Any, List import cv2 import gradio as gr import numpy as np from PIL import Image from css_styles import CSSStyles from scene_templates import SceneTemplateManager from inpainting_templates import InpaintingTemplateManager from scene_weaver_core import SceneWeaverCore from gpu_handlers import GPUHandlers logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(name)s] %(levelname)s: %(message)s', datefmt='%H:%M:%S' ) class UIManager: """ Gradio UI Manager with support for background generation and inpainting. Provides a professional interface with mode switching, template selection, and advanced parameter controls. GPU operations are delegated to GPUHandlers. Attributes: gpu_handlers: GPUHandlers instance for GPU operations template_manager: Scene template manager inpainting_template_manager: Inpainting template manager """ def __init__(self): self.sceneweaver = SceneWeaverCore() self.gpu_handlers = GPUHandlers( core=self.sceneweaver, inpainting_template_manager=InpaintingTemplateManager() ) self.template_manager = SceneTemplateManager() self.inpainting_template_manager = InpaintingTemplateManager() self.generation_history = [] self.inpainting_history = [] self._preview_sensitivity = 0.5 self._current_mode = "background" # "background" or "inpainting" def apply_template(self, display_name: str, current_negative: str) -> Tuple[str, str, float]: """ Apply a scene template to the prompt fields. Args: display_name: The display name from dropdown (e.g., "🏢 Modern Office") current_negative: Current negative prompt value Returns: Tuple of (prompt, negative_prompt, guidance_scale) """ if not display_name: return "", current_negative, 7.5 # Convert display name to template key template_key = self.template_manager.get_template_key_from_display(display_name) if not template_key: return "", current_negative, 7.5 template = self.template_manager.get_template(template_key) if template: prompt = template.prompt negative = self.template_manager.get_negative_prompt_for_template( template_key, current_negative ) guidance = template.guidance_scale return prompt, negative, guidance return "", current_negative, 7.5 def quick_preview( self, uploaded_image: Optional[Image.Image], sensitivity: float = 0.5 ) -> Optional[Image.Image]: """ Generate quick foreground preview using lightweight traditional methods. Args: uploaded_image: Uploaded PIL Image sensitivity: Detection sensitivity (0.0 - 1.0) Returns: Preview image with colored overlay or None """ if uploaded_image is None: return None try: logger.info(f"Generating quick preview (sensitivity={sensitivity:.2f})") img_array = np.array(uploaded_image.convert('RGB')) height, width = img_array.shape[:2] max_preview_size = 512 if max(width, height) > max_preview_size: scale = max_preview_size / max(width, height) new_w = int(width * scale) new_h = int(height * scale) img_array = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_AREA) height, width = new_h, new_w gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0) low_threshold = int(30 + (1 - sensitivity) * 50) high_threshold = int(100 + (1 - sensitivity) * 100) edges = cv2.Canny(blurred, low_threshold, high_threshold) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) dilated = cv2.dilate(edges, kernel, iterations=2) contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) mask = np.zeros((height, width), dtype=np.uint8) if contours: sorted_contours = sorted(contours, key=cv2.contourArea, reverse=True) min_area = (width * height) * 0.01 * (1 - sensitivity) for contour in sorted_contours: if cv2.contourArea(contour) > min_area: cv2.fillPoly(mask, [contour], 255) kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_close) overlay = img_array.copy().astype(np.float32) fg_mask = mask > 127 overlay[fg_mask] = overlay[fg_mask] * 0.5 + np.array([0, 255, 0]) * 0.5 bg_mask = mask <= 127 overlay[bg_mask] = overlay[bg_mask] * 0.5 + np.array([255, 0, 0]) * 0.5 overlay = np.clip(overlay, 0, 255).astype(np.uint8) original_size = uploaded_image.size preview_image = Image.fromarray(overlay) if preview_image.size != original_size: preview_image = preview_image.resize(original_size, Image.LANCZOS) logger.info("Quick preview generated successfully") return preview_image except Exception as e: logger.error(f"Quick preview failed: {e}") return None def _save_result(self, combined_image: Image.Image, prompt: str): """Save result with memory-conscious history management""" if not combined_image: return output_dir = Path("outputs") output_dir.mkdir(exist_ok=True) combined_image.save(output_dir / "latest_combined.png") self.generation_history.append({ "prompt": prompt, "timestamp": time.time() }) max_history = self.sceneweaver.max_history if len(self.generation_history) > max_history: self.generation_history = self.generation_history[-max_history:] def generate_handler( self, uploaded_image: Optional[Image.Image], prompt: str, combination_mode: str, focus_mode: str, negative_prompt: str, steps: int, guidance: float, progress=gr.Progress() ): """ Generation handler - delegates GPU work to GPUHandlers. Parameters ---------- uploaded_image : PIL.Image Input image prompt : str Background description combination_mode : str Composition mode focus_mode : str Focus mode negative_prompt : str Negative prompt steps : int Inference steps guidance : float Guidance scale progress : gr.Progress Progress callback Returns ------- tuple (combined, generated, original, status, download_btn_update) """ if uploaded_image is None: return None, None, None, "Please upload an image to get started!", gr.update(visible=False) if not prompt.strip(): return None, None, None, "Please describe the background scene you'd like!", gr.update(visible=False) try: def progress_callback(msg: str, pct: int): progress(pct / 100, desc=msg) # Delegate to GPUHandlers result = self.gpu_handlers.background_generate( image=uploaded_image, prompt=prompt, negative_prompt=negative_prompt, composition_mode=combination_mode, focus_mode=focus_mode, num_steps=int(steps), guidance_scale=float(guidance), progress_callback=progress_callback ) if result["success"]: combined = result["combined_image"] generated = result["generated_scene"] original = result["original_image"] self._save_result(combined, prompt) status_msg = "Image created successfully!" return combined, generated, original, status_msg, gr.update(visible=True) else: error_msg = result.get("error", "Something went wrong") return None, None, None, f"Error: {error_msg}", gr.update(visible=False) except Exception as e: error_traceback = traceback.format_exc() logger.error(f"Generation handler error: {str(e)}") logger.error(f"Traceback:\n{error_traceback}") return None, None, None, f"Error: {str(e)}", gr.update(visible=False) def create_interface(self): """Create professional user interface""" self._css = CSSStyles.get_main_css() # Check Gradio version for API compatibility self._gradio_version = gr.__version__ self._gradio_major = int(self._gradio_version.split('.')[0]) # Compatible with Gradio 4.44.0+ # Use minimal constructor arguments for maximum compatibility with gr.Blocks() as interface: # Inject CSS (compatible with all Gradio versions) gr.HTML(f"") # Header gr.HTML("""

🎨 SceneWeaver

AI-powered background generation and inpainting with professional edge processing

""") # Main Tabs for Mode Selection with gr.Tabs(elem_id="main-mode-tabs") as main_tabs: # Background Generation Tab with gr.Tab("Background Generation", elem_id="bg-gen-tab"): with gr.Row(): # Left Column - Input controls with gr.Column(scale=1, min_width=350, elem_classes=["feature-card"]): gr.HTML("""

📸 Upload & Generate

""") uploaded_image = gr.Image( label="Upload Your Image", type="pil", height=280, elem_classes=["input-field"] ) # Scene Template Selector (without Accordion to fix dropdown positioning in Gradio 5.x) template_dropdown = gr.Dropdown( label="Scene Templates", choices=[""] + self.template_manager.get_template_choices_sorted(), value="", info="24 curated scenes sorted A-Z (optional)", elem_classes=["template-dropdown"] ) prompt_input = gr.Textbox( label="Background Scene Description", placeholder="Select a template above or describe your own scene...", lines=3, elem_classes=["input-field"] ) combination_mode = gr.Dropdown( label="Composition Mode", choices=["center", "left_half", "right_half", "full"], value="center", info="center=Smart Center | left_half=Left Half | right_half=Right Half | full=Full Image", elem_classes=["input-field"] ) focus_mode = gr.Dropdown( label="Focus Mode", choices=["person", "scene"], value="person", info="person=Tight Crop | scene=Include Surrounding Objects", elem_classes=["input-field"] ) with gr.Accordion("Advanced Options", open=False): negative_prompt = gr.Textbox( label="Negative Prompt", value="blurry, low quality, distorted, people, characters", lines=2, elem_classes=["input-field"] ) steps_slider = gr.Slider( label="Quality Steps", minimum=15, maximum=50, value=25, step=5, elem_classes=["input-field"] ) guidance_slider = gr.Slider( label="Guidance Scale", minimum=5.0, maximum=15.0, value=7.5, step=0.5, elem_classes=["input-field"] ) generate_btn = gr.Button( "Generate Background", variant="primary", size="lg", elem_classes=["primary-button"] ) # Right Column - Results display with gr.Column(scale=2, elem_classes=["feature-card"], elem_id="results-gallery-centered"): gr.HTML("""

🎭 Results Gallery

""") # Loading notice gr.HTML("""
⏱️ First-time users: Initial model loading takes 1-2 minutes. Subsequent generations are much faster (~30s).
""") # Quick start guide gr.HTML("""
💡 Quick Start Guide

Step 1: Upload any image with a clear subject

Step 2: Describe or Choose your desired background scene

Step 3: Choose composition mode (center works best)

Step 4: Click Generate and wait for the magic!

Tip: For dark clothing, ensure good lighting in original photo.

""") with gr.Tabs(): with gr.TabItem("Final Result"): combined_output = gr.Image( label="Your Generated Image", elem_classes=["result-gallery"], show_label=False ) with gr.TabItem("Background"): generated_output = gr.Image( label="Generated Background", elem_classes=["result-gallery"], show_label=False ) with gr.TabItem("Original"): original_output = gr.Image( label="Processed Original", elem_classes=["result-gallery"], show_label=False ) status_output = gr.Textbox( label="Status", value="Ready to create! Upload an image and describe your vision.", interactive=False, elem_classes=["status-panel", "status-ready"] ) with gr.Row(): download_btn = gr.DownloadButton( "Download Result", value=None, visible=False, elem_classes=["secondary-button"] ) clear_btn = gr.Button( "Clear All", elem_classes=["secondary-button"] ) memory_btn = gr.Button( "Clean Memory", elem_classes=["secondary-button"] ) # Event handlers for Background Generation Tab # Template selection handler template_dropdown.change( fn=self.apply_template, inputs=[template_dropdown, negative_prompt], outputs=[prompt_input, negative_prompt, guidance_slider] ) generate_btn.click( fn=self.generate_handler, inputs=[ uploaded_image, prompt_input, combination_mode, focus_mode, negative_prompt, steps_slider, guidance_slider ], outputs=[ combined_output, generated_output, original_output, status_output, download_btn ] ) clear_btn.click( fn=lambda: (None, None, None, "Ready to create!", gr.update(visible=False)), outputs=[combined_output, generated_output, original_output, status_output, download_btn] ) memory_btn.click( fn=lambda: self.sceneweaver._ultra_memory_cleanup() or "Memory cleaned!", outputs=[status_output] ) combined_output.change( fn=lambda img: gr.update(value="outputs/latest_combined.png", visible=True) if (img is not None) else gr.update(visible=False), inputs=[combined_output], outputs=[download_btn] ) # End of Background Generation Tab # Inpainting Tab self.create_inpainting_tab() # Footer with tech credits (outside tabs) gr.HTML(""" """) return interface def launch(self, share: bool = True, debug: bool = False): """Launch the UI interface""" interface = self.create_interface() # Launch kwargs compatible with Gradio 4.44.0+ # Keep minimal for maximum compatibility launch_kwargs = { "share": share, "debug": debug, "show_error": True, "quiet": False } return interface.launch(**launch_kwargs) # INPAINTING UI METHODS def apply_inpainting_template( self, display_name: str, current_prompt: str ) -> Tuple[str, float, int, str, Any, Any, Any]: """ Apply an inpainting template to the UI fields. Parameters ---------- display_name : str Template display name from dropdown current_prompt : str Current prompt content Returns ------- tuple (prompt, conditioning_scale, feather_radius, conditioning_type, controlnet_settings_visibility, mode_info_html, model_selection_visibility) """ # Default returns for no template selected default_return = ( current_prompt, 0.7, 8, "canny", gr.update(visible=True), # Show ControlNet settings by default "", # No mode info gr.update(visible=True) # Show model selection by default ) if not display_name: return default_return template_key = self.inpainting_template_manager.get_template_key_from_display(display_name) if not template_key: return default_return template = self.inpainting_template_manager.get_template(template_key) if template: params = self.inpainting_template_manager.get_parameters_for_template(template_key) use_controlnet = params.get('use_controlnet', True) # Determine visibility and info based on mode if use_controlnet: controlnet_visibility = gr.update(visible=True) model_visibility = gr.update(visible=True) mode_info = """

🎛️ ControlNet Mode - Structure will be preserved using edge/depth guidance. You can adjust ControlNet settings and select model below.

""" else: # Pure Inpainting mode - hide both ControlNet and Model Selection controlnet_visibility = gr.update(visible=False) model_visibility = gr.update(visible=False) mode_info = """

🚀 Pure Inpainting Mode - Using dedicated SDXL Inpainting model.
Model and ControlNet settings are automatically configured for best results.

""" return ( current_prompt, params.get('controlnet_conditioning_scale', 0.7), params.get('feather_radius', 8), params.get('preferred_conditioning', 'canny'), controlnet_visibility, mode_info, model_visibility ) return default_return def extract_mask_from_editor(self, editor_output: Dict[str, Any]) -> Optional[Image.Image]: """ Extract mask from Gradio ImageEditor output. Handles different Gradio versions' output formats. Parameters ---------- editor_output : dict Output from gr.ImageEditor component Returns ------- PIL.Image or None Extracted mask as grayscale image """ if editor_output is None: return None try: # Gradio 5.x format if isinstance(editor_output, dict): # Check for 'layers' key (Gradio 5.x ImageEditor) if 'layers' in editor_output and editor_output['layers']: # Get the first layer as mask layer = editor_output['layers'][0] if isinstance(layer, np.ndarray): mask_array = layer elif isinstance(layer, Image.Image): mask_array = np.array(layer) else: return None # Check for 'composite' key elif 'composite' in editor_output: composite = editor_output['composite'] if isinstance(composite, np.ndarray): mask_array = composite elif isinstance(composite, Image.Image): mask_array = np.array(composite) else: return None else: return None elif isinstance(editor_output, np.ndarray): mask_array = editor_output elif isinstance(editor_output, Image.Image): mask_array = np.array(editor_output) else: logger.warning(f"Unexpected editor output type: {type(editor_output)}") return None # Convert to grayscale mask if len(mask_array.shape) == 3: if mask_array.shape[2] == 4: # RGBA format - extract white brush strokes from RGB channels # White brush strokes have high RGB values AND high alpha rgb_part = mask_array[:, :, :3] alpha_part = mask_array[:, :, 3] # Convert RGB to grayscale to detect white areas gray = cv2.cvtColor(rgb_part, cv2.COLOR_RGB2GRAY) # Combine: white areas (high gray value) with opacity (high alpha) # This captures white brush strokes mask_gray = np.minimum(gray, alpha_part) elif mask_array.shape[2] == 3: # RGB - convert to grayscale (white areas become white in mask) mask_gray = cv2.cvtColor(mask_array, cv2.COLOR_RGB2GRAY) else: mask_gray = mask_array[:, :, 0] else: # Already grayscale mask_gray = mask_array return Image.fromarray(mask_gray.astype(np.uint8), mode='L') except Exception as e: logger.error(f"Failed to extract mask from editor: {e}") return None def inpainting_handler( self, image: Optional[Image.Image], mask_editor: Dict[str, Any], prompt: str, template_dropdown: str, model_choice: str, conditioning_type: str, conditioning_scale: float, feather_radius: int, guidance_scale: float, num_steps: int, seed: int, progress: gr.Progress = gr.Progress() ) -> Tuple[Optional[Image.Image], Optional[Image.Image], str, int]: """ Handle inpainting generation request - delegates GPU work to GPUHandlers. Parameters ---------- image : PIL.Image Original image to inpaint mask_editor : dict Mask editor output prompt : str Text description of desired content template_dropdown : str Selected template (optional) model_choice : str Model key to use (juggernaut_xl, realvis_xl, sdxl_base, animagine_xl) conditioning_type : str ControlNet conditioning type conditioning_scale : float ControlNet influence strength feather_radius : int Mask feathering radius guidance_scale : float Guidance scale for generation num_steps : int Number of inference steps seed : int Random seed (-1 for random) progress : gr.Progress Progress callback Returns ------- tuple (result_image, control_image, status_message, used_seed) """ if image is None: return None, None, "⚠️ Please upload an image first", -1 # Extract mask mask = self.extract_mask_from_editor(mask_editor) if mask is None: return None, None, "⚠️ Please draw a mask on the image", -1 # Validate mask mask_array = np.array(mask) coverage = np.count_nonzero(mask_array > 127) / mask_array.size if coverage < 0.01: return None, None, "⚠️ Mask too small - please select a larger area", -1 if coverage > 0.95: return None, None, "⚠️ Mask too large - consider using background generation instead", -1 def progress_callback(msg: str, pct: int): progress(pct / 100, desc=msg) try: # Get template key if selected template_key = None if template_dropdown: template_key = self.inpainting_template_manager.get_template_key_from_display( template_dropdown ) # Delegate to GPUHandlers result_image, control_image, status, used_seed = self.gpu_handlers.inpainting_generate( image=image, mask=mask, prompt=prompt, template_key=template_key, model_key=model_choice, conditioning_type=conditioning_type, conditioning_scale=conditioning_scale, feather_radius=feather_radius, guidance_scale=guidance_scale, num_steps=num_steps, seed=int(seed) if seed is not None else -1, progress_callback=progress_callback ) # Store in history if successful if result_image is not None: self.inpainting_history.append({ 'result': result_image, 'prompt': prompt, 'seed': used_seed, 'time': time.time() }) if len(self.inpainting_history) > 3: self.inpainting_history.pop(0) return result_image, control_image, status, used_seed except Exception as e: logger.error(f"Inpainting handler error: {e}") logger.error(traceback.format_exc()) return None, None, f"❌ Error: {str(e)}", -1 def create_inpainting_tab(self) -> gr.Tab: """ Create the inpainting tab UI. Returns ------- gr.Tab Configured inpainting tab component """ with gr.Tab("Inpainting", elem_id="inpainting-tab") as tab: gr.HTML("""

ControlNet Inpainting BETA

Draw a mask to select the area you want to regenerate

""") # Model Selection Guide gr.HTML("""

📸 Model Selection Guide

🖼️ Photo Mode (Real Photos)

Best for: Photographs, portraits, product shots, nature photos

JuggernautXL - Best for portraits and people
RealVisXL - Best for scenes and objects

🎨 Anime Mode (Illustrations)

Best for: Anime, manga, illustrations, digital art, cartoons

Animagine XL - Best for anime/manga style
SDXL Base - Versatile for general art

""") with gr.Row(): # Left column - Input with gr.Column(scale=1): # Image upload inpaint_image = gr.Image( label="Upload Image", type="pil", height=300 ) # Mask editor mask_editor = gr.ImageEditor( label="Draw Mask (white = area to inpaint)", type="pil", height=300, brush=gr.Brush(colors=["#FFFFFF"], default_size=20), eraser=gr.Eraser(default_size=20), layers=True, sources=["upload"], image_mode="RGBA" ) # Template selection with gr.Accordion("Inpainting Templates", open=False): inpaint_template = gr.Dropdown( choices=[""] + self.inpainting_template_manager.get_template_choices_sorted(), value="", label="Select Template", elem_classes=["template-dropdown"] ) template_tips = gr.Markdown("") # Mode info (dynamically updated based on template) mode_info_html = gr.HTML("") # Prompt inpaint_prompt = gr.Textbox( label="Prompt", placeholder="Describe what you want to generate in the masked area...", lines=2 ) # Right column - Settings and Output with gr.Column(scale=1): # Model Selection (hidden for Pure Inpainting templates) with gr.Group(visible=True) as model_selection_group: with gr.Accordion("Model Selection", open=True): model_choice = gr.Dropdown( choices=[ ("🖼️ JuggernautXL v9 - Best for portraits & real photos", "juggernaut_xl"), ("🖼️ RealVisXL v4 - Best for realistic scenes", "realvis_xl"), ("🎨 SDXL Base - Versatile for general art", "sdxl_base"), ("🎨 Animagine XL 3.1 - Best for anime/manga", "animagine_xl"), ], value="juggernaut_xl", label="Select Model", info="Choose based on your image type (photo vs illustration)" ) # ControlNet Settings (hidden for Pure Inpainting templates) with gr.Group(visible=True) as controlnet_settings_group: with gr.Accordion("ControlNet Settings", open=True): conditioning_type = gr.Radio( choices=["canny", "depth"], value="canny", label="ControlNet Mode", info="Canny: preserves edges | Depth: preserves 3D structure" ) conditioning_scale = gr.Slider( minimum=0.05, maximum=1.0, value=0.7, step=0.05, label="ControlNet Strength", info="Higher = more structure preservation" ) # General Settings (always visible) with gr.Accordion("General Settings", open=True): feather_radius = gr.Slider( minimum=0, maximum=20, value=8, step=1, label="Feather Radius (px)", info="Edge blending softness" ) with gr.Accordion("Advanced Settings", open=False): inpaint_guidance = gr.Slider( minimum=5.0, maximum=15.0, value=7.5, step=0.5, label="Guidance Scale" ) inpaint_steps = gr.Slider( minimum=15, maximum=50, value=25, step=5, label="Inference Steps" ) # Seed control for reproducibility seed_input = gr.Number( label="Seed", value=-1, precision=0, info="-1 = random seed, or enter a specific number to reproduce results" ) # Generate button inpaint_btn = gr.Button( "Generate Inpainting", variant="primary", elem_classes=["primary-button"] ) # Processing time reminder gr.Markdown( """

Please be patient!
First run: 5-7 minutes (model initialization)
Subsequent runs: 2-3 minutes (model cached)

🔄 Want to make more changes? After each generation, please re-upload your image and draw a new mask if you want to apply additional edits.

""" ) # Status and Seed display inpaint_status = gr.Textbox( label="Status", value="Ready for inpainting", interactive=False ) # Display used seed for reproducibility with gr.Row(): used_seed_display = gr.Number( label="Used Seed (copy this to reproduce)", value=-1, precision=0, interactive=False ) copy_seed_btn = gr.Button( "📋 Use This Seed", size="sm", scale=0 ) # Output row with gr.Row(): with gr.Column(scale=1): inpaint_result = gr.Image( label="Result", type="pil", height=400 ) with gr.Column(scale=1): # Control image (structure guidance visualization) inpaint_control = gr.Image( label="Control Image (Structure Guidance)", type="pil", height=400 ) # Event handlers inpaint_template.change( fn=self.apply_inpainting_template, inputs=[inpaint_template, inpaint_prompt], outputs=[ inpaint_prompt, conditioning_scale, feather_radius, conditioning_type, controlnet_settings_group, mode_info_html, model_selection_group ] ) inpaint_template.change( fn=lambda x: self._get_template_tips(x), inputs=[inpaint_template], outputs=[template_tips] ) # Copy uploaded image to mask editor (as background) def set_mask_editor_background(image): """Set uploaded image as mask editor background.""" if image is None: return None # Return dict format for ImageEditor with background return {"background": image, "layers": [], "composite": None} inpaint_image.change( fn=set_mask_editor_background, inputs=[inpaint_image], outputs=[mask_editor] ) inpaint_btn.click( fn=self.inpainting_handler, inputs=[ inpaint_image, mask_editor, inpaint_prompt, inpaint_template, model_choice, conditioning_type, conditioning_scale, feather_radius, inpaint_guidance, inpaint_steps, seed_input ], outputs=[ inpaint_result, inpaint_control, inpaint_status, used_seed_display ] ) # Copy seed button - copies used seed to input copy_seed_btn.click( fn=lambda x: x, inputs=[used_seed_display], outputs=[seed_input] ) return tab def _get_template_tips(self, display_name: str) -> str: """Get usage tips for selected template.""" if not display_name: return "" template_key = self.inpainting_template_manager.get_template_key_from_display(display_name) if not template_key: return "" tips = self.inpainting_template_manager.get_usage_tips(template_key) if tips: return "**Tips:**\n" + "\n".join(f"- {tip}" for tip in tips) return ""