"""Stage Whisper — Interactive AI Theater""" try: from dotenv import load_dotenv; load_dotenv() except ImportError: pass import gradio as gr import os, json, time, threading, base64, re from pathlib import Path from PIL import Image ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY", "") OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "") BASE = Path(__file__).parent from core.voice_pool import VOICE_POOL from core.tts_engine import TTSEngine from core.image_generator import ImageGenerator from core.character_pipeline import CharacterPipeline import core.playwright as pw tts = TTSEngine(api_key=ELEVENLABS_API_KEY) img_gen = ImageGenerator(api_key=OPENROUTER_API_KEY) char_pipe = CharacterPipeline(api_key=OPENROUTER_API_KEY, image_generator=img_gen, base_dir=BASE) def _load_preset(name): with open(BASE / "presets" / f"{name}.json", encoding="utf-8") as f: return json.load(f) PRESETS = { "british_comedy": _load_preset("british_comedy"), "victorian_drama": _load_preset("victorian_drama"), "infidelity": _load_preset("infidelity"), } CHAR_COLORS = ["#c9a227","#7eb8c9","#c97b7e","#7ec994","#c99a7e","#a87ec9"] def _char_color(characters, name): for i, c in enumerate(characters): if c["name"] == name: return CHAR_COLORS[i % len(CHAR_COLORS)] return CHAR_COLORS[0] _stop_flag = threading.Event() _client_audio_done = threading.Event() def handle_audio_ended(): print(f"[DEBUG] handle_audio_ended triggered at {time.time()}!") _client_audio_done.set() _img_cache = {} _img_lock = threading.Lock() _audio_cache = {} _audio_fetching = set() _audio_lock = threading.Lock() _is_rewriting_active = False _active_script = None _current_run_id = 0.0 # ── Helpers ───────────────────────────────────────────────────── def _placeholder_path(): p = BASE / "preset_images" / "_black.png" if not p.exists(): p.parent.mkdir(parents=True, exist_ok=True) Image.new("RGB", (1280, 720), (0,0,0)).save(str(p)) return str(p) ON_SPACES = os.environ.get("SPACE_ID") is not None def _file_url(path): if not path: return "" # Convert path to absolute Path object to resolve relative/absolute mismatch p_obj = Path(path) if not p_obj.is_absolute(): abs_path = Path(BASE) / str(path).lstrip("/") else: abs_path = p_obj if not abs_path.exists(): print(f"[FILE_URL] Path not found: {path} (Resolved to: {abs_path})") return "" ext = abs_path.suffix.lower().lstrip(".") # Use resolve/main only for static files committed to git (sources, preset_images, presets) is_static = any(folder in str(abs_path) for folder in ["sources", "preset_images", "presets"]) if ON_SPACES and is_static: try: rel_path = abs_path.relative_to(Path(BASE)) return f"https://huggingface.co/spaces/{os.environ.get('SPACE_ID')}/resolve/main/{rel_path}" except Exception as e: print(f"[FILE_URL] Error converting relative path for static asset: {e}") pass try: with open(abs_path, "rb") as f: data = base64.b64encode(f.read()).decode() if ext == "mp4": mime = "video/mp4" elif ext == "mp3": mime = "audio/mpeg" else: mime = f"image/{'png' if ext=='png' else 'jpeg'}" return f"data:{mime};base64,{data}" except Exception as e: print(f"[FILE_URL] Error encoding file {abs_path}: {e}") return "" def _video_html(): vid_path = BASE / "sources/curtain-opening-animation.mp4" if not vid_path.exists(): return "" return f'' def _img_html(path=None): curtain_src = _file_url(BASE / "sources/one-side-curtain.png") if path == "LOADING": return f'''
Painting scene background...
''' src = _file_url(path) if path and Path(path).exists() else _file_url(_placeholder_path()) return f'''
''' def _ensure_image_nonblocking(scene): rel = scene.get("image_path", "") path = str(BASE / rel.lstrip("/")) if rel else "" with _img_lock: if path in _img_cache: return _img_cache[path] if path and Path(path).exists(): with _img_lock: _img_cache[path] = path return path if not path: return _placeholder_path() _prefetch(scene) return "LOADING" def _puppet_placeholder_path(): p = BASE / "preset_images" / "_puppet_placeholder.png" if not p.exists(): p.parent.mkdir(parents=True, exist_ok=True) img = Image.new("RGBA", (210, 300), (0, 0, 0, 0)) from PIL import ImageDraw draw = ImageDraw.Draw(img) # Draw a stylized simple gold/grey puppet silhouette matching the theater theme # Head draw.ellipse([75, 40, 135, 100], fill=(201, 162, 39, 100), outline=(201, 162, 39, 200), width=2) # Body/Shoulders draw.chord([40, 110, 170, 280], 180, 360, fill=(201, 162, 39, 60), outline=(201, 162, 39, 150), width=2) img.save(str(p)) return str(p) def _get_puppet_html(char_name, is_left, is_active, scene_idx, dial_idx, ex_char_name=None, is_new=False, ex_dial_idx=0): slot_name = "left" if is_left else "right" current_html = "" if char_name and char_name != "Director": path = char_pipe.get_asset(char_name, scene_idx, dial_idx) if not path: current_html = '
' else: url = _file_url(path) active_class = "active" if is_active else "inactive" if is_new: classes = f"{active_class} new-puppet" onload_attr = 'onload="var self=this; setTimeout(function(){ self.classList.add(\'loaded\'); }, 50);"' else: classes = f"{active_class} loaded" onload_attr = "" current_html = f'''
{char_name}
''' ex_html = "" if ex_char_name and ex_char_name != "Director": path = char_pipe.get_asset(ex_char_name, scene_idx, ex_dial_idx) if path: url = _file_url(path) ex_html = f'''
{ex_char_name}
''' return f'''
{ex_html} {current_html}
''' def _dial_html(char, action, text, color, left_char=None, right_char=None, active_slot=None, scene_idx=0, dial_idx=0, typewriter_duration=None, left_ex_char=None, right_ex_char=None, left_is_new=False, right_is_new=False, left_dial_idx=0, right_dial_idx=0, left_ex_dial_idx=0, right_ex_dial_idx=0): act = f'
— {action} —
' if action else "" unique_id = f"{scene_idx}_{dial_idx}_{int(time.time() * 1000)}" if typewriter_duration and typewriter_duration > 0: import html escaped_attr = html.escape(text, quote=True) dial_text_html = f'''
''' else: dial_text_html = f'
{text}
' if not left_char and not right_char: return f'
{act}
{char}
{dial_text_html}
' left_active = (active_slot == "left") right_active = (active_slot == "right") left_html = _get_puppet_html(left_char, is_left=True, is_active=left_active, scene_idx=scene_idx, dial_idx=left_dial_idx, ex_char_name=left_ex_char, is_new=left_is_new, ex_dial_idx=left_ex_dial_idx) right_html = _get_puppet_html(right_char, is_left=False, is_active=right_active, scene_idx=scene_idx, dial_idx=right_dial_idx, ex_char_name=right_ex_char, is_new=right_is_new, ex_dial_idx=right_ex_dial_idx) return f'''
{left_html}
{act}
{char}
{dial_text_html}
{right_html}
''' def _hist_html(history, characters): if not history: return "" rows = "".join( f'
{i.get("character", i.get("name", "Director"))}' f'"{i.get("line", i.get("text", ""))}"
' for i in history ) return f'
{rows}
' def _ensure_image(scene): rel = scene.get("image_path", "") path = str(BASE / rel.lstrip("/")) if rel else "" with _img_lock: if path in _img_cache: return _img_cache[path] if path and Path(path).exists(): with _img_lock: _img_cache[path] = path return path if not path: return _placeholder_path() try: Path(path).parent.mkdir(parents=True, exist_ok=True) img_gen.generate(scene["image_prompt"], path, aspect_ratio="21:9") with _img_lock: _img_cache[path] = path return path except Exception as e: print(f"[IMG] {e}"); return _placeholder_path() def _prefetch(scene): rel = scene.get("image_path", "") if not rel: return path = str(BASE / rel.lstrip("/")) with _img_lock: if path in _img_cache: return threading.Thread(target=_ensure_image, args=(scene,), daemon=True).start() def _prefetch_single_audio(script, si, di, use_flash=False): key = (si, di) with _audio_lock: if key in _audio_cache or key in _audio_fetching: return _audio_fetching.add(key) def fetch(): try: ab = _get_audio_direct(script, si, di, use_flash) if ab: with _audio_lock: _audio_cache[key] = ab except Exception as e: print(f"[Prefetch Error] {e}") finally: with _audio_lock: _audio_fetching.discard(key) threading.Thread(target=fetch, daemon=True).start() def _prefetch_audio(script, si, di, use_flash=False): scenes = script.get("scenes", []) if si >= len(scenes): return dialogues = scenes[si].get("dialogues", []) if isinstance(dialogues, str): dialogues = [dialogues] # Prefetch the next 3 lines in the current scene for offset in range(3): curr_di = di + offset if curr_di >= len(dialogues): # Try to prefetch the first line of the next scene next_si = si + 1 if next_si < len(scenes): next_dialogues = scenes[next_si].get("dialogues", []) if isinstance(next_dialogues, str): next_dialogues = [next_dialogues] if next_dialogues: _prefetch_single_audio(script, next_si, 0, use_flash) break _prefetch_single_audio(script, si, curr_di, use_flash) def _get_audio_direct(script, si, di, use_flash=False): dialogues = script["scenes"][si].get("dialogues", []) if isinstance(dialogues, str): dialogues = [{"character": "Director", "line": dialogues}] dl = dialogues[di] if isinstance(dl, str): dl = {"character": "Director", "line": dl} vid = None char_name = dl.get("character", dl.get("name", "Unknown")) for c in script.get("characters", []): if c["name"] == char_name: lbl = c.get("voice_label", "") vid = VOICE_POOL.get(lbl) if not vid: fb = {"warm_british_male": "charming_rogue", "sharp_british_female": "sharp_socialite", "comedic_male": "flustered_husband", "aristocratic_female": "iron_matriarch", "nervous_male": "nervous_suitor", "gruff_male": "weary_colonel", "young_female": "mischievous_maid", "elderly_male": "eccentric_solicitor"} vid = VOICE_POOL.get(fb.get(lbl, "")) break if not vid: import hashlib keys = list(VOICE_POOL.keys()) hash_val = int(hashlib.md5(char_name.encode()).hexdigest(), 16) lbl = keys[hash_val % len(keys)] vid = VOICE_POOL.get(lbl) script.setdefault("characters", []).append({ "name": char_name, "personality": "A newly introduced character.", "voice_label": lbl }) if not vid: return None v_id = vid["id"] if isinstance(vid, dict) else vid if not v_id or "PLACEHOLDER" in v_id: return None line_text = dl.get("line", dl.get("text", "")) if not line_text.strip(): return None try: model_id = "eleven_flash_v2_5" if use_flash else None raw_ab = tts.speak(line_text, v_id, model_id=model_id) if not raw_ab: return None try: from pydub import AudioSegment from pydub.silence import detect_nonsilent import io seg = AudioSegment.from_file(io.BytesIO(raw_ab), format="mp3") ranges = detect_nonsilent(seg, min_silence_len=250, silence_thresh=-45) if ranges: end_ms = min(len(seg), ranges[-1][1] + 150) if end_ms < len(seg) - 200: seg = seg[:end_ms] out_io = io.BytesIO() seg.export(out_io, format="mp3", bitrate="128k") raw_ab = out_io.getvalue() except Exception as e: print(f"[Silence Trim Error] {e}") return raw_ab except Exception as e: print(f"[TTS] {e}") return None def _get_audio(script, si, di, use_flash=False): key = (si, di) while True: with _audio_lock: if key not in _audio_fetching: break time.sleep(0.05) if _stop_flag.is_set(): return None with _audio_lock: if key in _audio_cache: return _audio_cache[key] ab = _get_audio_direct(script, si, di, use_flash) with _audio_lock: _audio_cache[key] = ab return ab def _audio_tmp(ab): import hashlib h = hashlib.md5(ab).hexdigest() cdir = BASE / "audio_cache" cdir.mkdir(exist_ok=True) path = cdir / f"{h}.mp3" if not path.exists(): with open(path, "wb") as f: f.write(ab) return str(path) # ── Playback ───────────────────────────────────────────────────── # ── Playback ───────────────────────────────────────────────────── def _run_script(script, start_scene=0, start_dial=0, delay_start=0, use_flash=False, skip_pipeline=False): global _current_run_id run_id = time.time() _current_run_id = run_id _stop_flag.clear(); _audio_cache.clear(); _audio_fetching.clear() if not skip_pipeline: char_pipe.start_pipeline(script) if delay_start > 0: time.sleep(delay_start) chars = script.get("characters", []) history = [] si = start_scene while True: if _current_run_id != run_id or _stop_flag.is_set(): return scenes = script.get("scenes", []) if si >= len(scenes): if _is_rewriting_active: time.sleep(0.1) continue else: break scene = scenes[si] left_char = None right_char = None left_ex_char = None right_ex_char = None last_active_slot = None # Track last spoken dial_idx per slot left_char_dial_idx = 0 right_char_dial_idx = 0 left_ex_dial_idx = 0 right_ex_dial_idx = 0 if _current_run_id != run_id or _stop_flag.is_set(): return # Block until this scene's background is ready, then show it bg_path = _ensure_image(scene) yield (_img_html(bg_path), _dial_html("","","🎨 Setting the scene…","#7a6a4e"), _hist_html(history,chars), None, "") if si == start_scene and delay_start > 0: time.sleep(1.0) else: time.sleep(0.8) start_d = start_dial if si == start_scene else 0 _prefetch_audio(script, si, start_d, use_flash) di = start_d while True: dialogues = scene.get("dialogues", []) if isinstance(dialogues, str): dialogues = [{"character": "Director", "line": dialogues}] if di >= len(dialogues): if _is_rewriting_active: time.sleep(0.1) continue else: break dl = dialogues[di] if isinstance(dl, str): dl = {"character": "Director", "line": dl} char = dl.get("character", dl.get("name", "Director")) action = dl.get("action", "") line = dl.get("line", dl.get("text", "")) color = _char_color(chars, char) # Manage Stage Slots left_ex_char = None right_ex_char = None left_ex_dial_idx = 0 right_ex_dial_idx = 0 left_is_new = False right_is_new = False active_slot = None if char != "Director": if char == left_char: active_slot = "left" left_char_dial_idx = di elif char == right_char: active_slot = "right" right_char_dial_idx = di else: if not left_char: left_char = char active_slot = "left" left_is_new = True left_char_dial_idx = di elif not right_char: right_char = char active_slot = "right" right_is_new = True right_char_dial_idx = di else: if last_active_slot == "left": right_ex_char = right_char right_ex_dial_idx = right_char_dial_idx right_char = char active_slot = "right" right_is_new = True right_char_dial_idx = di else: left_ex_char = left_char left_ex_dial_idx = left_char_dial_idx left_char = char active_slot = "left" left_is_new = True left_char_dial_idx = di if active_slot: last_active_slot = active_slot start_time = time.time() ab = _get_audio(script, si, di, use_flash) if ab: _client_audio_done.clear() atmp = _audio_tmp(ab) if ab else None audio_html = "" if atmp: audio_src = _file_url(atmp) audio_html = f'' # Extract clean line by removing [...] tags for theater screen clean_line = re.sub(r'\[[^\]]*\]', '', line) clean_line = re.sub(r'\s+', ' ', clean_line).strip() audio_duration = len(clean_line) * 0.065 if ab: try: from pydub import AudioSegment import io seg = AudioSegment.from_file(io.BytesIO(ab), format="mp3") audio_duration = len(seg) / 1000.0 except Exception as e: print(f"[Audio Duration Error] {e}") audio_duration = (len(ab) / 16000.0) # We copy dl so we can add our debugger flag without mutating the original script dl_hist = dict(dl) dl_hist["line"] = clean_line history.append(dl_hist) print(f"[DEBUG] [PLAYING] {char}: {clean_line} (audio: {audio_duration:.2f}s)") # Start talking: trigger client-side typewriter animation & autoplay audio simultaneously yield (_img_html(_ensure_image_nonblocking(scene)), _dial_html(char, action, clean_line, color, left_char=left_char, right_char=right_char, active_slot=active_slot, scene_idx=si, dial_idx=di, typewriter_duration=audio_duration, left_ex_char=left_ex_char, right_ex_char=right_ex_char, left_is_new=left_is_new, right_is_new=right_is_new, left_dial_idx=left_char_dial_idx, right_dial_idx=right_char_dial_idx, left_ex_dial_idx=left_ex_dial_idx, right_ex_dial_idx=right_ex_dial_idx), _hist_html(history, chars), audio_html, "") if ab: _client_audio_done.wait(timeout=max(0.05, audio_duration - 0.1)) else: elapsed = time.time() - start_time remaining = audio_duration - elapsed if remaining > 0: time.sleep(remaining) if _current_run_id != run_id or _stop_flag.is_set(): return # Stop talking: set puppet to idle print(f"[DEBUG] [SPOKEN] {char}: {clean_line}") dl_hist["spoken"] = True yield (_img_html(_ensure_image_nonblocking(scene)), _dial_html(char, action, clean_line, color, left_char=left_char, right_char=right_char, active_slot=active_slot, scene_idx=si, dial_idx=di, left_ex_char=left_ex_char, right_ex_char=right_ex_char, left_is_new=left_is_new, right_is_new=right_is_new, left_dial_idx=left_char_dial_idx, right_dial_idx=right_char_dial_idx, left_ex_dial_idx=left_ex_dial_idx, right_ex_dial_idx=right_ex_dial_idx), _hist_html(history, chars), gr.skip(), "") di += 1 _prefetch_audio(script, si, di, use_flash) si += 1 yield (gr.skip(), _dial_html("","","— The End —","#c9a227"), _hist_html(history,chars), None, "") # ── CSS ────────────────────────────────────────────────────────── CSS = """ @import url('https://fonts.googleapis.com/css2?family=IM+Fell+English:ital@0;1&display=swap'); *,*::before,*::after{box-sizing:border-box} body,.gradio-container{background:#210609!important;font-family:'IM Fell English',serif!important;color:#e8d4a8!important;margin:0!important;padding:0!important} .gradio-container{max-width:100%!important;padding:0!important} footer{display:none!important} #btn-audio-ended{opacity:0!important;position:absolute!important;pointer-events:none!important;width:0!important;height:0!important;overflow:hidden!important} #mode-value{display:none!important} /* Hide tab nav */ #main-tabs .tab-nav, div[role="tablist"], .tabs > div:first-child { display: none !important; } #main-tabs>.tabitem{border:none!important;padding:0!important;margin:0!important} /* Select screen */ .sel-wrap{position:relative;overflow:hidden;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#210609} .vid-container{position:fixed!important;top:0!important;left:0!important;width:100vw!important;height:100vh!important;z-index:0!important;padding:0!important;margin:0!important;border:none!important;background:transparent!important;pointer-events:none} .sel-video{width:100%;height:100%;object-fit:cover;transition:filter .8s ease;pointer-events:none;display:block;transform:scale(1.05)} .sel-content{z-index:1;position:relative;transition:opacity .5s ease;display:flex;flex-direction:column;align-items:center;gap:32px;padding:60px 40px;transform:translateY(-40px)} .ttl{font-size:4em;color:#c9a227;text-shadow:0 0 60px rgba(201,162,39,.4);margin:0;text-align:center;letter-spacing:.05em} .sub{font-style:italic;color:#7a6a4e;font-size:1.1em;margin:-20px 0 0;text-align:center} .pbtn-row{display:flex;gap:16px;flex-wrap:wrap;justify-content:center} #btn-comedy button,#btn-drama button,#btn-infidelity button{ background:linear-gradient(135deg,#140a0c,#2a1015)!important;border:1px solid #c9a22744!important; color:#c9a227!important;font-family:'IM Fell English',serif!important;font-size:1.05em!important; padding:18px 30px!important;border-radius:6px!important;min-width:200px!important;cursor:pointer!important; transition:border-color .3s,box-shadow .3s,transform .2s!important} #btn-comedy button:hover,#btn-drama button:hover,#btn-infidelity button:hover{ border-color:#c9a227!important;box-shadow:0 0 28px rgba(201,162,39,.25)!important;transform:translateY(-2px)!important} .div-txt{color:#7a6a4e;font-style:italic;font-size:.9em} #custom-topic textarea{background:#120508!important;border:1px solid #c9a22733!important;color:#e8d4a8!important; font-family:'IM Fell English',serif!important;font-size:1em!important;border-radius:6px!important;min-width:480px!important} #status-box{color:#c9a227;font-style:italic;font-size:.95em;min-height:24px;text-align:center} /* Theater screen — curtain reveal animation on entry */ .th-wrap{position:relative;min-height:100vh;background:radial-gradient(circle at center top, #3a0d18 0%, #210609 80%);display:flex;flex-direction:column;justify-content:flex-start!important;animation:curtain-open 1.4s cubic-bezier(.77,0,.18,1) both} @keyframes curtain-open{ 0%{clip-path:inset(0 50% 0 50%)} 100%{clip-path:inset(0 0% 0 0%)} } .scene-img-loading { background: #120508 !important; position: relative; overflow: hidden; } .scene-img-shimmer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(90deg, rgba(58,13,24,0.0) 0%, rgba(58,13,24,0.4) 50%, rgba(58,13,24,0.0) 100%); background-size: 200% 100%; animation: loading-shimmer 1.8s infinite linear; } @keyframes loading-shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } .loading-overlay-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #c9a227; font-family: 'IM Fell English', serif; font-style: italic; font-size: 1.25rem; z-index: 5; text-shadow: 0 0 10px rgba(0,0,0,0.9); letter-spacing: 0.05em; } /* Force Gradio's internal wrapper containers not to stretch */ .th-wrap div, .th-wrap .form, .th-wrap .gap { flex: none !important; height: auto !important; min-height: 0 !important; justify-content: flex-start !important; } .scene-img-wrap{position:relative!important;width:100%!important;height:440px!important;max-height:440px!important;overflow:hidden!important;background:#000!important;border-bottom:1px solid #c9a22722!important;flex:none!important} .img-curtain{position:absolute!important;top:0!important;height:120%!important;width:15%!important;max-width:150px!important;z-index:10!important;pointer-events:none!important;object-fit:fill!important;filter:drop-shadow(0 0 15px rgba(0,0,0,0.9))!important} .img-curtain-left{left:0!important} .img-curtain-right{right:0!important;transform:scaleX(-1)!important} .scene-img{width:100%!important;height:100%!important;max-height:440px!important;object-fit:cover;display:block!important;transition:opacity .6s!important} .th-lower{padding:10px 32px 16px!important;flex:none!important;height:auto!important;min-height:0!important;display:flex!important;flex-direction:column!important;gap:16px!important;margin-top:0!important} .action-text{font-style:italic;color:#7a6a4e;font-size:.85rem;margin-bottom:4px} .char-name{font-size:1rem;font-weight:bold;letter-spacing:.04em;margin-bottom:6px} .dialogue-text{color:#e8d4a8;font-size:1.1rem;line-height:1.8;min-height:2em} .dialogue-box{padding:12px 0} .ctrl-row{display:flex!important;align-items:center!important;gap:12px!important;border-top:1px solid #c9a22722!important;padding-top:14px!important;flex-wrap:wrap!important;width:100%!important;height:auto!important;flex:none!important} #hist-btn button{background:transparent!important;border:1px solid #c9a22744!important;color:#c9a227!important; font-size:1.1em!important;width:44px!important;height:44px!important;border-radius:50%!important;padding:0!important;min-width:44px!important} #history-panel{position:fixed;top:0;right:-420px;height:100vh;width:400px;z-index:500; background:rgba(10,5,8,.97);border-left:2px solid #c9a227;padding:24px 20px;overflow-y:auto;transition:right .3s} #history-panel.open{right:0} .history-inner{display:flex;flex-direction:column;gap:10px} .hist-row{padding:8px 0;border-bottom:1px solid #c9a22722} .hist-name{font-size:.85em;font-weight:bold;display:block;margin-bottom:2px} .hist-line{color:#9a8a6e;font-style:italic;font-size:.95em} .mode-toggle{display:flex;border:1px solid #c9a22744;border-radius:6px;overflow:hidden} .mode-btn{background:transparent;border:none;color:#7a6a4e;padding:8px 18px;font-family:'IM Fell English',serif;font-size:.9em;cursor:pointer;transition:all .2s} .mode-btn.active{background:#c9a22722;color:#c9a227} #interrupt-input textarea{background:#120508!important;border:1px solid #c9a22733!important;color:#e8d4a8!important;font-family:'IM Fell English',serif!important;border-radius:6px!important} .ov-fullscreen{position:fixed;inset:0;z-index:400;background:rgba(10,5,8,.92); display:flex!important;align-items:center;justify-content:center;flex-direction:column;gap:16px} .ov-txt{color:#c9a227;font-size:1.4em;font-style:italic} .ov-spin{width:40px;height:40px;border:3px solid #c9a22733;border-top-color:#c9a227;border-radius:50%;animation:spin 1s linear infinite} @keyframes spin{to{transform:rotate(360deg)}} @keyframes nb-in { 0% { opacity:0; } 100% { opacity:1; } } @keyframes nb-out { 0% { opacity:1; } 100% { opacity:0; visibility:hidden; } } @keyframes nt-in { 0% { opacity:0; filter:blur(10px); transform:translateY(20px) scale(0.9); } 100% { opacity:1; filter:blur(0px); transform:translateY(0px) scale(1); } } @keyframes nt-out { 0% { opacity:1; filter:blur(0px); transform:translateY(0px) scale(1); } 100% { opacity:0; filter:blur(10px); transform:translateY(-20px) scale(1.05); } } #audio-out{position:absolute!important;width:0px!important;height:0px!important;opacity:0!important;pointer-events:none!important;overflow:hidden!important;padding:0!important;margin:0!important;border:none!important} /* Character Puppet Theater Stage CSS */ .dialogue-container { display: flex !important; flex-direction: row !important; align-items: flex-end !important; justify-content: space-between !important; width: 100% !important; gap: 16px !important; position: relative !important; min-height: 300px !important; height: 300px !important; flex: none !important; } .th-wrap .dialogue-container .dialogue-box-wrap { flex: 1 !important; display: flex !important; flex-direction: column !important; justify-content: center !important; align-self: center !important; min-height: 120px !important; height: auto !important; margin-bottom: 0 !important; } .stage-slot { width: 210px !important; height: 300px !important; min-height: 300px !important; display: flex !important; align-items: flex-end !important; justify-content: center !important; position: relative !important; overflow: visible !important; flex: none !important; } .puppet-wrapper { position: relative !important; display: flex !important; align-items: flex-end !important; justify-content: center !important; height: 100% !important; width: 100% !important; flex: none !important; } .puppet-img { width: 100% !important; height: auto !important; max-height: 300px !important; object-fit: contain !important; transform-origin: bottom center !important; } .puppet-img.loaded { transition: opacity 0.8s cubic-bezier(0.25, 1, 0.5, 1), transform 0.8s cubic-bezier(0.25, 1, 0.5, 1), filter 0.8s cubic-bezier(0.25, 1, 0.5, 1) !important; } .puppet-img.active { opacity: 1.0 !important; filter: none !important; transform: scale(1.05) !important; } .puppet-img.inactive { opacity: 0.8 !important; filter: none !important; transform: scale(1.0) !important; } /* Initially hidden state for entering puppets */ .puppet-img.new-puppet { opacity: 0 !important; filter: blur(15px) grayscale(30%) !important; transform: scale(0.8) !important; } /* Transition targets once onload triggers */ .puppet-img.new-puppet.loaded { transition: opacity 1.6s cubic-bezier(0.25, 1, 0.5, 1), filter 1.6s cubic-bezier(0.25, 1, 0.5, 1), transform 1.6s cubic-bezier(0.25, 1, 0.5, 1) !important; } .puppet-img.new-puppet.loaded.active { opacity: 1.0 !important; filter: none !important; transform: scale(1.05) !important; } .puppet-img.new-puppet.loaded.inactive { opacity: 0.8 !important; filter: none !important; transform: scale(1.0) !important; } @keyframes puppet-blur-out { 0% { filter: blur(0px) grayscale(0%); opacity: 1; transform: scale(1); } 100% { filter: blur(15px) grayscale(70%); opacity: 0; transform: scale(0.8); } } .puppet-wrapper.puppet-ex { position: absolute !important; bottom: 0 !important; left: 50% !important; transform: translateX(-50%) !important; pointer-events: none !important; z-index: 1 !important; } .puppet-wrapper.puppet-ex .puppet-img { animation: puppet-blur-out 1.6s cubic-bezier(0.25, 1, 0.5, 1) forwards !important; } .puppet-loading { width: 50px !important; height: 100px !important; border-radius: 25px !important; background: linear-gradient(90deg, rgba(201,162,39,0.03) 25%, rgba(201,162,39,0.1) 50%, rgba(201,162,39,0.03) 75%) !important; background-size: 200% 100% !important; animation: loading-shimmer 1.4s infinite !important; margin-bottom: 20px !important; } @keyframes loading-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } """ OPEN_HIST_JS = "() => { const p=document.getElementById('history-panel'); if(p) p.classList.toggle('open'); }" def make_play_js(title=None, is_custom=False): title_str = f'"{title}"' if title else '""' custom_flag = 'true' if is_custom else 'false' return f""" async (...args) => {{ const isCustom = {custom_flag}; const vid = document.getElementById('curtain-video'); const content = document.querySelector('.sel-content'); if(content) {{ content.style.opacity = '0'; content.style.pointerEvents = 'none'; }} if (isCustom) {{ const msgs = [ "✍️ Writing your script…", "🎭 Casting the finest actors…", "🎨 Painting the scenery…", "🎤 Sound check in progress…", "💡 Setting the lights…", "🤫 Audience finding their seats…", ]; if(window.showTheaterLoading) window.showTheaterLoading(JSON.stringify(msgs)); }} else {{ let titleText = {title_str}; if(window.injectTitle) window.injectTitle(titleText); }} if(vid) {{ try {{ vid.playbackRate = 2.5; vid.play(); }} catch(e) {{}} await new Promise(r => setTimeout(r, 2400)); vid.style.filter = "blur(20px) brightness(0)"; await new Promise(r => setTimeout(r, 800)); }} return args; }} """ JS_FIX_AUTOPLAY = """ async () => { window.showTheaterLoading = (messagesJson) => { let old = document.getElementById("theater-loading-overlay"); if(old) old.remove(); if(window.theaterLoadingInterval) clearInterval(window.theaterLoadingInterval); const messages = JSON.parse(messagesJson); const div = document.createElement("div"); div.id = "theater-loading-overlay"; div.style.cssText = `position:fixed;inset:0;z-index:999999;background:radial-gradient(circle at center, #2b060a 0%, #0a0203 100%);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:24px;opacity:1;transition:opacity 0.8s ease;`; div.innerHTML = `
${messages[0]}
`; const container = document.querySelector('.gradio-container') || document.body; container.appendChild(div); let idx = 0; window.theaterLoadingInterval = setInterval(() => { const el = document.getElementById("theater-loading-msg"); if(!el) { clearInterval(window.theaterLoadingInterval); return; } idx = (idx + 1) % messages.length; el.style.opacity = '0'; setTimeout(() => { el.textContent = messages[idx]; el.style.opacity = '1'; }, 400); }, 3000); }; window.hideTheaterLoading = () => { if(window.theaterLoadingInterval) { clearInterval(window.theaterLoadingInterval); window.theaterLoadingInterval = null; } const el = document.getElementById("theater-loading-overlay"); if(el) { el.style.opacity = '0'; setTimeout(() => { if(el.parentNode) el.remove(); }, 800); } }; window.injectTitle = (titleText, noDelay = false) => { let old = document.getElementById("native-title-overlay"); if(old) old.remove(); const div = document.createElement("div"); div.id = "native-title-overlay"; const h = window.innerHeight; div.style.cssText = `position:absolute;top:0;left:0;width:100%;height:${h}px;z-index:99999;pointer-events:none;overflow:hidden;`; const bgAnim = noDelay ? "nb-in 0.8s ease forwards, nb-out 0.8s ease forwards 4.0s" : "nb-in 0.8s ease forwards 2.4s, nb-out 0.8s ease forwards 6.5s"; const txtAnim = noDelay ? "nt-in 1.5s ease-out forwards 0.5s, nt-out 1.5s ease-in forwards 2.5s" : "nt-in 1.5s ease-out forwards 3.0s, nt-out 1.5s ease-in forwards 5.5s"; div.innerHTML = `

${titleText}

`; const container = document.querySelector('.gradio-container') || document.body; container.appendChild(div); setTimeout(() => { if(div.parentNode) div.remove(); }, noDelay ? 5500 : 7500); }; window.injectRewriting = (...args) => { let old = document.getElementById("native-rewriting-overlay"); if(old) old.remove(); const div = document.createElement("div"); div.id = "native-rewriting-overlay"; const h = window.innerHeight; div.style.cssText = `position:absolute;top:0;left:0;width:100%;height:${h}px;z-index:99999;background:rgba(10,5,8,0.92);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;overflow:hidden;`; div.innerHTML = `
Rewriting…
`; const container = document.querySelector('.gradio-container') || document.body; container.appendChild(div); return args; }; window.removeRewriting = (...args) => { let old = document.getElementById("native-rewriting-overlay"); if(old) old.remove(); return args; }; // Force Gradio's internal wrappers inside .th-wrap to not stretch setInterval(() => { const imgWrap = document.querySelector('.scene-img-wrap'); const lower = document.querySelector('.th-lower'); const thWrap = document.querySelector('.th-wrap'); const collapseUpToThWrap = (el) => { let curr = el; while (curr && curr !== thWrap && curr !== document.body) { curr.style.setProperty('flex', 'none', 'important'); curr.style.setProperty('height', 'auto', 'important'); curr.style.setProperty('min-height', '0', 'important'); curr = curr.parentElement; } }; if (imgWrap) collapseUpToThWrap(imgWrap); if (lower) { collapseUpToThWrap(lower); lower.style.setProperty('flex', 'none', 'important'); lower.style.setProperty('height', 'auto', 'important'); lower.style.setProperty('min-height', '0', 'important'); for (const child of lower.children) { child.style.setProperty('flex', 'none', 'important'); child.style.setProperty('height', 'auto', 'important'); child.style.setProperty('min-height', '0', 'important'); } } }, 150); setInterval(() => { ['btn-comedy', 'btn-drama', 'btn-infidelity'].forEach(id => { const btn = document.getElementById(id); if(btn && !btn.hasAttribute('data-vid-bound')) { btn.setAttribute('data-vid-bound', 'true'); btn.addEventListener('click', () => { const vid = document.getElementById('curtain-video'); if(vid) { try { vid.playbackRate = 2.5; vid.play(); } catch(e) {} } }, true); } }); const tb = document.getElementById('custom-topic'); if(tb) { const inner = tb.querySelector('textarea') || tb.querySelector('input'); if(inner && !inner.hasAttribute('data-vid-bound')) { inner.setAttribute('data-vid-bound', 'true'); inner.addEventListener('keydown', (e) => { if(e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); } }, true); } } }, 500); } """ # ── Event handlers ─────────────────────────────────────────────── def open_theater(cur_state, preset_key, topic=""): print(f"[DBG] open_theater: {preset_key!r}") s = dict(cur_state) title = topic if preset_key: s["script"] = PRESETS.get(preset_key) if s["script"]: title = s["script"].get("title", preset_key.replace('_', ' ').title()) s["current_scene"] = 0 return s, gr.Tabs(selected=1), _img_html(), _dial_html("","","The curtain rises…","#7a6a4e"), "", None, "" def stream_preset(preset_key, cur_state): print(f"[DBG] stream: {preset_key}") script = PRESETS[preset_key] _stop_flag.clear() _client_audio_done.set() _audio_cache.clear() s = dict(cur_state); s["script"] = script for img, dial, hist, audio, status in _run_script(script, delay_start=2.5): yield s, img, dial, hist, audio, status def stream_comedy(s): yield from stream_preset("british_comedy", s) def stream_drama(s): yield from stream_preset("victorian_drama", s) def stream_infidelity(s): yield from stream_preset("infidelity", s) def stream_custom(topic, cur_state): _stop_flag.clear() _client_audio_done.set() _audio_cache.clear() s = dict(cur_state) _PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" # Phase 1: Generate script yield s, gr.skip(), gr.skip(), gr.skip(), gr.skip(), "✍️ Writing your script…" try: script = pw.generate_script(topic, OPENROUTER_API_KEY) except Exception as e: hide_trigger = f'' yield s, hide_trigger, gr.skip(), gr.skip(), gr.skip(), f"❌ {e}"; return # Unique run_id so images are never cached from old runs import uuid, html as _html run_id = uuid.uuid4().hex[:8] script["run_id"] = run_id for scene in script.get("scenes", []): if scene.get("image_path"): p = Path(scene["image_path"]) scene["image_path"] = f"generated_images/{p.stem}_{run_id}{p.suffix}" s["script"] = script # Phase 2: Character generation yield s, gr.skip(), gr.skip(), gr.skip(), gr.skip(), "🎭 Casting actors…" char_pipe.start_pipeline(script) for char in script.get("characters", []): cname = char.get("name", "") if cname: while True: with char_pipe._lock: ready = cname in char_pipe.reference_images if ready: break time.sleep(0.1) if _current_run_id != run_id or _stop_flag.is_set(): return # Phase 3: Generate all scene backgrounds scenes = script.get("scenes", []) for i, scene in enumerate(scenes): yield s, gr.skip(), gr.skip(), gr.skip(), gr.skip(), f"🎨 Painting scene {i+1}/{len(scenes)}…" _ensure_image(scene) if _current_run_id != run_id or _stop_flag.is_set(): return # All ready — hide loading overlay, play curtain animation, show LLM-generated title escaped_title = _html.escape(script.get("title", "Stage Whisper"), quote=True).replace("'", "\\'") trigger = (f'') yield s, trigger, gr.skip(), gr.skip(), gr.skip(), "" time.sleep(5.5) # let title animation play (noDelay: visible ~0.5s-4s, removed at 5.5s) # Play — skip pipeline since we already built it above for img, dial, hist, audio, status in _run_script(script, skip_pipeline=True): s["script"] = script yield s, img, dial, hist, audio, status def do_rewrite_stream(script, current_scene_index, text, mode, api_key): global _active_script, _is_rewriting_active try: completed = script["scenes"][:current_scene_index] completed_summary = json.dumps( [{"scene_number": s["scene_number"], "theme": s["theme"]} for s in completed], indent=2, ) if mode == "Director": user_prompt = f"""You are rewriting a theatrical script mid-performance. TITLE: {script['title']} GENRE: {script['genre']} CHARACTERS: {json.dumps(script['characters'], indent=2)} COMPLETED SCENES (do not rewrite these): {completed_summary} DIRECTOR EVENT (a sudden in-world occurrence that must be integrated): "{text}" Rewrite ALL remaining scenes (starting from scene number {current_scene_index + 1}) to naturally incorporate and follow from this director event. Keep the same characters, title, and genre. Return ONLY a JSON array of the rewritten scene objects (from scene {current_scene_index + 1} onward). Each scene: {{"scene_number": int, "theme": str, "image_prompt": str, "image_path": str, "dialogues": [{{"character": "string (must match a character name exactly)", "action": "string (physical stage direction only — no audio tags)", "line": "string (spoken line with optional embedded audio tags)", "interrupts_previous": bool}}]}} No preamble, no markdown. """ else: user_prompt = f"""You are rewriting a theatrical script mid-performance. TITLE: {script['title']} GENRE: {script['genre']} CHARACTERS: {json.dumps(script['characters'], indent=2)} COMPLETED SCENES (do not rewrite these): {completed_summary} CHARACTER DISRUPTION (a character does something unexpected, or a new character enters): "{text}" Rewrite ALL remaining scenes (starting from scene number {current_scene_index + 1}) to naturally integrate this character disruption. If a new character is introduced, add them to the ensemble and give them a fitting voice_label from: {json.dumps(pw._VOICE_LABELS)}. Keep the same title and genre. Return ONLY a JSON array of the rewritten scene objects (from scene {current_scene_index + 1} onward). Each scene: {{"scene_number": int, "theme": str, "image_prompt": str, "image_path": str, "dialogues": [{{"character": "string (must match a character name exactly)", "action": "string (physical stage direction only — no audio tags)", "line": "string (spoken line with optional embedded audio tags)", "interrupts_previous": bool}}]}} No preamble, no markdown. """ system_prompt = pw._SYSTEM_PROMPT_REWRITE for accumulated in pw.stream_model(api_key, system_prompt, user_prompt): if _stop_flag.is_set(): break raw = pw._strip_fences(accumulated).strip() parsed = None closers = ["", "]", "}", "]}", " ] } ]", " } ] } ]", " ] } } ]", " } } ] } ]"] for c in closers: try: parsed = json.loads(raw + c) break except json.JSONDecodeError: continue if parsed and isinstance(parsed, list): clean_scenes = [] for scene_idx, ps in enumerate(parsed): cs = { "scene_number": ps.get("scene_number", scene_idx + 1), "theme": ps.get("theme", ""), "image_prompt": ps.get("image_prompt", ""), "image_path": ps.get("image_path", ""), "dialogues": [] } dialogues = ps.get("dialogues", []) if isinstance(dialogues, list): for di, dl in enumerate(dialogues): if not isinstance(dl, dict): continue if "character" in dl and "line" in dl: if di < len(dialogues) - 1: cs["dialogues"].append(dl) else: line_val = str(dl["line"]) if line_val: pos = raw.rfind(line_val) if pos != -1: after = raw[pos + len(line_val):] if "}" in after: cs["dialogues"].append(dl) clean_scenes.append(cs) _active_script["scenes"] = completed + clean_scenes except Exception as e: print(f"[REWRITE STREAM ERROR] {e}") finally: _is_rewriting_active = False def handle_interrupt(text, cur_state, mode): if not text or not text.strip(): yield cur_state, "", gr.skip(), gr.skip(), gr.skip(), None, ""; return print(f"\n[DEBUG] [{mode.upper()} MANIPULATION] {text}") script = cur_state.get("script") if not script: yield cur_state, "", gr.skip(), gr.skip(), gr.skip(), None, "No script."; return _stop_flag.set() _client_audio_done.set() time.sleep(0.15) _stop_flag.clear() global _active_script, _is_rewriting_active _active_script = dict(script) _is_rewriting_active = True s = dict(cur_state); si = s.get("current_scene", 0) t = threading.Thread( target=do_rewrite_stream, args=(script, si, text, mode, OPENROUTER_API_KEY), daemon=True ) t.start() yield s, "", gr.skip(), _dial_html("Director", "[yells]", f'"{text}"... CUT! Scene {si+1}, take two!', "#c9a227"), "", None, "🎬 Directing actors..." def stream_interrupted(cur_state): _stop_flag.clear() _client_audio_done.set() _audio_cache.clear() global _active_script s = dict(cur_state) si = s.get("current_scene", 0) # ── 1. Wait for the LLM rewrite to finish ── deadline = time.time() + 120 while _is_rewriting_active: yield s, gr.skip(), gr.skip(), gr.skip(), gr.skip(), "🎬 Writing the new script..." time.sleep(0.5) if time.time() > deadline: break script = _active_script if not script: return # ── 2. Regenerate character assets (skip if chars unchanged) ── char_pipe.start_pipeline(script) for char in script.get("characters", []): cname = char.get("name", "") if cname: char_deadline = time.time() + 60 while True: with char_pipe._lock: ready = cname in char_pipe.reference_images if ready or time.time() > char_deadline: break yield s, gr.skip(), gr.skip(), gr.skip(), gr.skip(), f"🎭 Casting {cname}..." time.sleep(0.5) # ── 3. Prefetch ALL new scene backgrounds in background threads ── scenes = script.get("scenes", []) for scene_idx in range(si, len(scenes)): _prefetch(scenes[scene_idx]) # ── 4. Block until the first new scene background is ready ── if si < len(scenes): _ensure_image(scenes[si]) # ── 4. Fire JS to remove overlay via onload on a hidden pixel ── _PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" trigger_html = f'' yield s, trigger_html, gr.skip(), gr.skip(), gr.skip(), "" time.sleep(0.6) # brief pause so overlay fades before scene appears # ── 5. Play ── for img, dial, hist, audio, status in _run_script(script, start_scene=si, use_flash=True): s["script"] = script yield s, img, dial, hist, audio, status # ── Gradio Blocks ──────────────────────────────────────────────── with gr.Blocks() as demo: state = gr.State({"script": None, "current_scene": 0}) interrupt_mode = gr.State("Director") with gr.Tabs(elem_id="main-tabs", selected=0) as main_tabs: with gr.Tab("Select", id=0): with gr.Column(elem_classes="sel-wrap"): gr.HTML(_video_html(), elem_classes="vid-container") with gr.Column(elem_classes="sel-content"): gr.HTML('

🎭 Stage Whisper

' '

Choose your drama, or write your own.

') with gr.Row(elem_classes="pbtn-row"): btn_comedy = gr.Button("🇬🇧 British Comedy", elem_id="btn-comedy") btn_drama = gr.Button("🏚️ Victorian Drama", elem_id="btn-drama") btn_infidelity = gr.Button("💔 Infidelity", elem_id="btn-infidelity") gr.HTML('
— or —
') custom_topic = gr.Textbox(placeholder="Describe your own scenario and press Enter…", lines=2, elem_id="custom-topic", show_label=False, submit_btn=True) status_box = gr.HTML("", elem_id="status-box") with gr.Tab("Theater", id=1): with gr.Column(elem_classes="th-wrap"): h_scene_img = gr.HTML(_img_html()) with gr.Column(elem_classes="th-lower"): h_dial = gr.HTML(_dial_html("","","The curtain rises…","#7a6a4e")) with gr.Row(elem_classes="ctrl-row"): hist_btn = gr.Button("📜", elem_id="hist-btn", scale=0, min_width=44) with gr.Column(scale=1): mode_value = gr.Textbox(value="Director", visible=False, elem_id="mode-value") interrupt_input = gr.Textbox(placeholder="🎬 Director's cut — what happens next?", lines=1, elem_id="interrupt-input", show_label=False, submit_btn=True) h_history = gr.HTML("", elem_id="history-panel") audio_out = gr.HTML(visible=True, elem_id="audio-out") btn_audio_ended = gr.Textbox(visible=True, elem_id="btn-audio-ended") btn_audio_ended.change(fn=handle_audio_ended, inputs=[], outputs=[], queue=False) switch_outs = [state, main_tabs, h_scene_img, h_dial, h_history, audio_out, status_box] content_outs = [state, h_scene_img, h_dial, h_history, audio_out, status_box] btn_comedy.click(fn=lambda s: open_theater(s,"british_comedy"), inputs=[state], outputs=switch_outs, js=make_play_js(PRESETS["british_comedy"].get("title", "British Comedy")), show_progress="hidden").then(fn=stream_comedy, inputs=[state], outputs=content_outs, show_progress="hidden") btn_drama.click( fn=lambda s: open_theater(s,"victorian_drama"), inputs=[state], outputs=switch_outs, js=make_play_js(PRESETS["victorian_drama"].get("title", "Victorian Drama")), show_progress="hidden").then(fn=stream_drama, inputs=[state], outputs=content_outs, show_progress="hidden") btn_infidelity.click(fn=lambda s: open_theater(s,"infidelity"), inputs=[state], outputs=switch_outs, js=make_play_js(PRESETS["infidelity"].get("title", "The Glass Perimeter")), show_progress="hidden").then(fn=stream_infidelity, inputs=[state], outputs=content_outs, show_progress="hidden") custom_topic.submit(fn=lambda s, t: open_theater(s,"",t), inputs=[state, custom_topic], outputs=switch_outs, js=make_play_js("", True), show_progress="hidden").then(fn=stream_custom, inputs=[custom_topic,state], outputs=content_outs, show_progress="hidden") # mode is always Director hist_btn.click(fn=None, js=OPEN_HIST_JS) interrupt_outs = [state, interrupt_input, h_scene_img, h_dial, h_history, audio_out, status_box] interrupt_input.submit(fn=handle_interrupt, inputs=[interrupt_input, state, interrupt_mode], outputs=interrupt_outs, js="window.injectRewriting", show_progress="hidden").then(fn=stream_interrupted, inputs=[state], outputs=content_outs, show_progress="hidden") demo.load(None, None, None, js=JS_FIX_AUTOPLAY) demo.queue(max_size=3).launch(allowed_paths=[str(BASE)], css=CSS)