| import asyncio |
| import hashlib |
| from html import escape |
| import json |
| import os |
| from pathlib import Path |
| import tempfile |
| import time |
| from time import sleep |
|
|
| from dotenv import load_dotenv |
|
|
| |
| |
| load_dotenv(Path(__file__).resolve().parent / ".env") |
|
|
| os.environ["GRADIO_SSR_MODE"] = "False" |
|
|
| import gradio as gr |
|
|
| from puppet_theater import ( |
| DEFAULT_SHOW_LENGTH, |
| DEFAULT_HF_API_MODEL_ID, |
| DEFAULT_OPENBMB_MODEL_ID, |
| SHOW_LENGTH_PRESETS, |
| TheaterSession, |
| create_show_from_premise, |
| get_backend_status, |
| request_finale, |
| resolve_show_length, |
| run_one_beat, |
| story_phase, |
| story_progress, |
| summon_actor, |
| throw_prop, |
| warm_up_openbmb, |
| ) |
| from puppet_theater.trace import add_trace_event, render_trace_json, render_trace_summary, write_trace_json_file |
|
|
|
|
| EMPTY_STAGE = """ |
| <div class="puppet-stage stage-empty"> |
| <div class="stage-valance"></div> |
| <div class="stage-backdrop"> |
| <div class="stage-curtains stage-curtains-closed" aria-hidden="true"> |
| <div class="stage-curtain stage-curtain-left"></div> |
| <div class="stage-curtain stage-curtain-right"></div> |
| </div> |
| <div class="stage-empty-playbill"> |
| <p class="playbill-kicker">Tonight</p> |
| <h2 class="playbill-title">AI Puppet Theater</h2> |
| <p class="playbill-ornament" aria-hidden="true">— ✦ —</p> |
| <p class="playbill-tagline">Enter a premise and raise the curtain.</p> |
| </div> |
| </div> |
| <div class="stage-floorboards"></div> |
| </div> |
| """ |
|
|
| EMPTY_TRANSCRIPT = '<div class="conversation-empty">No show yet. The transcript will appear here.</div>' |
| EMPTY_AGENT_STATE = "<div class=\"agent-state-empty\">No agents on stage yet.</div>" |
| EMPTY_DIRECTOR_LOG = "No director notes yet." |
| EMPTY_TRACE = "No trace events yet." |
| EMPTY_TRACE_SUMMARY = "No trace events yet." |
| EMPTY_BACKEND = ( |
| "Actor engine: Deterministic\n" |
| "Active backend: deterministic\n" |
| "Director mode: deterministic\n" |
| "Show length: min=7, target=10, max=12\n" |
| "Available actor engines: Deterministic, Local LoRA Actor model, Local GGUF Actor model, Local OpenBMB, Hugging Face API\n" |
| "Available director modes: deterministic, local OpenBMB, Hugging Face API / LLM\n" |
| "OpenBMB model id: openbmb/MiniCPM5-1B\n" |
| "HF API model id: Qwen/Qwen3-4B-Instruct-2507:nscale\n" |
| "Local LoRA adapter: build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA\n" |
| "Local GGUF model: build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF/minicpm5-actor-q4_k_m.gguf\n" |
| "HF API token configured: false\n" |
| "ZeroGPU enabled: false\n" |
| "spaces available: false\n" |
| "ZeroGPU GPU decorator active: false\n" |
| "Torch version: unknown\n" |
| "CUDA available inside GPU function: not checked\n" |
| "Model status: unloaded\n" |
| "Fallback: deterministic safety path enabled\n" |
| "Note: Deterministic is the hosted-demo default. Local LoRA and GGUF load lazily only when selected." |
| ) |
| BACKEND_CHOICES = [ |
| ("Deterministic", "deterministic"), |
| ("Local LoRA Actor model", "local_lora"), |
| ("Local GGUF Actor model", "local_gguf"), |
| ("Local OpenBMB", "openbmb"), |
| ("Hugging Face API", "hf_api"), |
| ] |
| BACKEND_VALUES = {"deterministic", "local_lora", "local_gguf", "openbmb", "hf_api"} |
| LEGACY_BACKEND_VALUES: set[str] = set() |
| DIRECTOR_MODE_CHOICES = [ |
| ("Deterministic", "deterministic"), |
| ("Local OpenBMB", "openbmb"), |
| ("Hugging Face API / LLM", "hf_api"), |
| ] |
| DIRECTOR_MODE_VALUES = {"deterministic", "hf_api", "openbmb"} |
| OPENBMB_MODEL_ID = os.getenv("OPENBMB_MODEL_ID", DEFAULT_OPENBMB_MODEL_ID) |
| HF_API_MODEL_ID = os.getenv("HF_API_MODEL_ID", DEFAULT_HF_API_MODEL_ID) |
| ACTOR_ENGINE_LABELS = { |
| "deterministic": "Deterministic", |
| "local_lora": "Local LoRA model", |
| "local_gguf": "Local GGUF model", |
| "openbmb": "Local OpenBMB", |
| "hf_api": "Hugging Face API", |
| } |
| DEFAULT_ACTOR_ENGINE = ( |
| os.getenv("ACTOR_MODEL_BACKEND", "deterministic").strip().lower() or "deterministic" |
| ) |
| if DEFAULT_ACTOR_ENGINE not in BACKEND_VALUES: |
| DEFAULT_ACTOR_ENGINE = "deterministic" |
| DEFAULT_MAX_NEW_TOKENS = 120 |
| DEFAULT_TEMPERATURE = 0.75 |
| MIN_PLAYBACK_DELAY_SECONDS = 0.55 |
| MAX_PLAYBACK_DELAY_SECONDS = 1.85 |
| VOICE_MODE_CHOICES = [ |
| ("Off", "off"), |
| ("Browser TTS: Character Voices", "character"), |
| ("Browser TTS: Narrator Only", "narrator"), |
| ("Edge TTS: Character Voices", "edge_character"), |
| ] |
| DEFAULT_VOICE_MODE = "off" |
| EMPTY_TTS_PAYLOAD = "{}" |
| EMPTY_TTS_STATUS = "Voice mode is off." |
| EDGE_TTS_BACKEND = "edge_tts" |
| EDGE_TTS_MAX_CHARS = 500 |
| EDGE_TTS_CACHE_DIR = Path(tempfile.gettempdir()) / "ai-puppet-theater-edge-tts" |
| EDGE_TTS_MAX_FILES = 40 |
| EDGE_TTS_MAX_AGE_SECONDS = 60 * 60 * 6 |
| EDGE_TTS_VOICE_BY_SLOT = [ |
| "en-US-GuyNeural", |
| "en-US-JennyNeural", |
| "en-US-SteffanNeural", |
| "en-US-AriaNeural", |
| ] |
| EDGE_TTS_NARRATOR_VOICE = "en-US-AriaNeural" |
| SHOW_LENGTH_CHOICES = [ |
| ("Short", "short"), |
| ("Standard", "standard"), |
| ("Extended", "extended"), |
| ] |
| SHOW_LENGTH_VALUES = set(SHOW_LENGTH_PRESETS) |
| PROP_EMOJI = { |
| "rubber duck": "🐤", |
| "duck": "🐤", |
| "egg": "🥚", |
| "flowers": "💐", |
| "flower": "💐", |
| "tomato": "🍅", |
| "crown": "👑", |
| "tiny crown": "👑", |
| "scroll": "📜", |
| "banana": "🍌", |
| "mirror": "🪞", |
| } |
|
|
| DEFAULT_STAGE_PROPS: list[str] = [ |
| "rubber duck", |
| "egg", |
| "flowers", |
| "tomato", |
| "tiny crown", |
| "scroll", |
| ] |
|
|
| VOICE_STYLE_PRESETS = [ |
| { |
| "label": "Warm Puppet", |
| "pitch": 1.04, |
| "rate": 0.96, |
| "volume": 0.9, |
| "voiceNameHints": ["Google US English", "Samantha", "Jenny", "Aria"], |
| }, |
| { |
| "label": "Tiny Mischief", |
| "pitch": 1.14, |
| "rate": 0.98, |
| "volume": 0.88, |
| "voiceNameHints": ["Google UK English Female", "Samantha", "Jenny", "Aria"], |
| }, |
| { |
| "label": "Grumpy Elder", |
| "pitch": 0.92, |
| "rate": 0.9, |
| "volume": 0.9, |
| "voiceNameHints": ["Daniel", "Alex", "Google UK English Male", "Microsoft Guy"], |
| }, |
| { |
| "label": "Bright Hero", |
| "pitch": 1.02, |
| "rate": 1.02, |
| "volume": 0.9, |
| "voiceNameHints": ["Google US English", "Microsoft David", "Microsoft Mark", "Alex"], |
| }, |
| { |
| "label": "Soft Oracle", |
| "pitch": 0.98, |
| "rate": 0.86, |
| "volume": 0.82, |
| "voiceNameHints": ["Google UK English Female", "Microsoft Zira", "Samantha", "Aria"], |
| }, |
| ] |
| NARRATOR_VOICE_STYLE = { |
| "label": "Narrator", |
| "pitch": 0.96, |
| "rate": 0.88, |
| "volume": 0.82, |
| "voiceNameHints": ["Daniel", "Alex", "Google UK English Male", "Microsoft Guy", "Google US English"], |
| } |
|
|
|
|
| def _prop_dropdown_label(canonical: str) -> str: |
| emoji = PROP_EMOJI.get(canonical.lower(), "🎁") |
| title = " ".join(part.capitalize() for part in canonical.split()) |
| return f"{emoji} {title}" |
|
|
|
|
| PROP_DROPDOWN_CHOICES: list[tuple[str, str]] = [ |
| (_prop_dropdown_label(name), name) for name in DEFAULT_STAGE_PROPS |
| ] |
|
|
|
|
| def voice_style_for_actor(session: TheaterSession, speaker_name: str) -> dict[str, object]: |
| for index, actor in enumerate(session.actors): |
| if actor.name == speaker_name: |
| return dict(VOICE_STYLE_PRESETS[index % len(VOICE_STYLE_PRESETS)]) |
| return dict(NARRATOR_VOICE_STYLE) |
|
|
|
|
| def latest_tts_payload(session: TheaterSession | None) -> str: |
| if session is None or not session.transcript: |
| return EMPTY_TTS_PAYLOAD |
|
|
| latest_beat = session.transcript[-1] |
| latest_tool = session.latest_tool_result |
| if latest_tool is not None and latest_beat.tool_request is not None and latest_tool.actor_name == latest_beat.speaker: |
| payload = { |
| "id": f"{session.session_id}:{len(session.transcript)}:tool:{latest_tool.tool_name}", |
| "kind": "tool", |
| "speaker": "Stage Oracle", |
| "text": latest_tool.result, |
| "style": NARRATOR_VOICE_STYLE, |
| "narratorStyle": NARRATOR_VOICE_STYLE, |
| } |
| else: |
| payload = { |
| "id": f"{session.session_id}:{len(session.transcript)}:actor:{latest_beat.speaker}", |
| "kind": "actor", |
| "speaker": latest_beat.speaker, |
| "text": latest_beat.line, |
| "style": voice_style_for_actor(session, latest_beat.speaker), |
| "narratorStyle": NARRATOR_VOICE_STYLE, |
| } |
| return json.dumps(payload, ensure_ascii=True) |
|
|
|
|
| def latest_tts_data(session: TheaterSession | None) -> dict[str, object] | None: |
| if session is None or not session.transcript: |
| return None |
| try: |
| payload = json.loads(latest_tts_payload(session)) |
| except json.JSONDecodeError: |
| return None |
| return payload if isinstance(payload, dict) else None |
|
|
|
|
| def _clean_spoken_text(text: str, max_chars: int = EDGE_TTS_MAX_CHARS) -> str: |
| cleaned = " ".join(text.strip().split()) |
| if len(cleaned) <= max_chars: |
| return cleaned |
| truncated = cleaned[:max_chars].rsplit(" ", maxsplit=1)[0].strip() |
| return truncated or cleaned[:max_chars].strip() |
|
|
|
|
| def _summarize_tts_error(exc: Exception) -> str: |
| summary = " ".join(str(exc).strip().split()) |
| if not summary: |
| summary = exc.__class__.__name__ |
| return summary[:160] |
|
|
|
|
| def edge_voice_for_latest(session: TheaterSession, payload: dict[str, object]) -> str: |
| if payload.get("kind") == "tool": |
| return EDGE_TTS_NARRATOR_VOICE |
| speaker = str(payload.get("speaker") or "") |
| for index, actor in enumerate(session.actors): |
| if actor.name == speaker: |
| return EDGE_TTS_VOICE_BY_SLOT[index] if index < len(EDGE_TTS_VOICE_BY_SLOT) else EDGE_TTS_VOICE_BY_SLOT[-1] |
| return EDGE_TTS_NARRATOR_VOICE |
|
|
|
|
| def _edge_tts_cache_path(speaker: str, text: str, voice_name: str) -> Path: |
| digest = hashlib.sha256(f"{speaker}\0{voice_name}\0{text}".encode("utf-8")).hexdigest()[:24] |
| return EDGE_TTS_CACHE_DIR / f"{digest}.mp3" |
|
|
|
|
| def cleanup_edge_tts_cache() -> None: |
| try: |
| EDGE_TTS_CACHE_DIR.mkdir(parents=True, exist_ok=True) |
| now = time.time() |
| files = sorted( |
| [path for path in EDGE_TTS_CACHE_DIR.glob("*.mp3") if path.is_file()], |
| key=lambda path: path.stat().st_mtime, |
| reverse=True, |
| ) |
| for index, path in enumerate(files): |
| too_many = index >= EDGE_TTS_MAX_FILES |
| too_old = now - path.stat().st_mtime > EDGE_TTS_MAX_AGE_SECONDS |
| if too_many or too_old: |
| path.unlink(missing_ok=True) |
| except OSError: |
| return |
|
|
|
|
| async def _save_edge_tts_audio(text: str, voice_name: str, output_path: Path, timeout_seconds: float) -> None: |
| import edge_tts |
|
|
| communicate = edge_tts.Communicate(text=text, voice=voice_name) |
| await asyncio.wait_for(communicate.save(str(output_path)), timeout=timeout_seconds) |
|
|
|
|
| def _run_edge_tts_save(text: str, voice_name: str, output_path: Path, timeout_seconds: float) -> None: |
| try: |
| asyncio.run(_save_edge_tts_audio(text, voice_name, output_path, timeout_seconds)) |
| except RuntimeError: |
| loop = asyncio.new_event_loop() |
| try: |
| loop.run_until_complete(_save_edge_tts_audio(text, voice_name, output_path, timeout_seconds)) |
| finally: |
| loop.close() |
|
|
|
|
| def generate_edge_tts_audio( |
| session: TheaterSession | None, |
| voice_mode: str | None, |
| ) -> tuple[str | None, str]: |
| if voice_mode != "edge_character": |
| return None, ( |
| "Voice mode is off." |
| if voice_mode == "off" |
| else "Browser TTS is ready." |
| if voice_mode in {"character", "narrator"} |
| else EMPTY_TTS_STATUS |
| ) |
| if session is None: |
| return None, "Edge TTS is ready. Create a show and run a beat to generate audio." |
|
|
| payload = latest_tts_data(session) |
| if payload is None: |
| return None, "Edge TTS is ready. Run a beat to generate audio." |
|
|
| speaker = str(payload.get("speaker") or "Latest line") |
| text = _clean_spoken_text(str(payload.get("text") or "")) |
| voice_name = edge_voice_for_latest(session, payload) |
| if not text: |
| return None, "Edge TTS has no speakable line yet." |
|
|
| add_trace_event( |
| session, |
| "tts_requested", |
| voice_mode=voice_mode, |
| tts_backend=EDGE_TTS_BACKEND, |
| voice_name=voice_name, |
| fallback_used=False, |
| ) |
|
|
| try: |
| import edge_tts |
| except Exception as exc: |
| error_summary = _summarize_tts_error(exc) |
| add_trace_event( |
| session, |
| "tts_fallback", |
| voice_mode=voice_mode, |
| tts_backend=EDGE_TTS_BACKEND, |
| voice_name=voice_name, |
| fallback_used=True, |
| error_summary=error_summary, |
| ) |
| return None, "Edge TTS is not installed. Use Browser TTS or Off." |
|
|
| cleanup_edge_tts_cache() |
| try: |
| EDGE_TTS_CACHE_DIR.mkdir(parents=True, exist_ok=True) |
| audio_path = _edge_tts_cache_path(speaker, text, voice_name) |
| if not audio_path.exists(): |
| timeout_seconds = min(4.5, max(2.5, 1.6 + (len(text) / 120))) |
| _run_edge_tts_save(text, voice_name, audio_path, timeout_seconds) |
| add_trace_event( |
| session, |
| "tts_generated", |
| voice_mode=voice_mode, |
| tts_backend=EDGE_TTS_BACKEND, |
| voice_name=voice_name, |
| fallback_used=False, |
| ) |
| return str(audio_path), f"Edge TTS ready: {voice_name}" |
| except Exception as exc: |
| error_summary = _summarize_tts_error(exc) |
| add_trace_event( |
| session, |
| "tts_fallback", |
| voice_mode=voice_mode, |
| tts_backend=EDGE_TTS_BACKEND, |
| voice_name=voice_name, |
| fallback_used=True, |
| error_summary=error_summary, |
| ) |
| return None, f"Edge TTS failed. Use Browser TTS or Off. ({error_summary})" |
|
|
|
|
| def playback_delay_for_latest_line(session: TheaterSession, voice_mode: str | None = DEFAULT_VOICE_MODE) -> float: |
| if not session.transcript: |
| return MIN_PLAYBACK_DELAY_SECONDS |
| latest = session.transcript[-1].line |
| word_count = len(latest.split()) |
| if voice_mode == "edge_character": |
| delay = 1.15 + (word_count * 0.34) |
| return max(MIN_PLAYBACK_DELAY_SECONDS, min(6.0, delay)) |
| delay = 0.65 + (word_count * 0.28) |
| return max(MIN_PLAYBACK_DELAY_SECONDS, min(4.25, delay)) |
|
|
| CUSTOM_CSS = """ |
| body, |
| .gradio-container { |
| background: |
| radial-gradient(circle at 50% 0%, rgba(127, 29, 29, 0.18), transparent 28rem), |
| linear-gradient(180deg, #0b1020 0%, #070914 100%) !important; |
| color: #f8efe4 !important; |
| } |
| .gradio-container { |
| --theater-burgundy: #3b0a16; |
| --theater-plum: #2a1426; |
| --theater-plum-light: #3d1a35; |
| --theater-gold: #f6c453; |
| --theater-gold-soft: rgba(246, 196, 83, 0.35); |
| --theater-cream: #f8efe4; |
| --theater-amber-top: #fcd34d; |
| --theater-amber-mid: #d97706; |
| --theater-amber-deep: #9a3412; |
| --checkbox-label-padding: 0 !important; |
| box-sizing: border-box !important; |
| width: min(1200px, calc(100vw - 2rem)) !important; |
| max-width: min(1200px, calc(100vw - 2rem)) !important; |
| margin-left: auto !important; |
| margin-right: auto !important; |
| padding: 1rem 1rem 1.5rem !important; |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| } |
| .backstage-stack { |
| align-items: stretch !important; |
| gap: 0.85rem !important; |
| margin-left: auto !important; |
| margin-right: auto !important; |
| margin-top: 0.5rem !important; |
| max-width: 52rem; |
| width: 100% !important; |
| } |
| .backstage-stack > div { |
| min-width: 0 !important; |
| width: 100% !important; |
| } |
| .stage-output.block { |
| border: 1px solid var(--theater-gold-soft) !important; |
| border-radius: 14px !important; |
| box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35) !important; |
| overflow: hidden !important; |
| padding: 0 !important; |
| } |
| .stage-output .label-wrap { |
| background: linear-gradient(180deg, #5c1628 0%, #3b0a16 100%) !important; |
| border-bottom: 2px solid var(--theater-gold) !important; |
| border-radius: 12px 12px 0 0 !important; |
| margin: 0 !important; |
| padding: 0.35rem 0.75rem !important; |
| } |
| .stage-output .label-wrap span, |
| .stage-output label > span { |
| color: var(--theater-gold) !important; |
| font-family: Georgia, "Times New Roman", ui-serif, serif !important; |
| font-size: 0.82rem !important; |
| font-weight: 700 !important; |
| letter-spacing: 0.12em !important; |
| text-transform: uppercase !important; |
| } |
| .stage-output .html-container { |
| border-radius: 0 0 12px 12px !important; |
| } |
| .gradio-container .prose, |
| .gradio-container label, |
| .gradio-container span, |
| .gradio-container p { |
| color: #f8efe4; |
| } |
| .gradio-container textarea, |
| .gradio-container input { |
| background: rgba(10, 12, 23, 0.82) !important; |
| border-color: rgba(246, 196, 83, 0.24) !important; |
| color: #f8efe4 !important; |
| } |
| .gradio-container textarea::placeholder, |
| .gradio-container input::placeholder { |
| color: #9f8c7a !important; |
| } |
| .gradio-container footer { |
| color: rgba(203, 183, 161, 0.62) !important; |
| } |
| .gradio-container .block, |
| .gradio-container .form, |
| .gradio-container .panel, |
| .gradio-container .tabs, |
| .gradio-container .tabitem { |
| background: rgba(34, 17, 31, 0.56) !important; |
| border-color: rgba(246, 196, 83, 0.18) !important; |
| } |
| .gradio-container label, |
| .gradio-container .block-title, |
| .gradio-container .label-wrap { |
| color: #f8efe4 !important; |
| } |
| .gradio-container .block-info, |
| .gradio-container .label-wrap span, |
| .gradio-container label > span { |
| border-radius: 6px !important; |
| color: #ffd166 !important; |
| font-weight: 700 !important; |
| } |
| .gradio-container .accordion .block .label-wrap span { |
| background: transparent !important; |
| border: none !important; |
| border-radius: 0 !important; |
| box-shadow: none !important; |
| color: #cbb7a1 !important; |
| font-weight: 600 !important; |
| letter-spacing: 0.03em !important; |
| text-transform: none !important; |
| } |
| .gradio-container .wrap, |
| .gradio-container .styler, |
| .gradio-container .form, |
| .gradio-container .form > *, |
| .gradio-container .block > div { |
| background-color: transparent !important; |
| } |
| .gradio-container select, |
| .gradio-container [role="listbox"], |
| .gradio-container [role="combobox"] { |
| background: rgba(10, 12, 23, 0.82) !important; |
| border-color: rgba(246, 196, 83, 0.24) !important; |
| color: #f8efe4 !important; |
| } |
| .app-title h1 { |
| color: #fff7ed; |
| font-family: Georgia, "Times New Roman", ui-serif, serif; |
| font-size: 2.15rem; |
| font-weight: 700; |
| letter-spacing: 0.02em; |
| margin-bottom: 0; |
| text-align: center; |
| text-shadow: 0 2px 24px rgba(0, 0, 0, 0.45); |
| } |
| .app-title p { |
| color: #d4c4b4; |
| font-size: 0.95rem; |
| letter-spacing: 0.04em; |
| margin: 0.2rem 0 0.85rem; |
| text-align: center; |
| } |
| .gradio-container h3, |
| .gradio-container h3 span, |
| .gradio-container .prose h3, |
| .gradio-container .prose h3 span { |
| color: #f8efe4 !important; |
| } |
| .premise-panel { |
| background: |
| linear-gradient(145deg, rgba(246, 196, 83, 0.08) 0%, transparent 42%), |
| linear-gradient(180deg, rgba(52, 24, 46, 0.92) 0%, rgba(34, 17, 31, 0.94) 100%); |
| border: 1px solid rgba(246, 196, 83, 0.32) !important; |
| border-radius: 12px !important; |
| box-shadow: |
| 0 0 0 1px rgba(59, 10, 22, 0.55), |
| 0 18px 36px rgba(0, 0, 0, 0.28); |
| box-sizing: border-box !important; |
| overflow: hidden !important; |
| padding: 1rem 1.15rem 1.15rem !important; |
| } |
| .premise-panel .premise-stack, |
| .gradio-container .gr-group.premise-panel .premise-stack { |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| gap: 0.65rem !important; |
| margin: 0 !important; |
| padding: 0 !important; |
| width: 100% !important; |
| } |
| .premise-panel .premise-actions, |
| .gradio-container .gr-group.premise-panel .premise-actions { |
| box-sizing: border-box !important; |
| margin: 0 !important; |
| max-width: 100% !important; |
| padding: 0 !important; |
| width: 100% !important; |
| } |
| .premise-panel .form { |
| gap: 0.55rem !important; |
| padding: 0 !important; |
| } |
| .premise-panel .wrap, |
| .premise-panel .styler, |
| .premise-panel .form, |
| .premise-panel .block > div { |
| background: transparent !important; |
| } |
| .control-panel { |
| background: |
| linear-gradient(160deg, rgba(246, 196, 83, 0.06) 0%, transparent 50%), |
| linear-gradient(180deg, rgba(45, 22, 42, 0.95) 0%, rgba(26, 12, 24, 0.97) 100%); |
| border: 1px solid rgba(246, 196, 83, 0.28) !important; |
| border-radius: 12px !important; |
| box-shadow: |
| 0 0 0 1px rgba(59, 10, 22, 0.45), |
| 0 14px 32px rgba(0, 0, 0, 0.3); |
| padding: 1.15rem 1.2rem 1.25rem !important; |
| } |
| .show-controls-title { |
| padding-left: 8px; |
| padding-right: 8px; |
| padding-top: 6px; |
| } |
| .audience-title { |
| padding-left: 8px; |
| padding-right: 8px; |
| padding-top: 6px; |
| } |
| .control-panel .form { |
| gap: 0.75rem !important; |
| padding: 0 !important; |
| } |
| .control-panel .block, |
| .control-panel .wrap, |
| .control-panel .styler, |
| .control-panel .form, |
| .control-panel .block > div { |
| background: transparent !important; |
| } |
| .control-panel .row { |
| gap: 0.65rem !important; |
| } |
| .control-panel .prose { |
| margin-bottom: 0.65rem !important; |
| } |
| .control-panel .prose h3 { |
| margin-top: 0 !important; |
| } |
| .control-panel .label-wrap span, |
| .control-panel label > span, |
| .premise-panel .label-wrap span, |
| .premise-panel label > span { |
| background: transparent !important; |
| border: none !important; |
| border-radius: 0 !important; |
| box-shadow: none !important; |
| color: #e8ddd4 !important; |
| display: block !important; |
| font-size: 0.78rem !important; |
| font-weight: 600 !important; |
| letter-spacing: 0.04em !important; |
| padding: 0 0 0.28rem 0 !important; |
| text-transform: none !important; |
| } |
| .control-panel .dropdown-container, |
| .premise-panel .dropdown-container { |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| margin-top: 0 !important; |
| padding: 0 !important; |
| } |
| .control-panel .dropdown-container .wrap-inner, |
| .control-panel .dropdown-container .secondary-wrap, |
| .control-panel .single-select, |
| .premise-panel .dropdown-container .wrap-inner, |
| .premise-panel .dropdown-container .secondary-wrap { |
| background: rgba(8, 10, 18, 0.92) !important; |
| border: 1px solid rgba(246, 196, 83, 0.38) !important; |
| border-radius: 10px !important; |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04) !important; |
| } |
| .control-panel .dropdown-container button, |
| .control-panel .dropdown-container .icon, |
| .premise-panel .dropdown-container button { |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| } |
| /* Audience prop picker: clearer label, large click target, gold border, solid option list */ |
| .gradio-container .control-panel .prop-picker .label-wrap span, |
| .gradio-container .control-panel .prop-picker label > span { |
| color: #fcd34d !important; |
| font-family: Georgia, "Times New Roman", ui-serif, serif !important; |
| font-size: 0.82rem !important; |
| font-weight: 700 !important; |
| letter-spacing: 0.07em !important; |
| padding-bottom: 0.35rem !important; |
| text-transform: uppercase !important; |
| } |
| .gradio-container .control-panel .prop-picker .block-info, |
| .gradio-container .control-panel .prop-picker span[data-testid="block-info"] { |
| color: rgba(232, 221, 212, 0.88) !important; |
| font-size: 0.8rem !important; |
| font-weight: 500 !important; |
| line-height: 1.4 !important; |
| margin-top: 0.15rem !important; |
| max-width: 40rem; |
| } |
| .gradio-container .control-panel .prop-picker .single-select input, |
| .gradio-container .control-panel .prop-picker .single-select span { |
| font-size: 1rem !important; |
| letter-spacing: 0.02em !important; |
| } |
| /* Outer rim + padding so the whole control reads as one big control */ |
| .gradio-container .control-panel .prop-picker .dropdown-container { |
| background: rgba(14, 10, 22, 0.94) !important; |
| border: 2px solid rgba(246, 196, 83, 0.62) !important; |
| border-radius: 14px !important; |
| box-shadow: |
| 0 0 0 1px rgba(59, 10, 22, 0.45), |
| 0 8px 26px rgba(0, 0, 0, 0.42) !important; |
| box-sizing: border-box !important; |
| margin-top: 0.1rem !important; |
| padding: 0.35rem !important; |
| } |
| .gradio-container .control-panel .prop-picker .dropdown-container .wrap-inner, |
| .gradio-container .control-panel .prop-picker .dropdown-container .secondary-wrap, |
| .gradio-container .control-panel .prop-picker .single-select { |
| border-radius: 11px !important; |
| min-height: 3.5rem !important; |
| transition: |
| border-color 0.15s ease, |
| box-shadow 0.15s ease !important; |
| } |
| .gradio-container .control-panel .prop-picker .dropdown-container .wrap-inner, |
| .gradio-container .control-panel .prop-picker .dropdown-container .secondary-wrap { |
| align-items: center !important; |
| background: rgba(8, 10, 18, 0.98) !important; |
| border: 2px solid rgba(246, 196, 83, 0.48) !important; |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06) !important; |
| padding: 0.85rem 1.05rem !important; |
| } |
| .gradio-container .control-panel .prop-picker .single-select { |
| min-height: 3.5rem !important; |
| } |
| .gradio-container .control-panel .prop-picker .dropdown-container button { |
| min-height: 2.85rem !important; |
| min-width: 2.85rem !important; |
| } |
| .gradio-container .control-panel .prop-picker .dropdown-container:focus-within { |
| border-color: #f6c453 !important; |
| box-shadow: |
| 0 0 0 2px rgba(246, 196, 83, 0.35), |
| 0 10px 28px rgba(0, 0, 0, 0.48) !important; |
| } |
| .gradio-container .control-panel .prop-picker .dropdown-container:focus-within .wrap-inner, |
| .gradio-container .control-panel .prop-picker .dropdown-container:focus-within .secondary-wrap { |
| border-color: #f6c453 !important; |
| box-shadow: |
| 0 0 0 1px rgba(246, 196, 83, 0.35), |
| inset 0 1px 0 rgba(255, 255, 255, 0.07) !important; |
| } |
| /* Closed-state dropdown cue: chevron always visible on the trigger (not the open list) */ |
| .gradio-container .control-panel .prop-picker .dropdown-container, |
| .gradio-container .control-panel .prop-picker .dropdown-container .wrap-inner, |
| .gradio-container .control-panel .prop-picker .dropdown-container .secondary-wrap { |
| overflow: visible !important; |
| } |
| .gradio-container .control-panel .prop-picker .dropdown-container .wrap-inner { |
| position: relative !important; |
| } |
| .gradio-container .control-panel .prop-picker .dropdown-container .wrap-inner::after { |
| border-color: var(--theater-gold) transparent transparent transparent; |
| border-style: solid; |
| border-width: 8px 6px 0 6px; |
| content: ""; |
| filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); |
| opacity: 1; |
| pointer-events: none; |
| position: absolute; |
| right: 0.72rem; |
| top: 50%; |
| transform: translateY(-40%); |
| z-index: 50; |
| } |
| .gradio-container .control-panel .prop-picker .dropdown-container button svg { |
| height: 0 !important; |
| opacity: 0 !important; |
| overflow: hidden !important; |
| width: 0 !important; |
| } |
| .control-panel .row, |
| .premise-panel .row, |
| .premise-panel .premise-actions { |
| background: transparent !important; |
| } |
| .control-panel h3 { |
| border-bottom: 1px solid rgba(246, 196, 83, 0.2); |
| color: #fff7ed; |
| font-family: Georgia, "Times New Roman", ui-serif, serif; |
| font-size: 0.92rem; |
| font-weight: 700; |
| letter-spacing: 0.06em; |
| margin: 0 0 0.5rem; |
| padding-bottom: 0.35rem; |
| text-transform: uppercase; |
| } |
| .control-panel .prose, |
| .control-panel .prose h3, |
| .control-panel h3 * { |
| color: #f8efe4 !important; |
| } |
| .puppet-stage { |
| border: 5px solid #3b0a16; |
| border-radius: 14px; |
| background: |
| linear-gradient(90deg, rgba(59, 10, 22, 0.98) 0 10%, transparent 10% 90%, rgba(59, 10, 22, 0.98) 90% 100%), |
| linear-gradient(180deg, rgba(42, 20, 38, 0.96), rgba(13, 6, 14, 0.98)); |
| box-sizing: border-box; |
| color: #f8efe4; |
| display: flex; |
| flex-direction: column; |
| align-items: stretch; |
| justify-content: stretch; |
| height: 600px; |
| max-height: 600px; |
| min-height: 600px; |
| position: relative; |
| overflow: hidden; |
| box-shadow: |
| 0 24px 48px rgba(0, 0, 0, 0.38), |
| inset 0 0 42px rgba(0, 0, 0, 0.58); |
| } |
| .puppet-stage::before, |
| .puppet-stage::after { |
| content: ""; |
| position: absolute; |
| top: 0; |
| bottom: 0; |
| width: 13%; |
| background: |
| repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0 14px, transparent 14px 28px), |
| linear-gradient(180deg, #8b1e3f 0%, #7f1d1d 54%, #3b0a16 100%); |
| box-shadow: inset -16px 0 28px rgba(0, 0, 0, 0.22); |
| z-index: 2; |
| } |
| .puppet-stage::before { |
| left: 0; |
| } |
| .puppet-stage::after { |
| right: 0; |
| transform: scaleX(-1); |
| } |
| .stage-valance { |
| height: 48px; |
| background: |
| repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.06) 0 22px, transparent 22px 44px), |
| linear-gradient(180deg, #8b1e3f 0%, #7f1d1d 100%); |
| border-bottom: 4px solid #f6c453; |
| box-shadow: 0 10px 20px rgba(0, 0, 0, 0.34); |
| position: relative; |
| z-index: 3; |
| } |
| .stage-curtains { |
| display: flex; |
| flex-direction: row; |
| inset: 0; |
| overflow: hidden; |
| pointer-events: none; |
| position: absolute; |
| z-index: 15; |
| } |
| .stage-curtain { |
| background: |
| repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.1) 0 2px, transparent 2px 24px), |
| linear-gradient(180deg, #7f1533 0%, #4a0a18 55%, #2d050e 100%); |
| box-shadow: |
| inset 0 0 38px rgba(0, 0, 0, 0.5), |
| inset -5px 0 0 rgba(246, 196, 83, 0.32); |
| flex: 1 1 50%; |
| height: 100%; |
| min-width: 50%; |
| } |
| .stage-curtains-animate .stage-curtain-left { |
| animation: stage-curtain-left-open 1.45s cubic-bezier(0.22, 1, 0.36, 1) 0.1s forwards; |
| transform-origin: left center; |
| } |
| .stage-curtains-animate .stage-curtain-right { |
| animation: stage-curtain-right-open 1.45s cubic-bezier(0.22, 1, 0.36, 1) 0.1s forwards; |
| box-shadow: |
| inset 0 0 38px rgba(0, 0, 0, 0.5), |
| inset 5px 0 0 rgba(246, 196, 83, 0.32); |
| transform-origin: right center; |
| } |
| .stage-curtains-closed .stage-curtain-left, |
| .stage-curtains-closed .stage-curtain-right { |
| animation: none !important; |
| } |
| .stage-curtains-closed .stage-curtain-left { |
| transform: translateX(1.5%); |
| } |
| .stage-curtains-closed .stage-curtain-right { |
| box-shadow: |
| inset 0 0 38px rgba(0, 0, 0, 0.5), |
| inset 5px 0 0 rgba(246, 196, 83, 0.32); |
| transform: translateX(-1.5%); |
| } |
| @keyframes stage-curtain-left-open { |
| from { transform: translateX(0); } |
| to { transform: translateX(-108%); } |
| } |
| @keyframes stage-curtain-right-open { |
| from { transform: translateX(0); } |
| to { transform: translateX(108%); } |
| } |
| @media (prefers-reduced-motion: reduce) { |
| .stage-curtains-animate .stage-curtain-left, |
| .stage-curtains-animate .stage-curtain-right { |
| animation: none !important; |
| opacity: 0; |
| transform: none !important; |
| } |
| } |
| .stage-backdrop { |
| background: |
| radial-gradient(circle at 50% 8%, rgba(255, 224, 150, 0.28), transparent 19rem), |
| radial-gradient(circle at 24% 58%, rgba(255, 224, 150, 0.12), transparent 14rem), |
| linear-gradient(180deg, #2a1426 0%, #22111f 62%, #130911 100%); |
| display: flex; |
| flex: 1 1 0; |
| flex-direction: column; |
| min-height: 0; |
| overflow: hidden; |
| padding: 0.72rem 7.2rem 0.8rem; |
| position: relative; |
| z-index: 1; |
| } |
| .stage-backdrop::after { |
| background: linear-gradient(180deg, transparent 0%, rgba(124, 63, 23, 0.46) 100%); |
| bottom: 0; |
| content: ""; |
| height: 32%; |
| left: 0; |
| position: absolute; |
| right: 0; |
| } |
| .stage-marquee { |
| color: #fff7ed; |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: 1.6rem; |
| font-weight: 700; |
| letter-spacing: 0; |
| text-align: center; |
| text-shadow: 0 4px 18px rgba(0, 0, 0, 0.72); |
| position: relative; |
| z-index: 2; |
| overflow-wrap: anywhere; |
| } |
| .stage-copy { |
| max-width: 54rem; |
| color: #cbb7a1; |
| font-size: 0.84rem; |
| line-height: 1.35; |
| margin: 0.25rem auto 0; |
| text-align: center; |
| position: relative; |
| z-index: 2; |
| } |
| .stage-copy strong { |
| color: #f8efe4; |
| } |
| .stage-empty-playbill { |
| border: 2px solid rgba(246, 196, 83, 0.55); |
| border-radius: 4px; |
| box-shadow: |
| 0 0 0 1px rgba(59, 10, 22, 0.9), |
| 0 0 48px rgba(255, 209, 102, 0.22), |
| inset 0 0 36px rgba(0, 0, 0, 0.35); |
| left: 50%; |
| max-width: min(22rem, 88%); |
| padding: 1.35rem 1.5rem 1.45rem; |
| position: absolute; |
| text-align: center; |
| top: 50%; |
| transform: translate(-50%, -50%); |
| z-index: 22; |
| background: |
| radial-gradient(ellipse 120% 80% at 50% 20%, rgba(255, 224, 160, 0.14), transparent 55%), |
| linear-gradient(180deg, rgba(42, 20, 38, 0.92) 0%, rgba(19, 9, 17, 0.94) 100%); |
| } |
| .playbill-kicker { |
| color: #ffd166; |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: 0.72rem; |
| font-weight: 700; |
| letter-spacing: 0.38em; |
| margin: 0 0 0.35rem; |
| text-transform: uppercase; |
| } |
| .playbill-title { |
| color: #fff7ed; |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: clamp(1.35rem, 3.2vw, 1.85rem); |
| font-weight: 700; |
| letter-spacing: 0.04em; |
| line-height: 1.15; |
| margin: 0; |
| text-shadow: 0 3px 22px rgba(0, 0, 0, 0.75); |
| } |
| .playbill-ornament { |
| color: rgba(246, 196, 83, 0.75); |
| font-size: 0.82rem; |
| letter-spacing: 0.12em; |
| margin: 0.45rem 0 0.35rem; |
| } |
| .playbill-tagline { |
| color: #e8d5c4; |
| font-size: 0.95rem; |
| line-height: 1.45; |
| margin: 0; |
| } |
| .puppet-stage.stage-empty .stage-backdrop::before { |
| background: radial-gradient(ellipse 70% 55% at 50% 42%, rgba(255, 240, 200, 0.12), transparent 62%); |
| content: ""; |
| inset: 0; |
| pointer-events: none; |
| position: absolute; |
| z-index: 1; |
| } |
| .stage-floorboards { |
| height: 58px; |
| background: |
| repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0 2px, transparent 2px 72px), |
| linear-gradient(180deg, #8a4b22 0%, #7c3f17 100%); |
| border-top: 2px solid rgba(246, 196, 83, 0.28); |
| position: relative; |
| z-index: 3; |
| } |
| .speech-bubble { |
| animation: bubble-in 0.24s ease-out; |
| background: rgba(18, 10, 18, 0.92); |
| border: 1px solid rgba(246, 196, 83, 0.5); |
| border-radius: 16px; |
| box-shadow: 0 18px 30px rgba(0, 0, 0, 0.34); |
| box-sizing: border-box; |
| color: #f8efe4; |
| left: 0; |
| margin: 0 0 0.35rem 0; |
| max-height: 7.25rem; |
| overflow-x: hidden; |
| overflow-y: auto; |
| padding: 0.72rem 0.95rem; |
| position: absolute; |
| right: 0; |
| bottom: 100%; |
| text-align: center; |
| width: auto; |
| z-index: 6; |
| } |
| .speech-bubble::after { |
| border-left: 10px solid transparent; |
| border-right: 10px solid transparent; |
| border-top: 12px solid rgba(246, 196, 83, 0.5); |
| bottom: -12px; |
| content: ""; |
| left: 50%; |
| position: absolute; |
| transform: translateX(-50%); |
| } |
| .speech-speaker { |
| color: #ffd166; |
| font-size: 0.78rem; |
| font-weight: 800; |
| letter-spacing: 0.08em; |
| margin-bottom: 0.18rem; |
| text-transform: uppercase; |
| } |
| .speech-line { |
| color: #f8efe4; |
| font-size: 0.96rem; |
| line-height: 1.35; |
| overflow-wrap: anywhere; |
| } |
| .actor-row { |
| align-items: flex-end; |
| display: flex; |
| flex-shrink: 0; |
| flex-wrap: wrap; |
| gap: 0.55rem; |
| justify-content: center; |
| margin-top: 0.91rem; |
| position: relative; |
| z-index: 3; |
| } |
| .actor-column { |
| align-items: center; |
| display: flex; |
| flex: 0 0 auto; |
| flex-direction: column; |
| justify-content: flex-end; |
| min-width: 0; |
| position: relative; |
| } |
| .actor-card { |
| background: rgba(70, 38, 36, 0.72); |
| border: 1px solid rgba(246, 196, 83, 0.45); |
| border-radius: 16px 16px 10px 10px; |
| box-shadow: 0 14px 28px rgba(0, 0, 0, 0.28); |
| min-height: 132px; |
| padding: 0.58rem 0.62rem 0.72rem; |
| position: relative; |
| transform-origin: bottom center; |
| text-align: center; |
| z-index: 1; |
| } |
| .actor-card::after { |
| background: #7c3f17; |
| border-radius: 0 0 8px 8px; |
| bottom: -22px; |
| box-shadow: inset 0 -5px 8px rgba(0, 0, 0, 0.2); |
| content: ""; |
| height: 22px; |
| left: calc(50% - 8px); |
| position: absolute; |
| width: 16px; |
| } |
| .actor-card.active { |
| animation: puppet-bounce 0.78s ease-in-out infinite alternate; |
| border-color: #ffd166; |
| box-shadow: |
| 0 0 0 2px rgba(255, 209, 102, 0.22), |
| 0 0 34px rgba(255, 209, 102, 0.46), |
| 0 16px 34px rgba(0, 0, 0, 0.34); |
| } |
| .actor-avatar { |
| background: radial-gradient(circle, rgba(255, 209, 102, 0.2), rgba(59, 10, 22, 0.3)); |
| border: 1px solid rgba(246, 196, 83, 0.34); |
| border-radius: 999px; |
| display: inline-grid; |
| font-size: 1.7rem; |
| height: 3rem; |
| overflow: hidden; |
| place-items: center; |
| text-align: center; |
| width: 3rem; |
| } |
| .actor-avatar.has-image { |
| padding: 0; |
| } |
| .actor-avatar .actor-avatar-img { |
| border-radius: 999px; |
| display: block; |
| height: 100%; |
| object-fit: cover; |
| width: 100%; |
| } |
| .actor-name { |
| color: #f8efe4; |
| font-weight: 700; |
| line-height: 1.15; |
| margin-top: 0.35rem; |
| text-align: center; |
| } |
| .speaking-pill { |
| background: #ffd166; |
| border-radius: 999px; |
| color: #3b0a16; |
| display: inline-block; |
| font-size: 0.64rem; |
| font-weight: 800; |
| margin-top: 0.26rem; |
| padding: 0.12rem 0.44rem; |
| text-transform: uppercase; |
| } |
| .actor-detail { |
| color: #cbb7a1; |
| font-size: 0.72rem; |
| line-height: 1.28; |
| margin-top: 0.35rem; |
| } |
| .actor-detail strong { |
| color: #f8efe4; |
| } |
| .held-prop { |
| margin-top: 0.42rem; |
| } |
| .held-prop span { |
| background: rgba(246, 196, 83, 0.14); |
| border: 1px solid rgba(246, 196, 83, 0.32); |
| border-radius: 999px; |
| color: #ffd166; |
| display: inline-block; |
| font-size: 0.68rem; |
| font-weight: 700; |
| padding: 0.12rem 0.42rem; |
| } |
| .beat-counter { |
| color: #ffd166; |
| font-weight: 800; |
| margin-top: 0.55rem; |
| position: relative; |
| text-align: center; |
| z-index: 3; |
| } |
| .stage-events { |
| display: grid; |
| gap: 0.4rem; |
| margin-top: 0.55rem; |
| position: relative; |
| z-index: 3; |
| } |
| .audience-action, |
| .prop-pile, |
| .tool-clue { |
| background: rgba(42, 20, 38, 0.7); |
| border: 1px solid rgba(246, 196, 83, 0.25); |
| border-radius: 999px; |
| color: #f8efe4; |
| margin: 0 auto; |
| max-width: 48rem; |
| padding: 0.38rem; |
| text-align: center; |
| width: 100%; |
| } |
| .audience-action strong, |
| .prop-pile strong, |
| .tool-clue strong { |
| color: #ffd166; |
| } |
| .prop-token { |
| animation: prop-pop 0.22s ease-out; |
| background: rgba(246, 196, 83, 0.17); |
| border: 1px solid rgba(246, 196, 83, 0.5); |
| border-radius: 999px; |
| color: #fff7ed; |
| display: inline-block; |
| margin: 0.2rem; |
| padding: 0.22rem 0.55rem; |
| } |
| .gradio-container button { |
| background: linear-gradient(180deg, #4f2f45 0%, #2f1828 100%) !important; |
| border: 1px solid rgba(246, 196, 83, 0.26) !important; |
| border-radius: 10px !important; |
| color: #f8efe4 !important; |
| font-weight: 600 !important; |
| letter-spacing: 0.03em !important; |
| min-height: 2.45rem !important; |
| padding: 0.5rem 1rem !important; |
| transition: |
| background 0.15s ease, |
| border-color 0.15s ease, |
| box-shadow 0.15s ease, |
| transform 0.12s ease !important; |
| } |
| .gradio-container button:hover:not(:disabled) { |
| transform: translateY(-1px); |
| } |
| .gradio-container button:active:not(:disabled) { |
| transform: translateY(0); |
| } |
| .gradio-container button.primary, |
| .gradio-container button.primary-action, |
| .gradio-container button.run-one-action { |
| background: linear-gradient(180deg, #fde68a 0%, #d97706 42%, #b45309 100%) !important; |
| border: 1px solid rgba(252, 211, 77, 0.65) !important; |
| box-shadow: |
| 0 2px 0 rgba(124, 45, 18, 0.65), |
| 0 10px 26px rgba(217, 119, 6, 0.35) !important; |
| color: #1a0a06 !important; |
| text-shadow: 0 1px 0 rgba(255, 247, 237, 0.35); |
| } |
| .gradio-container button.primary:hover:not(:disabled), |
| .gradio-container button.primary-action:hover:not(:disabled), |
| .gradio-container button.run-one-action:hover:not(:disabled) { |
| background: linear-gradient(180deg, #fef3c7 0%, #ea580c 45%, #c2410c 100%) !important; |
| box-shadow: |
| 0 2px 0 rgba(124, 45, 18, 0.55), |
| 0 12px 28px rgba(234, 88, 12, 0.38) !important; |
| } |
| .gradio-container button.secondary, |
| .gradio-container button.audience-action-button { |
| background: linear-gradient(180deg, #6b2a3a 0%, #4a1522 48%, #2d0c14 100%) !important; |
| border: 1px solid rgba(252, 165, 165, 0.35) !important; |
| box-shadow: |
| inset 0 1px 0 rgba(255, 255, 255, 0.07), |
| 0 6px 18px rgba(0, 0, 0, 0.32) !important; |
| color: #fff5f5 !important; |
| } |
| .gradio-container button.secondary:hover:not(:disabled), |
| .gradio-container button.audience-action-button:hover:not(:disabled) { |
| border-color: rgba(254, 202, 202, 0.55) !important; |
| box-shadow: |
| inset 0 1px 0 rgba(255, 255, 255, 0.12), |
| 0 8px 22px rgba(0, 0, 0, 0.36) !important; |
| } |
| .gradio-container button.cue-action { |
| background: linear-gradient(180deg, #5c4a2f 0%, #3d3020 50%, #261c12 100%) !important; |
| border: 1px solid rgba(246, 196, 83, 0.45) !important; |
| box-shadow: |
| inset 0 1px 0 rgba(255, 255, 255, 0.08), |
| 0 6px 18px rgba(0, 0, 0, 0.3) !important; |
| color: #fff8eb !important; |
| } |
| .gradio-container button.cue-action:hover:not(:disabled) { |
| background: linear-gradient(180deg, #6e5a38 0%, #4a3b26 50%, #322818 100%) !important; |
| border-color: rgba(253, 224, 71, 0.55) !important; |
| box-shadow: |
| inset 0 1px 0 rgba(255, 255, 255, 0.12), |
| 0 8px 22px rgba(0, 0, 0, 0.34) !important; |
| } |
| .gradio-container button.reset-action { |
| background: linear-gradient(180deg, #5c1218 0%, #3b0a16 55%, #2a0508 100%) !important; |
| border: 1px solid rgba(246, 196, 83, 0.28) !important; |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05) !important; |
| color: #f5e6dc !important; |
| } |
| .gradio-container button.reset-action:hover:not(:disabled) { |
| border-color: rgba(255, 209, 102, 0.45) !important; |
| } |
| .transcript-box { |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| } |
| .conversation-empty { |
| color: #cbb7a1; |
| font-size: 0.88rem; |
| padding: 0.45rem 0.2rem; |
| } |
| .conversation-transcript { |
| display: grid; |
| gap: 0.38rem; |
| padding: 0.15rem 0; |
| } |
| .conversation-turn { |
| max-width: min(42rem, 84%); |
| } |
| .conversation-turn.speaker-changed { |
| margin-top: 0.42rem; |
| } |
| .conversation-turn.same-speaker { |
| margin-top: 0.04rem; |
| } |
| .conversation-turn:nth-child(even) { |
| justify-self: end; |
| } |
| .conversation-speaker { |
| color: #ffd166; |
| font-size: 0.68rem; |
| font-weight: 800; |
| letter-spacing: 0.07em; |
| margin: 0 0 0.12rem 0.18rem; |
| text-transform: uppercase; |
| } |
| .conversation-bubble { |
| background: rgba(10, 12, 23, 0.88); |
| border: 1px solid rgba(246, 196, 83, 0.24); |
| border-radius: 11px; |
| box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24); |
| color: #f8efe4; |
| font-size: 0.9rem; |
| line-height: 1.38; |
| padding: 0.54rem 0.68rem; |
| overflow-wrap: anywhere; |
| } |
| .conversation-turn.latest .conversation-bubble { |
| background: rgba(246, 196, 83, 0.13); |
| border-color: rgba(246, 196, 83, 0.52); |
| box-shadow: |
| 0 0 0 1px rgba(246, 196, 83, 0.14), |
| 0 10px 24px rgba(0, 0, 0, 0.28); |
| } |
| .voice-status { |
| color: #cbb7a1; |
| font-size: 0.8rem; |
| min-height: 1.2rem; |
| padding: 0.15rem 0.05rem 0; |
| } |
| .voice-status strong { |
| color: #ffd166; |
| } |
| .edge-audio-player { |
| height: 1px !important; |
| margin: 0 !important; |
| min-height: 1px !important; |
| opacity: 0 !important; |
| overflow: hidden !important; |
| padding: 0 !important; |
| pointer-events: none !important; |
| } |
| .edge-audio-player, |
| .edge-audio-player *, |
| .edge-audio-player .audio-container { |
| max-height: 1px !important; |
| } |
| .edge-audio-player .label-wrap { |
| display: none !important; |
| } |
| .agent-state-grid { |
| display: grid; |
| gap: 0.55rem; |
| grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); |
| } |
| .agent-state-card { |
| background: rgba(10, 12, 23, 0.72); |
| border: 1px solid rgba(246, 196, 83, 0.22); |
| border-radius: 8px; |
| box-sizing: border-box; |
| padding: 0.62rem 0.68rem; |
| } |
| .agent-state-name { |
| color: #ffd166; |
| font-weight: 800; |
| line-height: 1.2; |
| margin-bottom: 0.28rem; |
| } |
| .agent-state-avatar { |
| border-radius: 4px; |
| height: 1.15rem; |
| object-fit: cover; |
| vertical-align: text-bottom; |
| width: 1.15rem; |
| } |
| .agent-state-line { |
| color: #f8efe4; |
| font-size: 0.78rem; |
| line-height: 1.35; |
| overflow-wrap: anywhere; |
| } |
| .agent-state-line strong { |
| color: #d8c6b6; |
| } |
| .agent-state-empty { |
| color: #cbb7a1; |
| font-size: 0.86rem; |
| padding: 0.35rem 0; |
| } |
| .gradio-container .accordion { |
| background: rgba(13, 6, 14, 0.72) !important; |
| border: 1px solid rgba(246, 196, 83, 0.22) !important; |
| border-radius: 12px !important; |
| color: #f8efe4 !important; |
| overflow: hidden; |
| } |
| .gradio-container .accordion .block, |
| .gradio-container .accordion .form, |
| .gradio-container .accordion .wrap:not(.label-wrap) { |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| } |
| .gradio-container .accordion textarea, |
| .gradio-container .accordion .wrap-inner { |
| background: rgba(10, 12, 23, 0.88) !important; |
| border: 1px solid rgba(246, 196, 83, 0.22) !important; |
| border-radius: 8px !important; |
| } |
| .gradio-container .accordion .no-field-label .label-wrap { |
| display: none !important; |
| } |
| .gradio-container .accordion .no-field-label .wrap { |
| margin-top: 0 !important; |
| } |
| .gradio-container .accordion > .label-wrap { |
| background: linear-gradient(180deg, rgba(72, 28, 52, 0.98) 0%, rgba(42, 20, 38, 0.99) 100%) !important; |
| border-bottom: 1px solid rgba(246, 196, 83, 0.22) !important; |
| } |
| .gradio-container .accordion > .label-wrap span { |
| background: transparent !important; |
| border: none !important; |
| border-radius: 0 !important; |
| box-shadow: none !important; |
| color: #ffd166 !important; |
| font-family: Georgia, "Times New Roman", ui-serif, serif !important; |
| font-size: 0.78rem !important; |
| letter-spacing: 0.08em !important; |
| text-transform: uppercase !important; |
| } |
| .gradio-container .accordion > .label-wrap button { |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| color: #ffd166 !important; |
| } |
| .gradio-container .accordion > .label-wrap button:hover { |
| background: rgba(255, 209, 102, 0.12) !important; |
| } |
| .gradio-container .accordion .dropdown-container { |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| } |
| .gradio-container .accordion .dropdown-container .wrap-inner, |
| .gradio-container .accordion .dropdown-container .secondary-wrap { |
| background: rgba(8, 10, 18, 0.92) !important; |
| border: 1px solid rgba(246, 196, 83, 0.32) !important; |
| border-radius: 10px !important; |
| } |
| .gradio-container input[type="range"] { |
| accent-color: #f6c453; |
| } |
| .gradio-container .slider input { |
| --slider-color: #f6c453; |
| } |
| @keyframes puppet-bounce { |
| from { transform: translateY(0) rotate(-0.4deg); } |
| to { transform: translateY(-7px) rotate(0.7deg); } |
| } |
| @keyframes bubble-in { |
| from { opacity: 0; transform: translateY(8px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| @keyframes prop-pop { |
| from { opacity: 0; transform: scale(0.86); } |
| to { opacity: 1; transform: scale(1); } |
| } |
| @media (max-width: 760px) { |
| .puppet-stage { |
| height: 600px; |
| max-height: 600px; |
| min-height: 600px; |
| } |
| .puppet-stage::before, |
| .puppet-stage::after { |
| width: 7%; |
| } |
| .stage-backdrop { |
| padding: 0.8rem 1.4rem; |
| } |
| .stage-marquee { |
| font-size: 1.2rem; |
| } |
| .actor-row { |
| gap: 0.45rem; |
| } |
| .backstage-stack { |
| gap: 0.65rem !important; |
| max-width: 100% !important; |
| } |
| .actor-card { |
| min-height: 126px; |
| } |
| } |
| |
| /* Final Gradio chrome overrides: keep the whole app in the theater palette. */ |
| /* Generic groups only — theater panels use .control-panel / .premise-panel below. */ |
| .gradio-container .gr-group:not(.control-panel):not(.premise-panel) { |
| background: rgba(34, 17, 31, 0.84) !important; |
| border: 1px solid rgba(246, 196, 83, 0.2) !important; |
| border-radius: 8px !important; |
| color: #f8efe4 !important; |
| } |
| .gradio-container .gr-group.control-panel { |
| border-radius: 12px !important; |
| box-sizing: border-box !important; |
| } |
| .gradio-container .gr-group.premise-panel { |
| border-radius: 12px !important; |
| box-sizing: border-box !important; |
| overflow: hidden !important; |
| } |
| .gradio-container .gr-group .form, |
| .gradio-container .gr-group .block, |
| .gradio-container .gr-group .wrap, |
| .gradio-container .gr-group .wrap-inner, |
| .gradio-container .gr-group .secondary-wrap, |
| .gradio-container .gr-group .input-container, |
| .gradio-container .gr-group label { |
| color: #f8efe4 !important; |
| } |
| .gradio-container input, |
| .gradio-container textarea, |
| .gradio-container select { |
| background: rgba(10, 12, 23, 0.9) !important; |
| color: #f8efe4 !important; |
| } |
| .gradio-container input[type="checkbox"] { |
| background: rgba(10, 12, 23, 0.9) !important; |
| border: 1px solid rgba(246, 196, 83, 0.42) !important; |
| box-shadow: inset 0 0 0 2px rgba(10, 12, 23, 0.9) !important; |
| } |
| .gradio-container input[type="checkbox"]:checked { |
| background: #f6c453 !important; |
| border-color: #fcd34d !important; |
| box-shadow: |
| inset 0 0 0 3px rgba(10, 12, 23, 0.9), |
| 0 0 0 2px rgba(246, 196, 83, 0.2) !important; |
| } |
| .gradio-container input[type="checkbox"]:checked + span { |
| color: #fcd34d !important; |
| } |
| .gradio-container .control-panel .dropdown-container, |
| .gradio-container .premise-panel .dropdown-container { |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| } |
| .gradio-container .control-panel .dropdown-container .wrap-inner, |
| .gradio-container .control-panel .dropdown-container .secondary-wrap, |
| .gradio-container .control-panel .single-select, |
| .gradio-container .premise-panel .dropdown-container .wrap-inner, |
| .gradio-container .premise-panel .dropdown-container .secondary-wrap { |
| background: rgba(8, 10, 18, 0.92) !important; |
| border: 1px solid rgba(246, 196, 83, 0.38) !important; |
| border-radius: 10px !important; |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04) !important; |
| } |
| .gradio-container .control-panel input, |
| .gradio-container .control-panel textarea, |
| .gradio-container .premise-panel textarea { |
| background: rgba(8, 10, 18, 0.92) !important; |
| border: 1px solid rgba(246, 196, 83, 0.3) !important; |
| border-radius: 10px !important; |
| } |
| .gradio-container .html-container, |
| .gradio-container .gradio-style { |
| width: 100% !important; |
| } |
| .puppet-stage { |
| height: 600px; |
| max-height: 600px; |
| min-height: 600px; |
| width: 100%; |
| } |
| .puppet-stage::before, |
| .puppet-stage::after { |
| width: clamp(56px, 9%, 110px); |
| } |
| .stage-backdrop { |
| padding: 0.78rem clamp(4.1rem, 11vw, 8.8rem) 0.72rem; |
| } |
| .stage-marquee { |
| font-size: clamp(1.25rem, 2.1vw, 1.72rem); |
| white-space: normal; |
| } |
| .speech-bubble { |
| max-height: 6.75rem; |
| padding: 0.58rem 0.82rem; |
| } |
| .actor-row { |
| align-items: flex-end; |
| display: flex; |
| flex-shrink: 0; |
| flex-wrap: wrap; |
| gap: 0.62rem; |
| justify-content: center; |
| margin-top: 0.82rem; |
| } |
| .actor-column { |
| position: relative; |
| } |
| .actor-card { |
| align-content: start; |
| background: radial-gradient(circle at 50% 18%, rgba(246, 196, 83, 0.13), rgba(70, 38, 36, 0.72) 58%); |
| border-radius: 18px; |
| display: grid; |
| justify-items: center; |
| min-height: 108px; |
| padding: 0.5rem 0.45rem 0.56rem; |
| } |
| .actor-card::after { |
| bottom: -20px; |
| height: 20px; |
| width: 14px; |
| } |
| .actor-avatar { |
| font-size: 2rem; |
| height: 3.3rem; |
| overflow: hidden; |
| width: 3.3rem; |
| } |
| .actor-name { |
| font-size: 0.82rem; |
| margin-top: 0.28rem; |
| } |
| .actor-detail { |
| display: -webkit-box; |
| font-size: 0.66rem; |
| line-height: 1.18; |
| margin-top: 0.2rem; |
| max-width: 11rem; |
| min-height: 1.55rem; |
| overflow: hidden; |
| -webkit-box-orient: vertical; |
| -webkit-line-clamp: 2; |
| } |
| .held-prop { |
| margin-top: 0.26rem; |
| } |
| .held-prop span { |
| font-size: 0.62rem; |
| padding: 0.08rem 0.34rem; |
| } |
| .speaking-pill { |
| font-size: 0.58rem; |
| margin-top: 0.18rem; |
| padding: 0.08rem 0.36rem; |
| } |
| .stage-events { |
| gap: 0.32rem; |
| margin-top: 0.64rem; |
| } |
| .audience-action, |
| .prop-pile, |
| .tool-clue { |
| max-width: 45rem; |
| padding: 0.3rem 0.55rem; |
| } |
| @media (max-width: 760px) { |
| .gradio-container { |
| width: min(100vw, calc(100vw - 0.75rem)) !important; |
| } |
| .puppet-stage::before, |
| .puppet-stage::after { |
| width: 30px; |
| } |
| .stage-backdrop { |
| padding: 0.75rem 2.45rem; |
| } |
| .actor-row { |
| gap: 0.45rem; |
| } |
| .actor-card { |
| min-height: 102px; |
| padding-left: 0.28rem; |
| padding-right: 0.28rem; |
| } |
| } |
| |
| /* Compact stage pass: keep the theater look, reduce scrolling, and keep controls close. */ |
| .gradio-container { |
| padding-top: 0.65rem !important; |
| } |
| .app-title h1 { |
| font-size: 1.95rem; |
| } |
| .app-title p { |
| margin-bottom: 0.55rem; |
| } |
| .stage-output, |
| .stage-output .html-container, |
| .stage-output .gradio-style { |
| margin-bottom: 0 !important; |
| } |
| .puppet-stage { |
| height: 600px; |
| max-height: 600px; |
| min-height: 600px; |
| } |
| .stage-empty-playbill { |
| max-width: min(19rem, 92%); |
| padding: 1rem 1.1rem 1.12rem; |
| } |
| .playbill-kicker { |
| font-size: 0.62rem; |
| letter-spacing: 0.28em; |
| } |
| .playbill-title { |
| font-size: clamp(1.12rem, 4vw, 1.42rem); |
| } |
| .playbill-tagline { |
| font-size: 0.82rem; |
| } |
| .stage-valance { |
| height: 34px; |
| border-bottom-width: 3px; |
| } |
| .stage-backdrop { |
| padding: 0.48rem clamp(3.9rem, 9vw, 7.3rem) 0.46rem; |
| } |
| .stage-marquee { |
| font-size: clamp(1.15rem, 1.9vw, 1.52rem); |
| } |
| .stage-copy { |
| font-size: 0.76rem; |
| line-height: 1.25; |
| margin-top: 0.14rem; |
| } |
| .speech-bubble { |
| border-radius: 12px; |
| max-height: 5.75rem; |
| padding: 0.42rem 0.7rem; |
| } |
| .speech-speaker { |
| font-size: 0.68rem; |
| } |
| .speech-line { |
| font-size: 0.86rem; |
| overflow-wrap: anywhere; |
| } |
| .actor-row { |
| align-items: flex-end; |
| display: flex; |
| flex-shrink: 0; |
| flex-wrap: wrap; |
| gap: 0.5rem; |
| justify-content: center; |
| margin-top: 5.50rem; |
| } |
| .actor-column { |
| position: relative; |
| } |
| .actor-card { |
| border-radius: 14px; |
| min-height: 88px; |
| padding: 0.38rem 0.36rem 0.44rem; |
| } |
| .actor-card::after { |
| bottom: -16px; |
| height: 16px; |
| } |
| .actor-avatar { |
| font-size: 1.65rem; |
| height: 2.55rem; |
| overflow: hidden; |
| width: 2.55rem; |
| } |
| .actor-name { |
| font-size: 0.74rem; |
| margin-top: 0.2rem; |
| } |
| .actor-detail { |
| font-size: 0.6rem; |
| line-height: 1.12; |
| margin-top: 0.14rem; |
| min-height: 1.35rem; |
| } |
| .speaking-pill { |
| font-size: 0.52rem; |
| margin-top: 0.14rem; |
| } |
| .held-prop { |
| margin-top: 0.18rem; |
| } |
| .held-prop span { |
| font-size: 0.55rem; |
| } |
| .stage-events { |
| gap: 0.24rem; |
| margin-top: 0.46rem; |
| } |
| .audience-action, |
| .prop-pile, |
| .tool-clue { |
| font-size: 0.78rem; |
| max-width: 39rem; |
| padding: 0.22rem 0.5rem; |
| } |
| .prop-token { |
| margin: 0.08rem; |
| padding: 0.12rem 0.4rem; |
| } |
| .beat-counter { |
| font-size: 0.84rem; |
| margin-top: 0.34rem; |
| } |
| .stage-floorboards { |
| height: 40px; |
| } |
| .control-panel { |
| margin-top: 0 !important; |
| padding: 1rem 1.1rem 1.15rem !important; |
| } |
| .control-panel h3 { |
| margin-bottom: 0.2rem; |
| } |
| .gradio-container .row { |
| gap: 0.55rem !important; |
| padding: 12px !important; |
| } |
| /* Column gap separates Show Controls from Audience; avoid doubling with sibling margin. */ |
| .backstage-stack .control-panel + .control-panel { |
| margin-top: 0 !important; |
| } |
| .transcript-section, |
| .gradio-container .accordion { |
| margin-top: 0.55rem !important; |
| } |
| @media (max-width: 760px) { |
| .puppet-stage { |
| height: 600px; |
| max-height: 600px; |
| min-height: 600px; |
| } |
| .stage-backdrop { |
| padding: 0.52rem 2.15rem; |
| } |
| .actor-row { |
| gap: 0.42rem; |
| } |
| .speech-line { |
| font-size: 0.8rem; |
| } |
| } |
| /* Gradio dropdown option panels are often portaled; keep them opaque and above the stage */ |
| [role="listbox"] { |
| z-index: 10050 !important; |
| background-color: rgba(12, 10, 20, 0.98) !important; |
| border: 2px solid rgba(246, 196, 83, 0.58) !important; |
| border-radius: 12px !important; |
| box-shadow: 0 22px 56px rgba(0, 0, 0, 0.72) !important; |
| backdrop-filter: blur(12px); |
| -webkit-backdrop-filter: blur(12px); |
| max-height: min(70vh, 22rem) !important; |
| overflow-y: auto !important; |
| overflow-x: hidden !important; |
| } |
| [role="option"] { |
| padding: 0.55rem 0.85rem !important; |
| font-size: 0.95rem !important; |
| } |
| [role="option"][aria-selected="true"], |
| [role="option"]:hover, |
| [role="option"][data-highlighted] { |
| background-color: rgba(246, 196, 83, 0.18) !important; |
| } |
| /* Gradio 6.5.x: listbox inside .contain — min 40px panel floor + 40px option rows */ |
| .gradio-container.gradio-container-6-5-1 .contain [role="listbox"] { |
| min-height: 40px !important; |
| padding-left: 8px !important; |
| } |
| .gradio-container.gradio-container-6-5-1 .contain [role="listbox"] [role="option"] { |
| align-items: center !important; |
| box-sizing: border-box !important; |
| display: flex !important; |
| min-height: 40px !important; |
| } |
| """ |
|
|
|
|
| THEATER_THEME = gr.themes.Soft( |
| primary_hue=gr.themes.colors.amber, |
| secondary_hue=gr.themes.colors.rose, |
| neutral_hue=gr.themes.colors.stone, |
| font=(gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"), |
| font_mono=(gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "Consolas", "monospace"), |
| ) |
|
|
|
|
| BROWSER_TTS_JS = r""" |
| async (mode, payloadJson, previewKind = "", options = {}) => { |
| const root = globalThis; |
| const speech = root.speechSynthesis; |
| root.aiPuppetSpeech = root.aiPuppetSpeech || { |
| lastAutoId: null, |
| voices: [], |
| voicesReady: false, |
| queue: [], |
| speaking: false, |
| }; |
| root.aiPuppetSpeech.voices = Array.isArray(root.aiPuppetSpeech.voices) ? root.aiPuppetSpeech.voices : []; |
| root.aiPuppetSpeech.queue = Array.isArray(root.aiPuppetSpeech.queue) ? root.aiPuppetSpeech.queue : []; |
| root.aiPuppetSpeech.voicesReady = Boolean(root.aiPuppetSpeech.voicesReady); |
| root.aiPuppetSpeech.speaking = Boolean(root.aiPuppetSpeech.speaking); |
| const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (char) => ({ |
| "&": "&", |
| "<": "<", |
| ">": ">", |
| '"': """, |
| "'": "'", |
| })[char]); |
| if (mode === "off") { |
| if (speech) { |
| root.aiPuppetSpeech.queue = []; |
| root.aiPuppetSpeech.speaking = false; |
| speech.cancel(); |
| } |
| return '<div class="voice-status">Voice mode is off.</div>'; |
| } |
| if (mode !== "character" && mode !== "narrator") { |
| return '<div class="voice-status">Edge TTS uses generated audio for each latest line.</div>'; |
| } |
| if (!("speechSynthesis" in root) || typeof SpeechSynthesisUtterance === "undefined") { |
| return '<div class="voice-status"><strong>Browser TTS unavailable.</strong> The show still works without voice.</div>'; |
| } |
| |
| const cleanSpokenText = (value) => { |
| return String(value || "") |
| .replace(/\[[^\]]{1,140}\]/g, " ") |
| .replace(/\*[^*]{1,140}\*/g, " ") |
| .replace(/\([^)]{1,90}\)/g, " ") |
| .replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, " ") |
| .replace(/\s+/g, " ") |
| .trim(); |
| }; |
| const loadVoices = () => new Promise((resolve) => { |
| const current = speech.getVoices ? speech.getVoices() : []; |
| if (current.length > 0) { |
| root.aiPuppetSpeech.voices = current; |
| root.aiPuppetSpeech.voicesReady = true; |
| resolve(current); |
| return; |
| } |
| let settled = false; |
| const finish = () => { |
| if (settled) return; |
| settled = true; |
| const voices = speech.getVoices ? speech.getVoices() : []; |
| root.aiPuppetSpeech.voices = voices; |
| root.aiPuppetSpeech.voicesReady = voices.length > 0; |
| resolve(voices); |
| }; |
| speech.onvoiceschanged = () => finish(); |
| setTimeout(finish, 700); |
| }); |
| const selectVoice = (voices, style) => { |
| if (!voices || voices.length === 0) return null; |
| const hints = (style.voiceNameHints || []).map((hint) => String(hint).toLowerCase()); |
| const byHint = voices.find((candidate) => { |
| const name = String(candidate.name || "").toLowerCase(); |
| return hints.some((hint) => name.includes(hint) || hint.includes(name)); |
| }); |
| if (byHint) return byHint; |
| const english = voices.find((candidate) => { |
| const lang = String(candidate.lang || "").toLowerCase(); |
| const name = String(candidate.name || "").toLowerCase(); |
| return lang.startsWith("en") || name.includes("english"); |
| }); |
| if (english) return english; |
| const anyVoice = voices[0] || null; |
| if (anyVoice) return anyVoice; |
| if (Number.isInteger(style.voiceIndex) && voices.length > 0) { |
| return voices[Math.abs(style.voiceIndex) % voices.length] || null; |
| } |
| return null; |
| }; |
| const payloadFromJson = () => { |
| try { |
| return JSON.parse(payloadJson || "{}"); |
| } catch (_error) { |
| return null; |
| } |
| }; |
| const buildTask = (payload) => { |
| const narratorStyle = payload.narratorStyle || payload.style || {}; |
| const activeStyle = mode === "narrator" || previewKind === "narrator" |
| ? narratorStyle |
| : (payload.style || narratorStyle); |
| let text = payload.text; |
| let speaker = payload.speaker ? String(payload.speaker) : "Latest line"; |
| if (previewKind === "actor") { |
| text = `Previewing ${activeStyle.label || "character voice"}. The puppet voice is softer now.`; |
| speaker = activeStyle.label || "Actor voice"; |
| } else if (previewKind === "narrator") { |
| text = "Previewing narrator voice. The curtain rises, and the scene begins."; |
| speaker = activeStyle.label || "Narrator"; |
| } else if (mode === "narrator") { |
| speaker = "Narrator"; |
| } |
| text = cleanSpokenText(text); |
| return { id: payload.id || `${Date.now()}:${speaker}`, text, speaker, style: activeStyle }; |
| }; |
| const updateStatus = (html) => { |
| const status = document.querySelector(".voice-status"); |
| if (status) status.outerHTML = html; |
| }; |
| const drainQueue = async () => { |
| if (root.aiPuppetSpeech.speaking || root.aiPuppetSpeech.queue.length === 0) return; |
| const task = root.aiPuppetSpeech.queue.shift(); |
| try { |
| const voices = await loadVoices(); |
| const utterance = new SpeechSynthesisUtterance(task.text); |
| utterance.pitch = Math.max(0.1, Math.min(2, Number(task.style.pitch ?? 1))); |
| utterance.rate = Math.max(0.1, Math.min(2, Number(task.style.rate ?? 1))); |
| utterance.volume = Math.max(0, Math.min(1, Number(task.style.volume ?? 1))); |
| const voice = selectVoice(voices, task.style); |
| if (voice) utterance.voice = voice; |
| const label = task.style.label ? ` (${escapeHtml(task.style.label)})` : ""; |
| const statusHtml = `<div class="voice-status">Speaking: <strong>${escapeHtml(task.speaker)}</strong>${label}</div>`; |
| root.aiPuppetSpeech.speaking = true; |
| updateStatus(statusHtml); |
| utterance.onend = () => { |
| root.aiPuppetSpeech.speaking = false; |
| drainQueue(); |
| }; |
| utterance.onerror = () => { |
| root.aiPuppetSpeech.speaking = false; |
| drainQueue(); |
| }; |
| speech.speak(utterance); |
| } catch (error) { |
| root.aiPuppetSpeech.speaking = false; |
| updateStatus('<div class="voice-status"><strong>Browser TTS could not start.</strong> Try Speak Latest Line once, then run the show again.</div>'); |
| } |
| }; |
| |
| const payload = payloadFromJson(); |
| if (!payload) { |
| return '<div class="voice-status">No speakable line is ready yet.</div>'; |
| } |
| const task = buildTask(payload); |
| if (!task.text) { |
| return '<div class="voice-status">No speakable line is ready yet.</div>'; |
| } |
| |
| if (!options.queue) { |
| root.aiPuppetSpeech.queue = []; |
| root.aiPuppetSpeech.speaking = false; |
| speech.cancel(); |
| } |
| root.aiPuppetSpeech.queue.push(task); |
| drainQueue(); |
| const label = task.style.label ? ` (${escapeHtml(task.style.label)})` : ""; |
| const prefix = options.queue && root.aiPuppetSpeech.speaking ? "Queued" : "Speaking"; |
| return `<div class="voice-status">${prefix}: <strong>${escapeHtml(task.speaker)}</strong>${label}</div>`; |
| } |
| """ |
|
|
|
|
| AUTO_BROWSER_TTS_JS = r""" |
| async (mode, autoSpeak, payloadJson) => { |
| const root = globalThis; |
| root.aiPuppetSpeech = root.aiPuppetSpeech || {}; |
| root.aiPuppetSpeech.lastAutoId = root.aiPuppetSpeech.lastAutoId || null; |
| root.aiPuppetSpeech.voices = Array.isArray(root.aiPuppetSpeech.voices) ? root.aiPuppetSpeech.voices : []; |
| root.aiPuppetSpeech.queue = Array.isArray(root.aiPuppetSpeech.queue) ? root.aiPuppetSpeech.queue : []; |
| root.aiPuppetSpeech.voicesReady = Boolean(root.aiPuppetSpeech.voicesReady); |
| root.aiPuppetSpeech.speaking = Boolean(root.aiPuppetSpeech.speaking); |
| const speech = root.speechSynthesis; |
| try { |
| if (mode === "off") { |
| if (speech) speech.cancel(); |
| return '<div class="voice-status">Voice mode is off.</div>'; |
| } |
| if (mode !== "character" && mode !== "narrator") { |
| return '<div class="voice-status">Edge TTS uses generated audio for each latest line.</div>'; |
| } |
| if (!autoSpeak) { |
| return '<div class="voice-status">Auto-speak is off. Use Speak Latest Line to replay.</div>'; |
| } |
| let payload = {}; |
| try { |
| payload = JSON.parse(payloadJson || "{}"); |
| } catch (_error) { |
| return '<div class="voice-status">No speakable line is ready yet.</div>'; |
| } |
| if (!payload.id || root.aiPuppetSpeech.lastAutoId === payload.id) { |
| return '<div class="voice-status">Browser TTS is ready.</div>'; |
| } |
| root.aiPuppetSpeech.lastAutoId = payload.id; |
| const speakLatest = %s; |
| return await speakLatest(mode, payloadJson, "", { queue: true }); |
| } catch (error) { |
| const detail = error && error.message ? String(error.message) : String(error || "unknown error"); |
| const safeDetail = detail.replace(/[&<>"']/g, (char) => ({ |
| "&": "&", |
| "<": "<", |
| ">": ">", |
| '"': """, |
| "'": "'", |
| })[char]); |
| return `<div class="voice-status"><strong>Auto-speak could not start.</strong> ${safeDetail}</div>`; |
| } |
| } |
| """ % BROWSER_TTS_JS |
|
|
|
|
| REGISTER_BROWSER_TTS_JS = r""" |
| () => { |
| globalThis.aiPuppetSpeakLatest = %s; |
| window.aiPuppetEdgeAudio = window.aiPuppetEdgeAudio || { |
| queue: [], |
| current: null, |
| currentSrc: "", |
| playing: false, |
| lastSeenSrc: "", |
| lastSeenAt: 0, |
| completedSrcAt: {}, |
| drainTimer: null, |
| interLineDelayMs: 650, |
| observerReady: false, |
| }; |
| const edgeStatus = (html) => { |
| const status = document.querySelector(".voice-status"); |
| if (status) status.outerHTML = html; |
| }; |
| const edgeModeActive = () => { |
| const checked = [...document.querySelectorAll('input[type="radio"]')].find((input) => input.checked); |
| return checked && checked.value === "edge_character"; |
| }; |
| const edgePauseNativePlayers = () => { |
| document.querySelectorAll(".edge-audio-player audio").forEach((audio) => { |
| audio.pause(); |
| audio.currentTime = 0; |
| }); |
| }; |
| const edgeDrainQueue = () => { |
| const state = window.aiPuppetEdgeAudio; |
| if (state.drainTimer) { |
| clearTimeout(state.drainTimer); |
| state.drainTimer = null; |
| } |
| if (state.playing || state.queue.length === 0) return; |
| const task = state.queue.shift(); |
| state.playing = true; |
| state.currentSrc = task.src; |
| edgePauseNativePlayers(); |
| const audio = new Audio(task.src); |
| audio.preload = "auto"; |
| state.current = audio; |
| audio.onended = () => { |
| state.playing = false; |
| state.current = null; |
| state.completedSrcAt[task.src] = Date.now(); |
| state.currentSrc = ""; |
| state.drainTimer = setTimeout(edgeDrainQueue, state.interLineDelayMs); |
| }; |
| audio.onerror = () => { |
| state.playing = false; |
| state.current = null; |
| state.currentSrc = ""; |
| edgeStatus('<div class="voice-status"><strong>Edge TTS playback failed.</strong> Use Browser TTS or expand/retry audio.</div>'); |
| state.drainTimer = setTimeout(edgeDrainQueue, state.interLineDelayMs); |
| }; |
| const playResult = audio.play(); |
| if (playResult && typeof playResult.catch === "function") { |
| playResult.catch(() => { |
| state.playing = false; |
| state.current = null; |
| state.currentSrc = ""; |
| edgeStatus('<div class="voice-status"><strong>Edge TTS is ready.</strong> Browser blocked autoplay; click the page once and run the next line.</div>'); |
| }); |
| } |
| }; |
| const edgeEnqueueSrc = (src) => { |
| // Gradio's mounted audio element is the reliable autoplay path for Edge TTS. |
| // Full Act pacing waits between generated files, so this observer no longer |
| // starts a separate Audio() object that browsers may block. |
| return; |
| if (!src || !edgeModeActive()) return; |
| const absoluteSrc = new URL(src, window.location.href).href; |
| const state = window.aiPuppetEdgeAudio; |
| const now = Date.now(); |
| if (state.lastSeenSrc === absoluteSrc && now - state.lastSeenAt < 1200) return; |
| if (state.currentSrc === absoluteSrc) return; |
| if (state.queue.some((task) => task.src === absoluteSrc)) return; |
| if (state.completedSrcAt[absoluteSrc] && now - state.completedSrcAt[absoluteSrc] < 30000) return; |
| state.lastSeenSrc = absoluteSrc; |
| state.lastSeenAt = now; |
| edgePauseNativePlayers(); |
| state.queue.push({ src: absoluteSrc }); |
| edgeStatus(state.playing |
| ? '<div class="voice-status">Edge TTS queued next line.</div>' |
| : '<div class="voice-status">Edge TTS playing latest line.</div>'); |
| edgeDrainQueue(); |
| }; |
| const edgeScanAudio = () => { |
| const audio = document.querySelector(".edge-audio-player audio"); |
| const source = document.querySelector(".edge-audio-player source"); |
| const src = (audio && (audio.currentSrc || audio.src)) || (source && source.src) || ""; |
| edgeEnqueueSrc(src); |
| }; |
| window.aiPuppetEdgeAudio.enqueueSrc = edgeEnqueueSrc; |
| window.aiPuppetEdgeAudio.clear = () => { |
| const state = window.aiPuppetEdgeAudio; |
| state.queue = []; |
| state.playing = false; |
| state.currentSrc = ""; |
| state.lastSeenSrc = ""; |
| state.completedSrcAt = {}; |
| if (state.drainTimer) { |
| clearTimeout(state.drainTimer); |
| state.drainTimer = null; |
| } |
| if (state.current) { |
| state.current.pause(); |
| state.current.currentTime = 0; |
| state.current = null; |
| } |
| edgePauseNativePlayers(); |
| }; |
| if (!window.aiPuppetEdgeAudio.observerReady) { |
| const observer = new MutationObserver(() => edgeScanAudio()); |
| observer.observe(document.body, { |
| attributes: true, |
| attributeFilter: ["src"], |
| childList: true, |
| subtree: true, |
| }); |
| window.aiPuppetEdgeAudio.observerReady = true; |
| setTimeout(edgeScanAudio, 250); |
| } |
| document.body.dataset.edgeAudioQueueReady = "true"; |
| if ("speechSynthesis" in globalThis && globalThis.speechSynthesis.getVoices) { |
| globalThis.aiPuppetSpeech = globalThis.aiPuppetSpeech || {}; |
| globalThis.aiPuppetSpeech.lastAutoId = globalThis.aiPuppetSpeech.lastAutoId || null; |
| globalThis.aiPuppetSpeech.voices = Array.isArray(globalThis.aiPuppetSpeech.voices) ? globalThis.aiPuppetSpeech.voices : []; |
| globalThis.aiPuppetSpeech.queue = Array.isArray(globalThis.aiPuppetSpeech.queue) ? globalThis.aiPuppetSpeech.queue : []; |
| globalThis.aiPuppetSpeech.voicesReady = Boolean(globalThis.aiPuppetSpeech.voicesReady); |
| globalThis.aiPuppetSpeech.speaking = Boolean(globalThis.aiPuppetSpeech.speaking); |
| const load = () => { |
| const voices = globalThis.speechSynthesis.getVoices(); |
| globalThis.aiPuppetSpeech.voices = voices; |
| globalThis.aiPuppetSpeech.voicesReady = voices.length > 0; |
| }; |
| globalThis.speechSynthesis.onvoiceschanged = load; |
| load(); |
| } |
| globalThis.aiPuppetStopSpeaking = () => { |
| if (window.aiPuppetEdgeAudio && window.aiPuppetEdgeAudio.clear) { |
| window.aiPuppetEdgeAudio.clear(); |
| } |
| document.querySelectorAll("audio").forEach((audio) => { |
| audio.pause(); |
| audio.currentTime = 0; |
| }); |
| if ("speechSynthesis" in globalThis) { |
| globalThis.aiPuppetSpeech = globalThis.aiPuppetSpeech || {}; |
| globalThis.aiPuppetSpeech.queue = []; |
| globalThis.aiPuppetSpeech.speaking = false; |
| globalThis.speechSynthesis.cancel(); |
| return '<div class="voice-status">Speech stopped.</div>'; |
| } |
| return '<div class="voice-status"><strong>Browser TTS unavailable.</strong> The show still works without voice.</div>'; |
| }; |
| return '<div class="voice-status">Voice mode is off.</div>'; |
| } |
| """ % BROWSER_TTS_JS |
|
|
|
|
| STOP_SPEAKING_JS = r""" |
| () => { |
| if (globalThis.aiPuppetStopSpeaking) { |
| return globalThis.aiPuppetStopSpeaking(); |
| } |
| document.querySelectorAll("audio").forEach((audio) => { |
| audio.pause(); |
| audio.currentTime = 0; |
| }); |
| if ("speechSynthesis" in globalThis) { |
| globalThis.aiPuppetSpeech = globalThis.aiPuppetSpeech || {}; |
| globalThis.aiPuppetSpeech.queue = []; |
| globalThis.aiPuppetSpeech.speaking = false; |
| globalThis.speechSynthesis.cancel(); |
| return '<div class="voice-status">Speech stopped.</div>'; |
| } |
| return '<div class="voice-status"><strong>Browser TTS unavailable.</strong> The show still works without voice.</div>'; |
| } |
| """ |
|
|
|
|
| def render_stage(session: TheaterSession | None) -> str: |
| if session is None: |
| return EMPTY_STAGE |
|
|
| show_opening_curtain = session.play_opening_curtain |
| if session.play_opening_curtain: |
| session.play_opening_curtain = False |
|
|
| actor_columns: list[str] = [] |
| latest_beat = session.transcript[-1] if session.transcript else None |
| latest_speaker = latest_beat.speaker if latest_beat else None |
| for actor in session.actors: |
| active_class = " active" if actor.name == latest_speaker else "" |
| active_label = '<div class="speaking-pill">Now speaking</div>' if actor.name == latest_speaker else "" |
| role_line = actor.goal.split(".", maxsplit=1)[0] |
| held_prop = actor.held_prop or "nothing" |
| held_emoji = PROP_EMOJI.get(held_prop.lower(), "🎁") if actor.held_prop else "" |
| bubble_html = "" |
| if latest_beat is not None and actor.name == latest_speaker: |
| bubble_html = f""" |
| <div class="speech-bubble"> |
| <div class="speech-speaker">{escape(latest_beat.speaker)}</div> |
| <div class="speech-line">{escape(latest_beat.line)}</div> |
| </div> |
| """ |
| avatar_class = "actor-avatar has-image" if actor.avatar_image_url else "actor-avatar" |
| if actor.avatar_image_url: |
| avatar_inner = f'<img class="actor-avatar-img" src="{escape(actor.avatar_image_url)}" alt="" loading="lazy" />' |
| else: |
| avatar_inner = escape(actor.avatar) |
| actor_columns.append( |
| f""" |
| <div class="actor-column"> |
| {bubble_html} |
| <div class="actor-card{active_class}"> |
| <div class="{avatar_class}">{avatar_inner}</div> |
| <div class="actor-name">{escape(actor.name)}</div> |
| {active_label} |
| <div class="actor-detail">{escape(role_line)}</div> |
| <div class="held-prop"><span>Holding: {escape((held_emoji + " ") if held_emoji else "")}{escape(held_prop)}</span></div> |
| </div> |
| </div> |
| """ |
| ) |
| audience_action = "" |
| if session.latest_audience_action is not None: |
| audience_action = f""" |
| <div class="audience-action"> |
| <strong>Audience:</strong> {escape(session.latest_audience_action)} |
| </div> |
| """ |
| prop_pile = "" |
| if session.props: |
| prop_tokens = "".join( |
| f'<span class="prop-token">{escape(PROP_EMOJI.get(prop.lower(), "🎁"))} {escape(prop)}</span>' |
| for prop in session.props |
| ) |
| prop_pile = f""" |
| <div class="prop-pile"> |
| <strong>Props on stage:</strong> {prop_tokens} |
| </div> |
| """ |
| tool_clue = "" |
| if session.latest_tool_result is not None: |
| tool_clue = f""" |
| <div class="tool-clue"> |
| <strong>Tool clue:</strong> {escape(session.latest_tool_result.result)} |
| </div> |
| """ |
|
|
| phase = story_phase(session) |
| progress_percent = round(story_progress(session) * 100) |
| performed_beats = len(session.transcript) |
| curtain_html = "" |
| if show_opening_curtain: |
| curtain_html = """ |
| <div class="stage-curtains stage-curtains-animate" aria-hidden="true"> |
| <div class="stage-curtain stage-curtain-left"></div> |
| <div class="stage-curtain stage-curtain-right"></div> |
| </div> |
| """ |
|
|
| backdrop_style = "" |
| if session.backdrop_image_url: |
| u = escape(session.backdrop_image_url) |
| backdrop_style = ( |
| f' style="background-image: linear-gradient(180deg, rgba(42,20,38,0.82) 0%, rgba(19,9,17,0.88) 62%, rgba(13,6,14,0.92) 100%), url({u}); ' |
| 'background-size: cover; background-position: center; background-blend-mode: multiply;"' |
| ) |
|
|
| return f""" |
| <div class="puppet-stage stage-live"> |
| <div class="stage-valance"></div> |
| <div class="stage-backdrop"{backdrop_style}> |
| {curtain_html} |
| <div class="stage-marquee">{escape(session.show_title)}</div> |
| <div class="stage-copy"> |
| <strong>Setting:</strong> {escape(session.setting)}<br /> |
| <strong>Premise:</strong> {escape(session.premise)}<br /> |
| <strong>Phase:</strong> {escape(phase.title())} · <strong>Progress:</strong> {progress_percent}% |
| </div> |
| <div class="actor-row"> |
| {''.join(actor_columns)} |
| </div> |
| <div class="stage-events"> |
| {audience_action} |
| {tool_clue} |
| {prop_pile} |
| </div> |
| <div class="beat-counter">Beat {performed_beats} of {session.max_beats} · target {session.target_beats} · {escape(phase.title())}</div> |
| </div> |
| <div class="stage-floorboards"></div> |
| </div> |
| """ |
|
|
|
|
| def render_transcript(session: TheaterSession | None) -> str: |
| if session is None: |
| return EMPTY_TRANSCRIPT |
|
|
| if not session.transcript: |
| return '<div class="conversation-empty">No puppet lines yet. Run one beat to start the scene.</div>' |
|
|
| turns: list[str] = [] |
| previous_speaker: str | None = None |
| latest_index = len(session.transcript) |
| for index, beat in enumerate(session.transcript, start=1): |
| speaker_changed = previous_speaker is not None and previous_speaker != beat.speaker |
| gap_class = "speaker-changed" if speaker_changed else "same-speaker" |
| latest_class = " latest" if index == latest_index else "" |
| turns.append( |
| f""" |
| <div class="conversation-turn {gap_class}{latest_class}"> |
| <div class="conversation-speaker">{escape(beat.speaker)}</div> |
| <div class="conversation-bubble">{escape(beat.line)}</div> |
| </div> |
| """ |
| ) |
| previous_speaker = beat.speaker |
|
|
| return f'<div class="conversation-transcript">{"".join(turns)}</div>' |
|
|
|
|
| def render_agent_state(session: TheaterSession | None) -> str: |
| if session is None: |
| return EMPTY_AGENT_STATE |
|
|
| cards: list[str] = [] |
| for actor in session.actors: |
| held_props = actor.held_props or ([actor.held_prop] if actor.held_prop else []) |
| held = ", ".join(held_props) if held_props else "none" |
| recent_memory = actor.recent_memory[-1] if actor.recent_memory else "No memory yet." |
| current_goal = actor.current_goal or actor.goal |
| face = ( |
| f'<img class="agent-state-avatar" src="{escape(actor.avatar_image_url)}" alt="" /> ' |
| if actor.avatar_image_url |
| else f"{escape(actor.avatar)} " |
| ) |
| cards.append( |
| f""" |
| <div class="agent-state-card"> |
| <div class="agent-state-name">{face}{escape(actor.name)}</div> |
| <div class="agent-state-line"><strong>Mood:</strong> {escape(actor.mood)}</div> |
| <div class="agent-state-line"><strong>Goal:</strong> {escape(current_goal)}</div> |
| <div class="agent-state-line"><strong>Props:</strong> {escape(held)}</div> |
| <div class="agent-state-line"><strong>Secret:</strong> {escape(actor.secret_status)}</div> |
| <div class="agent-state-line"><strong>Memory:</strong> {escape(recent_memory)}</div> |
| </div> |
| """ |
| ) |
| return f'<div class="agent-state-grid">{"".join(cards)}</div>' |
|
|
|
|
| def render_director_log(session: TheaterSession | None) -> str: |
| if session is None: |
| return EMPTY_DIRECTOR_LOG |
| return "\n".join(f"- {entry}" for entry in session.director_log) |
|
|
|
|
| def render_trace(session: TheaterSession | None) -> str: |
| return render_trace_json(session) |
|
|
|
|
| def render_trace_summary_text(session: TheaterSession | None) -> str: |
| return render_trace_summary(session) |
|
|
|
|
| def normalize_backend_name(backend_name: str | None) -> str: |
| return backend_name if backend_name in BACKEND_VALUES or backend_name in LEGACY_BACKEND_VALUES else "deterministic" |
|
|
|
|
| def actor_engine_label(backend_name: str | None) -> str: |
| return ACTOR_ENGINE_LABELS.get(normalize_backend_name(backend_name), "Deterministic") |
|
|
|
|
| def actor_model_id_for_backend(backend_name: str | None) -> str | None: |
| backend = normalize_backend_name(backend_name) |
| if backend == "openbmb": |
| return OPENBMB_MODEL_ID |
| if backend == "hf_api": |
| return HF_API_MODEL_ID |
| if backend == "local_lora": |
| return os.getenv( |
| "ACTOR_LORA_ADAPTER", |
| "build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA", |
| ) |
| if backend == "local_gguf": |
| model_path = os.getenv("ACTOR_GGUF_MODEL_PATH", "").strip() |
| if model_path: |
| return Path(model_path).name |
| repo_id = os.getenv( |
| "ACTOR_GGUF_REPO_ID", |
| "build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF", |
| ) |
| filename = os.getenv("ACTOR_GGUF_FILENAME", "minicpm5-actor-q4_k_m.gguf") |
| return f"{repo_id}/{filename}" |
| return None |
|
|
|
|
| def normalize_director_mode(director_mode: str | None) -> str: |
| return director_mode if director_mode in DIRECTOR_MODE_VALUES else "deterministic" |
|
|
|
|
| def normalize_show_length(show_length: str | None) -> str: |
| return show_length if show_length in SHOW_LENGTH_VALUES else DEFAULT_SHOW_LENGTH |
|
|
|
|
| def normalize_max_new_tokens(max_new_tokens: int | float | None) -> int: |
| if max_new_tokens is None: |
| return DEFAULT_MAX_NEW_TOKENS |
| return max(16, min(160, int(max_new_tokens))) |
|
|
|
|
| def normalize_temperature(temperature: int | float | None) -> float: |
| if temperature is None: |
| return DEFAULT_TEMPERATURE |
| return max(0.0, min(1.5, float(temperature))) |
|
|
|
|
| def apply_backend_selection( |
| session: TheaterSession | None, |
| backend_name: str | None, |
| director_mode: str | None, |
| max_new_tokens: int | float | None = None, |
| temperature: int | float | None = None, |
| ) -> TheaterSession | None: |
| if session is None: |
| return None |
| session.backend_name = normalize_backend_name(backend_name) |
| session.backend_model_id = actor_model_id_for_backend(session.backend_name) |
| session.director_mode = normalize_director_mode(director_mode) |
| session.backend_max_new_tokens = normalize_max_new_tokens(max_new_tokens) |
| session.backend_temperature = normalize_temperature(temperature) |
| return session |
|
|
|
|
| def render_backend_settings( |
| session: TheaterSession | None, |
| backend_name: str | None = None, |
| director_mode: str | None = None, |
| max_new_tokens: int | float | None = None, |
| temperature: int | float | None = None, |
| ) -> str: |
| selected_backend = normalize_backend_name(backend_name) |
| selected_director_mode = normalize_director_mode(director_mode) |
| active_backend = session.backend_name if session is not None else selected_backend |
| active_director_mode = session.director_mode if session is not None else selected_director_mode |
| active_model_id = session.backend_model_id if session is not None else None |
| min_beats, target_beats, max_beats = ( |
| (session.min_beats, session.target_beats, session.max_beats) |
| if session is not None |
| else resolve_show_length(DEFAULT_SHOW_LENGTH)[1:] |
| ) |
| if active_backend == "openbmb" or active_director_mode == "openbmb": |
| active_model_id = active_model_id or OPENBMB_MODEL_ID |
| if active_backend == "hf_api" or active_director_mode == "hf_api": |
| active_model_id = active_model_id or HF_API_MODEL_ID |
| active_model_id = active_model_id or actor_model_id_for_backend(active_backend) |
| status = get_backend_status(active_backend) |
| openbmb_status = get_backend_status("openbmb") |
| hf_api_status = get_backend_status("hf_api") |
| lora_status = get_backend_status("local_lora") |
| gguf_status = get_backend_status("local_gguf") |
| configured_max_new_tokens = ( |
| session.backend_max_new_tokens if session is not None else normalize_max_new_tokens(max_new_tokens) |
| ) |
| configured_temperature = ( |
| session.backend_temperature if session is not None else normalize_temperature(temperature) |
| ) |
| latency = f"{status.latest_latency_ms}ms" if status.latest_latency_ms is not None else "none yet" |
| fallback_reason = status.latest_fallback_reason or "none" |
| hf_api_fallback_reason = hf_api_status.latest_fallback_reason or "none" |
| if status.latest_fallback_used: |
| if active_backend == "local_lora": |
| actor_engine_status = "Local LoRA failed, fallback used" |
| elif active_backend == "local_gguf": |
| actor_engine_status = "Local GGUF failed, fallback used" |
| else: |
| actor_engine_status = f"Actor engine: {actor_engine_label(active_backend)} failed, fallback used" |
| else: |
| actor_engine_status = f"Actor engine: {actor_engine_label(active_backend)}" |
| cuda_available = ( |
| str(status.cuda_available_in_gpu_fn).lower() |
| if status.cuda_available_in_gpu_fn is not None |
| else "not checked" |
| ) |
| return ( |
| f"{actor_engine_status}\n" |
| f"Active backend: {active_backend}\n" |
| f"Director mode: {active_director_mode}\n" |
| f"Show length: min={min_beats}, target={target_beats}, max={max_beats}\n" |
| "Available actor engines: Deterministic, Local LoRA Actor model, Local GGUF Actor model, Local OpenBMB, Hugging Face API\n" |
| "Available director modes: deterministic, local OpenBMB, Hugging Face API / LLM\n" |
| f"Active model id: {active_model_id or 'not selected'}\n" |
| f"OpenBMB model id: {OPENBMB_MODEL_ID}\n" |
| f"HF API model id: {HF_API_MODEL_ID}\n" |
| f"Local LoRA adapter: {os.getenv('ACTOR_LORA_ADAPTER', 'build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA')}\n" |
| f"Local LoRA status: {lora_status.load_status}\n" |
| f"Local GGUF model: {gguf_status.model_id or 'not selected'}\n" |
| f"Local GGUF status: {gguf_status.load_status}\n" |
| f"HF API token configured: {str(bool(hf_api_status.token_configured)).lower()}\n" |
| f"ZeroGPU enabled: {str(status.zerogpu_enabled).lower()}\n" |
| f"spaces available: {str(status.spaces_available).lower()}\n" |
| f"ZeroGPU GPU decorator active: {str(status.zerogpu_gpu_active).lower()}\n" |
| f"Torch version: {status.torch_version or 'unknown'}\n" |
| f"CUDA available inside GPU function: {cuda_available}\n" |
| f"Model status: {status.load_status}\n" |
| f"OpenBMB status: {openbmb_status.load_status}\n" |
| f"HF API status: {hf_api_status.load_status}\n" |
| f"Latest latency: {latency}\n" |
| f"Latest fallback reason: {fallback_reason}\n" |
| f"HF API latest fallback reason: {hf_api_fallback_reason}\n" |
| f"Generation: max_new_tokens={configured_max_new_tokens}, temperature={configured_temperature:.2f}\n" |
| "Fallback behavior: model errors or invalid output fall back to deterministic generation\n" |
| "Note: Deterministic is the hosted-demo default. Local LoRA and GGUF load lazily only when selected." |
| ) |
|
|
|
|
| def render_outputs(session: TheaterSession | None, voice_mode: str | None = DEFAULT_VOICE_MODE): |
| edge_audio, tts_status = generate_edge_tts_audio(session, voice_mode) |
| return ( |
| render_stage(session), |
| render_transcript(session), |
| latest_tts_payload(session), |
| edge_audio, |
| f'<div class="voice-status">{escape(tts_status)}</div>', |
| render_agent_state(session), |
| render_director_log(session), |
| render_trace_summary_text(session), |
| render_trace(session), |
| write_trace_json_file(session), |
| render_backend_settings(session), |
| ) |
|
|
|
|
| def create_show( |
| premise: str, |
| session: TheaterSession | None, |
| show_length: str, |
| backend_name: str, |
| director_mode: str, |
| max_new_tokens: int | float, |
| temperature: int | float, |
| voice_mode: str, |
| ): |
| premise = premise.strip() |
| selected_show_length = normalize_show_length(show_length) |
| selected_backend = normalize_backend_name(backend_name) |
| selected_director_mode = normalize_director_mode(director_mode) |
| selected_max_new_tokens = normalize_max_new_tokens(max_new_tokens) |
| selected_temperature = normalize_temperature(temperature) |
| if not premise: |
| return ( |
| None, |
| EMPTY_STAGE, |
| "No premise yet. Add a premise to raise the curtain.", |
| EMPTY_TTS_PAYLOAD, |
| None, |
| f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>', |
| EMPTY_AGENT_STATE, |
| EMPTY_DIRECTOR_LOG, |
| EMPTY_TRACE_SUMMARY, |
| EMPTY_TRACE, |
| None, |
| render_backend_settings( |
| None, |
| selected_backend, |
| selected_director_mode, |
| selected_max_new_tokens, |
| selected_temperature, |
| ), |
| ) |
|
|
| session = create_show_from_premise( |
| premise, |
| backend_name=selected_backend, |
| backend_model_id=actor_model_id_for_backend(selected_backend), |
| backend_max_new_tokens=selected_max_new_tokens, |
| backend_temperature=selected_temperature, |
| director_mode=selected_director_mode, |
| show_length=selected_show_length, |
| ) |
| return session, *render_outputs(session, voice_mode) |
|
|
|
|
| def reset_show(): |
| return ( |
| None, |
| "", |
| "rubber duck", |
| "", |
| EMPTY_STAGE, |
| EMPTY_TRANSCRIPT, |
| EMPTY_TTS_PAYLOAD, |
| None, |
| f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>', |
| EMPTY_AGENT_STATE, |
| EMPTY_DIRECTOR_LOG, |
| EMPTY_TRACE_SUMMARY, |
| EMPTY_TRACE, |
| None, |
| DEFAULT_SHOW_LENGTH, |
| DEFAULT_ACTOR_ENGINE, |
| "deterministic", |
| DEFAULT_MAX_NEW_TOKENS, |
| DEFAULT_TEMPERATURE, |
| False, |
| DEFAULT_VOICE_MODE, |
| True, |
| EMPTY_BACKEND, |
| ) |
|
|
|
|
| def advance_one_beat( |
| session: TheaterSession | None, |
| backend_name: str, |
| director_mode: str, |
| max_new_tokens: int | float, |
| temperature: int | float, |
| voice_mode: str, |
| ): |
| if session is None: |
| return ( |
| None, |
| EMPTY_STAGE, |
| "Create a show before running a beat.", |
| EMPTY_TTS_PAYLOAD, |
| None, |
| f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>', |
| EMPTY_AGENT_STATE, |
| EMPTY_DIRECTOR_LOG, |
| EMPTY_TRACE_SUMMARY, |
| EMPTY_TRACE, |
| None, |
| render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature), |
| ) |
|
|
| session = apply_backend_selection(session, backend_name, director_mode, max_new_tokens, temperature) |
| session = run_one_beat(session) |
| return session, *render_outputs(session, voice_mode) |
|
|
|
|
| def advance_full_act( |
| session: TheaterSession | None, |
| backend_name: str, |
| director_mode: str, |
| max_new_tokens: int | float, |
| temperature: int | float, |
| use_deterministic_full_act: bool, |
| voice_mode: str, |
| ): |
| if session is None: |
| yield ( |
| None, |
| EMPTY_STAGE, |
| "Create a show before running the full act.", |
| EMPTY_TTS_PAYLOAD, |
| None, |
| f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>', |
| EMPTY_AGENT_STATE, |
| EMPTY_DIRECTOR_LOG, |
| EMPTY_TRACE_SUMMARY, |
| EMPTY_TRACE, |
| None, |
| render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature), |
| ) |
| return |
|
|
| session = apply_backend_selection(session, backend_name, director_mode, max_new_tokens, temperature) |
| selected_backend = session.backend_name |
| selected_director_mode = session.director_mode |
| deterministic_full_act = ( |
| (selected_backend in {"openbmb", "local_lora", "local_gguf"} or selected_director_mode == "openbmb") |
| and use_deterministic_full_act |
| ) |
| if deterministic_full_act: |
| session.director_log.append( |
| "A local model backend is selected, so Run Full Act will use deterministic playback for this run." |
| ) |
| add_trace_event( |
| session, |
| "full_act_deterministic_playback", |
| backend_name=selected_backend, |
| director_mode=selected_director_mode, |
| fallback_used=True, |
| fallback_reason="Local model full-act playback uses deterministic mode to keep the demo responsive.", |
| ) |
|
|
| if session.beat_index >= session.max_beats: |
| if deterministic_full_act: |
| session.backend_name = "deterministic" |
| session.director_mode = "deterministic" |
| session = run_one_beat(session) |
| if deterministic_full_act: |
| session.backend_name = selected_backend |
| session.director_mode = selected_director_mode |
| yield session, *render_outputs(session, voice_mode) |
| return |
|
|
| while session.beat_index < session.max_beats: |
| if deterministic_full_act: |
| session.backend_name = "deterministic" |
| session.director_mode = "deterministic" |
| session = run_one_beat(session) |
| if deterministic_full_act: |
| session.backend_name = selected_backend |
| session.director_mode = selected_director_mode |
| yield session, *render_outputs(session, voice_mode) |
| if session.beat_index < session.max_beats: |
| sleep(playback_delay_for_latest_line(session, voice_mode)) |
|
|
|
|
| def throw_audience_prop( |
| session: TheaterSession | None, |
| prop_name: str, |
| backend_name: str, |
| director_mode: str, |
| max_new_tokens: int | float, |
| temperature: int | float, |
| voice_mode: str, |
| ): |
| if session is None: |
| return ( |
| None, |
| EMPTY_STAGE, |
| "Create a show before throwing a prop.", |
| EMPTY_TTS_PAYLOAD, |
| None, |
| f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>', |
| EMPTY_AGENT_STATE, |
| EMPTY_DIRECTOR_LOG, |
| EMPTY_TRACE_SUMMARY, |
| EMPTY_TRACE, |
| None, |
| render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature), |
| ) |
|
|
| session = apply_backend_selection(session, backend_name, director_mode, max_new_tokens, temperature) |
| session = throw_prop(session, prop_name) |
| return session, *render_outputs(session, voice_mode) |
|
|
|
|
| def summon_audience_actor( |
| session: TheaterSession | None, |
| actor_name: str, |
| backend_name: str, |
| director_mode: str, |
| max_new_tokens: int | float, |
| temperature: int | float, |
| voice_mode: str, |
| ): |
| if session is None: |
| return ( |
| None, |
| EMPTY_STAGE, |
| "Create a show before summoning an actor.", |
| EMPTY_TTS_PAYLOAD, |
| None, |
| f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>', |
| EMPTY_AGENT_STATE, |
| EMPTY_DIRECTOR_LOG, |
| EMPTY_TRACE_SUMMARY, |
| EMPTY_TRACE, |
| None, |
| render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature), |
| ) |
|
|
| session = apply_backend_selection(session, backend_name, director_mode, max_new_tokens, temperature) |
| session = summon_actor(session, actor_name) |
| return session, *render_outputs(session, voice_mode) |
|
|
|
|
| def request_audience_finale( |
| session: TheaterSession | None, |
| backend_name: str, |
| director_mode: str, |
| max_new_tokens: int | float, |
| temperature: int | float, |
| voice_mode: str, |
| ): |
| if session is None: |
| return ( |
| None, |
| EMPTY_STAGE, |
| "Create a show before requesting a finale.", |
| EMPTY_TTS_PAYLOAD, |
| None, |
| f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>', |
| EMPTY_AGENT_STATE, |
| EMPTY_DIRECTOR_LOG, |
| EMPTY_TRACE_SUMMARY, |
| EMPTY_TRACE, |
| None, |
| render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature), |
| ) |
|
|
| session = apply_backend_selection(session, backend_name, director_mode, max_new_tokens, temperature) |
| session = request_finale(session) |
| return session, *render_outputs(session, voice_mode) |
|
|
|
|
| def warm_up_backend( |
| session: TheaterSession | None, |
| max_new_tokens: int | float, |
| temperature: int | float, |
| ): |
| selected_max_new_tokens = normalize_max_new_tokens(max_new_tokens) |
| selected_temperature = normalize_temperature(temperature) |
| status = warm_up_openbmb( |
| max_new_tokens=selected_max_new_tokens, |
| temperature=selected_temperature, |
| ) |
| if session is not None: |
| session.backend_max_new_tokens = selected_max_new_tokens |
| session.backend_temperature = selected_temperature |
| if status.load_status == "loaded": |
| session.director_log.append(f"OpenBMB warm-up loaded {status.model_id}.") |
| add_trace_event( |
| session, |
| "backend_warmup", |
| backend_name="openbmb", |
| model_id=status.model_id, |
| latency_ms=status.latest_latency_ms, |
| validation_status="loaded", |
| fallback_used=False, |
| ) |
| else: |
| reason = status.latest_fallback_reason or "unknown error" |
| session.director_log.append(f"OpenBMB warm-up failed: {reason}.") |
| add_trace_event( |
| session, |
| "backend_warmup", |
| backend_name="openbmb", |
| model_id=status.model_id, |
| latency_ms=status.latest_latency_ms, |
| validation_status=status.load_status, |
| fallback_used=True, |
| fallback_reason=reason, |
| ) |
| return ( |
| session, |
| render_director_log(session), |
| render_trace_summary_text(session), |
| render_trace(session), |
| write_trace_json_file(session), |
| render_backend_settings( |
| session, |
| "openbmb", |
| session.director_mode if session is not None else "deterministic", |
| selected_max_new_tokens, |
| selected_temperature, |
| ), |
| ) |
|
|
|
|
| with gr.Blocks(title="AI Puppet Theater") as app: |
| session_state = gr.State(None) |
|
|
| gr.Markdown( |
| """ |
| # AI Puppet Theater |
| Create a tiny AI puppet show, then interrupt it from the audience. |
| """, |
| elem_classes=["app-title"], |
| ) |
|
|
| with gr.Group(elem_classes=["control-panel", "premise-panel"]): |
| with gr.Column(elem_classes=["premise-stack"]): |
| premise_input = gr.Textbox( |
| label="Premise", |
| placeholder="A moon detective interrogates a suspicious toaster...", |
| lines=1, |
| ) |
| show_length_select = gr.Dropdown( |
| choices=SHOW_LENGTH_CHOICES, |
| value=DEFAULT_SHOW_LENGTH, |
| label="Show Length", |
| interactive=True, |
| ) |
| with gr.Row(elem_classes=["premise-actions"]): |
| create_button = gr.Button("Create Show", variant="primary", elem_classes=["primary-action"]) |
| reset_button = gr.Button("Reset", elem_classes=["reset-action"]) |
|
|
| stage_output = gr.HTML(value=EMPTY_STAGE, label="Stage", elem_classes=["stage-output"]) |
| latest_tts_payload_output = gr.Textbox(value=EMPTY_TTS_PAYLOAD, visible=False) |
|
|
| with gr.Column(elem_classes=["backstage-stack"]): |
| with gr.Group(elem_classes=["control-panel"]): |
| gr.Markdown("### Show Controls", elem_classes=["show-controls-title", "panel-heading"]) |
| with gr.Row(): |
| run_one_button = gr.Button( |
| "Run One Beat", |
| variant="primary", |
| elem_classes=["run-one-action"], |
| ) |
| run_full_button = gr.Button("Run Full Act", elem_classes=["cue-action"]) |
|
|
| with gr.Group(elem_classes=["control-panel"]): |
| gr.Markdown("### Voice", elem_classes=["show-controls-title", "panel-heading"]) |
| voice_mode_select = gr.Radio( |
| choices=VOICE_MODE_CHOICES, |
| value=DEFAULT_VOICE_MODE, |
| label="Voice Mode", |
| interactive=True, |
| ) |
| auto_speak_checkbox = gr.Checkbox( |
| value=True, |
| label="Auto-speak latest line", |
| interactive=True, |
| ) |
| with gr.Row(): |
| speak_latest_button = gr.Button("Speak Latest Line", elem_classes=["cue-action"]) |
| stop_speaking_button = gr.Button("Stop Speaking", elem_classes=["reset-action"]) |
| with gr.Row(): |
| preview_actor_voice_button = gr.Button("Preview Actor Voice", elem_classes=["cue-action"]) |
| preview_narrator_voice_button = gr.Button("Preview Narrator Voice", elem_classes=["cue-action"]) |
| tts_status_output = gr.HTML( |
| value=f'<div class="voice-status">{EMPTY_TTS_STATUS}</div>', |
| label="Voice Status", |
| elem_classes=["no-field-label"], |
| ) |
| edge_tts_audio_output = gr.Audio( |
| value=None, |
| label="Latest Edge TTS line", |
| type="filepath", |
| autoplay=True, |
| interactive=False, |
| elem_classes=["edge-audio-player", "no-field-label"], |
| ) |
|
|
| with gr.Group(elem_classes=["control-panel"]): |
| gr.Markdown("### Audience", elem_classes=["audience-title", "panel-heading"]) |
| prop_input = gr.Dropdown( |
| choices=PROP_DROPDOWN_CHOICES, |
| value="rubber duck", |
| allow_custom_value=True, |
| filterable=True, |
| label="Choose a prop", |
| info="Pick from the chest or type your own, then press **Throw Prop**.", |
| elem_classes=["prop-picker"], |
| ) |
| with gr.Row(): |
| throw_prop_button = gr.Button( |
| "Throw Prop", |
| elem_classes=["audience-action-button"], |
| ) |
| request_finale_button = gr.Button( |
| "Request Finale", |
| elem_classes=["audience-action-button"], |
| ) |
| actor_input = gr.Textbox(label="Summon Actor", placeholder="Professor Button") |
| summon_actor_button = gr.Button( |
| "Summon Actor", |
| elem_classes=["audience-action-button"], |
| ) |
|
|
| with gr.Accordion("Transcript", open=False, elem_classes=["transcript-section"]): |
| transcript_output = gr.HTML( |
| value=EMPTY_TRANSCRIPT, |
| label="Transcript", |
| elem_classes=["transcript-box", "no-field-label"], |
| ) |
| with gr.Accordion("Agent State", open=False): |
| agent_state_output = gr.HTML( |
| value=EMPTY_AGENT_STATE, |
| label="Agent State", |
| elem_classes=["no-field-label"], |
| ) |
| with gr.Accordion("Behind the Curtain", open=False): |
| trace_summary_output = gr.Textbox( |
| value=EMPTY_TRACE_SUMMARY, |
| label="Trace Summary", |
| lines=8, |
| interactive=False, |
| buttons=["copy"], |
| elem_classes=["no-field-label"], |
| ) |
| director_output = gr.Textbox( |
| value=EMPTY_DIRECTOR_LOG, |
| label="Director Log", |
| lines=6, |
| interactive=False, |
| elem_classes=["no-field-label"], |
| ) |
| with gr.Accordion("Trace / Debug", open=False): |
| trace_output = gr.Textbox( |
| value=EMPTY_TRACE, |
| label="Trace JSON Preview", |
| lines=10, |
| interactive=False, |
| elem_classes=["no-field-label"], |
| ) |
| trace_download = gr.DownloadButton( |
| "Download Trace JSON", |
| value=None, |
| elem_classes=["cue-action"], |
| ) |
| with gr.Accordion("Backend", open=False): |
| backend_select = gr.Dropdown( |
| choices=BACKEND_CHOICES, |
| value=DEFAULT_ACTOR_ENGINE, |
| label="Actor Engine", |
| interactive=True, |
| ) |
| director_mode_select = gr.Dropdown( |
| choices=DIRECTOR_MODE_CHOICES, |
| value="deterministic", |
| label="Director Mode", |
| interactive=True, |
| ) |
| with gr.Row(): |
| max_new_tokens_input = gr.Slider( |
| minimum=16, |
| maximum=160, |
| value=DEFAULT_MAX_NEW_TOKENS, |
| step=8, |
| label="Max New Tokens", |
| interactive=True, |
| ) |
| temperature_input = gr.Slider( |
| minimum=0.0, |
| maximum=1.5, |
| value=DEFAULT_TEMPERATURE, |
| step=0.1, |
| label="Temperature", |
| interactive=True, |
| ) |
| deterministic_full_act_input = gr.Checkbox( |
| value=False, |
| label="Force deterministic playback for local-model full-act runs", |
| interactive=True, |
| ) |
| warm_up_button = gr.Button("Warm up OpenBMB", elem_classes=["cue-action"]) |
| backend_output = gr.Textbox( |
| value=EMPTY_BACKEND, |
| label="Model Settings", |
| lines=8, |
| interactive=False, |
| elem_classes=["no-field-label"], |
| ) |
|
|
| create_event = create_button.click( |
| create_show, |
| inputs=[ |
| premise_input, |
| session_state, |
| show_length_select, |
| backend_select, |
| director_mode_select, |
| max_new_tokens_input, |
| temperature_input, |
| voice_mode_select, |
| ], |
| outputs=[ |
| session_state, |
| stage_output, |
| transcript_output, |
| latest_tts_payload_output, |
| edge_tts_audio_output, |
| tts_status_output, |
| agent_state_output, |
| director_output, |
| trace_summary_output, |
| trace_output, |
| trace_download, |
| backend_output, |
| ], |
| ) |
| run_one_event = run_one_button.click( |
| advance_one_beat, |
| inputs=[ |
| session_state, |
| backend_select, |
| director_mode_select, |
| max_new_tokens_input, |
| temperature_input, |
| voice_mode_select, |
| ], |
| outputs=[ |
| session_state, |
| stage_output, |
| transcript_output, |
| latest_tts_payload_output, |
| edge_tts_audio_output, |
| tts_status_output, |
| agent_state_output, |
| director_output, |
| trace_summary_output, |
| trace_output, |
| trace_download, |
| backend_output, |
| ], |
| ) |
| run_full_event = run_full_button.click( |
| advance_full_act, |
| inputs=[ |
| session_state, |
| backend_select, |
| director_mode_select, |
| max_new_tokens_input, |
| temperature_input, |
| deterministic_full_act_input, |
| voice_mode_select, |
| ], |
| outputs=[ |
| session_state, |
| stage_output, |
| transcript_output, |
| latest_tts_payload_output, |
| edge_tts_audio_output, |
| tts_status_output, |
| agent_state_output, |
| director_output, |
| trace_summary_output, |
| trace_output, |
| trace_download, |
| backend_output, |
| ], |
| ) |
| throw_prop_event = throw_prop_button.click( |
| throw_audience_prop, |
| inputs=[ |
| session_state, |
| prop_input, |
| backend_select, |
| director_mode_select, |
| max_new_tokens_input, |
| temperature_input, |
| voice_mode_select, |
| ], |
| outputs=[ |
| session_state, |
| stage_output, |
| transcript_output, |
| latest_tts_payload_output, |
| edge_tts_audio_output, |
| tts_status_output, |
| agent_state_output, |
| director_output, |
| trace_summary_output, |
| trace_output, |
| trace_download, |
| backend_output, |
| ], |
| ) |
| summon_actor_event = summon_actor_button.click( |
| summon_audience_actor, |
| inputs=[ |
| session_state, |
| actor_input, |
| backend_select, |
| director_mode_select, |
| max_new_tokens_input, |
| temperature_input, |
| voice_mode_select, |
| ], |
| outputs=[ |
| session_state, |
| stage_output, |
| transcript_output, |
| latest_tts_payload_output, |
| edge_tts_audio_output, |
| tts_status_output, |
| agent_state_output, |
| director_output, |
| trace_summary_output, |
| trace_output, |
| trace_download, |
| backend_output, |
| ], |
| ) |
| request_finale_event = request_finale_button.click( |
| request_audience_finale, |
| inputs=[ |
| session_state, |
| backend_select, |
| director_mode_select, |
| max_new_tokens_input, |
| temperature_input, |
| voice_mode_select, |
| ], |
| outputs=[ |
| session_state, |
| stage_output, |
| transcript_output, |
| latest_tts_payload_output, |
| edge_tts_audio_output, |
| tts_status_output, |
| agent_state_output, |
| director_output, |
| trace_summary_output, |
| trace_output, |
| trace_download, |
| backend_output, |
| ], |
| ) |
| warm_up_button.click( |
| warm_up_backend, |
| inputs=[session_state, max_new_tokens_input, temperature_input], |
| outputs=[session_state, director_output, trace_summary_output, trace_output, trace_download, backend_output], |
| ) |
| reset_button.click( |
| reset_show, |
| outputs=[ |
| session_state, |
| premise_input, |
| prop_input, |
| actor_input, |
| stage_output, |
| transcript_output, |
| latest_tts_payload_output, |
| edge_tts_audio_output, |
| tts_status_output, |
| agent_state_output, |
| director_output, |
| trace_summary_output, |
| trace_output, |
| trace_download, |
| show_length_select, |
| backend_select, |
| director_mode_select, |
| max_new_tokens_input, |
| temperature_input, |
| deterministic_full_act_input, |
| voice_mode_select, |
| auto_speak_checkbox, |
| backend_output, |
| ], |
| ) |
| app.load( |
| None, |
| outputs=[tts_status_output], |
| js=REGISTER_BROWSER_TTS_JS, |
| ) |
| voice_mode_select.change( |
| None, |
| inputs=[voice_mode_select], |
| outputs=[tts_status_output], |
| js=r""" |
| (mode) => { |
| if (mode === "edge_character") { |
| window.aiPuppetEdgeAudio = window.aiPuppetEdgeAudio || { |
| queue: [], |
| current: null, |
| currentSrc: "", |
| playing: false, |
| lastSeenSrc: "", |
| lastSeenAt: 0, |
| completedSrcAt: {}, |
| drainTimer: null, |
| interLineDelayMs: 650, |
| observerReady: false, |
| }; |
| const edgeStatus = (html) => { |
| const status = document.querySelector(".voice-status"); |
| if (status) status.outerHTML = html; |
| }; |
| const edgePauseNativePlayers = () => { |
| document.querySelectorAll(".edge-audio-player audio").forEach((audio) => { |
| audio.pause(); |
| audio.currentTime = 0; |
| }); |
| }; |
| const edgeDrainQueue = () => { |
| const state = window.aiPuppetEdgeAudio; |
| if (state.drainTimer) { |
| clearTimeout(state.drainTimer); |
| state.drainTimer = null; |
| } |
| if (state.playing || state.queue.length === 0) return; |
| const task = state.queue.shift(); |
| state.playing = true; |
| state.currentSrc = task.src; |
| edgePauseNativePlayers(); |
| const audio = new Audio(task.src); |
| audio.preload = "auto"; |
| state.current = audio; |
| audio.onended = () => { |
| state.playing = false; |
| state.current = null; |
| state.completedSrcAt[task.src] = Date.now(); |
| state.currentSrc = ""; |
| state.drainTimer = setTimeout(edgeDrainQueue, state.interLineDelayMs); |
| }; |
| audio.onerror = () => { |
| state.playing = false; |
| state.current = null; |
| state.currentSrc = ""; |
| edgeStatus('<div class="voice-status"><strong>Edge TTS playback failed.</strong> Use Browser TTS or retry the line.</div>'); |
| state.drainTimer = setTimeout(edgeDrainQueue, state.interLineDelayMs); |
| }; |
| const playResult = audio.play(); |
| if (playResult && typeof playResult.catch === "function") { |
| playResult.catch(() => { |
| state.playing = false; |
| state.current = null; |
| state.currentSrc = ""; |
| edgeStatus('<div class="voice-status"><strong>Edge TTS is ready.</strong> Browser blocked autoplay; click the page once and run the next line.</div>'); |
| }); |
| } |
| }; |
| const edgeEnqueueSrc = (src) => { |
| // Gradio's mounted audio element is the reliable autoplay path for Edge TTS. |
| // Full Act pacing waits between generated files, so this observer no longer |
| // starts a separate Audio() object that browsers may block. |
| return; |
| if (!src) return; |
| const absoluteSrc = new URL(src, window.location.href).href; |
| const state = window.aiPuppetEdgeAudio; |
| const now = Date.now(); |
| if (state.lastSeenSrc === absoluteSrc && now - state.lastSeenAt < 1200) return; |
| if (state.currentSrc === absoluteSrc) return; |
| if (state.queue.some((task) => task.src === absoluteSrc)) return; |
| if (state.completedSrcAt[absoluteSrc] && now - state.completedSrcAt[absoluteSrc] < 30000) return; |
| state.lastSeenSrc = absoluteSrc; |
| state.lastSeenAt = now; |
| edgePauseNativePlayers(); |
| state.queue.push({ src: absoluteSrc }); |
| edgeStatus(state.playing |
| ? '<div class="voice-status">Edge TTS queued next line.</div>' |
| : '<div class="voice-status">Edge TTS playing latest line.</div>'); |
| edgeDrainQueue(); |
| }; |
| const edgeScanAudio = () => { |
| const audio = document.querySelector(".edge-audio-player audio"); |
| const source = document.querySelector(".edge-audio-player source"); |
| const src = (audio && (audio.currentSrc || audio.src)) || (source && source.src) || ""; |
| edgeEnqueueSrc(src); |
| }; |
| window.aiPuppetEdgeAudio.enqueueSrc = edgeEnqueueSrc; |
| window.aiPuppetEdgeAudio.clear = () => { |
| const state = window.aiPuppetEdgeAudio; |
| state.queue = []; |
| state.playing = false; |
| state.currentSrc = ""; |
| state.lastSeenSrc = ""; |
| state.completedSrcAt = {}; |
| if (state.drainTimer) { |
| clearTimeout(state.drainTimer); |
| state.drainTimer = null; |
| } |
| if (state.current) { |
| state.current.pause(); |
| state.current.currentTime = 0; |
| state.current = null; |
| } |
| edgePauseNativePlayers(); |
| }; |
| if (!window.aiPuppetEdgeAudio.observerReady) { |
| const observer = new MutationObserver(() => edgeScanAudio()); |
| observer.observe(document.body, { |
| attributes: true, |
| attributeFilter: ["src"], |
| childList: true, |
| subtree: true, |
| }); |
| window.aiPuppetEdgeAudio.observerReady = true; |
| } |
| document.body.dataset.edgeAudioQueueReady = "true"; |
| setTimeout(edgeScanAudio, 250); |
| return '<div class="voice-status">Edge TTS will generate audio for each latest line when available.</div>'; |
| } |
| if (mode !== "off") { |
| if (!("speechSynthesis" in globalThis) || typeof SpeechSynthesisUtterance === "undefined") { |
| return '<div class="voice-status"><strong>Browser TTS unavailable.</strong> The show still works without voice.</div>'; |
| } |
| if (globalThis.speechSynthesis.getVoices) globalThis.speechSynthesis.getVoices(); |
| try { |
| const unlock = new SpeechSynthesisUtterance(" "); |
| unlock.volume = 0; |
| unlock.rate = 1; |
| globalThis.speechSynthesis.cancel(); |
| globalThis.speechSynthesis.speak(unlock); |
| } catch (_error) { |
| // Some browsers ignore silent unlock utterances; normal speaking still works. |
| } |
| const status = mode === "narrator" |
| ? '<div class="voice-status">Browser TTS narrator-only mode is ready.</div>' |
| : '<div class="voice-status">Browser TTS character voices are ready.</div>'; |
| return status; |
| } |
| if ("speechSynthesis" in globalThis) globalThis.speechSynthesis.cancel(); |
| return '<div class="voice-status">Voice mode is off.</div>'; |
| } |
| """, |
| ) |
| auto_speak_checkbox.change( |
| None, |
| inputs=[voice_mode_select, auto_speak_checkbox], |
| outputs=[tts_status_output], |
| js=r""" |
| (mode, autoSpeak) => { |
| if (mode === "off") { |
| return autoSpeak |
| ? '<div class="voice-status">Auto-speak is ready, but voice mode is off.</div>' |
| : '<div class="voice-status">Auto-speak is off.</div>'; |
| } |
| return autoSpeak |
| ? '<div class="voice-status">Auto-speak will read each new line.</div>' |
| : '<div class="voice-status">Auto-speak is off. Use Speak Latest Line manually.</div>'; |
| } |
| """, |
| ) |
| speak_latest_button.click( |
| None, |
| inputs=[voice_mode_select, latest_tts_payload_output], |
| outputs=[tts_status_output], |
| js=BROWSER_TTS_JS, |
| ) |
| preview_actor_voice_button.click( |
| None, |
| inputs=[voice_mode_select, latest_tts_payload_output], |
| outputs=[tts_status_output], |
| js=r""" |
| (mode, payloadJson) => { |
| const activeMode = mode === "off" ? "character" : mode; |
| const payload = payloadJson && payloadJson !== "{}" |
| ? payloadJson |
| : JSON.stringify({ |
| speaker: "Warm Puppet", |
| text: "Previewing actor voice.", |
| style: { |
| label: "Warm Puppet", |
| pitch: 1.04, |
| rate: 0.96, |
| volume: 0.9, |
| voiceNameHints: ["Google US English", "Samantha", "Jenny", "Aria"], |
| }, |
| narratorStyle: { |
| label: "Narrator", |
| pitch: 0.96, |
| rate: 0.88, |
| volume: 0.82, |
| voiceNameHints: ["Daniel", "Alex", "Google UK English Male", "Microsoft Guy", "Google US English"], |
| }, |
| }); |
| const speakLatest = %s; |
| return speakLatest(activeMode, payload, "actor"); |
| } |
| """ % BROWSER_TTS_JS, |
| ) |
| preview_narrator_voice_button.click( |
| None, |
| inputs=[voice_mode_select, latest_tts_payload_output], |
| outputs=[tts_status_output], |
| js=r""" |
| (_mode, payloadJson) => { |
| const payload = payloadJson && payloadJson !== "{}" |
| ? payloadJson |
| : JSON.stringify({ |
| speaker: "Narrator", |
| text: "Previewing narrator voice.", |
| style: { |
| label: "Warm Puppet", |
| pitch: 1.04, |
| rate: 0.96, |
| volume: 0.9, |
| voiceNameHints: ["Google US English", "Samantha", "Jenny", "Aria"], |
| }, |
| narratorStyle: { |
| label: "Narrator", |
| pitch: 0.96, |
| rate: 0.88, |
| volume: 0.82, |
| voiceNameHints: ["Daniel", "Alex", "Google UK English Male", "Microsoft Guy", "Google US English"], |
| }, |
| }); |
| const speakLatest = %s; |
| return speakLatest("narrator", payload, "narrator"); |
| } |
| """ % BROWSER_TTS_JS, |
| ) |
| stop_speaking_button.click( |
| None, |
| outputs=[tts_status_output], |
| js=STOP_SPEAKING_JS, |
| ) |
| reset_button.click( |
| None, |
| outputs=[tts_status_output], |
| js=STOP_SPEAKING_JS, |
| ) |
| latest_tts_payload_output.change( |
| None, |
| inputs=[voice_mode_select, auto_speak_checkbox, latest_tts_payload_output], |
| outputs=[tts_status_output], |
| js=AUTO_BROWSER_TTS_JS, |
| ) |
| for event in [ |
| create_event, |
| run_one_event, |
| run_full_event, |
| throw_prop_event, |
| summon_actor_event, |
| request_finale_event, |
| ]: |
| event.then( |
| None, |
| inputs=[voice_mode_select, auto_speak_checkbox, latest_tts_payload_output], |
| outputs=[tts_status_output], |
| js=AUTO_BROWSER_TTS_JS, |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| |
| |
| |
| app.launch(css=CUSTOM_CSS, theme=THEATER_THEME, ssr_mode=False) |
|
|