build-small-hackathon/Hackathon-IA-VisualNovel
5
1"""Entry point. Thin by design — all logic lives in the `visualnovel` package.2 3Two UIs:4 - default: a custom VN frontend (frontend/index.html) served by `gradio.Server`, talking to5 @app.api endpoints via the Gradio JS client. (Off-Brand / custom-UI bonus.)6 - GRADIO_MVP_UI=1: a plain gr.Blocks UI to de-risk the loop in Phase 0/1.7 8Run modes9---------10 uv run python app.py # use whatever's in .env (VN_MOCK default: 1)11 uv run python app.py --mode mock # force VN_MOCK=1 (no models needed)12 uv run python app.py --mode prod # force VN_MOCK=0 (real backends)13 uv run python app.py --mode debug # VN_MOCK=0 + verbose logging + live monitor14"""15 16from __future__ import annotations17 18# Shim: must run BEFORE gradio import ?19try:20 import spaces as _spaces21 22 if not hasattr(_spaces, "gradio_auto_wrap"):23 _spaces.gradio_auto_wrap = lambda fn: fn24except ImportError:25 pass26 27# ── Mode selection: must run BEFORE any visualnovel import ──────────────────28# config.py reads os.getenv() at import time via load_dotenv(), so we must29# set the env vars first.30import argparse31import atexit32import logging33import os34 35 36def _apply_mode() -> str | None:37 p = argparse.ArgumentParser(add_help=False)38 p.add_argument(39 "--mode",40 choices=["mock", "prod", "debug"],41 default=None,42 help=(43 "mock → VN_MOCK=1 (no models, default) | "44 "prod → VN_MOCK=0 (real backends) | "45 "debug → VN_MOCK=0 + verbose logs + live resource monitor"46 ),47 )48 args, _ = p.parse_known_args()49 if args.mode == "mock":50 os.environ["VN_MOCK"] = "1"51 elif args.mode == "prod":52 os.environ["VN_MOCK"] = "0"53 elif args.mode == "debug":54 os.environ["VN_MOCK"] = "0"55 os.environ["VN_DEBUG"] = "1"56 return args.mode57 58 59_RUN_MODE = _apply_mode()60 61logging.basicConfig(62 level=logging.WARNING, # keep third-party libs quiet63 format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",64 datefmt="%H:%M:%S",65)66if _RUN_MODE == "debug":67 # debug-level logs only for our own package; third-party stays at WARNING68 logging.getLogger("visualnovel").setLevel(logging.DEBUG)69 70# ── Silence known noisy ML dependency warnings ────────────────────────────71# transformers reads this env var at import time — set BEFORE anything imports it72# (its advisories, e.g. "CLIPImageProcessor requires torchvision", bypass stdlib logging).73os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")74 75import warnings # noqa: E40276 77# huggingface_hub: deprecated symlinks arg (internal, not our call)78warnings.filterwarnings("ignore", message=".*local_dir_use_symlinks.*")79# huggingface_hub: unauthenticated rate-limit notice (surfaced as UserWarning too)80warnings.filterwarnings("ignore", message=".*unauthenticated.*")81# transformers: catch any remaining FutureWarnings we don't control (e.g. upstream renames)82warnings.filterwarnings("ignore", category=FutureWarning, module="transformers")83 84# Suppress WARNING-level noise from ML frameworks; errors still surface85for _lib in ("transformers", "diffusers", "huggingface_hub", "phonemizer"):86 logging.getLogger(_lib).setLevel(logging.ERROR)87 88# ── Project imports (after env vars are set) ─────────────────────────────────89from pathlib import Path90 91from visualnovel import config92from visualnovel.engine import Engine93from visualnovel.metrics import collector94from visualnovel.schemas import SetupForm95 96# Activate monitoring only in debug mode (no-op otherwise)97if config.DEBUG:98 collector.activate(config.RUNS_DIR)99 atexit.register(collector.save_report)100 101if not config.USE_MOCK:102 import subprocess103 import sys104 105 # Check only the deps required by the configured backends106 _missing: list[str] = []107 108 if config.LLM_BACKEND == "llamacpp":109 try:110 import llama_cpp # noqa: F401111 except ImportError:112 _missing.append("llamacpp")113 114 if config.LLM_BACKEND == "transformers":115 try:116 import transformers # noqa: F401117 except ImportError:118 _missing.append("transformers")119 120 if config.IMAGE_BACKEND in ("local", "lightning"):121 try:122 import diffusers # noqa: F401123 except ImportError:124 _missing.append("image")125 126 if config.TTS_BACKEND == "kokoro":127 try:128 import kokoro_onnx # noqa: F401129 import soundfile # noqa: F401130 except ImportError:131 _missing.append("tts")132 133 if config.LLM_BACKEND == "modal" or config.IMAGE_BACKEND == "modal":134 try:135 import modal # noqa: F401136 except ImportError:137 _missing.append("modal")138 139 if _missing:140 extras = ",".join(_missing)141 print(f"[setup] Missing dependencies — run: uv sync --extra {extras}")142 sys.exit(1)143 144 # Only fetch the GGUF when the llama.cpp backend is active145 if config.LLM_BACKEND == "llamacpp":146 _gguf_path = config.MODELS_DIR / config.LLM_GGUF_FILE147 if not _gguf_path.exists():148 print(f"[setup] Model not found at {_gguf_path} — running download script…")149 subprocess.run(150 [sys.executable, str(Path(__file__).parent / "scripts" / "download_models.py")],151 check=True,152 )153 154ENGINE = Engine() # single-session game155 156if not config.USE_MOCK and config.LLM_BACKEND == "modal":157 try:158 ENGINE.llm.warmup() # fire-and-forget: warm the GPU container before the first turn159 except Exception as exc:160 print(f"[setup] Modal warmup skipped: {exc}")161 162FRONTEND = Path(__file__).parent / "frontend" / "index.html"163 164try:165 import spaces # type: ignore166 167 def gpu(fn=None, **kw): # supports @gpu and @gpu(duration=...)168 return spaces.GPU(**kw)(fn) if fn is not None else spaces.GPU(**kw)169except Exception: # pragma: no cover170 171 def gpu(fn=None, **kw):172 return fn if fn is not None else (lambda f: f)173 174 175# =========================================================================== #176# Custom frontend via gradio.Server177# =========================================================================== #178def build_server():179 from fastapi.responses import HTMLResponse180 from fastapi.staticfiles import StaticFiles181 from gradio import Server182 183 app = Server()184 # serve generated images (backdrops/sprites) as static files at /images/<name>185 app.mount("/images", StaticFiles(directory=str(config.CACHE_DIR)), name="images")186 # serve background music tracks at /music/<name>.mp3|ogg187 _music_dir = Path(__file__).parent / "frontend" / "music"188 _music_dir.mkdir(exist_ok=True)189 app.mount("/music", StaticFiles(directory=str(_music_dir)), name="music")190 191 @app.get("/", response_class=HTMLResponse)192 async def home() -> str:193 return FRONTEND.read_text(encoding="utf-8")194 195 @app.api(name="themes")196 def themes() -> dict:197 return {"themes": config.THEMES, "tones": config.TONES}198 199 @app.api(name="start")200 @gpu201 def start(202 theme: str = "school",203 tone: str = "romantic",204 seed: int | None = None,205 player_name: str = "",206 ) -> dict:207 form = SetupForm(208 theme=theme, tone=tone, seed=seed, player_name=player_name.strip() or "the wanderer"209 )210 return ENGINE.start(form).model_dump()211 212 @app.api(name="start_text")213 @gpu214 def start_text(215 theme: str = "school",216 tone: str = "romantic",217 seed: int | None = None,218 player_name: str = "",219 ) -> dict:220 """Phase 1 — LLM init only. Returns text-only ViewState (no images)."""221 form = SetupForm(222 theme=theme, tone=tone, seed=seed, player_name=player_name.strip() or "the wanderer"223 )224 return ENGINE.start_text(form).model_dump()225 226 @app.api(name="start_images")227 @gpu228 def start_images() -> dict:229 """Phase 2 — paint backdrop + sprite. Call after start_text."""230 return ENGINE.start_images().model_dump()231 232 @app.api(name="turn")233 @gpu234 def turn(player_input: str, action: str = "talk", target: str = "") -> dict:235 return ENGINE.play_turn(player_input, action=action, target=target).model_dump()236 237 @app.api(name="turn_text")238 @gpu239 def turn_text(player_input: str, action: str = "talk", target: str = "") -> dict:240 """Phase 1 — STT + LLM + state. Returns text-only ViewState (dialogue first)."""241 return ENGINE.play_turn_text(player_input, action=action, target=target).model_dump()242 243 @app.api(name="turn_images")244 @gpu245 def turn_images() -> dict:246 """Phase 2 — paint + TTS. Call after turn_text."""247 return ENGINE.play_turn_images().model_dump()248 249 @app.api(name="session_info")250 def session_info() -> dict:251 """Peek at the persisted session — cheap file read, no GPU."""252 from visualnovel.engine import session_info as _info # noqa: PLC0415253 254 return _info()255 256 @app.api(name="resume")257 @gpu258 def resume() -> dict:259 """Restore the last persisted session (paints + TTS)."""260 view = ENGINE.resume()261 if view is None:262 return {"error": "no session to resume"}263 return view.model_dump()264 265 @app.api(name="save_data")266 def save_data() -> dict:267 """Return current game state as JSON string for client-side download."""268 return {"json": ENGINE.save_data()}269 270 @app.api(name="load_file")271 @gpu272 def load_file(data: str) -> dict:273 """Restore game from a JSON string uploaded by the client."""274 return ENGINE.load_data(data).model_dump()275 276 @app.api(name="transcribe")277 @gpu278 def transcribe(audio: dict) -> dict:279 # `audio` is a Gradio FileData-like dict with a "path" key.280 path = audio["path"] if isinstance(audio, dict) else audio281 return {"text": ENGINE.transcribe(path)}282 283 # ── Debug dashboard — only registered when VN_DEBUG=1 ────────────────284 if config.DEBUG:285 import asyncio as _asyncio286 import json as _json287 288 from fastapi.responses import StreamingResponse289 290 _debug_html = Path(__file__).parent / "frontend" / "debug.html"291 292 @app.get("/debug", response_class=HTMLResponse)293 async def debug_dashboard() -> str:294 return _debug_html.read_text(encoding="utf-8")295 296 @app.get("/debug/stream")297 async def debug_stream() -> StreamingResponse:298 async def _gen():299 try:300 while True:301 yield f"data: {_json.dumps(collector.snapshot())}\n\n"302 await _asyncio.sleep(1.0)303 except _asyncio.CancelledError:304 pass305 306 return StreamingResponse(307 _gen(),308 media_type="text/event-stream",309 headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},310 )311 312 @app.get("/debug/report")313 async def debug_report() -> dict:314 collector.save_report()315 return {"status": "ok"}316 317 return app318 319 320# =========================================================================== #321# MVP fallback: plain gr.Blocks322# =========================================================================== #323def build_mvp():324 import gradio as gr325 326 def on_start(theme, tone):327 v = ENGINE.start(SetupForm(theme=theme, tone=tone))328 bg = v.backdrop_url and (config.CACHE_DIR / Path(v.backdrop_url).name)329 return str(bg) if bg else None, f"**{v.speaker}** ({v.emotion}): {v.dialogue}"330 331 def on_turn(msg):332 v = ENGINE.play_turn(msg)333 bg = v.backdrop_url and (config.CACHE_DIR / Path(v.backdrop_url).name)334 return str(bg) if bg else None, f"**{v.speaker}** ({v.emotion}): {v.dialogue}", ""335 336 with gr.Blocks(title="Ephemeral Hearts (MVP)") as demo:337 gr.Markdown("## 💕 Ephemeral Hearts — MVP loop")338 with gr.Row():339 theme = gr.Dropdown(list(config.THEMES), value="school", label="Theme")340 tone = gr.Dropdown(config.TONES, value="romantic", label="Tone")341 start_btn = gr.Button("Enter the story", variant="primary")342 scene = gr.Image(label="Scene", height=420)343 dialogue = gr.Markdown()344 with gr.Row():345 box = gr.Textbox(placeholder="Say something…", scale=4, label="")346 send = gr.Button("Speak", scale=1)347 start_btn.click(on_start, [theme, tone], [scene, dialogue])348 send.click(on_turn, [box], [scene, dialogue, box])349 box.submit(on_turn, [box], [scene, dialogue, box])350 return demo351 352 353if __name__ == "__main__":354 if config.MVP_UI:355 build_mvp().launch()356 else:357 build_server().launch(show_error=True)358 