| """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 |
|
|
| |
| 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 "" |
| |
| 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(".") |
| |
| 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'<video class="sel-video" id="curtain-video" src="{_file_url(vid_path)}" muted playsinline></video>' |
|
|
| def _img_html(path=None): |
| curtain_src = _file_url(BASE / "sources/one-side-curtain.png") |
| if path == "LOADING": |
| return f'''<div class="scene-img-wrap scene-img-loading"> |
| <div class="scene-img-shimmer"></div> |
| <img class="img-curtain img-curtain-left" src="{curtain_src}"/> |
| <img class="img-curtain img-curtain-right" src="{curtain_src}"/> |
| <div class="loading-overlay-text">Painting scene background...</div> |
| </div>''' |
| src = _file_url(path) if path and Path(path).exists() else _file_url(_placeholder_path()) |
| return f'''<div class="scene-img-wrap"> |
| <img class="img-curtain img-curtain-left" src="{curtain_src}"/> |
| <img class="img-curtain img-curtain-right" src="{curtain_src}"/> |
| <img class="scene-img" src="{src}"/> |
| </div>''' |
|
|
| 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.ellipse([75, 40, 135, 100], fill=(201, 162, 39, 100), outline=(201, 162, 39, 200), width=2) |
| |
| 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 = '<div class="puppet-loading"></div>' |
| 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'''<div class="puppet-wrapper"> |
| <img class="puppet-img {classes}" id="puppet-{char_name}" src="{url}" alt="{char_name}" {onload_attr}/> |
| </div>''' |
| |
| 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'''<div class="puppet-wrapper puppet-ex"> |
| <img class="puppet-img" src="{url}" alt="{ex_char_name}"/> |
| </div>''' |
| |
| return f'''<div class="stage-slot slot-{slot_name}"> |
| {ex_html} |
| {current_html} |
| </div>''' |
|
|
| 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'<div class="action-text">β {action} β</div>' 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'''<div class="dialogue-text" id="typed-text-{unique_id}" data-typewriter-text="{escaped_attr}"></div> |
| <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" style="display:none;" onload=" |
| (function(id) {{ |
| var el = document.getElementById(id); |
| if (!el || el.getAttribute('data-typewriter-started')) return; |
| el.setAttribute('data-typewriter-started', 'true'); |
| var txt = el.getAttribute('data-typewriter-text') || ''; |
| var duration = {typewriter_duration} * 1000; |
| var charDelay = duration / txt.length; |
| charDelay = Math.max(15, charDelay); |
| var idx = 0; |
| function type() {{ |
| var currentEl = document.getElementById(id); |
| if (!currentEl) return; |
| if (idx < txt.length) {{ |
| currentEl.textContent += txt.charAt(idx); |
| idx++; |
| setTimeout(type, charDelay); |
| }} |
| }} |
| type(); |
| }})('typed-text-{unique_id}'); |
| "/>''' |
| else: |
| dial_text_html = f'<div class="dialogue-text">{text}</div>' |
|
|
| if not left_char and not right_char: |
| return f'<div class="dialogue-box">{act}<div class="char-name" style="color:{color}">{char}</div>{dial_text_html}</div>' |
| |
| 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'''<div class="dialogue-container"> |
| {left_html} |
| <div class="dialogue-box-wrap"> |
| <div class="dialogue-box"> |
| {act} |
| <div class="char-name" style="color:{color}">{char}</div> |
| {dial_text_html} |
| </div> |
| </div> |
| {right_html} |
| </div>''' |
|
|
| def _hist_html(history, characters): |
| if not history: return "" |
| rows = "".join( |
| f'<div class="hist-row"><span class="hist-name" style="color:{_char_color(characters, i.get("character", i.get("name", "Director")))}">{i.get("character", i.get("name", "Director"))}</span>' |
| f'<span class="hist-line">"{i.get("line", i.get("text", ""))}"</span></div>' |
| for i in history |
| ) |
| return f'<div class="history-inner">{rows}</div>' |
|
|
| 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] |
| |
| |
| for offset in range(3): |
| curr_di = di + offset |
| if curr_di >= len(dialogues): |
| |
| 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) |
|
|
| |
| |
| 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 |
| |
| |
| 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 |
| |
| 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) |
| |
| |
| 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'<audio autoplay src="{audio_src}" onended="var t=document.querySelector(\'#btn-audio-ended textarea\'); if(t) {{ t.value=Date.now(); t.dispatchEvent(new Event(\'input\', {{bubbles:true}})); }}"></audio>' |
| |
| |
| 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) |
| |
| |
| 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)") |
| |
| |
| 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 |
| |
| |
| 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 = """ |
| @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 = `<div style="width:50px;height:50px;border:4px solid rgba(201,162,39,0.2);border-top-color:#c9a227;border-radius:50%;animation:spin 1.2s linear infinite;"></div><div id="theater-loading-msg" style="color:#c9a227;font-size:1.8em;font-style:italic;font-family:'IM Fell English',serif;text-shadow:0 0 10px rgba(201,162,39,0.3);min-height:2em;text-align:center;transition:opacity 0.4s ease;opacity:1;">${messages[0]}</div>`; |
| 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 = `<div style="position:absolute;top:0;left:0;width:100%;height:100%;background:#0a0508;opacity:0;animation:${bgAnim};z-index:1;"></div><h1 style="position:absolute;top:${(h/2)-150}px;left:0;width:100%;z-index:10;color:#c9a227;font-size:4em;text-align:center;margin:0;padding:0;opacity:0;animation:${txtAnim};text-shadow:0 0 40px rgba(201,162,39,0.5);font-family:'IM Fell English',serif;">${titleText}</h1>`; |
| 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 = `<div class="ov-spin" style="width:40px;height:40px;border:3px solid rgba(201,162,39,0.2);border-top-color:#c9a227;border-radius:50%;animation:spin 1s linear infinite;"></div><div style="color:#c9a227;font-size:1.4em;font-style:italic;">Rewritingβ¦</div>`; |
| 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); |
| } |
| """ |
|
|
| |
| 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" |
|
|
| |
| 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'<img src="{_PIXEL}" style="display:none;" onload="if(window.hideTheaterLoading)window.hideTheaterLoading();"/>' |
| yield s, hide_trigger, gr.skip(), gr.skip(), gr.skip(), f"β {e}"; return |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| escaped_title = _html.escape(script.get("title", "Stage Whisper"), quote=True).replace("'", "\\'") |
| trigger = (f'<img src="{_PIXEL}" style="display:none;" onload="' |
| f'if(window.hideTheaterLoading) window.hideTheaterLoading();' |
| f'setTimeout(function(){{if(window.injectTitle) window.injectTitle(\'{ escaped_title }\', true);}}, 400);"/>') |
| yield s, trigger, gr.skip(), gr.skip(), gr.skip(), "" |
| time.sleep(5.5) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| scenes = script.get("scenes", []) |
| for scene_idx in range(si, len(scenes)): |
| _prefetch(scenes[scene_idx]) |
|
|
| |
| if si < len(scenes): |
| _ensure_image(scenes[si]) |
|
|
| |
| _PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" |
| trigger_html = f'<img src="{_PIXEL}" style="display:none;" onload="if(window.removeRewriting)window.removeRewriting();"/>' |
| yield s, trigger_html, gr.skip(), gr.skip(), gr.skip(), "" |
| time.sleep(0.6) |
|
|
| |
| 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 |
|
|
| |
| 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('<h1 class="ttl">π Stage Whisper</h1>' |
| '<p class="sub">Choose your drama, or write your own.</p>') |
| 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('<div class="div-txt">β or β</div>') |
| 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") |
|
|
| |
| 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) |
|
|