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()