Thomas Chauvel
π¨ FIX: Match font size between 'What do you want to create?' and 'Render Settings'
a51e7e7
| """ | |
| FIBO Virtual Photo Studio - Hugging Face Gradio Space | |
| A photographer-friendly UI for BRIA FIBO's JSON-native controllability. | |
| """ | |
| import gradio as gr | |
| import json | |
| import base64 | |
| import io | |
| from pathlib import Path | |
| from typing import Optional, Tuple, List | |
| import asyncio | |
| from PIL import Image, ImageDraw, ImageFont | |
| from schemas import Preset, StyleProfile | |
| from fibo_client import generate_async | |
| # Load default presets | |
| DEFAULT_PRESET_PATH = Path("presets/default_preset.json") | |
| DEFAULT_STYLE_PATH = Path("presets/default_style_profile.json") | |
| def load_default_preset() -> str: | |
| """Load the default preset JSON.""" | |
| if DEFAULT_PRESET_PATH.exists(): | |
| return DEFAULT_PRESET_PATH.read_text() | |
| return Preset().model_dump_json(indent=2) | |
| def load_default_style() -> str: | |
| """Load the default style profile JSON.""" | |
| if DEFAULT_STYLE_PATH.exists(): | |
| return DEFAULT_STYLE_PATH.read_text() | |
| return StyleProfile().model_dump_json(indent=2) | |
| # State management | |
| current_preset = json.loads(load_default_preset()) | |
| current_style = json.loads(load_default_style()) | |
| # Focal length to FOV conversion (for full-frame 35mm sensor) | |
| FOCAL_LENGTH_TO_FOV = { | |
| "14mm (Ultra-wide)": 114, | |
| "24mm (Wide)": 84, | |
| "35mm (Wide-normal)": 63, | |
| "50mm (Normal)": 47, | |
| "85mm (Portrait)": 28, | |
| "135mm (Telephoto)": 18, | |
| "200mm (Super-telephoto)": 12 | |
| } | |
| FOV_TO_FOCAL_LENGTH = {v: k for k, v in FOCAL_LENGTH_TO_FOV.items()} | |
| # Human-readable labels for dropdowns | |
| ANGLE_LABELS = { | |
| "Eye Level": "eye_level", | |
| "High Angle": "high", | |
| "Low Angle": "low", | |
| "Three Quarter": "three_quarter", | |
| "Overhead (Bird's Eye)": "overhead", | |
| "Dutch Angle (Tilted)": "dutch_angle" | |
| } | |
| FRAMING_LABELS = { | |
| "Rule of Thirds": "rule_of_thirds", | |
| "Centered": "centered", | |
| "Wide Shot": "wide", | |
| "Tight/Close-Up": "tight", | |
| "Portrait": "portrait", | |
| "Landscape": "landscape" | |
| } | |
| LIGHTING_LABELS = { | |
| "Softbox - Warm": "softbox_warm", | |
| "Softbox - Cool": "softbox_cool", | |
| "Ring Light": "ring_light", | |
| "Studio Key Light": "studio_key", | |
| "Natural Window Light": "natural_window", | |
| "Golden Hour": "golden_hour", | |
| "Sunset": "sunset", | |
| "Blue Hour": "blue_hour", | |
| "Backlight": "backlight", | |
| "Dramatic Side Light": "dramatic_side", | |
| "Neon": "neon", | |
| "Spotlight": "spotlight", | |
| "Overcast Diffused": "overcast_diffused", | |
| "Harsh Midday": "harsh_midday", | |
| "Moonlight": "moonlight" | |
| } | |
| DIRECTION_LABELS = { | |
| "Key Left": "key_left", | |
| "Key Right": "key_right", | |
| "Backlight": "backlight", | |
| "Overhead": "overhead", | |
| "Under": "under", | |
| "Fill": "fill", | |
| "Rim": "rim", | |
| "Broad": "broad", | |
| "Short": "short" | |
| } | |
| BACKGROUND_LABELS = { | |
| # Studio | |
| "Studio Paper - White": "studio_paper_white", | |
| "Studio Paper - Gray": "studio_paper_gray", | |
| "Studio Paper - Beige": "studio_paper_beige", | |
| "Studio Paper - Black": "studio_paper_black", | |
| "Studio Cyclorama - White": "studio_cyclorama_white", | |
| "Studio Cyclorama - Gray": "studio_cyclorama_gray", | |
| # Interior | |
| "Interior: Living Room (Modern)": "interior_living_room_modern", | |
| "Interior: Bedroom (Cozy)": "interior_bedroom_cozy", | |
| "Interior: Kitchen (Bright)": "interior_kitchen_bright", | |
| "Interior: Office (Minimalist)": "interior_office_minimalist", | |
| "Interior: Loft (Industrial)": "interior_loft_industrial", | |
| "Interior: Cafe (Rustic)": "interior_cafe_rustic", | |
| "Interior: Gallery (White Walls)": "interior_gallery_white_walls", | |
| "Interior: Library (Wooden)": "interior_library_wooden", | |
| "Interior: Bathroom (Marble)": "interior_bathroom_marble", | |
| # Exterior | |
| "Exterior: Urban Street": "exterior_urban_street", | |
| "Exterior: Park (Green)": "exterior_park_green", | |
| "Exterior: Beach (Sand)": "exterior_beach_sand", | |
| "Exterior: Mountain Landscape": "exterior_mountain_landscape", | |
| "Exterior: Forest (Trees)": "exterior_forest_trees", | |
| "Exterior: City Skyline": "exterior_city_skyline", | |
| "Exterior: Desert (Dunes)": "exterior_desert_dunes", | |
| "Exterior: Rooftop City View": "exterior_rooftop_city_view", | |
| "Exterior: Garden (Flowers)": "exterior_garden_flowers", | |
| "Exterior: Alley (Brick Wall)": "exterior_alley_brick_wall", | |
| "Exterior: Parking Lot": "exterior_parking_lot", | |
| "Exterior: Countryside": "exterior_countryside", | |
| # Textures | |
| "Texture: Concrete Wall (Raw)": "concrete_wall_raw", | |
| "Texture: Brick Wall (Red)": "brick_wall_red", | |
| "Texture: Wood Floor (Natural)": "wood_floor_natural", | |
| "Texture: Marble Surface (White)": "marble_surface_white", | |
| "Texture: Metal (Brushed Steel)": "metal_brushed_steel", | |
| "Texture: Fabric (Linen)": "fabric_linen_texture", | |
| "Texture: Stone Wall (Rough)": "stone_wall_rough", | |
| # Effects | |
| "Effect: Bokeh Blur (Warm)": "bokeh_blur_warm", | |
| "Effect: Bokeh Blur (Cool)": "bokeh_blur_cool", | |
| "Effect: Gradient (Warm Orange)": "gradient_warm_orange", | |
| "Effect: Gradient (Cool Blue)": "gradient_cool_blue", | |
| "Effect: Gradient (Purple/Pink)": "gradient_purple_pink", | |
| "Solid: Neutral Gray": "solid_neutral_gray", | |
| "Solid: Black": "solid_black", | |
| "Solid: White": "solid_white", | |
| "Transparent/Seamless": "transparent_seamless" | |
| } | |
| FILM_LABELS = { | |
| "Digital - Clean": "Digital_Clean", | |
| "Digital - Cinematic": "Digital_Cinematic", | |
| "Digital - HDR": "Digital_HDR", | |
| "Film: Kodak Portra 400": "Portra400-ish", | |
| "Film: Kodak Portra 800": "Portra800-ish", | |
| "Film: Kodak Ektar 100": "Ektar100-ish", | |
| "Film: Ilford HP5 (B&W)": "HP5-ish", | |
| "Film: Kodak Tri-X (B&W)": "TriX-ish", | |
| "Film: Fuji Velvia 50": "Velvia50-ish", | |
| "Film: Fuji Superia 400": "Superia400-ish", | |
| "Film: CineStill 800T": "CineStill800T-ish", | |
| "Film: Fuji Provia 100F": "Provia100F-ish", | |
| "Instant: Instax": "Instax-ish", | |
| "Instant: Polaroid": "Polaroid-ish" | |
| } | |
| # Reverse mappings (code -> label) | |
| ANGLE_VALUES = {v: k for k, v in ANGLE_LABELS.items()} | |
| FRAMING_VALUES = {v: k for k, v in FRAMING_LABELS.items()} | |
| LIGHTING_VALUES = {v: k for k, v in LIGHTING_LABELS.items()} | |
| DIRECTION_VALUES = {v: k for k, v in DIRECTION_LABELS.items()} | |
| BACKGROUND_VALUES = {v: k for k, v in BACKGROUND_LABELS.items()} | |
| FILM_VALUES = {v: k for k, v in FILM_LABELS.items()} | |
| def update_preset_from_controls( | |
| # Camera | |
| fov: int, aperture: float, shutter: float, iso: int, | |
| # Composition | |
| angle: str, framing: str, | |
| # Lighting | |
| light_preset: str, intensity: int, direction: str, | |
| # Color | |
| contrast: int, saturation: int, kelvin: int, tint: int, | |
| # Background & Film | |
| background: str, background_custom: str, film: str, | |
| # Render | |
| guidance: float, steps: int, width: int, height: int, seed: Optional[int], | |
| # Prompts | |
| prompt: str, neg_prompt: str | |
| ) -> str: | |
| """Update preset JSON from UI controls.""" | |
| global current_preset | |
| # Convert labels to code values | |
| angle_code = ANGLE_LABELS.get(angle, angle) | |
| framing_code = FRAMING_LABELS.get(framing, framing) | |
| light_code = LIGHTING_LABELS.get(light_preset, light_preset) | |
| direction_code = DIRECTION_LABELS.get(direction, direction) | |
| background_code = BACKGROUND_LABELS.get(background, background) | |
| film_code = FILM_LABELS.get(film, film) | |
| # Prioritize custom background if filled, otherwise use preset | |
| bg_value = background_custom.strip() if background_custom and background_custom.strip() else background_code | |
| current_preset.update({ | |
| "prompt": prompt, | |
| "negative_prompt": neg_prompt, | |
| "controls": { | |
| "camera": { | |
| "fov": fov, | |
| "aperture": aperture, | |
| "shutter": shutter, | |
| "iso": iso | |
| }, | |
| "composition": { | |
| "angle": angle_code, | |
| "framing": framing_code | |
| }, | |
| "lighting": { | |
| "preset": light_code, | |
| "intensity": intensity, | |
| "direction": direction_code | |
| }, | |
| "color": { | |
| "palette": current_preset.get("controls", {}).get("color", {}).get("palette", ["#F7E7CE", "#7C5E3B"]), | |
| "contrast": contrast, | |
| "saturation": saturation, | |
| "wb": { | |
| "kelvin": kelvin, | |
| "tint": tint | |
| } | |
| }, | |
| "background": { | |
| "style": bg_value | |
| }, | |
| "film_profile": { | |
| "name": film_code | |
| } | |
| }, | |
| "render": { | |
| "guidance": guidance, | |
| "steps": steps, | |
| "variation_count": 1, # Always 1 image per call to reduce API usage | |
| "resolution": { | |
| "width": width, | |
| "height": height | |
| }, | |
| "seed": seed if seed and seed > 0 else None | |
| } | |
| }) | |
| return json.dumps(current_preset, indent=2) | |
| def update_preset_from_json(preset_json: str) -> Tuple[str, str]: | |
| """Update preset from manual JSON edit.""" | |
| global current_preset | |
| try: | |
| preset_dict = json.loads(preset_json) | |
| # Validate with Pydantic | |
| validated = Preset(**preset_dict) | |
| current_preset = validated.model_dump() | |
| return json.dumps(current_preset, indent=2), "β Preset updated successfully" | |
| except Exception as e: | |
| return preset_json, f"β Invalid JSON: {str(e)}" | |
| def update_style_from_json(style_json: str) -> Tuple[str, str]: | |
| """Update style profile from manual JSON edit.""" | |
| global current_style | |
| try: | |
| style_dict = json.loads(style_json) | |
| # Validate with Pydantic | |
| validated = StyleProfile(**style_dict) | |
| current_style = validated.model_dump() | |
| return json.dumps(current_style, indent=2), "β Style profile updated successfully" | |
| except Exception as e: | |
| return style_json, f"β Invalid JSON: {str(e)}" | |
| async def generate_images_progressive( | |
| preset_json: str, | |
| style_json: str, | |
| subject_image: Optional[gr.Image] = None, | |
| use_style: bool = False | |
| ) -> Tuple[List, str, int, List]: | |
| """Generate images using FIBO API and return seeds for each variation.""" | |
| try: | |
| # Parse JSONs | |
| preset = json.loads(preset_json) | |
| style = json.loads(style_json) if use_style else None | |
| # Read image bytes if provided | |
| image_bytes = None | |
| if subject_image is not None: | |
| if isinstance(subject_image, str): # File path | |
| with open(subject_image, "rb") as f: | |
| image_bytes = f.read() | |
| elif hasattr(subject_image, "read"): # File-like object | |
| image_bytes = subject_image.read() | |
| # Call FIBO API | |
| result = await generate_async(preset, image_bytes, style) | |
| # Get base seed used for generation | |
| base_seed = result.get("seed", preset.get("render", {}).get("seed", 0)) | |
| # Process images - convert to PIL Images (no overlay) | |
| images = [] | |
| seeds = [] # Track seed for each image | |
| for idx, img_data in enumerate(result.get("images", [])): | |
| if "b64" in img_data: | |
| # Decode base64 to PIL Image | |
| img_bytes = base64.b64decode(img_data["b64"]) | |
| pil_image = Image.open(io.BytesIO(img_bytes)) | |
| images.append(pil_image) | |
| # Store seed for this variation | |
| variation_seed = base_seed + idx | |
| seeds.append(variation_seed) | |
| elif "url" in img_data: | |
| # For URL-based responses (future support) | |
| images.append(img_data["url"]) | |
| seeds.append(base_seed + idx) | |
| # No info message needed | |
| info_msg = "" | |
| return images, info_msg, base_seed, seeds | |
| except Exception as e: | |
| error_msg = f"β **Error**: {str(e)}" | |
| return [], error_msg, 0, [] | |
| # Build Gradio Interface | |
| def build_ui(): | |
| # Custom CSS for Google Sans font and custom styling | |
| custom_css = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap'); | |
| * { | |
| font-family: 'Google Sans', 'GoogleSansText', sans-serif !important; | |
| } | |
| body { | |
| font-family: 'Google Sans', 'GoogleSansText', sans-serif !important; | |
| } | |
| /* Force white background everywhere - NO GREY */ | |
| html, body, .gradio-container, .main, .app, #root, | |
| .contain, .flex, .gap, div, section, article { | |
| background: white !important; | |
| background-color: white !important; | |
| } | |
| /* Remove ALL backgrounds from UI elements */ | |
| *, *::before, *::after { | |
| background-color: transparent !important; | |
| } | |
| /* Text inputs get white background with borders */ | |
| input[type="text"], | |
| textarea { | |
| background: white !important; | |
| background-color: white !important; | |
| border: 1px solid #000000 !important; | |
| border-radius: 4px !important; | |
| padding: 8px !important; | |
| } | |
| /* DROPDOWNS - NUCLEAR OPTION WHITE BACKGROUND */ | |
| select, | |
| select *, | |
| .dropdown, | |
| .dropdown *, | |
| .gr-dropdown, | |
| .gr-dropdown *, | |
| .dropdown select, | |
| select option, | |
| .dropdown-container, | |
| [class*="dropdown"], | |
| [class*="dropdown"] *, | |
| [class*="Dropdown"], | |
| [class*="Dropdown"] *, | |
| div[class*="wrap"]:has(select), | |
| div[class*="wrap"]:has(select) *, | |
| .wrap:has(select), | |
| .wrap:has(select) * { | |
| background: white !important; | |
| background-color: white !important; | |
| } | |
| /* Select elements - FORCE white */ | |
| select, | |
| select:focus, | |
| select:hover, | |
| select:active { | |
| background: white !important; | |
| background-color: white !important; | |
| border: 1px solid #000000 !important; | |
| border-radius: 4px !important; | |
| padding: 8px !important; | |
| color: #000000 !important; | |
| } | |
| /* Override Gradio's dropdown wrapper */ | |
| label:has(select) { | |
| background: transparent !important; | |
| } | |
| label:has(select) > div, | |
| label:has(select) > div > *, | |
| label:has(select) div { | |
| background: white !important; | |
| } | |
| /* Gradio specific dropdown classes */ | |
| .gr-box:has(select), | |
| .gr-form:has(select), | |
| div.block:has(select) { | |
| background: transparent !important; | |
| } | |
| .gr-box:has(select) *:not(label), | |
| .gr-form:has(select) *:not(label) { | |
| background: white !important; | |
| } | |
| /* Target Gradio's Dropdown component wrapper */ | |
| [class*="svelte"]:has(select) { | |
| background: white !important; | |
| } | |
| /* DROPDOWN MENU (expanded list) - ULTRA AGGRESSIVE WHITE */ | |
| select option, | |
| option { | |
| background: white !important; | |
| background-color: white !important; | |
| color: #000000 !important; | |
| } | |
| /* Dropdown popup/menu */ | |
| select:focus, | |
| select:active, | |
| select[open] { | |
| background: white !important; | |
| } | |
| /* Target the native select dropdown menu on different browsers */ | |
| select optgroup, | |
| select option:checked, | |
| select option:hover { | |
| background: white !important; | |
| background-color: white !important; | |
| } | |
| /* Gradio's custom dropdown overlay/popup */ | |
| .dropdown-menu, | |
| .dropdown-content, | |
| .options, | |
| .option-list, | |
| [role="listbox"], | |
| [role="menu"], | |
| [role="option"], | |
| ul[role="listbox"], | |
| div[role="listbox"], | |
| .gr-dropdown-menu, | |
| [class*="dropdown"][class*="menu"], | |
| [class*="options"], | |
| [class*="Option"], | |
| [data-testid*="dropdown"], | |
| [id*="dropdown-menu"], | |
| .svelte-dropdown, | |
| [class*="svelte"] [class*="options"], | |
| [class*="svelte"] [class*="menu"] { | |
| background: white !important; | |
| background-color: white !important; | |
| } | |
| /* All children of dropdown menus */ | |
| [role="listbox"] *, | |
| [role="menu"] *, | |
| [role="option"] *, | |
| .dropdown-menu *, | |
| .options * { | |
| background: white !important; | |
| background-color: white !important; | |
| color: #000000 !important; | |
| } | |
| /* Popup overlays */ | |
| .popup, | |
| .overlay, | |
| [class*="popup"], | |
| [class*="overlay"] { | |
| background: white !important; | |
| } | |
| /* LIMIT DROPDOWN TO 10 OPTIONS WITH SCROLL */ | |
| select { | |
| max-height: 320px !important; | |
| overflow-y: auto !important; | |
| } | |
| /* Dropdown menu height limit - show ~10 options */ | |
| .dropdown-menu, | |
| .dropdown-content, | |
| .options, | |
| .option-list, | |
| [role="listbox"], | |
| [role="menu"], | |
| ul[role="listbox"], | |
| div[role="listbox"], | |
| .gr-dropdown-menu, | |
| [class*="dropdown"][class*="menu"], | |
| [class*="options"], | |
| .svelte-dropdown, | |
| [class*="svelte"] [class*="options"] { | |
| max-height: 320px !important; | |
| overflow-y: auto !important; | |
| overflow-x: hidden !important; | |
| } | |
| /* Individual option height (assuming ~32px per option) */ | |
| select option, | |
| [role="option"], | |
| .option { | |
| min-height: 32px !important; | |
| padding: 8px 12px !important; | |
| } | |
| /* Scrollbar styling */ | |
| select::-webkit-scrollbar, | |
| .dropdown-menu::-webkit-scrollbar, | |
| [role="listbox"]::-webkit-scrollbar { | |
| width: 8px !important; | |
| } | |
| select::-webkit-scrollbar-track, | |
| .dropdown-menu::-webkit-scrollbar-track, | |
| [role="listbox"]::-webkit-scrollbar-track { | |
| background: #f1f1f1 !important; | |
| } | |
| select::-webkit-scrollbar-thumb, | |
| .dropdown-menu::-webkit-scrollbar-thumb, | |
| [role="listbox"]::-webkit-scrollbar-thumb { | |
| background: #888 !important; | |
| border-radius: 4px !important; | |
| } | |
| select::-webkit-scrollbar-thumb:hover, | |
| .dropdown-menu::-webkit-scrollbar-thumb:hover, | |
| [role="listbox"]::-webkit-scrollbar-thumb:hover { | |
| background: #555 !important; | |
| } | |
| /* Number inputs - no border by default */ | |
| input[type="number"] { | |
| background: transparent !important; | |
| border: none !important; | |
| } | |
| /* Buttons white background */ | |
| button { | |
| background: white !important; | |
| background-color: white !important; | |
| border: 1px solid #000000 !important; | |
| } | |
| /* Spacing: Header to main controls */ | |
| #header-section { | |
| margin-bottom: 40px !important; | |
| } | |
| #main-controls-row { | |
| margin-top: 0px !important; | |
| } | |
| /* Spacing: Between render inputs and generate button */ | |
| #render-inputs-row { | |
| margin-bottom: 20px !important; | |
| } | |
| /* Generate button - special styling */ | |
| #generate-btn { | |
| background: #5f6368 !important; | |
| background-color: #5f6368 !important; | |
| color: white !important; | |
| border: none !important; | |
| border-radius: 50px !important; | |
| padding: 12px 32px !important; | |
| font-size: 1.1rem !important; | |
| font-weight: 500 !important; | |
| margin-top: 20px !important; | |
| margin-bottom: 0px !important; | |
| width: 100% !important; | |
| cursor: pointer !important; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.15) !important; | |
| transition: all 0.2s ease !important; | |
| } | |
| #generate-btn:hover { | |
| background: #4d5256 !important; | |
| box-shadow: 0 3px 8px rgba(0,0,0,0.25) !important; | |
| transform: translateY(-1px) !important; | |
| } | |
| /* Reset button styling */ | |
| #reset-btn { | |
| background: #e8eaed !important; | |
| color: #202124 !important; | |
| border: 1px solid #dadce0 !important; | |
| border-radius: 50px !important; | |
| padding: 12px 32px !important; | |
| font-size: 1.1rem !important; | |
| font-weight: 500 !important; | |
| } | |
| #reset-btn:hover { | |
| background: #f1f3f4 !important; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important; | |
| } | |
| /* Align Generate and Reset buttons on same line - same height and alignment */ | |
| #generate-btn, | |
| #reset-btn { | |
| height: 48px !important; | |
| min-height: 48px !important; | |
| max-height: 48px !important; | |
| display: flex !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| vertical-align: top !important; | |
| margin-top: 0 !important; | |
| margin-bottom: 0 !important; | |
| } | |
| /* Ensure button row container aligns buttons */ | |
| #generate-btn + *, | |
| #reset-btn + * { | |
| margin-left: 0 !important; | |
| } | |
| /* Button row alignment - target row containing buttons */ | |
| .gradio-row:has(#generate-btn), | |
| .gradio-row:has(#reset-btn) { | |
| align-items: center !important; | |
| display: flex !important; | |
| gap: 12px !important; | |
| } | |
| /* Set Seed button styling */ | |
| #show-seed-btn { | |
| background: transparent !important; | |
| color: #5f6368 !important; | |
| border: 1px solid #dadce0 !important; | |
| border-radius: 4px !important; | |
| padding: 6px 12px !important; | |
| font-size: 0.875rem !important; | |
| } | |
| #show-seed-btn:hover { | |
| background: #f8f9fa !important; | |
| border-color: #5f6368 !important; | |
| } | |
| /* Remove icons from LABELS only, not buttons */ | |
| .label-wrap::before, | |
| .label-wrap::after { | |
| display: none !important; | |
| content: none !important; | |
| visibility: hidden !important; | |
| } | |
| /* Gallery - remove icon from label only, not buttons */ | |
| #image-gallery label::before, | |
| #image-gallery .label-wrap::before, | |
| #image-gallery .label-wrap::after { | |
| display: none !important; | |
| content: "" !important; | |
| visibility: hidden !important; | |
| } | |
| /* Hide icon/emoji in gallery label text */ | |
| #image-gallery .label-wrap { | |
| font-family: 'Google Sans', sans-serif !important; | |
| } | |
| /* Gallery label styling */ | |
| #image-gallery label, | |
| #image-gallery .label-wrap, | |
| #image-gallery .label-wrap span { | |
| font-family: 'Google Sans', sans-serif !important; | |
| background: transparent !important; | |
| } | |
| /* Hide Gradio's default gallery icon in labels only */ | |
| .gallery .label-wrap::before, | |
| [id*="gallery"] .label-wrap::before { | |
| display: none !important; | |
| } | |
| /* Gallery image display - FULL LAYOUT */ | |
| #image-gallery { | |
| height: auto !important; | |
| border: none !important; | |
| margin-top: 0px !important; | |
| padding-top: 0px !important; | |
| margin-bottom: 0px !important; | |
| overflow: visible !important; | |
| } | |
| /* Gallery wrapper containers - Clean */ | |
| #image-gallery .wrap, | |
| #image-gallery .contain, | |
| #image-gallery .block { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| width: 100% !important; | |
| box-sizing: border-box !important; | |
| } | |
| /* Override preview container and all its children - No height limits */ | |
| #image-gallery > div:first-child, | |
| #image-gallery > div:first-child *, | |
| #image-gallery [class*="preview"], | |
| #image-gallery [class*="preview"] * { | |
| max-height: none !important; | |
| height: auto !important; | |
| min-height: 0 !important; | |
| } | |
| /* Thumbnails - Keep constrained */ | |
| #image-gallery button { | |
| max-height: 140px !important; | |
| } | |
| /* Remove spacing after generate button */ | |
| #generate-btn + * { | |
| margin-top: 0 !important; | |
| } | |
| /* Hide gallery action buttons (expand, share, close) - AGGRESSIVE */ | |
| #image-gallery button[aria-label="Maximize"], | |
| #image-gallery button[aria-label="Expand"], | |
| #image-gallery button[aria-label="Fullscreen"], | |
| #image-gallery button[aria-label="Share"], | |
| #image-gallery button[aria-label="Close"], | |
| #image-gallery button[title="Maximize"], | |
| #image-gallery button[title="Expand"], | |
| #image-gallery button[title="Fullscreen"], | |
| #image-gallery button[title="Share"], | |
| #image-gallery button[title="Close"], | |
| #image-gallery .icon-buttons, | |
| #image-gallery .image-buttons, | |
| #image-gallery .icon-button-wrapper, | |
| #image-gallery .top-panel, | |
| #image-gallery .hide-top-corner, | |
| #image-gallery button.preview .icon-button-wrapper, | |
| #image-gallery button.preview .top-panel, | |
| #image-gallery .thumbnail-item button:not([aria-label*="Image"]), | |
| .gallery-button-group, | |
| [class*="gallery"] [aria-label*="Maximize"], | |
| [class*="gallery"] [aria-label*="Fullscreen"], | |
| [class*="gallery"] [aria-label*="Share"], | |
| [id*="image-gallery"] button[class*="icon"]:not([aria-label*="Image"]), | |
| [id*="image-gallery"] .icon-button-wrapper, | |
| [id*="image-gallery"] .top-panel, | |
| [id*="image-gallery"] .icon-button, | |
| button.preview .icon-button-wrapper, | |
| button.preview .top-panel, | |
| button.preview .icon-button { | |
| display: none !important; | |
| visibility: hidden !important; | |
| opacity: 0 !important; | |
| pointer-events: none !important; | |
| width: 0 !important; | |
| height: 0 !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| } | |
| /* Gallery - Simple CSS only approach */ | |
| #image-gallery { | |
| width: 100% !important; | |
| } | |
| /* Preview container - ALWAYS VISIBLE, Remove height constraints */ | |
| #image-gallery > div:first-child { | |
| display: block !important; | |
| visibility: visible !important; | |
| width: 100% !important; | |
| height: auto !important; | |
| max-height: none !important; | |
| margin-bottom: 24px !important; | |
| } | |
| /* Preview button and media button - Remove height constraints */ | |
| #image-gallery button.preview, | |
| #image-gallery .media-button { | |
| height: auto !important; | |
| max-height: none !important; | |
| width: 100% !important; | |
| } | |
| /* Preview image - Simple full size */ | |
| #image-gallery [data-testid="detailed-image"], | |
| #image-gallery .media-button img { | |
| width: 100% !important; | |
| height: auto !important; | |
| max-height: none !important; | |
| object-fit: contain !important; | |
| cursor: pointer !important; | |
| } | |
| /* Image expand modal overlay */ | |
| .image-expand-modal { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.95); | |
| z-index: 10000; | |
| cursor: pointer; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| .image-expand-modal.active { | |
| display: flex !important; | |
| } | |
| .image-expand-modal img { | |
| max-width: 100vw; | |
| max-height: 100vh; | |
| width: auto; | |
| height: auto; | |
| object-fit: contain; | |
| cursor: pointer; | |
| margin: auto; | |
| } | |
| /* Hide thumbnails completely - we only show one image at a time */ | |
| #image-gallery > div:last-child, | |
| #image-gallery [class*="thumbnail"]:not([class*="preview"]), | |
| #image-gallery .grid-wrap, | |
| #image-gallery .thumbnails { | |
| display: none !important; | |
| visibility: hidden !important; | |
| height: 0 !important; | |
| max-height: 0 !important; | |
| overflow: hidden !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| } | |
| /* Gallery container - single image display with relative positioning for navigation */ | |
| #image-gallery { | |
| position: relative !important; | |
| } | |
| /* Preview container - always visible */ | |
| #image-gallery > div:first-child { | |
| display: block !important; | |
| visibility: visible !important; | |
| width: 100% !important; | |
| height: auto !important; | |
| max-height: none !important; | |
| margin-bottom: 0 !important; | |
| } | |
| /* Carousel navigation overlay */ | |
| #carousel-navigation { | |
| position: absolute !important; | |
| top: 0 !important; | |
| left: 0 !important; | |
| width: 100% !important; | |
| height: 100% !important; | |
| pointer-events: none !important; | |
| z-index: 5 !important; | |
| } | |
| /* Carousel navigation buttons */ | |
| #carousel-prev, | |
| #carousel-next { | |
| pointer-events: auto !important; | |
| transition: all 0.2s ease !important; | |
| } | |
| #carousel-prev:hover, | |
| #carousel-next:hover { | |
| background: rgba(0,0,0,0.9) !important; | |
| transform: translateY(-50%) scale(1.1) !important; | |
| } | |
| #carousel-prev:active, | |
| #carousel-next:active { | |
| transform: translateY(-50%) scale(0.95) !important; | |
| } | |
| #carousel-counter { | |
| pointer-events: none !important; | |
| } | |
| /* Thumbnail buttons - Clean and consistent */ | |
| #image-gallery button { | |
| flex: 1 !important; | |
| height: 140px !important; | |
| min-height: 140px !important; | |
| max-height: 140px !important; | |
| padding: 8px !important; | |
| margin: 0 !important; | |
| background: #f5f5f5 !important; | |
| border: 2px solid #e0e0e0 !important; | |
| border-radius: 4px !important; | |
| overflow: hidden !important; | |
| cursor: pointer !important; | |
| transition: all 0.2s ease !important; | |
| display: flex !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| box-sizing: border-box !important; | |
| } | |
| #image-gallery button:hover { | |
| border-color: #4285f4 !important; | |
| transform: scale(1.02) !important; | |
| } | |
| #image-gallery button[aria-selected="true"] { | |
| border-color: #4285f4 !important; | |
| border-width: 3px !important; | |
| } | |
| /* Thumbnail images - Clean sizing */ | |
| #image-gallery button img { | |
| object-fit: contain !important; | |
| width: 100% !important; | |
| height: 100% !important; | |
| max-height: 124px !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| display: block !important; | |
| } | |
| /* Individual gallery items - NO BORDERS */ | |
| #image-gallery button, | |
| #image-gallery .thumbnail-item, | |
| #image-gallery .grid-wrap > *, | |
| #image-gallery [data-testid="image"], | |
| #image-gallery .image-container, | |
| #image-gallery .image-frame { | |
| border: none !important; | |
| box-shadow: none !important; | |
| outline: none !important; | |
| } | |
| /* Gallery preview modal - FULL SIZE */ | |
| #image-gallery .preview-container, | |
| #image-gallery .modal { | |
| background: rgba(0, 0, 0, 0.9) !important; | |
| } | |
| #image-gallery .preview-container img, | |
| #image-gallery .modal img { | |
| object-fit: contain !important; | |
| max-width: 90vw !important; | |
| max-height: 90vh !important; | |
| width: auto !important; | |
| height: auto !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| } | |
| /* Ensure ALL button icons are ALWAYS visible */ | |
| button svg, | |
| button img, | |
| button .icon, | |
| .gr-slider button svg, | |
| .gr-number button svg, | |
| input + button svg, | |
| [type="number"] + button svg { | |
| display: inline-block !important; | |
| visibility: visible !important; | |
| opacity: 1 !important; | |
| width: 18px !important; | |
| height: 18px !important; | |
| color: #5f6368 !important; | |
| fill: currentColor !important; | |
| } | |
| /* Make sure buttons themselves are always visible */ | |
| button { | |
| opacity: 1 !important; | |
| visibility: visible !important; | |
| } | |
| /* Slider styling - clean and simple */ | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 100%; | |
| height: 6px; | |
| background: transparent; | |
| outline: none; | |
| border-radius: 3px; | |
| } | |
| /* Slider track - just grey, no blue fill */ | |
| input[type="range"]::-webkit-slider-runnable-track { | |
| width: 100%; | |
| height: 6px; | |
| background: #e0e0e0; | |
| border-radius: 3px; | |
| } | |
| input[type="range"]::-moz-range-track { | |
| width: 100%; | |
| height: 6px; | |
| background: #e0e0e0; | |
| border-radius: 3px; | |
| } | |
| /* Slider thumb - blue circle */ | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| background: #1a73e8; | |
| border: none; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.3); | |
| margin-top: -6px; | |
| } | |
| input[type="range"]::-moz-range-thumb { | |
| width: 18px; | |
| height: 18px; | |
| background: #1a73e8; | |
| border: none; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.3); | |
| } | |
| /* Slider thumb hover */ | |
| input[type="range"]::-webkit-slider-thumb:hover { | |
| background: #1557b0; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.4); | |
| } | |
| input[type="range"]::-moz-range-thumb:hover { | |
| background: #1557b0; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.4); | |
| } | |
| /* Slider labels and numbers */ | |
| .gr-slider label { | |
| font-weight: 700 !important; | |
| color: #000000 !important; | |
| margin-bottom: 8px !important; | |
| } | |
| /* Number input boxes - wider for 6 digits */ | |
| input[type="number"] { | |
| font-size: 0.875rem !important; | |
| font-weight: 400 !important; | |
| color: #000000 !important; | |
| padding: 4px 8px !important; | |
| min-width: 70px !important; | |
| max-width: 80px !important; | |
| height: 32px !important; | |
| text-align: center !important; | |
| line-height: 1.5 !important; | |
| border: none !important; | |
| background: transparent !important; | |
| } | |
| /* Slider number display - compact, no border */ | |
| .gr-slider input[type="number"] { | |
| min-width: 55px !important; | |
| max-width: 60px !important; | |
| border: none !important; | |
| background: transparent !important; | |
| } | |
| /* Seed input - has border */ | |
| #seed-input input[type="number"] { | |
| border: 1px solid #000000 !important; | |
| background: white !important; | |
| border-radius: 4px !important; | |
| min-width: 120px !important; | |
| max-width: none !important; | |
| padding: 8px 12px !important; | |
| } | |
| /* Refresh/reset buttons - MATCH NUMBER INPUT HEIGHT */ | |
| .gr-slider button, | |
| input[type="number"] + button, | |
| .gr-number button, | |
| button[aria-label*="Clear"], | |
| button[aria-label*="Reset"] { | |
| min-width: 32px !important; | |
| width: 32px !important; | |
| height: 32px !important; | |
| padding: 6px !important; | |
| display: inline-flex !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| margin-left: 6px !important; | |
| border-radius: 50% !important; | |
| background: transparent !important; | |
| border: none !important; | |
| cursor: pointer !important; | |
| flex-shrink: 0 !important; | |
| opacity: 1 !important; | |
| visibility: visible !important; | |
| transition: background 0.2s ease !important; | |
| } | |
| /* Hover effect for refresh buttons */ | |
| .gr-slider button:hover, | |
| input[type="number"] + button:hover, | |
| .gr-number button:hover { | |
| background: rgba(0, 0, 0, 0.05) !important; | |
| } | |
| /* Ensure buttons are always visible */ | |
| .gr-slider button, | |
| .gr-number button { | |
| position: relative !important; | |
| z-index: 10 !important; | |
| } | |
| /* Icons inside buttons - PROPORTIONAL SIZE */ | |
| .gr-slider button svg, | |
| input[type="number"] + button svg, | |
| .gr-number button svg, | |
| button svg { | |
| width: 18px !important; | |
| height: 18px !important; | |
| display: inline-block !important; | |
| visibility: visible !important; | |
| opacity: 1 !important; | |
| color: #5f6368 !important; | |
| fill: currentColor !important; | |
| } | |
| /* Input groups - better alignment and layout */ | |
| .gr-slider .wrap, | |
| .gr-number .wrap, | |
| .gr-slider, | |
| .gr-number { | |
| display: flex !important; | |
| align-items: center !important; | |
| gap: 6px !important; | |
| flex-wrap: nowrap !important; | |
| } | |
| /* Number input containers */ | |
| .gr-number, | |
| div:has(> input[type="number"]) { | |
| display: flex !important; | |
| align-items: center !important; | |
| gap: 6px !important; | |
| } | |
| /* Make sure buttons don't shrink or hide */ | |
| .gr-slider button, | |
| .gr-number button, | |
| input[type="number"] + button { | |
| flex-shrink: 0 !important; | |
| flex-grow: 0 !important; | |
| } | |
| /* Main prompt styling */ | |
| #main-prompt { | |
| position: relative; | |
| width: 100%; | |
| } | |
| /* Hide the subject upload component completely */ | |
| #subject-upload { | |
| display: none !important; | |
| height: 0 !important; | |
| width: 0 !important; | |
| overflow: hidden !important; | |
| position: absolute !important; | |
| opacity: 0 !important; | |
| pointer-events: auto !important; | |
| } | |
| /* Make sure file input is still clickable */ | |
| #subject-upload input[type="file"] { | |
| position: absolute !important; | |
| opacity: 0 !important; | |
| pointer-events: auto !important; | |
| } | |
| /* Remove borders from containers */ | |
| .block, | |
| .gr-box, | |
| .gr-form, | |
| .gr-panel, | |
| div.form, | |
| fieldset, | |
| .label-wrap { | |
| border: none !important; | |
| box-shadow: none !important; | |
| background: transparent !important; | |
| } | |
| /* Accordion - minimal border */ | |
| .accordion, | |
| .gr-accordion, | |
| details { | |
| border: 1px solid #e0e0e0 !important; | |
| border-radius: 4px !important; | |
| background: transparent !important; | |
| } | |
| /* Main title styling - black and bold */ | |
| h1 { | |
| font-size: 40px !important; | |
| font-weight: 700 !important; | |
| color: #000000 !important; | |
| } | |
| h2, h3, h4 { | |
| color: #000000 !important; | |
| font-weight: 700 !important; | |
| } | |
| /* Section headings - black and bold */ | |
| h3 { | |
| font-weight: 700 !important; | |
| font-size: 1.125rem !important; | |
| color: #000000 !important; | |
| } | |
| /* Accordion labels - black and bold */ | |
| .label-wrap span, | |
| button.label-wrap span, | |
| .accordion-header span, | |
| .accordion-header { | |
| font-weight: 700 !important; | |
| font-size: 1.125rem !important; | |
| color: #000000 !important; | |
| } | |
| /* All labels black and bold */ | |
| label, .label { | |
| color: #000000 !important; | |
| font-weight: 700 !important; | |
| } | |
| /* Top row labels - same size and aligned */ | |
| #main-prompt label, | |
| #main-prompt .label-wrap, | |
| .render-settings-label, | |
| .render-settings-label p, | |
| .render-settings-label strong, | |
| .render-settings-label .markdown-text { | |
| font-size: 1rem !important; | |
| font-weight: 700 !important; | |
| color: #000000 !important; | |
| margin-bottom: 0.5rem !important; | |
| line-height: 1.5rem !important; | |
| margin-top: 0 !important; | |
| padding-top: 0 !important; | |
| } | |
| /* Remove extra margins from markdown */ | |
| .render-settings-label .markdown-text, | |
| .render-settings-label { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| } | |
| /* Align columns at the top */ | |
| .gradio-row > .gradio-column { | |
| align-items: flex-start !important; | |
| } | |
| /* Make sure both labels start at same position */ | |
| #main-prompt, | |
| .markdown-text { | |
| margin-top: 0 !important; | |
| padding-top: 0 !important; | |
| } | |
| /* Text elements */ | |
| p, span, div { | |
| color: #333333 !important; | |
| } | |
| /* Input fields */ | |
| input, textarea, select { | |
| color: #000000 !important; | |
| } | |
| /* Markdown text */ | |
| .markdown-text, .prose { | |
| color: #000000 !important; | |
| } | |
| .markdown-text strong, .prose strong { | |
| color: #000000 !important; | |
| font-weight: 700 !important; | |
| } | |
| /* Main prompt input styling */ | |
| #main-prompt textarea { | |
| font-size: 1.5rem !important; | |
| line-height: 1.4 !important; | |
| color: #000000 !important; | |
| } | |
| #main-prompt textarea::placeholder { | |
| font-size: 1.5rem !important; | |
| color: #666666 !important; | |
| } | |
| #main-prompt label { | |
| color: #000000 !important; | |
| font-weight: 700 !important; | |
| font-size: 1rem !important; | |
| } | |
| """ | |
| # JavaScript for typing animation | |
| custom_js = """ | |
| <script> | |
| function startTypingAnimation() { | |
| const texts = [ | |
| "A vintage blue car", | |
| "A close-up portrait of a green iguana sitting on a moss-covered tree branch", | |
| "Professional product shot of a luxury leather handbag", | |
| "Architectural exterior of a modern minimalist house" | |
| ]; | |
| let textIndex = 0; | |
| let charIndex = 0; | |
| let isDeleting = false; | |
| let isTyping = true; | |
| function findTextarea() { | |
| return document.querySelector('#main-prompt textarea') || | |
| document.querySelector('textarea[placeholder]') || | |
| document.querySelector('textarea'); | |
| } | |
| function typeText() { | |
| const textarea = findTextarea(); | |
| if (!textarea) { | |
| setTimeout(typeText, 200); | |
| return; | |
| } | |
| // Stop animation if user has typed anything | |
| if (textarea.value && textarea.value.length > 0) { | |
| isTyping = false; | |
| return; | |
| } | |
| // Stop animation if user is focused on the field | |
| if (document.activeElement === textarea) { | |
| return; | |
| } | |
| if (!isTyping) return; | |
| const currentText = texts[textIndex]; | |
| let speed = 80; | |
| if (isDeleting) { | |
| // Delete character | |
| textarea.setAttribute('placeholder', currentText.substring(0, charIndex - 1)); | |
| charIndex--; | |
| speed = 40; | |
| if (charIndex === 0) { | |
| isDeleting = false; | |
| textIndex = (textIndex + 1) % texts.length; | |
| speed = 500; | |
| } | |
| } else { | |
| // Type character | |
| textarea.setAttribute('placeholder', currentText.substring(0, charIndex + 1)); | |
| charIndex++; | |
| speed = 80; | |
| if (charIndex === currentText.length) { | |
| isDeleting = true; | |
| speed = 2000; // Pause at end | |
| } | |
| } | |
| setTimeout(typeText, speed); | |
| } | |
| // Stop animation when user focuses on textarea | |
| function setupFocusListener() { | |
| const textarea = findTextarea(); | |
| if (textarea) { | |
| textarea.addEventListener('focus', function() { | |
| isTyping = false; | |
| }); | |
| textarea.addEventListener('input', function() { | |
| isTyping = false; | |
| }); | |
| } else { | |
| setTimeout(setupFocusListener, 200); | |
| } | |
| } | |
| // Wait for Gradio to load, then start | |
| setTimeout(function() { | |
| typeText(); | |
| setupFocusListener(); | |
| }, 1500); | |
| } | |
| // Start animation when page is ready | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', startTypingAnimation); | |
| } else { | |
| startTypingAnimation(); | |
| } | |
| </script> | |
| """ | |
| with gr.Blocks(title="FIBO Virtual Photo Studio ποΈ", theme=gr.themes.Soft(), css=custom_css) as demo: | |
| gr.Markdown(""" | |
| # FIBO Virtual Photo Studio | |
| **Controllable photo studio powered by BRIA FIBO** | |
| """, elem_id="header-section") | |
| # Top row: Prompt + Render Settings side by side | |
| with gr.Row(elem_id="main-controls-row"): | |
| with gr.Column(scale=1): | |
| prompt = gr.Textbox( | |
| label="What do you want to create?", | |
| value="", | |
| placeholder="A vintage blue car", | |
| lines=5, | |
| elem_id="main-prompt" | |
| ) | |
| with gr.Column(scale=1): | |
| gr.Markdown("**Render Settings**", elem_classes="render-settings-label") | |
| with gr.Row(elem_id="render-inputs-row"): | |
| seed = gr.Number(label="Seed (optional, for reproducibility)", value=None, precision=0, elem_id="seed-input", visible=False) | |
| show_seed_btn = gr.Button("Set Seed", variant="secondary", size="sm", elem_id="show-seed-btn", visible=True, scale=1) | |
| with gr.Row(): | |
| generate_btn = gr.Button("Generate", variant="primary", size="lg", elem_id="generate-btn", scale=3) | |
| reset_btn = gr.Button("Reset", variant="secondary", size="lg", elem_id="reset-btn", scale=1) | |
| # Hidden image upload (will be triggered by button) | |
| subject_image = gr.Image( | |
| label="", | |
| type="filepath", | |
| elem_id="subject-upload", | |
| visible=False | |
| ) | |
| # JavaScript to add upload button to prompt box | |
| gr.HTML(""" | |
| <script> | |
| function addUploadButton() { | |
| // Find the prompt textarea container | |
| const promptContainer = document.querySelector('#main-prompt'); | |
| if (!promptContainer) { | |
| setTimeout(addUploadButton, 200); | |
| return; | |
| } | |
| // Check if button already exists | |
| if (document.querySelector('#upload-btn-overlay')) { | |
| return; | |
| } | |
| // Create button | |
| const button = document.createElement('button'); | |
| button.id = 'upload-btn-overlay'; | |
| button.innerHTML = 'πΈ Upload Image'; | |
| button.style.cssText = ` | |
| position: absolute; | |
| bottom: 50px; | |
| right: 20px; | |
| padding: 8px 16px; | |
| font-size: 0.9rem; | |
| background: #4285f4; | |
| color: white; | |
| border: none; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-weight: 500; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| z-index: 1000; | |
| font-family: 'Google Sans', sans-serif; | |
| `; | |
| button.onmouseover = function() { | |
| this.style.background = '#357ae8'; | |
| this.style.boxShadow = '0 3px 6px rgba(0,0,0,0.15)'; | |
| }; | |
| button.onmouseout = function() { | |
| this.style.background = '#4285f4'; | |
| this.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; | |
| }; | |
| button.onclick = function() { | |
| const fileInput = document.querySelector('#subject-upload input[type="file"]'); | |
| if (fileInput) { | |
| fileInput.click(); | |
| } else { | |
| console.error('File input not found'); | |
| } | |
| }; | |
| // Make prompt container relative | |
| promptContainer.style.position = 'relative'; | |
| // Append button to container | |
| promptContainer.appendChild(button); | |
| // Add padding to textarea to avoid overlap | |
| const textarea = promptContainer.querySelector('textarea'); | |
| if (textarea) { | |
| textarea.style.paddingRight = '160px'; | |
| textarea.style.paddingBottom = '50px'; | |
| } | |
| } | |
| // Initialize on load | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', addUploadButton); | |
| } else { | |
| setTimeout(addUploadButton, 500); | |
| } | |
| // Image expand on click | |
| function setupImageExpand() { | |
| // Create modal overlay | |
| let modal = document.querySelector('.image-expand-modal'); | |
| if (!modal) { | |
| modal = document.createElement('div'); | |
| modal.className = 'image-expand-modal'; | |
| modal.innerHTML = '<img src="" alt="Expanded image">'; | |
| document.body.appendChild(modal); | |
| } | |
| const modalImg = modal.querySelector('img'); | |
| // Function to open modal | |
| function openModal(imgSrc) { | |
| modalImg.src = imgSrc; | |
| modal.classList.add('active'); | |
| document.body.style.overflow = 'hidden'; | |
| } | |
| // Function to close modal | |
| function closeModal() { | |
| modal.classList.remove('active'); | |
| document.body.style.overflow = ''; | |
| } | |
| // Click on modal to close | |
| modal.addEventListener('click', function(e) { | |
| if (e.target === modal || e.target === modalImg) { | |
| closeModal(); | |
| } | |
| }); | |
| // ESC key to close | |
| document.addEventListener('keydown', function(e) { | |
| if (e.key === 'Escape' && modal.classList.contains('active')) { | |
| closeModal(); | |
| } | |
| }); | |
| // Add click handlers to preview images | |
| function addClickHandlers() { | |
| const previewImages = document.querySelectorAll('#image-gallery [data-testid="detailed-image"], #image-gallery .media-button img'); | |
| previewImages.forEach(img => { | |
| // Skip if already has handler | |
| if (img.hasAttribute('data-expand-handler')) { | |
| return; | |
| } | |
| // Mark as handled | |
| img.setAttribute('data-expand-handler', 'true'); | |
| // Add click handler | |
| img.addEventListener('click', function(e) { | |
| e.stopPropagation(); | |
| const imgSrc = this.src || this.getAttribute('src'); | |
| if (imgSrc) { | |
| openModal(imgSrc); | |
| } | |
| }); | |
| }); | |
| } | |
| // Setup handlers initially and when gallery updates | |
| addClickHandlers(); | |
| // Custom carousel navigation | |
| function setupCarouselNavigation() { | |
| const gallery = document.querySelector('#image-gallery'); | |
| let navContainer = document.querySelector('#carousel-navigation'); | |
| const prevBtn = document.querySelector('#carousel-prev'); | |
| const nextBtn = document.querySelector('#carousel-next'); | |
| const counter = document.querySelector('#carousel-counter'); | |
| if (!gallery) { | |
| setTimeout(setupCarouselNavigation, 500); | |
| return; | |
| } | |
| // Move navigation container inside gallery if it's not already there | |
| if (navContainer && navContainer.parentElement !== gallery) { | |
| gallery.style.position = 'relative'; | |
| gallery.appendChild(navContainer); | |
| } else if (!navContainer) { | |
| // Create navigation if it doesn't exist | |
| navContainer = document.createElement('div'); | |
| navContainer.id = 'carousel-navigation'; | |
| navContainer.style.cssText = 'display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 5;'; | |
| navContainer.innerHTML = ` | |
| <button id="carousel-prev" style="position: absolute; left: 20px; top: 50%; transform: translateY(-50%); background: rgba(0,0,0,0.7); color: white; border: none; border-radius: 50%; width: 48px; height: 48px; font-size: 24px; cursor: pointer; z-index: 10; display: flex; align-items: center; justify-content: center; pointer-events: auto;">βΉ</button> | |
| <button id="carousel-next" style="position: absolute; right: 20px; top: 50%; transform: translateY(-50%); background: rgba(0,0,0,0.7); color: white; border: none; border-radius: 50%; width: 48px; height: 48px; font-size: 24px; cursor: pointer; z-index: 10; display: flex; align-items: center; justify-content: center; pointer-events: auto;">βΊ</button> | |
| <div id="carousel-counter" style="position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.7); color: white; padding: 8px 16px; border-radius: 20px; font-size: 14px; z-index: 10; pointer-events: none;">1 / 1</div> | |
| `; | |
| gallery.style.position = 'relative'; | |
| gallery.appendChild(navContainer); | |
| } | |
| // Re-get buttons after creating/moving container | |
| const prevBtnFinal = document.querySelector('#carousel-prev'); | |
| const nextBtnFinal = document.querySelector('#carousel-next'); | |
| const counterFinal = document.querySelector('#carousel-counter'); | |
| if (!prevBtnFinal || !nextBtnFinal || !counterFinal) { | |
| setTimeout(setupCarouselNavigation, 500); | |
| return; | |
| } | |
| let currentIndex = 0; | |
| let totalImages = 0; | |
| function updateCarousel() { | |
| // Get all images from gallery - count by finding all thumbnail buttons | |
| const thumbnails = gallery.querySelectorAll('.thumbnail-item, button[aria-label*="Thumbnail"], button[aria-label*="thumbnail"]'); | |
| totalImages = thumbnails.length || 1; // At least 1 (the preview) | |
| // If we can't find thumbnails, try counting images in the gallery data | |
| if (totalImages === 0) { | |
| const galleryImages = gallery.querySelectorAll('img'); | |
| totalImages = Math.max(1, galleryImages.length); | |
| } | |
| if (totalImages <= 1) { | |
| // Hide navigation if only one or no images | |
| navContainer.style.display = 'none'; | |
| return; | |
| } | |
| // Show navigation | |
| navContainer.style.display = 'block'; | |
| // Update counter | |
| counterFinal.textContent = `${currentIndex + 1} / ${totalImages}`; | |
| // Update button states | |
| prevBtnFinal.style.opacity = currentIndex === 0 ? '0.5' : '1'; | |
| prevBtnFinal.style.cursor = currentIndex === 0 ? 'not-allowed' : 'pointer'; | |
| nextBtnFinal.style.opacity = currentIndex === totalImages - 1 ? '0.5' : '1'; | |
| nextBtnFinal.style.cursor = currentIndex === totalImages - 1 ? 'not-allowed' : 'pointer'; | |
| // Change the selected image by clicking the thumbnail (even if hidden) | |
| if (thumbnails[currentIndex]) { | |
| thumbnails[currentIndex].click(); | |
| } else { | |
| // Fallback: try to find and click any button that represents this index | |
| const allButtons = gallery.querySelectorAll('button'); | |
| const thumbnailButtons = Array.from(allButtons).filter(btn => { | |
| const ariaLabel = btn.getAttribute('aria-label') || ''; | |
| return ariaLabel.includes('Thumbnail') || ariaLabel.includes('thumbnail'); | |
| }); | |
| if (thumbnailButtons[currentIndex]) { | |
| thumbnailButtons[currentIndex].click(); | |
| } | |
| } | |
| } | |
| // Previous button | |
| prevBtnFinal.addEventListener('click', function(e) { | |
| e.stopPropagation(); | |
| if (currentIndex > 0) { | |
| currentIndex--; | |
| updateCarousel(); | |
| // Seed will be updated via gallery.select event | |
| } | |
| }); | |
| // Next button | |
| nextBtnFinal.addEventListener('click', function(e) { | |
| e.stopPropagation(); | |
| if (currentIndex < totalImages - 1) { | |
| currentIndex++; | |
| updateCarousel(); | |
| // Seed will be updated via gallery.select event | |
| } | |
| }); | |
| // Keyboard navigation | |
| document.addEventListener('keydown', function(e) { | |
| if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') { | |
| return; // Don't navigate when typing | |
| } | |
| if (e.key === 'ArrowLeft' && currentIndex > 0) { | |
| currentIndex--; | |
| updateCarousel(); | |
| } else if (e.key === 'ArrowRight' && currentIndex < totalImages - 1) { | |
| currentIndex++; | |
| updateCarousel(); | |
| } | |
| }); | |
| // Watch for gallery updates | |
| let lastImageCount = 0; | |
| const observer = new MutationObserver(function() { | |
| setTimeout(function() { | |
| addClickHandlers(); | |
| const newCount = gallery.querySelectorAll('.thumbnail-item, button[aria-label*="Thumbnail"]').length; | |
| // Reset to first image if new images were generated | |
| if (newCount > lastImageCount) { | |
| currentIndex = 0; | |
| lastImageCount = newCount; | |
| } | |
| updateCarousel(); | |
| }, 100); | |
| }); | |
| observer.observe(gallery, { childList: true, subtree: true }); | |
| // Initial update | |
| setTimeout(function() { | |
| updateCarousel(); | |
| lastImageCount = totalImages; | |
| }, 500); | |
| setTimeout(updateCarousel, 1000); | |
| setTimeout(updateCarousel, 2000); | |
| } | |
| // Initialize carousel navigation | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', setupCarouselNavigation); | |
| } else { | |
| setTimeout(setupCarouselNavigation, 1000); | |
| } | |
| } | |
| // Initialize image expand | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', setupImageExpand); | |
| } else { | |
| setTimeout(setupImageExpand, 1000); | |
| } | |
| // Accordion auto-close removed per user request | |
| // (Not reliably implementable in Hugging Face Spaces) | |
| </script> | |
| """) | |
| with gr.Row(): | |
| # Left column: All controls (50% width) | |
| with gr.Column(scale=1): | |
| # Negative Prompt | |
| with gr.Accordion("Negative Prompt", open=False): | |
| neg_prompt = gr.Textbox( | |
| label="Things to avoid", | |
| value=current_preset.get("negative_prompt", ""), | |
| placeholder="blurry, watermark, oversaturated, amateur, low quality", | |
| lines=2 | |
| ) | |
| # Camera settings | |
| with gr.Accordion("Camera Settings", open=False): | |
| focal_length = gr.Dropdown( | |
| choices=list(FOCAL_LENGTH_TO_FOV.keys()), | |
| value="50mm (Normal)", | |
| label="Focal Length (35mm equivalent)", | |
| info="Choose lens perspective" | |
| ) | |
| fov = gr.Slider(18, 114, value=47, step=1, label="Field of View (Β°)", | |
| info="Fine-tune FOV manually") | |
| with gr.Row(): | |
| aperture = gr.Slider(1.2, 16, value=2.8, step=0.1, label="Aperture (f-stop)") | |
| shutter = gr.Slider(0.001, 1.0, value=0.008, step=0.001, label="Shutter (s)", info="Exposure time") | |
| with gr.Row(): | |
| iso = gr.Slider(100, 6400, value=200, step=100, label="ISO") | |
| # Composition settings | |
| with gr.Accordion("Composition", open=False): | |
| with gr.Row(): | |
| angle = gr.Dropdown( | |
| choices=list(ANGLE_LABELS.keys()), | |
| value="Eye Level", | |
| label="Camera Angle" | |
| ) | |
| framing = gr.Dropdown( | |
| choices=list(FRAMING_LABELS.keys()), | |
| value="Rule of Thirds", | |
| label="Framing Style" | |
| ) | |
| # Lighting settings | |
| with gr.Accordion("Lighting", open=False): | |
| light_preset = gr.Dropdown( | |
| choices=list(LIGHTING_LABELS.keys()), | |
| value="Softbox - Warm", | |
| label="Lighting Preset" | |
| ) | |
| with gr.Row(): | |
| intensity = gr.Slider(0, 100, value=60, step=5, label="Intensity (%)") | |
| direction = gr.Dropdown( | |
| choices=list(DIRECTION_LABELS.keys()), | |
| value="Key Left", | |
| label="Light Direction" | |
| ) | |
| # Background & Environment | |
| with gr.Accordion("Background & Environment", open=False): | |
| gr.Markdown("**Choose a preset OR type your own:**") | |
| background = gr.Dropdown( | |
| choices=list(BACKGROUND_LABELS.keys()), | |
| value="Studio Paper - Beige", | |
| label="Preset Background", | |
| info="Select from pre-made backgrounds" | |
| ) | |
| gr.Markdown("**β OR β**") | |
| background_custom = gr.Textbox( | |
| label="Custom Background Environment", | |
| placeholder="Type any background you want: sunset over ocean, neon cyberpunk alley, zen garden, inside spaceship...", | |
| lines=2, | |
| info="If filled, this overrides the preset above" | |
| ) | |
| # Color grading | |
| with gr.Accordion("Color Grading", open=False): | |
| with gr.Row(): | |
| contrast = gr.Slider(-10, 10, value=6, step=1, label="Contrast") | |
| saturation = gr.Slider(-10, 10, value=-4, step=1, label="Saturation") | |
| with gr.Row(): | |
| kelvin = gr.Slider(3000, 7500, value=5600, step=100, label="White Balance (K)") | |
| tint = gr.Slider(-10, 10, value=3, step=1, label="Tint") | |
| # Film look | |
| with gr.Accordion("Film Profile", open=False): | |
| film = gr.Dropdown( | |
| choices=list(FILM_LABELS.keys()), | |
| value="Film: Kodak Portra 400", | |
| label="Film Aesthetic" | |
| ) | |
| # Advanced render settings (collapsed) | |
| with gr.Accordion("Advanced Render Settings", open=False): | |
| with gr.Row(): | |
| guidance = gr.Slider(1, 20, value=5.0, step=0.5, label="Guidance Scale") | |
| steps = gr.Slider(10, 50, value=50, step=1, label="Inference Steps") | |
| with gr.Row(): | |
| width = gr.Slider(512, 2048, value=1024, step=64, label="Width (px)") | |
| height = gr.Slider(512, 2048, value=1024, step=64, label="Height (px)") | |
| # Advanced JSON editing | |
| with gr.Accordion("Advanced: Edit Preset JSON", open=False): | |
| gr.Markdown("For advanced users: Edit the full preset configuration") | |
| preset_json = gr.Code( | |
| value=load_default_preset(), | |
| language="json", | |
| label="Preset JSON", | |
| lines=12 | |
| ) | |
| preset_update_btn = gr.Button("Apply JSON Changes", size="sm") | |
| preset_status = gr.Markdown("") | |
| # Right column: Generation & Gallery (50% width) | |
| with gr.Column(scale=1): | |
| gallery = gr.Gallery( | |
| label="Generated Images", | |
| columns=1, | |
| rows=1, | |
| height=800, | |
| object_fit="contain", | |
| show_label=False, | |
| elem_id="image-gallery", | |
| show_download_button=False, | |
| preview=True, | |
| selected_index=0 | |
| ) | |
| # Custom carousel navigation HTML (will be positioned over gallery) | |
| carousel_nav = gr.HTML(""" | |
| <div id="carousel-navigation" style="display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 5;"> | |
| <button id="carousel-prev" style="position: absolute; left: 20px; top: 50%; transform: translateY(-50%); background: rgba(0,0,0,0.7); color: white; border: none; border-radius: 50%; width: 48px; height: 48px; font-size: 24px; cursor: pointer; z-index: 10; display: flex; align-items: center; justify-content: center; pointer-events: auto;">βΉ</button> | |
| <button id="carousel-next" style="position: absolute; right: 20px; top: 50%; transform: translateY(-50%); background: rgba(0,0,0,0.7); color: white; border: none; border-radius: 50%; width: 48px; height: 48px; font-size: 24px; cursor: pointer; z-index: 10; display: flex; align-items: center; justify-content: center; pointer-events: auto;">βΊ</button> | |
| <div id="carousel-counter" style="position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.7); color: white; padding: 8px 16px; border-radius: 20px; font-size: 14px; z-index: 10; pointer-events: none;">1 / 1</div> | |
| </div> | |
| """, elem_id="carousel-navigation") | |
| # Hidden states for compatibility | |
| style_json = gr.State(load_default_style()) | |
| use_style = gr.State(False) | |
| seeds_state = gr.State([]) # Store seeds for each generated image | |
| # Sync focal length dropdown with FOV slider | |
| def focal_length_to_fov(focal_length_str: str) -> int: | |
| """Convert focal length selection to FOV.""" | |
| return FOCAL_LENGTH_TO_FOV.get(focal_length_str, 47) | |
| def fov_to_focal_length_hint(fov_value: int) -> str: | |
| """Get closest focal length hint for FOV value.""" | |
| if fov_value in FOV_TO_FOCAL_LENGTH: | |
| return FOV_TO_FOCAL_LENGTH[fov_value] | |
| # Find closest | |
| closest_fov = min(FOCAL_LENGTH_TO_FOV.values(), key=lambda x: abs(x - fov_value)) | |
| return FOV_TO_FOCAL_LENGTH.get(closest_fov, "Custom FOV") | |
| # When focal length changes, update FOV | |
| focal_length.change( | |
| fn=focal_length_to_fov, | |
| inputs=[focal_length], | |
| outputs=[fov] | |
| ) | |
| # Wire up controls to update JSON | |
| control_inputs = [ | |
| fov, aperture, shutter, iso, | |
| angle, framing, | |
| light_preset, intensity, direction, | |
| contrast, saturation, kelvin, tint, | |
| background, background_custom, film, | |
| guidance, steps, width, height, seed, | |
| prompt, neg_prompt | |
| ] | |
| for control in control_inputs: | |
| control.change( | |
| fn=update_preset_from_controls, | |
| inputs=control_inputs, | |
| outputs=[preset_json] | |
| ) | |
| # JSON update button | |
| preset_update_btn.click( | |
| fn=update_preset_from_json, | |
| inputs=[preset_json], | |
| outputs=[preset_json, preset_status] | |
| ) | |
| # Reset function - reset all settings to defaults | |
| def reset_all_settings(): | |
| """Reset all settings to default values.""" | |
| default_preset = json.loads(load_default_preset()) | |
| controls = default_preset.get("controls", {}) | |
| render = default_preset.get("render", {}) | |
| # Map default values to UI controls | |
| return ( | |
| "", # prompt | |
| default_preset.get("negative_prompt", ""), # neg_prompt | |
| "50mm (Normal)", # focal_length | |
| controls.get("camera", {}).get("fov", 47), # fov | |
| controls.get("camera", {}).get("aperture", 2.8), # aperture | |
| controls.get("camera", {}).get("shutter", 0.008), # shutter | |
| controls.get("camera", {}).get("iso", 200), # iso | |
| "Eye Level", # angle | |
| "Rule of Thirds", # framing | |
| "Softbox - Warm", # light_preset | |
| controls.get("lighting", {}).get("intensity", 60), # intensity | |
| "Key Left", # direction | |
| "Studio Paper - Beige", # background | |
| "", # background_custom | |
| controls.get("color", {}).get("contrast", 6), # contrast | |
| controls.get("color", {}).get("saturation", -4), # saturation | |
| controls.get("color", {}).get("wb", {}).get("kelvin", 5600), # kelvin | |
| controls.get("color", {}).get("wb", {}).get("tint", 3), # tint | |
| "Film: Kodak Portra 400", # film | |
| render.get("guidance", 5.0), # guidance | |
| render.get("steps", 50), # steps | |
| render.get("resolution", {}).get("width", 1024), # width | |
| render.get("resolution", {}).get("height", 1024), # height | |
| load_default_preset(), # preset_json | |
| None, # seed (reset to None) | |
| gr.update(visible=False), # seed visibility (hide it) | |
| gr.update(visible=True) # show_seed_btn (show "Set Seed" button again) | |
| ) | |
| reset_btn.click( | |
| fn=reset_all_settings, | |
| inputs=[], | |
| outputs=[ | |
| prompt, neg_prompt, focal_length, fov, aperture, shutter, iso, | |
| angle, framing, light_preset, intensity, direction, | |
| background, background_custom, contrast, saturation, kelvin, tint, | |
| film, guidance, steps, width, height, preset_json, seed, seed, show_seed_btn | |
| ] | |
| ) | |
| # Show seed input when "Set Seed" button is clicked | |
| def show_seed_input(): | |
| return gr.update(visible=True), gr.update(visible=False) | |
| show_seed_btn.click( | |
| fn=show_seed_input, | |
| inputs=[], | |
| outputs=[seed, show_seed_btn] | |
| ) | |
| # Generate button - updates seed only, shows seed if set | |
| def generate_images_handler(preset_json, style_json, subject_image, use_style): | |
| """Generate images and update seed for reproducibility.""" | |
| images, info, used_seed, seeds = asyncio.run(generate_images_progressive(preset_json, style_json, subject_image, use_style)) | |
| # Show seed input if seed is set, hide "Set Seed" button | |
| seed_visible = used_seed is not None and used_seed > 0 | |
| return images, used_seed, seeds, gr.update(visible=seed_visible), gr.update(visible=False) | |
| generate_btn.click( | |
| fn=generate_images_handler, | |
| inputs=[preset_json, style_json, subject_image, use_style], | |
| outputs=[gallery, seed, seeds_state, seed, show_seed_btn] | |
| ) | |
| # Add typing animation JavaScript | |
| gr.HTML(custom_js) | |
| # Use Gradio's native gallery.select() event to update seed | |
| def update_seed_on_select(seeds_list, evt: gr.SelectData): | |
| """Update seed input when gallery image is selected.""" | |
| selected_index = evt.index | |
| print(f"πΌοΈ Gallery image selected: index {selected_index}") | |
| print(f"π Seeds list: {seeds_list}") | |
| if seeds_list and selected_index < len(seeds_list): | |
| selected_seed = seeds_list[selected_index] | |
| print(f"β Updating seed to: {selected_seed}") | |
| return selected_seed | |
| else: | |
| print(f"β Invalid index or empty seeds list") | |
| return None | |
| # Gallery select event - triggered when clicking any thumbnail | |
| gallery.select( | |
| fn=update_seed_on_select, | |
| inputs=[seeds_state], | |
| outputs=[seed], | |
| show_progress=False | |
| ) | |
| return demo | |
| if __name__ == "__main__": | |
| demo = build_ui() | |
| demo.queue() # Enable queuing for async functions | |
| demo.launch() | |