CoolFace
Apppublic

build-small-hackathon/Hackathon-IA-VisualNovel

sourceHugging Faceapache-2.0updated 3mo agoView on Hugging Face
5likes
app.py358 linesDownload Raw Back to root
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