""" Ars-Fabula Visual Novel — Gradio entrypoint. The VN UI is split across the `ui/` package: ui.styles — CSS + onload JS ui.media — data: URI image embedding ui.bootstrap — constants, initial scene, engine construction ui.screens — HTML builders for the canvas ui.beats — response → paced display beats + screen render ui.callbacks — Gradio event handlers (make_callbacks) This module only assembles the gr.Blocks layout, wires events, and launches. Run with: python app.py # production (tries real model) ARS_FABULA_BACKEND=mock python app.py # test mode (no server) ARS_FABULA_VOICE=kokoro python app.py # voiced lines via Kokoro """ from __future__ import annotations import os # ── Hugging Face Spaces / ZeroGPU ────────────────────────────────────── # SPACE_ID is set by the Spaces runtime. Two Space flavours share this file: # • Docker/T4 Space — the Dockerfile sets ARS_FABULA_BACKEND=server and # ARS_FABULA_COMFY_MODE stays "http" (a real ComfyUI subprocess runs). # • ZeroGPU Gradio Space — backend defaults to transformers below; a # subprocess can't hold a GPU here, so we drive ComfyUI in-process via # the "embed" transport (@spaces.GPU). See comfy_embed.py. ON_SPACES = bool(os.getenv("SPACE_ID")) if ON_SPACES: os.environ.setdefault("ARS_FABULA_BACKEND", "transformers") if os.environ["ARS_FABULA_BACKEND"] == "transformers": # ZeroGPU path: live cast + live novel backgrounds, both in-process. os.environ.setdefault("ARS_FABULA_COMFY_MODE", "embed") os.environ.setdefault("ARS_FABULA_BG", "comfyui") import gradio as gr from providers import get_background_provider from ui.styles import CSS, ONLOAD_JS from ui.bootstrap import make_initial_scene, DEFAULT_CAST, STATIC_DIR from ui.screens import _title_screen_html from ui.callbacks import make_callbacks def create_ui(): """Build and return the Gradio VN app.""" bg_backend = os.getenv("ARS_FABULA_BG", "preset") bg_provider = get_background_provider(bg_backend) (on_start, on_choice, on_save, on_load, on_reset, on_advance, on_free_submit, on_casting_confirm, on_casting_redo, on_menu_load) = make_callbacks(bg_provider) initial_scene = make_initial_scene() with gr.Blocks(title="Ars-Fabula VN") as app: # ── State ────────────────────────────────────────────── scene_state = gr.State(initial_scene) engine_ref = gr.State(None) cast_data = gr.State(DEFAULT_CAST) prev_bg_state = gr.State("") beats_state = gr.State([]) # ordered display beats for this turn beat_idx_state = gr.State(0) # which beat is currently shown turn_streaming = gr.State(False) # True while the model is still writing # this turn — withholds the choices/bar casting_state = gr.State(None) # interactive casting review state # ── The stage: the canvas plus the free-text bar overlaid inside its # frame. The wrapping Column is the positioned ancestor the bar # anchors to (#stage { position: relative }), so the overlay stays # docked in the canvas instead of flying across the viewport — the # failure mode of an earlier absolute version with no such ancestor. with gr.Column(elem_id="stage"): # Initial value is the TITLE SCREEN. vn_screen = gr.HTML(_title_screen_html()) # Free-text action bar — docked inside the canvas, in the same # centered column as the choices (which stack just above it) and # styled to match the choice buttons, so it feels like part of the # interaction cluster. Revealed only at the END of a turn (JS # toggles body.vn-playing once the last beat's text is shown or # choices appear), not while earlier beats are still being read. with gr.Row(elem_id="free-input-row"): free_input = gr.Textbox( scale=8, lines=1, max_lines=1, placeholder="Your choice…", label=None, show_label=False, container=False, elem_id="free-input", ) free_submit = gr.Button("➤", scale=1, size="sm", elem_id="free-submit-btn") # ── Cast Setup — MENU ONLY (renderd as HTML inside the canvas, NOT Gradio). # Shown on the main menu after Begin → Generated; hidden once a game # starts, restored on Reset (which returns to the title). # Hidden bridge controls: the in-canvas HTML buttons set these via JS # (same pattern as preset-box / player-name-box) before clicking the # hidden generate button. ── cs_n_0 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-n-0") cs_c_0 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-c-0") cs_t_0 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-t-0") cs_n_1 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-n-1") cs_c_1 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-c-1") cs_t_1 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-t-1") cs_n_2 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-n-2") cs_c_2 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-c-2") cs_t_2 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-t-2") cs_fast = gr.Checkbox(value=False, visible="hidden", elem_id="cs-fast") generate_cast_btn = gr.Button("Generate", visible="hidden", elem_id="generate-cast-btn") # ── Hidden sinks (callback outputs the JS never reads) ─── cast_panel = gr.HTML(visible=False) scene_info = gr.Markdown(visible=False) narration_log = gr.Textbox(visible=False, interactive=False) # CSS-collapsed, not visible=False — the