"""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''''''
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''''''
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)