""" Minecraftify! — turn any photo into a Minecraft-style scene. Gradio UI with FLUX.2 diffusion model + Minecraft LoRA for converting images to Minecraft style. """ import os import sys import logging import threading import time import torch import gradio as gr import spaces from PIL import Image from huggingface_hub import snapshot_download from diffusers import DiffusionPipeline logging.basicConfig( level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s", force=True, ) logger = logging.getLogger("minecraftify") def log_event(message: str): logger.info(message) print(f"[minecraftify] {message}", flush=True) _original_unraisablehook = sys.unraisablehook def _ignore_asyncio_closed_fd_warning(unraisable): object_name = getattr(unraisable.object, "__qualname__", "") is_asyncio_loop_cleanup = object_name == "BaseEventLoop.__del__" is_closed_fd_warning = ( unraisable.exc_type is ValueError and "Invalid file descriptor: -1" in str(unraisable.exc_value) ) if is_asyncio_loop_cleanup and is_closed_fd_warning: return _original_unraisablehook(unraisable) sys.unraisablehook = _ignore_asyncio_closed_fd_warning log_event("App module imported") # ---------------------------------------------------------------------- # CUSTOM MINECRAFT CSS — THE ULTIMATE THEME # ---------------------------------------------------------------------- CUSTOM_CSS = """ /* ══════════════════════════════════════════════════════════════════════ MINECRAFT THEME — Premium Pixel-Art UI ══════════════════════════════════════════════════════════════════════ */ /* ── Fonts ── */ @import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=VT323&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap'); /* ── CSS Variables ── */ :root { --mc-green: #5D8C3E; --mc-green-light: #7CB342; --mc-green-dark: #3B5E28; --mc-grass-top: #6DBE45; --mc-dirt: #8B6914; --mc-dirt-dark: #6B4F10; --mc-dirt-light: #9B7924; --mc-stone: #7F7F7F; --mc-stone-dark: #5A5A5A; --mc-stone-light: #999999; --mc-cobble: #6E6E6E; --mc-sky-day: #78A7FF; --mc-sky-horizon: #B4D4FF; --mc-text: #FFFFFF; --mc-text-shadow: #3F3F00; --mc-gold: #FFAA00; --mc-gold-dark: #CC8800; --mc-red: #FF5555; --mc-aqua: #55FFFF; --mc-dark-bg: #1E1E1E; --mc-panel-bg: rgba(0, 0, 0, 0.72); --mc-panel-inner: rgba(0, 0, 0, 0.35); --mc-border: #1A1A1A; --mc-border-light: #3A3A3A; --mc-highlight: #C6C6C6; --mc-inventory-slot: #8B8B8B; --mc-inventory-inner: #373737; } /* ── Animated Panorama Background ── */ .gradio-container { background: linear-gradient(180deg, #78A7FF 0%, #B4D4FF 30%, #C8E0FF 38%, #6DBE45 38.5%, #5D8C3E 40%, #3B5E28 42%, #8B6914 42.5%, #7A5B10 55%, #6B4F10 70%, #5A4210 85%, #3D2D0A 100% ) !important; background-attachment: fixed !important; font-family: 'VT323', monospace !important; min-height: 100vh; position: relative; overflow-x: hidden; } /* ── Floating Block Particles (Pure CSS) ── */ .gradio-container::before { content: ''; position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; background-image: radial-gradient(2px 2px at 10% 20%, rgba(255,255,255,0.4) 50%, transparent 50%), radial-gradient(2px 2px at 30% 60%, rgba(255,255,255,0.3) 50%, transparent 50%), radial-gradient(2px 2px at 50% 10%, rgba(255,255,255,0.5) 50%, transparent 50%), radial-gradient(2px 2px at 70% 40%, rgba(255,255,255,0.35) 50%, transparent 50%), radial-gradient(2px 2px at 90% 80%, rgba(255,255,255,0.4) 50%, transparent 50%), radial-gradient(3px 3px at 20% 85%, rgba(255,255,255,0.2) 50%, transparent 50%), radial-gradient(2px 2px at 80% 15%, rgba(255,255,255,0.45) 50%, transparent 50%), radial-gradient(2px 2px at 45% 75%, rgba(255,255,255,0.25) 50%, transparent 50%); animation: sparkle 4s ease-in-out infinite alternate; } @keyframes sparkle { 0% { opacity: 0.4; } 50% { opacity: 0.8; } 100% { opacity: 0.5; } } /* ── All content above particles ── */ .gradio-container > * { position: relative; z-index: 1; } /* ── Main wrapper ── */ .main-wrap { max-width: 1150px; margin: 0 auto; } /* ══════════════════════════════════════════════════════════════════════ TITLE AREA — Minecraft Logo Style ══════════════════════════════════════════════════════════════════════ */ .mc-title-area { text-align: center; padding: 35px 20px 10px 20px; position: relative; } .mc-title-area h1 { font-family: 'Silkscreen', cursive !important; font-size: 3.5rem !important; color: #FFFFFF !important; text-shadow: 4px 4px 0px #3F3F00, -2px -2px 0px #000, 2px -2px 0px #000, -2px 2px 0px #000, 2px 2px 0px #000, 0px 0px 20px rgba(109, 190, 69, 0.4) !important; margin: 0 !important; letter-spacing: 3px; line-height: 1.2 !important; animation: title-float 3s ease-in-out infinite; } @keyframes title-float { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(-6px); } } .mc-subtitle { font-family: 'VT323', monospace !important; font-size: 1.4rem !important; color: #E8E8E8 !important; text-shadow: 1px 1px 3px rgba(0,0,0,0.9) !important; margin-top: 10px !important; max-width: 600px; margin-left: auto !important; margin-right: auto !important; line-height: 1.4 !important; letter-spacing: 0.5px; } .mc-version-tag { display: inline-block; font-family: 'Press Start 2P', cursive !important; font-size: 0.55rem !important; color: var(--mc-gold) !important; background: rgba(0,0,0,0.6); border: 1px solid var(--mc-gold-dark); padding: 4px 10px; margin-top: 10px; letter-spacing: 1px; animation: gold-glow 2s ease-in-out infinite alternate; } @keyframes gold-glow { 0% { box-shadow: 0 0 5px rgba(255, 170, 0, 0.2); } 100% { box-shadow: 0 0 15px rgba(255, 170, 0, 0.5); } } /* ══════════════════════════════════════════════════════════════════════ PANELS — Minecraft Inventory Style ══════════════════════════════════════════════════════════════════════ */ .mc-panel { background: var(--mc-panel-bg) !important; border: 3px solid var(--mc-border) !important; border-radius: 0px !important; box-shadow: inset 2px 2px 0px rgba(255,255,255,0.08), inset -2px -2px 0px rgba(0,0,0,0.4), 6px 6px 0px rgba(0,0,0,0.5), 0 0 40px rgba(0,0,0,0.3) !important; padding: 20px !important; position: relative; overflow: hidden; } /* Subtle noise texture overlay on panels */ .mc-panel::before { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-image: repeating-linear-gradient( 0deg, transparent, transparent 2px, rgba(255,255,255,0.015) 2px, rgba(255,255,255,0.015) 4px ), repeating-linear-gradient( 90deg, transparent, transparent 2px, rgba(255,255,255,0.01) 2px, rgba(255,255,255,0.01) 4px ); pointer-events: none; image-rendering: pixelated; } /* ── Inner groups ── */ .gradio-group { background: var(--mc-panel-inner) !important; border: 2px solid var(--mc-border-light) !important; border-radius: 0px !important; } /* ══════════════════════════════════════════════════════════════════════ SECTION HEADERS — Pixel Label Style ══════════════════════════════════════════════════════════════════════ */ .mc-section-label { font-family: 'Silkscreen', cursive !important; font-size: 1rem !important; color: var(--mc-gold) !important; text-shadow: 2px 2px 0px rgba(0,0,0,0.8) !important; letter-spacing: 1.5px; padding: 6px 14px; margin-bottom: 10px; background: linear-gradient(90deg, rgba(255,170,0,0.12) 0%, transparent 100%); border-left: 3px solid var(--mc-gold); display: flex; align-items: center; gap: 8px; } .mc-section-label .mc-icon { font-size: 1.6rem; filter: drop-shadow(1px 1px 0px rgba(0,0,0,0.6)); } /* ══════════════════════════════════════════════════════════════════════ LABELS & TEXT ══════════════════════════════════════════════════════════════════════ */ .gradio-container label, .gradio-container .label-wrap span { font-family: 'VT323', monospace !important; font-size: 1.2rem !important; color: var(--mc-gold) !important; text-shadow: 1px 1px 0px rgba(0,0,0,0.7) !important; letter-spacing: 1px; } .gradio-container .gr-prose p, .gradio-container .gr-prose li { font-family: 'VT323', monospace !important; color: #C8C8C8 !important; } /* ══════════════════════════════════════════════════════════════════════ BUTTON — Minecraft Stone Button (Authentic) ══════════════════════════════════════════════════════════════════════ */ .mc-btn-primary, .gr-button-primary, button.primary { font-family: 'Silkscreen', cursive !important; font-size: 1.15rem !important; letter-spacing: 1px; background: linear-gradient(180deg, #9A9A9A 0%, #888888 20%, #6A6A6A 45%, #585858 55%, #484848 80%, #3A3A3A 100% ) !important; color: var(--mc-text) !important; border: 3px solid #000 !important; border-radius: 0px !important; box-shadow: inset 2px 2px 0px rgba(255,255,255,0.3), inset -2px -2px 0px rgba(0,0,0,0.5), 0 4px 0px #222 !important; text-shadow: 2px 2px 0px #3F3F00 !important; padding: 14px 40px !important; cursor: pointer; transition: all 0.05s ease !important; text-transform: none !important; position: relative; image-rendering: pixelated; } .mc-btn-primary:hover, .gr-button-primary:hover, button.primary:hover { background: linear-gradient(180deg, #B0B0B0 0%, #A0A0A0 20%, #888888 45%, #787878 55%, #686868 80%, #585858 100% ) !important; color: #FFFFAA !important; box-shadow: inset 2px 2px 0px rgba(255,255,255,0.4), inset -2px -2px 0px rgba(0,0,0,0.4), 0 4px 0px #222 !important; } .mc-btn-primary:active, .gr-button-primary:active, button.primary:active { background: linear-gradient(180deg, #4A4A4A 0%, #3A3A3A 50%, #5A5A5A 100% ) !important; box-shadow: inset -2px -2px 0px rgba(255,255,255,0.15), inset 2px 2px 0px rgba(0,0,0,0.5), 0 1px 0px #222 !important; transform: translateY(3px); } /* ══════════════════════════════════════════════════════════════════════ IMAGE UPLOAD — Crafting Table Slot Style ══════════════════════════════════════════════════════════════════════ */ .mc-upload-area { position: relative; } .image-upload, .gr-image, .gr-file { border: 3px solid var(--mc-inventory-inner) !important; border-radius: 0px !important; background: rgba(0,0,0,0.55) !important; box-shadow: inset 2px 2px 0px rgba(0,0,0,0.5), inset -2px -2px 0px rgba(139,139,139,0.2) !important; transition: border-color 0.2s ease, box-shadow 0.2s ease; image-rendering: pixelated; } .image-upload:hover, .gr-image:hover { border-color: var(--mc-gold) !important; box-shadow: inset 2px 2px 0px rgba(0,0,0,0.5), inset -2px -2px 0px rgba(139,139,139,0.2), 0 0 15px rgba(255, 170, 0, 0.2) !important; } /* ══════════════════════════════════════════════════════════════════════ INPUT FIELDS — Minecraft Sign / Book Style ══════════════════════════════════════════════════════════════════════ */ textarea, input[type="text"], input[type="number"] { font-family: 'VT323', monospace !important; font-size: 1.15rem !important; background: rgba(0,0,0,0.6) !important; color: var(--mc-text) !important; border: 2px solid var(--mc-inventory-inner) !important; border-radius: 0px !important; box-shadow: inset 1px 1px 0px rgba(0,0,0,0.4) !important; image-rendering: pixelated; } textarea:focus, input[type="text"]:focus, input[type="number"]:focus { border-color: var(--mc-gold) !important; outline: none !important; box-shadow: 0 0 0 1px var(--mc-gold), inset 1px 1px 0px rgba(0,0,0,0.4) !important; } textarea::placeholder, input::placeholder { color: #666 !important; font-family: 'VT323', monospace !important; } /* ══════════════════════════════════════════════════════════════════════ SLIDERS — Minecraft XP Bar Style ══════════════════════════════════════════════════════════════════════ */ input[type="range"] { accent-color: var(--mc-green-light) !important; height: 6px; } input[type="range"]::-webkit-slider-thumb { background: var(--mc-green-light) !important; border: 2px solid #000 !important; width: 16px; height: 16px; border-radius: 0px !important; image-rendering: pixelated; } /* ══════════════════════════════════════════════════════════════════════ ACCORDION — Chest Opening Style ══════════════════════════════════════════════════════════════════════ */ .gradio-accordion { background: rgba(0,0,0,0.3) !important; border: 2px solid var(--mc-border-light) !important; border-radius: 0px !important; overflow: hidden; } .gradio-accordion .label-wrap { background: rgba(0,0,0,0.4) !important; border-radius: 0px !important; padding: 10px 14px !important; transition: background 0.2s ease; } .gradio-accordion .label-wrap:hover { background: rgba(255,170,0,0.1) !important; } .gradio-accordion .label-wrap span { font-family: 'VT323', monospace !important; font-size: 1.15rem !important; color: var(--mc-highlight) !important; } /* ══════════════════════════════════════════════════════════════════════ STATUS / GENERATING — Enchantment Glow ══════════════════════════════════════════════════════════════════════ */ .generating { border-color: var(--mc-aqua) !important; animation: enchant-glow 1.5s ease-in-out infinite alternate !important; } @keyframes enchant-glow { 0% { box-shadow: 0 0 8px rgba(85, 255, 255, 0.3), inset 0 0 8px rgba(85, 255, 255, 0.1); } 100% { box-shadow: 0 0 25px rgba(85, 255, 255, 0.5), inset 0 0 15px rgba(85, 255, 255, 0.2); } } /* ══════════════════════════════════════════════════════════════════════ TIPS SECTION — Book & Quill Style ══════════════════════════════════════════════════════════════════════ */ .mc-tips { background: rgba(0,0,0,0.55) !important; border: 2px solid var(--mc-inventory-inner) !important; border-radius: 0px !important; padding: 18px 22px !important; margin-top: 16px; box-shadow: inset 1px 1px 0px rgba(255,255,255,0.05), inset -1px -1px 0px rgba(0,0,0,0.3) !important; } .mc-tips p, .mc-tips li { font-family: 'VT323', monospace !important; color: #B8B8B8 !important; font-size: 1.1rem !important; text-shadow: 1px 1px 0px rgba(0,0,0,0.5) !important; line-height: 1.6 !important; } .mc-tips strong, .mc-tips .mc-tip-title { color: var(--mc-gold) !important; font-family: 'Silkscreen', cursive !important; font-size: 0.9rem !important; } .mc-tips li::marker { color: var(--mc-green-light); } /* ══════════════════════════════════════════════════════════════════════ FOOTER ══════════════════════════════════════════════════════════════════════ */ .mc-footer { text-align: center; padding: 20px 10px 30px; } .mc-footer p { font-family: 'VT323', monospace !important; color: rgba(255,255,255,0.35) !important; font-size: 1rem !important; letter-spacing: 0.5px; } .mc-footer a { color: var(--mc-gold) !important; text-decoration: none; } /* ══════════════════════════════════════════════════════════════════════ DIVIDER — Bedrock Layer ══════════════════════════════════════════════════════════════════════ */ .mc-divider { height: 4px; background: repeating-linear-gradient( 90deg, #2A2A2A 0px, #2A2A2A 8px, #1A1A1A 8px, #1A1A1A 16px, #333333 16px, #333333 24px, #1A1A1A 24px, #1A1A1A 32px ); margin: 16px 0; image-rendering: pixelated; } /* ══════════════════════════════════════════════════════════════════════ ARROW BETWEEN COLUMNS — Crafting Arrow ══════════════════════════════════════════════════════════════════════ */ .mc-arrow { display: flex; align-items: center; justify-content: center; font-size: 2.5rem; color: var(--mc-highlight); text-shadow: 2px 2px 0px rgba(0,0,0,0.7); animation: arrow-pulse 1.5s ease-in-out infinite; padding-top: 160px; } @keyframes arrow-pulse { 0%, 100% { opacity: 0.6; transform: translateX(0px); } 50% { opacity: 1; transform: translateX(4px); } } /* ══════════════════════════════════════════════════════════════════════ RESPONSIVE ══════════════════════════════════════════════════════════════════════ */ @media (max-width: 768px) { .mc-title-area h1 { font-size: 2rem !important; } .mc-arrow { padding-top: 10px; transform: rotate(90deg); } .mc-section-label { font-size: 0.85rem !important; } } /* ══════════════════════════════════════════════════════════════════════ SCROLLBAR — Stone Brick Style ══════════════════════════════════════════════════════════════════════ */ ::-webkit-scrollbar { width: 12px; } ::-webkit-scrollbar-track { background: #2A2A2A; border-left: 1px solid #1A1A1A; } ::-webkit-scrollbar-thumb { background: linear-gradient(180deg, #777 0%, #555 100%); border: 2px solid #1A1A1A; } ::-webkit-scrollbar-thumb:hover { background: linear-gradient(180deg, #999 0%, #777 100%); } /* ── Dark mode override fix ── */ .dark .gradio-container, .dark { background: linear-gradient(180deg, #78A7FF 0%, #B4D4FF 30%, #C8E0FF 38%, #6DBE45 38.5%, #5D8C3E 40%, #3B5E28 42%, #8B6914 42.5%, #7A5B10 55%, #6B4F10 70%, #5A4210 85%, #3D2D0A 100% ) !important; } """ # ---------------------------------------------------------------------- # PERSISTENT STORAGE PATHS # ---------------------------------------------------------------------- BASE_MODEL_ID = "black-forest-labs/FLUX.2-klein-4B" LORA_ID = "AnimeOverlord/flux2-klein-4b-mc-v2" # Mount this path in the Space settings SPACE_DATA_DIR = "/data" # Put Hugging Face cache on persistent storage too HF_HOME = os.path.join(SPACE_DATA_DIR, ".huggingface") HF_HUB_CACHE = os.path.join(HF_HOME, "hub") # Store the downloaded model files here MODEL_ROOT = os.path.join(SPACE_DATA_DIR, "models") MODEL_DIR = os.path.join(MODEL_ROOT, "FLUX.2-klein-4B") import tempfile import shutil LORA_DIR = os.path.join(tempfile.gettempdir(), "flux2-klein-4b-mc-v2") LORA_CACHE_DIR = os.path.join(tempfile.gettempdir(), "hf-lora-cache") # Set these before any HF downloads happen os.environ["HF_HOME"] = HF_HOME os.environ["HF_HUB_CACHE"] = HF_HUB_CACHE os.makedirs(MODEL_DIR, exist_ok=True) os.makedirs(LORA_DIR, exist_ok=True) _pipe = None _pipe_ready = False _live_lock = threading.Lock() _live_latest_frame = None _live_latest_frame_id = 0 _live_capture_count = 0 _live_active = False IMAGE_MODE = "Image" LIVE_MODE = "Live Camera" GRADIO_MAJOR = int(gr.__version__.split(".", 1)[0]) MC_THEME = gr.themes.Base( primary_hue="green", neutral_hue="stone", font=gr.themes.GoogleFont("VT323"), font_mono=gr.themes.GoogleFont("VT323"), ) BLOCKS_KWARGS = {"title": "Minecraftify! ⛏️"} LAUNCH_KWARGS = {} if GRADIO_MAJOR >= 6: LAUNCH_KWARGS.update({"css": CUSTOM_CSS, "theme": MC_THEME, "ssr_mode": False}) else: BLOCKS_KWARGS.update({"css": CUSTOM_CSS, "theme": MC_THEME}) log_event(f"Gradio version detected: {gr.__version__}") log_event(f"Blocks kwargs: {BLOCKS_KWARGS}") log_event(f"Launch kwargs: {LAUNCH_KWARGS}") def _ensure_model_files(): """Download models if not already cached.""" log_event("Checking model files...") os.makedirs(MODEL_DIR, exist_ok=True) base_marker = os.path.join(MODEL_DIR, "model_index.json") if not os.path.exists(base_marker): log_event(f"Downloading base model: {BASE_MODEL_ID}") snapshot_download( repo_id=BASE_MODEL_ID, local_dir=MODEL_DIR, local_dir_use_symlinks=False, ) log_event(f"Base model downloaded to {MODEL_DIR}") else: log_event(f"Base model already cached at {MODEL_DIR}") # Always refresh LoRA in temp storage shutil.rmtree(LORA_DIR, ignore_errors=True) shutil.rmtree(LORA_CACHE_DIR, ignore_errors=True) os.makedirs(LORA_DIR, exist_ok=True) os.makedirs(LORA_CACHE_DIR, exist_ok=True) log_event(f"Downloading LoRA fresh to temp: {LORA_ID}") snapshot_download( repo_id=LORA_ID, local_dir=LORA_DIR, cache_dir=LORA_CACHE_DIR, local_dir_use_symlinks=False, allow_patterns=["pytorch_lora_weights.safetensors"], ) log_event(f"LoRA downloaded to temp: {LORA_DIR}") def _load_pipe(): global _pipe, _pipe_ready if _pipe_ready and _pipe is not None: log_event("Pipeline already loaded") return _pipe log_event("Loading pipeline") _ensure_model_files() pipe = DiffusionPipeline.from_pretrained( MODEL_DIR, torch_dtype=torch.bfloat16, device_map="cuda", ) pipe.load_lora_weights( LORA_DIR, weight_name="pytorch_lora_weights.safetensors", ) # Force modules to bf16 in case anything stayed fp32 for module_name in ["transformer", "vae", "text_encoder", "image_encoder"]: if hasattr(pipe, module_name): getattr(pipe, module_name).to(dtype=torch.bfloat16) pipe.set_progress_bar_config(disable=False) _pipe = pipe _pipe_ready = True log_event("Pipeline ready") return pipe # ---------------------------------------------------------------------- # INFERENCE FUNCTION # ---------------------------------------------------------------------- def _minecraftify_image( image: Image.Image, steps: int, guidance_scale: float, seed: int, extra_details: str, ): log_event( f"Starting inference: size={getattr(image, 'size', None)}, " f"steps={steps}, guidance={guidance_scale}, seed={seed}" ) pipe = _load_pipe() image = image.convert("RGB") prompt = ( "Convert this image into a faithful vanilla Minecraft version of the same scene. " "Preserve the original composition, camera angle, layout, all existing objects and especially colour and patterns. " "Recreate the scene using only vanilla Minecraft blocks, textures, materials, and lighting. " "Turn terrain into blocky Minecraft voxels with visible voxel structure and stepped geometry. " "Replace realistic surfaces with cubic Minecraft blocks and pixelated textures. " "Convert people, animals, and other living things into custom Minecraft mobs while preserving their pose, placement, colours and patterns. " "Let human faces be in the style of Minecraft characters, cuboid structure. " "Keep the result visually consistent with an authentic vanilla Minecraft screenshot. " "MOST IMPORTANT THING IS PRESERVE THE IMAGE CONTENTS(colours etc) AND REMOVE EXTRA ITEMS." ) if extra_details and extra_details.strip(): prompt += f"\n\nAdditional scene notes: {extra_details.strip()}" generator = torch.Generator(device="cuda").manual_seed(int(seed)) with torch.inference_mode(): out = pipe( image=image, prompt=prompt, num_inference_steps=int(steps), guidance_scale=float(guidance_scale), generator=generator, height=512, width=896 ) log_event("Inference complete") output_img = out.images[0] log_event(f"Output image size: {output_img.size}") torch.cuda.empty_cache() return output_img @spaces.GPU(duration=120) def minecraftify( image: Image.Image, steps: int, guidance_scale: float, seed: int, extra_details: str, ): if image is None: log_event("Still image inference blocked: no image") raise gr.Error("⛏️ Please upload a photo first!") log_event("Still image inference requested") return _minecraftify_image(image, steps, guidance_scale, seed, extra_details) @spaces.GPU(duration=240) def _minecraftify_live_frame( frame: Image.Image, steps: int, guidance_scale: float, seed: int, extra_details: str, ): """GPU-wrapped inference for individual live frames.""" log_event(f"GPU-allocated: Processing live frame with steps={steps}") return _minecraftify_image(frame, steps, guidance_scale, seed, extra_details) def capture_live_frame(image: Image.Image): global _live_latest_frame, _live_latest_frame_id, _live_capture_count if image is None: return None with _live_lock: _live_latest_frame = image.copy() _live_latest_frame_id += 1 _live_capture_count += 1 frame_id = _live_latest_frame_id capture_count = _live_capture_count if capture_count == 1 or capture_count % 30 == 0: log_event(f"Captured live webcam frame id={frame_id}, size={getattr(image, 'size', None)}") return None def minecraftify_live_loop( steps: int, guidance_scale: float, seed: int, extra_details: str, ): log_event("Live GPU loop started") last_processed_frame_id = 0 while True: with _live_lock: is_active = _live_active frame = _live_latest_frame.copy() if _live_latest_frame is not None else None frame_id = _live_latest_frame_id if not is_active: log_event("Live GPU loop stopped") return if frame is None: time.sleep(0.1) continue if frame_id == last_processed_frame_id: time.sleep(0.02) continue last_processed_frame_id = frame_id log_event(f"Processing newest live frame id={frame_id}") output = _minecraftify_live_frame(frame, steps, guidance_scale, seed, extra_details) yield output def _set_live_active(image: Image.Image): """Auto-enable live processing when recording starts.""" global _live_active if image is not None and not _live_active: _live_active = True log_event("Live recording detected, auto-starting processing") return None def switch_input_mode(mode: str): global _live_active log_event(f"Input mode changed: {mode}") image_mode = mode == IMAGE_MODE if image_mode: _live_active = False return ( gr.update(visible=image_mode), gr.update(visible=image_mode), gr.update(visible=not image_mode), gr.update(interactive=True), gr.update(visible=not image_mode, interactive=False), False, ) # ---------------------------------------------------------------------- # UI # ---------------------------------------------------------------------- log_event("Building Gradio UI") with gr.Blocks(**BLOCKS_KWARGS) as demo: # ── Title Banner ── gr.HTML("""
Upload any photo and watch it transform into a blocky, pixel-art Minecraft masterpiece