import gradio as gr import os import torch import commons import utils from models import SynthesizerTrn import numpy as np import json import requests import logging import traceback import sys import random import re # Setup logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) # Fungsi untuk mendownload file dari Hugging Face def download_file(url, local_path): """Download file dari URL ke local path""" try: response = requests.get(url, stream=True) response.raise_for_status() os.makedirs(os.path.dirname(local_path), exist_ok=True) with open(local_path, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) logger.info(f"File downloaded: {local_path}") return True except Exception as e: logger.error(f"Error downloading {url}: {e}") return False # Base URL dari Hugging Face BASE_URL = "https://huggingface.co/spaces/Rosmontis-Chan/Yosuga-no-Sora/resolve/main/saved_model" # File yang diperlukan - SEMUA FILE DARI HUGGING FACE FILES_TO_DOWNLOAD = { "config.json": f"{BASE_URL}/3/config.json", "model.pth": f"{BASE_URL}/3/model.pth", "cover.png": f"{BASE_URL}/3/cover.png" } # Path lokal LOCAL_DIR = "saved_model/3" LOCAL_PATHS = { "config": os.path.join(LOCAL_DIR, "config.json"), "model": os.path.join(LOCAL_DIR, "model.pth"), "cover": os.path.join(LOCAL_DIR, "cover.png") } # Download file jika belum ada logger.info("Checking for required files...") for file_name, url in FILES_TO_DOWNLOAD.items(): local_path = os.path.join(LOCAL_DIR, file_name) if not os.path.exists(local_path): logger.info(f"Downloading {file_name}...") success = download_file(url, local_path) if not success: logger.error(f"Failed to download {file_name}") else: logger.info(f"{file_name} already exists") # Mapping untuk nama karakter Jepang ke Romaji CHARACTER_MAPPING = { "春日野穹": "Kasugano Sora", "天女目瑛": "Amatsume Akira", "依媛奈緒": "Yorihime Nao", "渚一葉": "Ichika Nagisa", "Kasugano Sora": "春日野穹", "Amatsume Akira": "天女目瑛", "Yorihime Nao": "依媛奈緒", "Ichika Nagisa": "渚一葉", } # Fungsi untuk mendapatkan display name dengan Romaji def get_display_name(name): """Format nama karakter dengan Romaji dalam kurung""" if name in CHARACTER_MAPPING: return f"{name} ({CHARACTER_MAPPING[name]})" # Jika tidak ada mapping, cek apakah ini nama Romaji for jp_name, romaji in CHARACTER_MAPPING.items(): if romaji == name: return f"{jp_name} ({name})" # Jika tidak ditemukan, kembalikan aslinya return name # Fungsi konversi teks ke urutan angka dengan error handling yang lebih baik def get_text(text, hps): """Convert text to sequence""" try: from text import text_to_sequence logger.info(f"Processing text: {text[:50]}...") try: text_norm = text_to_sequence(text, hps.symbols, hps.data.text_cleaners) logger.info("Using text_to_sequence with symbols and cleaners") except TypeError as e: logger.warning(f"Version 1 failed: {e}. Trying alternative...") try: text_norm = text_to_sequence(text, cleaner_names=hps.data.text_cleaners) logger.info("Using text_to_sequence with cleaner_names only") except TypeError as e2: logger.warning(f"Version 2 failed: {e2}. Trying simple version...") text_norm = text_to_sequence(text) logger.info("Using simple text_to_sequence") if hps.data.add_blank: text_norm = commons.intersperse(text_norm, 0) text_norm = torch.LongTensor(text_norm) logger.info(f"Text converted to sequence of length: {len(text_norm)}") return text_norm except Exception as e: logger.error(f"Error in get_text: {e}") logger.error(traceback.format_exc()) try: if hasattr(hps, 'symbols'): symbol_to_id = {s: i for i, s in enumerate(hps.symbols)} text_norm = [] for char in text: if char in symbol_to_id: text_norm.append(symbol_to_id[char]) if hps.data.add_blank: text_norm = commons.intersperse(text_norm, 0) logger.info(f"Fallback successful: {len(text_norm)} tokens") return torch.LongTensor(text_norm) except Exception as e2: logger.error(f"Fallback also failed: {e2}") logger.warning("All methods failed, using default text") default_text = "こんにちは" text_norm = text_to_sequence(default_text, hps.symbols, hps.data.text_cleaners) if hps.data.add_blank: text_norm = commons.intersperse(text_norm, 0) return torch.LongTensor(text_norm) # Memuat model def load_model(): try: if not os.path.exists(LOCAL_PATHS["config"]): logger.error(f"Config file not found at {LOCAL_PATHS['config']}") return None, None, [], [] if not os.path.exists(LOCAL_PATHS["model"]): logger.error(f"Model file not found at {LOCAL_PATHS['model']}") return None, None, [], [] logger.info("Loading model...") hps = utils.get_hparams_from_file(LOCAL_PATHS["config"]) net_g = SynthesizerTrn( len(hps.symbols), hps.data.filter_length // 2 + 1, hps.train.segment_size // hps.data.hop_length, n_speakers=hps.data.n_speakers, **hps.model ) try: utils.load_checkpoint(LOCAL_PATHS["model"], net_g, None) logger.info("Model loaded using utils.load_checkpoint") except Exception as e: logger.warning(f"Standard load failed: {e}. Trying alternative...") checkpoint = torch.load(LOCAL_PATHS["model"], map_location='cpu') state_dict = {} for k, v in checkpoint.items(): if isinstance(k, str): if k.startswith('net_g.'): state_dict[k[6:]] = v elif k.startswith('module.'): state_dict[k[7:]] = v else: state_dict[k] = v missing_keys, unexpected_keys = net_g.load_state_dict(state_dict, strict=False) if missing_keys: logger.warning(f"Missing keys: {missing_keys}") if unexpected_keys: logger.warning(f"Unexpected keys: {unexpected_keys}") net_g.eval() speaker_choices = [] speaker_display_names = [] if hasattr(hps, 'speakers') and hps.speakers: for s in hps.speakers: if s and str(s) != "None" and str(s).strip(): speaker_choices.append(str(s)) display_name = get_display_name(str(s)) speaker_display_names.append(display_name) if not speaker_choices: speaker_choices = ["春日野穹", "天女目瑛", "依媛奈緒", "渚一葉"] speaker_display_names = [get_display_name(name) for name in speaker_choices] logger.info(f"Model loaded successfully with {len(speaker_choices)} speakers") return hps, net_g, speaker_choices, speaker_display_names except Exception as e: logger.error(f"Error loading model: {e}") logger.error(traceback.format_exc()) return None, None, [], [] # Load model saat startup hps, net_g, speaker_choices, speaker_display_names = load_model() # Path untuk gambar COVER_PATH = LOCAL_PATHS["cover"] if os.path.exists(LOCAL_PATHS["cover"]) else None def tts_generate(speaker_index, text, speed): if net_g is None or hps is None or not text: logger.error("Model not loaded or text is empty") return None, COVER_PATH if not speaker_choices or speaker_index >= len(speaker_choices): speaker_index = 0 logger.warning("Invalid speaker index, using 0") try: speaker_name = speaker_choices[speaker_index] if speaker_index < len(speaker_choices) else "Unknown" logger.info(f"Generating TTS for speaker {speaker_index}: {speaker_name}") logger.info(f"Text: {text[:100]}...") logger.info(f"Speed: {speed}") text = str(text).strip() if not text: logger.error("Empty text provided") return None, COVER_PATH # Validasi Japanese characters has_japanese = bool(re.search(r'[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f]', text)) if not has_japanese: logger.warning("Text may not contain Japanese characters") stn_tst = get_text(text, hps) with torch.no_grad(): x_tst = stn_tst.unsqueeze(0) x_tst_lengths = torch.LongTensor([stn_tst.size(0)]) sid = torch.LongTensor([speaker_index]) speed = float(speed) if speed <= 0 or speed > 10: speed = 1.0 logger.warning(f"Speed out of range, set to 1.0") audio = net_g.infer( x_tst, x_tst_lengths, sid=sid, noise_scale=0.667, noise_scale_w=0.8, length_scale=1.0/speed )[0][0,0].data.cpu().float().numpy() if len(audio) > 0: audio_max = np.abs(audio).max() if audio_max > 0: audio = (audio / audio_max * 32767).astype(np.int16) else: audio = np.zeros(44100, dtype=np.int16) else: audio = np.zeros(44100, dtype=np.int16) return (hps.data.sampling_rate, audio), COVER_PATH except Exception as e: logger.error(f"Error generating audio: {e}") logger.error(traceback.format_exc()) return None, COVER_PATH # Refresh model function (kembalikan tuple lengkap untuk update) def refresh_model(): global hps, net_g, speaker_choices, speaker_display_names logger.info("Refreshing model...") hps, net_g, speaker_choices, speaker_display_names = load_model() if speaker_display_names and len(speaker_display_names) > 0: # Kembalikan: radio_update, selected_idx, selected_display_text, info_html, status_msg, cover_img_update radio_update = gr.update(choices=speaker_display_names, value=speaker_display_names[0]) selected_idx = 0 selected_display_text = f"📍 *Selected: **{speaker_display_names[0]}***" # Buat HTML info karakter info_html = '
' for jp, disp in zip(speaker_choices, speaker_display_names): romaji = disp.split('(')[-1].rstrip(')') if '(' in disp else "" info_html += f"""
{jp}
{romaji}
""" info_html += '
' status_msg = f"✅ Model refreshed! Loaded {len(speaker_display_names)} speakers" cover_update = gr.update(value=COVER_PATH) if COVER_PATH else gr.update() return radio_update, selected_idx, selected_display_text, info_html, status_msg, cover_update else: radio_update = gr.update(choices=["No characters"], value="No characters") selected_idx = 0 selected_display_text = "📍 *Selected: None*" info_html = "
No characters available
" status_msg = "❌ Failed to load speakers" cover_update = gr.update() return radio_update, selected_idx, selected_display_text, info_html, status_msg, cover_update # Test TTS function def test_generation(): if net_g is None: return None, "❌ Model not loaded" test_text = "こんにちは、世界。私はあなたの声を聞いています。" try: result, _ = tts_generate(0, test_text, 1.0) if result: speaker_display = speaker_display_names[0] if speaker_display_names else "Default" return result, f"✅ Test successful with {speaker_display}!" else: return None, "❌ Test failed - no audio generated" except Exception as e: return None, f"❌ Test error: {str(e)[:100]}" # Fungsi untuk mendapatkan teks random def get_random_jp(): texts = [ "こんにちは!", "お元気ですか?", "先生、お疲れ様です!", "大好きだよ!", "また明日ね。", "お兄ちゃん、大好き!", "一緒にいようね。", "今日はいい天気ですね。" ] return random.choice(texts) # --- CSS GABUNGAN (asli + gaya dari app(1).py) --- css = """ /* Reset dan dasar */ * { box-sizing: border-box; } body { background: linear-gradient(135deg, #f5f7fa 0%, #e4e8f0 100%); min-height: 100vh; margin: 0; padding: 20px; font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; color: #333; } .gradio-container { background: white !important; border-radius: 24px !important; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.05) !important; border: 1px solid rgba(255, 255, 255, 0.9) !important; backdrop-filter: blur(20px); max-width: 1200px !important; margin: 20px auto !important; padding: 0 !important; overflow: hidden; } .header-style { text-align: center; padding: 50px 30px 40px !important; background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); border-bottom: 1px solid rgba(0, 0, 0, 0.05); margin: 0 !important; position: relative; overflow: hidden; } .header-style::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); } .header-style h1 { font-family: 'Segoe UI', sans-serif; font-weight: 700; letter-spacing: 1px; color: #2c3e50; margin: 0 0 10px 0; font-size: 2.8rem; text-shadow: 0 1px 2px rgba(0,0,0,0.1); background: linear-gradient(135deg, #2c3e50 0%, #4a6491 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .header-style p { color: #7f8c8d; font-size: 1.1rem; letter-spacing: 3px; margin-top: 15px; font-weight: 300; text-transform: uppercase; opacity: 0.9; } .btn-lux { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; color: white !important; border-radius: 16px !important; border: none !important; height: 60px !important; font-weight: 600 !important; font-size: 1.1rem !important; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; margin: 15px 0 !important; box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3), 0 1px 3px rgba(0, 0, 0, 0.1) !important; } .btn-lux:hover { transform: translateY(-2px) !important; box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4), 0 2px 5px rgba(0, 0, 0, 0.1) !important; } .btn-secondary { background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%) !important; color: #495057 !important; border-radius: 12px !important; border: 1px solid #dee2e6 !important; height: 45px !important; font-weight: 500 !important; cursor: pointer; transition: all 0.2s ease !important; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05) !important; } .btn-secondary:hover { background: linear-gradient(135deg, #ffffff 0%, #f1f3f5 100%) !important; border-color: #adb5bd !important; transform: translateY(-1px); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08) !important; } /* Radio group untuk karakter (seperti tombol) */ .character-radio-group { display: grid !important; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)) !important; gap: 10px !important; background: #fafbfc !important; padding: 15px !important; border-radius: 16px !important; border: 2px solid #e9ecef !important; margin-bottom: 20px !important; } .character-radio-group .gr-radio { background: white !important; border: 1px solid #e2e8f0 !important; border-left: 5px solid #a8e6cf !important; border-radius: 12px !important; padding: 12px 10px !important; margin: 0 !important; cursor: pointer !important; transition: all 0.2s !important; font-weight: 500 !important; color: #4a5568 !important; } .character-radio-group .gr-radio:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.05); border-color: #cbd5e0 !important; } .character-radio-group .gr-radio.selected { background: #e6fffa !important; border-left-color: #319795 !important; border-color: #81e6d9 !important; color: #234e52 !important; } /* Warning card */ .warning-card { background: #fffcf0; border: 2px dashed #ffd3b6; border-radius: 16px; padding: 15px; margin: 20px 0; text-align: center; } .warning-title { color: #f5a623; font-weight: 800; font-size: 13px; letter-spacing: 1px; } .warning-text { color: #855d1a; font-size: 12px; font-weight: 600; margin-top: 5px; } /* Tombol random (jp-btn) */ .jp-btn { background: #f8fafc !important; border: 1px solid #cbd5e1 !important; color: #475569 !important; font-weight: 700 !important; border-radius: 10px !important; margin-bottom: 15px !important; font-size: 12px !important; width: 100%; transition: all 0.2s; } .jp-btn:hover { background: #f1f5f9 !important; border-color: #94a3b8 !important; } /* GIF container */ .gif-container { display: flex; justify-content: center; margin: 20px 0; } .gif-container img { border-radius: 16px; max-width: 100%; height: auto; border: 1px solid #e1f0e5; box-shadow: 0 4px 12px rgba(0,0,0,0.05); } /* Character Information Panel - selalu terlihat */ .char-info-panel { background: white; border: 2px solid #e1f0e5; border-radius: 16px; padding: 15px; margin: 20px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.03); } .char-info-title { font-weight: 800; color: #2d3748; border-bottom: 2px solid #a8e6cf; display: inline-block; padding-bottom: 5px; margin-bottom: 15px; font-size: 1.1rem; } .char-info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; max-height: 300px; overflow-y: auto; padding: 5px; } .char-card { border: 1px solid #e1f0e5; border-radius: 12px; padding: 10px; border-left: 5px solid #a8e6cf; background: #fff; } .char-jp { font-weight: 700; color: #2d3748; font-size: 14px; } .char-romaji { color: #a0aec0; font-size: 11px; margin-top: 4px; } /* Textbox */ .textbox-style textarea { background: #f8f9fa !important; border: 2px solid #e9ecef !important; color: #2c3e50 !important; border-radius: 16px !important; padding: 20px !important; font-size: 1rem !important; min-height: 180px !important; transition: all 0.3s ease !important; font-family: 'Segoe UI', sans-serif; } .textbox-style textarea:focus { border-color: #667eea !important; background: white !important; box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1) !important; } /* Slider */ .slider-style .gr-slider { background: #f8f9fa !important; border-radius: 10px !important; border: 2px solid #e9ecef !important; } .slider-style .gr-slider .gr-slider-track { background: linear-gradient(90deg, #667eea 0%, #764ba2 100%) !important; } /* Image container */ .image-container { border-radius: 20px !important; overflow: hidden !important; border: 2px solid white !important; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.05) !important; background: white !important; width: 100% !important; height: 450px !important; display: flex !important; align-items: center !important; justify-content: center !important; } .image-container img { width: 100% !important; height: 100% !important; object-fit: cover !important; object-position: center center !important; border-radius: 18px !important; } /* Status boxes */ .status-box { background: #f8f9fa !important; border: 2px solid #e9ecef !important; border-radius: 16px !important; padding: 20px !important; margin: 15px 0 !important; color: #2c3e50; } .success-box { background: linear-gradient(135deg, #f0fff4 0%, #e6fffa 100%) !important; border: 2px solid #c6f6d5 !important; color: #22543d !important; border-radius: 16px !important; padding: 20px !important; } .footer-style { text-align: center; margin-top: 40px; padding: 25px; color: #7f8c8d; font-size: 0.9rem; border-top: 1px solid #f1f2f6; background: #f8f9fa; border-radius: 0 0 24px 24px; } @media (max-width: 768px) { .grid-container { grid-template-columns: 1fr !important; gap: 20px !important; padding: 20px !important; } .image-container { height: 350px !important; } .character-radio-group { grid-template-columns: 1fr !important; } } """ # --- MEMBANGUN UI --- with gr.Blocks(css=css, title="Yosuga no Sora TTS") as demo: # Header gr.HTML("""

KASUGANO SORA

YOSUGA NO SORA PREMIUM TTS

""") with gr.Row(elem_classes="grid-container"): with gr.Column(scale=1): # Status Box status_md = gr.Markdown(f"""
System Status
Model: {'LOADED' if net_g else 'NOT LOADED'}
Characters: {len(speaker_choices) if speaker_choices else 0}
Cover: {'PRESENT' if os.path.exists(COVER_PATH) else 'MISSING'}

v3.0 • Ready for generation
""") # Cover Image img_disp = gr.Image(value=COVER_PATH, show_label=False, interactive=False, elem_classes="image-container") if COVER_PATH else gr.Image(value=None, show_label=False, interactive=False, elem_classes="image-container") # --- SELECT CHARACTER (Radio bergaya tombol) --- gr.Markdown("### 🎭 Select Character") selected_char_idx = gr.State(value=0 if speaker_choices else None) selected_char_display = gr.Markdown(f"📍 *Selected: **{speaker_display_names[0] if speaker_display_names else 'None'}***") if speaker_display_names: char_radio = gr.Radio( choices=speaker_display_names, value=speaker_display_names[0], label=None, type="index", elem_classes="character-radio-group" ) else: char_radio = gr.Radio(choices=["No characters"], value="No characters", label=None, type="index", elem_classes="character-radio-group") def update_selected(idx): if idx is not None and speaker_display_names and idx < len(speaker_display_names): return idx, f"📍 *Selected: **{speaker_display_names[idx]}***" return 0, "📍 *Selected: None*" char_radio.change(update_selected, inputs=[char_radio], outputs=[selected_char_idx, selected_char_display]) # --- WARNING CARD --- gr.HTML("""
🔖 PERINGATAN MINNA 🔖
Cara pakai: klik character, masukkan text, lalu Generate! ✨
""") # --- Random Text Button --- random_btn = gr.Button("🎲 INPUTS RANDOM TEXT 🎲", elem_classes="jp-btn") # --- GIF --- gr.HTML("""
Kurumi GIF
""") # --- Character Information Panel (selalu terlihat) --- # Panel ini akan diupdate saat refresh, jadi kita buat sebagai komponen HTML char_info_html = gr.HTML(value="") # Fungsi untuk mengisi awal def build_char_info_html(): if speaker_choices and speaker_display_names: info_html = '
📑 Character Information
' for jp, disp in zip(speaker_choices, speaker_display_names): romaji = disp.split('(')[-1].rstrip(')') if '(' in disp else "" info_html += f"""
{jp}
{romaji}
""" info_html += '
' return info_html else: return '
📑 Character Information
No characters available
' char_info_html.value = build_char_info_html() # --- Refresh & Test Buttons --- with gr.Row(): refresh_btn = gr.Button("🔄 Refresh Model", elem_classes="btn-secondary") test_btn = gr.Button("🔊 Test Audio", elem_classes="btn-secondary") refresh_status = gr.Markdown("", elem_classes="status-box") # Speed slider speed_sl = gr.Slider(0.5, 2.0, 1.0, step=0.1, label="⚡ SPEED CONTROL", elem_classes="slider-style") with gr.Column(scale=2): # Text Input text_in = gr.Textbox( label="📝 ENTER JAPANESE TEXT", placeholder="例: お兄ちゃん、大好きだよ... (onii-chan, daisuki da yo)", lines=8, elem_classes="textbox-style" ) # Example texts accordion with gr.Accordion("💡 Example Texts with Romaji", open=False): gr.Markdown(""" **Common Phrases:** - こんにちは、世界 (konnichiwa, sekai) - Hello, world - お元気ですか? (ogenki desu ka?) - How are you? - 今日はいい天気ですね (kyou wa ii tenki desu ne) - Nice weather today - ありがとうございます (arigatou gozaimasu) - Thank you **Yosuga no Sora Phrases:** - お兄ちゃん、待って! (onii-chan, matte!) - Brother, wait! - 私、ずっと好きでした (watashi, zutto suki deshita) - I've always loved you - 一緒にいようね (issho ni iyou ne) - Let's stay together """) # Generate Button gen_btn = gr.Button("✨ GENERATE VOICE ✨", elem_classes="btn-lux") # Audio Output audio_out = gr.Audio(label="🎧 GENERATED VOICE", type="numpy", interactive=False) # Clear Button clear_btn = gr.Button("🗑️ Clear All", elem_classes="btn-secondary") # Footer gr.HTML(""" """) # --- EVENT HANDLERS --- # Generate gen_btn.click( tts_generate, inputs=[selected_char_idx, text_in, speed_sl], outputs=[audio_out, img_disp] ) # Random text random_btn.click(get_random_jp, outputs=[text_in]) # Clear clear_btn.click(lambda: ("", None, ""), outputs=[text_in, audio_out, refresh_status]) # Test test_btn.click(test_generation, outputs=[audio_out, refresh_status]) # Refresh: update radio, state, selected display, panel info, status, dan cover refresh_btn.click( refresh_model, outputs=[char_radio, selected_char_idx, selected_char_display, char_info_html, refresh_status, img_disp] ) # --- LAUNCH --- if __name__ == "__main__": print("=" * 60) print("YOSUGA NO SORA TTS - PREMIUM VOICE GENERATION") print("=" * 60) print(f"Model Status: {'✅ LOADED' if net_g else '❌ NOT LOADED'}") print(f"Characters: {len(speaker_choices) if speaker_choices else 0}") if speaker_display_names: for i, name in enumerate(speaker_display_names): print(f" {i+1}. {name}") if os.path.exists(LOCAL_PATHS["cover"]): print(f"Cover Image: ✅ PRESENT at {LOCAL_PATHS['cover']}") else: print(f"Cover Image: ❌ MISSING - Expected at {LOCAL_PATHS['cover']}") print("=" * 60) print("Starting server on http://localhost:7860") print("Press Ctrl+C to stop") print("=" * 60) demo.launch( server_name="0.0.0.0", server_port=7860, share=False, show_error=True )