diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..26736b149f33ba71b3f2f80be0e7ceb9b0b4f6ec 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,52 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +static/bg/bg_classroom.png filter=lfs diff=lfs merge=lfs -text +static/bg/bg_hallway.png filter=lfs diff=lfs merge=lfs -text +static/bg/bg_library.png filter=lfs diff=lfs merge=lfs -text +static/bg/classroom_bg.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/_preframe/marie_angry.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/_preframe/marie_embarrassed.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/_preframe/marie_laugh.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/_preframe/marie_neutral.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/_preframe/marie_sad.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/_preframe/marie_smile.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/_preframe/marie_surprised.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/marie_angry.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/marie_base.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/marie_embarrassed.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/marie_laugh.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/marie_neutral.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/marie_sad.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/marie_smile.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/marie/marie_surprised.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/_preframe/sakura_angry.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/_preframe/sakura_embarrassed.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/_preframe/sakura_laugh.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/_preframe/sakura_neutral.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/_preframe/sakura_sad.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/_preframe/sakura_smile.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/_preframe/sakura_surprised.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/sakura_angry.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/sakura_base.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/sakura_embarrassed.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/sakura_laugh.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/sakura_neutral.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/sakura_sad.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/sakura_smile.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/sakura/sakura_surprised.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/_preframe/yuki_angry.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/_preframe/yuki_embarrassed.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/_preframe/yuki_laugh.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/_preframe/yuki_neutral.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/_preframe/yuki_sad.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/_preframe/yuki_smile.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/_preframe/yuki_surprised.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/yuki_angry.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/yuki_base.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/yuki_embarrassed.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/yuki_laugh.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/yuki_neutral.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/yuki_sad.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/yuki_smile.png filter=lfs diff=lfs merge=lfs -text +static/sprites/curated/yuki/yuki_surprised.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..75d965ecfc05c633d7657924418a95c89f5ed04e --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ + +# Virtual environment +.venv/ + +# Testing +.pytest_cache/ +*.coverage +htmlcov/ + +# IDE +.claude/ +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Runtime +*.log +logs/ + +# Generated assets & runtime data (regenerable sprites/backgrounds, +# save slots, contact-sheet report) β€” kept out of version control. +static/ +saves/ +cast_report.md + +# Windows alternate-data-stream / scratch artifacts +*:Zone.Identifier +*:PG$Secure +*:Avecto.Zone.Identifier +*.bak diff --git a/.pytest_cache/.gitignore b/.pytest_cache/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..bc1a1f6167d09c909aad37280b760bb715d0f1da --- /dev/null +++ b/.pytest_cache/.gitignore @@ -0,0 +1,2 @@ +# Created by pytest automatically. +* diff --git a/.pytest_cache/CACHEDIR.TAG b/.pytest_cache/CACHEDIR.TAG new file mode 100644 index 0000000000000000000000000000000000000000..fce15ad7eaa74e5682b644c84efb75334c112f95 --- /dev/null +++ b/.pytest_cache/CACHEDIR.TAG @@ -0,0 +1,4 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by pytest. +# For information about cache directory tags, see: +# https://bford.info/cachedir/spec.html diff --git a/.pytest_cache/README.md b/.pytest_cache/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b89018ced91c0a8af7f3f23ce8901870da89f3a0 --- /dev/null +++ b/.pytest_cache/README.md @@ -0,0 +1,8 @@ +# pytest cache directory # + +This directory contains data from the pytest's cache plugin, +which provides the `--lf` and `--ff` options, as well as the `cache` fixture. + +**Do not** commit this to version control. + +See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information. diff --git a/.pytest_cache/v/cache/lastfailed b/.pytest_cache/v/cache/lastfailed new file mode 100644 index 0000000000000000000000000000000000000000..9e26dfeeb6e641a33dae4961196235bdb965b21b --- /dev/null +++ b/.pytest_cache/v/cache/lastfailed @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/.pytest_cache/v/cache/nodeids b/.pytest_cache/v/cache/nodeids new file mode 100644 index 0000000000000000000000000000000000000000..6e1317900fed150f6efcdaaa2c3a6ff4c36c15e4 --- /dev/null +++ b/.pytest_cache/v/cache/nodeids @@ -0,0 +1,76 @@ +[ + "tests/test_engine_mock.py::TestChoiceFlow::test_apply_choice_effects", + "tests/test_engine_mock.py::TestChoiceFlow::test_choice_affection_triggers", + "tests/test_engine_mock.py::TestChoiceFlow::test_choices_set_by_model", + "tests/test_engine_mock.py::TestSaveLoad::test_cast_persistence_across_saves", + "tests/test_engine_mock.py::TestSaveLoad::test_scene_state_roundtrip", + "tests/test_engine_mock.py::TestVNTurnLoop::test_cast_lock_cleanup", + "tests/test_engine_mock.py::TestVNTurnLoop::test_expression_changes_in_narration", + "tests/test_engine_mock.py::TestVNTurnLoop::test_first_turn_returns_narration", + "tests/test_engine_mock.py::TestVNTurnLoop::test_multi_turn_preserves_state", + "tests/test_engine_mock.py::TestVNTurnLoop::test_tool_calls_parsed_in_narration", + "tests/test_trace_log.py::test_mock_model_is_not_logged", + "tests/test_trace_log.py::test_real_model_turn_is_logged", + "tests/test_trace_log.py::test_trace_disabled_by_env", + "tests/test_unit.py::TestCastLock::test_check_case_insensitive", + "tests/test_unit.py::TestCastLock::test_check_nonexistent_character", + "tests/test_unit.py::TestCastLock::test_check_valid_character", + "tests/test_unit.py::TestCastLock::test_clean_noncast_dialogue", + "tests/test_unit.py::TestCastLock::test_repair_cast_violation_flags", + "tests/test_unit.py::TestCastMember::test_character_immutability", + "tests/test_unit.py::TestCastMember::test_expression_map_completeness", + "tests/test_unit.py::TestCastMember::test_get_sprite_fallback", + "tests/test_unit.py::TestCastPipeline::test_cast_character_identity", + "tests/test_unit.py::TestCastPipeline::test_cast_to_dict_roundtrip", + "tests/test_unit.py::TestCastPipeline::test_curated_cast_has_all_expressions", + "tests/test_unit.py::TestCastPipeline::test_curated_cast_sprite_paths_exist", + "tests/test_unit.py::TestCastPipeline::test_pipeline_returns_list", + "tests/test_unit.py::TestCastPipeline::test_standard_expressions_constant", + "tests/test_unit.py::TestEnterExitCharacter::test_enter_character_adds_to_present", + "tests/test_unit.py::TestEnterExitCharacter::test_enter_character_cast_lock_violation", + "tests/test_unit.py::TestEnterExitCharacter::test_enter_character_idempotent", + "tests/test_unit.py::TestEnterExitCharacter::test_enter_character_routed", + "tests/test_unit.py::TestEnterExitCharacter::test_exit_character_cast_lock_violation", + "tests/test_unit.py::TestEnterExitCharacter::test_exit_character_not_present_no_error", + "tests/test_unit.py::TestEnterExitCharacter::test_exit_character_removes_from_present", + "tests/test_unit.py::TestEnterExitCharacter::test_exit_character_routed", + "tests/test_unit.py::TestEnterExitCharacter::test_parse_enter_character", + "tests/test_unit.py::TestEnterExitCharacter::test_parse_exit_character", + "tests/test_unit.py::TestExpressionState::test_choice_expression_changes_applied", + "tests/test_unit.py::TestExpressionState::test_invalid_expression_leaves_state", + "tests/test_unit.py::TestExpressionState::test_set_expression_updates_scene_state", + "tests/test_unit.py::TestExpressionState::test_state_banner_shows_expressions", + "tests/test_unit.py::TestSaveLoadJson::test_engine_save_load_roundtrip", + "tests/test_unit.py::TestSaveLoadJson::test_load_missing_slot_raises", + "tests/test_unit.py::TestSaveLoadJson::test_savedata_dict_roundtrip", + "tests/test_unit.py::TestSceneState::test_affection_flags", + "tests/test_unit.py::TestSceneState::test_has_affection", + "tests/test_unit.py::TestSceneState::test_initial_state", + "tests/test_unit.py::TestSceneState::test_present_characters", + "tests/test_unit.py::TestSelectiveVoicing::test_dialogue_recorded_in_scene", + "tests/test_unit.py::TestSelectiveVoicing::test_no_voice_without_expression_change", + "tests/test_unit.py::TestSelectiveVoicing::test_voices_line_after_expression_change", + "tests/test_unit.py::TestSetBackgroundTool::test_parse_set_background", + "tests/test_unit.py::TestSetBackgroundTool::test_set_background_mutation", + "tests/test_unit.py::TestSetBackgroundTool::test_set_background_requires_key", + "tests/test_unit.py::TestSetBackgroundTool::test_set_background_routed", + "tests/test_unit.py::TestToolParsing::test_loads_lenient_preserves_colon_in_values", + "tests/test_unit.py::TestToolParsing::test_no_tools_returns_empty", + "tests/test_unit.py::TestToolParsing::test_offer_choices_lenient_loose_json", + "tests/test_unit.py::TestToolParsing::test_offer_choices_with_apostrophes_routes_ok", + "tests/test_unit.py::TestToolParsing::test_parse_advance_scene", + "tests/test_unit.py::TestToolParsing::test_parse_apostrophe_in_double_quoted_arg", + "tests/test_unit.py::TestToolParsing::test_parse_apostrophe_in_plain_single_quoted_arg", + "tests/test_unit.py::TestToolParsing::test_parse_apostrophe_in_trailing_single_quoted_arg", + "tests/test_unit.py::TestToolParsing::test_parse_bare_unquoted_json_value", + "tests/test_unit.py::TestToolParsing::test_parse_choices_with_apostrophes", + "tests/test_unit.py::TestToolParsing::test_parse_multiple_tools", + "tests/test_unit.py::TestToolParsing::test_parse_offer_choices", + "tests/test_unit.py::TestToolParsing::test_parse_save_checkpoint", + "tests/test_unit.py::TestToolParsing::test_parse_set_expression", + "tests/test_unit.py::TestToolRouting::test_advance_scene_mutation", + "tests/test_unit.py::TestToolRouting::test_apply_choice_effects", + "tests/test_unit.py::TestToolRouting::test_offer_choices_sets_state", + "tests/test_unit.py::TestToolRouting::test_set_expression_invalid_character", + "tests/test_unit.py::TestToolRouting::test_set_expression_valid" +] \ No newline at end of file diff --git a/README.md b/README.md index 2b9fb5ffd14cf7444885b2e48c1a350f211ec5b8..72659fca443e9708a79291d0ef6e30350fde3183 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,55 @@ --- -title: Ars Fabula Vn -emoji: πŸ“Š +title: Ars Fabula VN +emoji: 🌸 colorFrom: pink -colorTo: pink +colorTo: indigo sdk: gradio -sdk_version: 6.17.3 -python_version: '3.13' +sdk_version: 6.16.0 app_file: app.py pinned: false +license: apache-2.0 +short_description: An AI-driven visual novel β€” a small LLM writes every scene +models: + - google/gemma-3-4b-it +tags: + - build-small-hackathon --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# 🌸 Ars Fabula VN + +An AI-driven visual novel where every scene is written live by a **small +local-class LLM**. The model narrates, voices a three-character cast, +swaps expressions and backgrounds, and offers branching choices β€” through +a plain-text tool-call protocol (`[TOOL: set_expression …]`) that any +small instruct model can speak. No fine-tune, no function-calling API. + +**Track:** Thousand Token Wood Β· **Hackathon:** Build Small + +## How to play + +1. Press **Begin** on the title screen. +2. Read at your own pace β€” click / Space / Enter advances each beat. +3. Pick a choice button, or type any action you want in the free-text bar. +4. Save/Load slots persist for your session. + +## What's running + +| Piece | On this Space (ZeroGPU) | At home | +|---|---|---| +| Scene writer | `google/gemma-3-4b-it` via transformers, `@spaces.GPU` | Gemma 4 26B-A4B MoE on a custom llama.cpp fork (MTP speculative decoding) on an 8 GB GPU | +| Character sprites | Curated set (pre-baked with the pipeline below) | Live ComfyUI pipeline: face-gated base gen β†’ expression warping β†’ background removal | +| Backgrounds | Preset library (same pipeline) | Preset + live txt2img for novel scene keys | + +The engine is model-independent: the same code drives a mock model (for +tests), a llama.cpp server, or an in-process transformers model β€” picked +by `ARS_FABULA_BACKEND`. On an 8 GB card the LLM and the diffusion stack +can't co-reside, so the home setup stops/relaunches servers around image +bakes; on ZeroGPU none of that is needed β€” the GPU is granted per call. + +## Run it locally + +```bash +pip install -r requirements.txt gradio==6.16.0 +python app.py # in-process model: ARS_FABULA_BACKEND=transformers +ARS_FABULA_BACKEND=mock python app.py # no model needed +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..501c7cae9dbd7b89e1c0b97190b6b3d486e784a7 --- /dev/null +++ b/app.py @@ -0,0 +1,295 @@ +""" +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. There, the LLM runs in-process +# via transformers (+ @spaces.GPU); llama-server/ComfyUI don't exist, and +# their fallbacks (curated cast, preset backgrounds, mock voice) apply. +ON_SPACES = bool(os.getenv("SPACE_ID")) +if ON_SPACES: + os.environ.setdefault("ARS_FABULA_BACKEND", "transformers") + os.environ.setdefault("ARS_FABULA_MODEL", "google/gemma-3-4b-it") + +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 + 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