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