ArsVie commited on
Commit
b3485eb
·
verified ·
1 Parent(s): 86f68ac

Deploy Ars Fabula VN (ZeroGPU)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +49 -0
  2. .gitignore +39 -0
  3. .pytest_cache/.gitignore +2 -0
  4. .pytest_cache/CACHEDIR.TAG +4 -0
  5. .pytest_cache/README.md +8 -0
  6. .pytest_cache/v/cache/lastfailed +1 -0
  7. .pytest_cache/v/cache/nodeids +76 -0
  8. README.md +48 -6
  9. app.py +295 -0
  10. cast_pipeline.py +1382 -0
  11. model_client.py +541 -0
  12. providers.py +425 -0
  13. requirements.txt +7 -0
  14. static/bg/_placeholder.png +0 -0
  15. static/bg/bg_cafe.png +0 -0
  16. static/bg/bg_classroom.png +3 -0
  17. static/bg/bg_courtyard.png +0 -0
  18. static/bg/bg_gate.png +0 -0
  19. static/bg/bg_hallway.png +3 -0
  20. static/bg/bg_home_room.png +0 -0
  21. static/bg/bg_library.png +3 -0
  22. static/bg/bg_park.png +0 -0
  23. static/bg/bg_rooftop.png +0 -0
  24. static/bg/bg_sunset_hill.png +0 -0
  25. static/bg/classroom_bg.png +3 -0
  26. static/sprites/curated/marie/_facegate_mask.png +0 -0
  27. static/sprites/curated/marie/_preframe/marie_angry.png +3 -0
  28. static/sprites/curated/marie/_preframe/marie_embarrassed.png +3 -0
  29. static/sprites/curated/marie/_preframe/marie_laugh.png +3 -0
  30. static/sprites/curated/marie/_preframe/marie_neutral.png +3 -0
  31. static/sprites/curated/marie/_preframe/marie_sad.png +3 -0
  32. static/sprites/curated/marie/_preframe/marie_smile.png +3 -0
  33. static/sprites/curated/marie/_preframe/marie_surprised.png +3 -0
  34. static/sprites/curated/marie/marie_angry.png +3 -0
  35. static/sprites/curated/marie/marie_base.png +3 -0
  36. static/sprites/curated/marie/marie_embarrassed.png +3 -0
  37. static/sprites/curated/marie/marie_laugh.png +3 -0
  38. static/sprites/curated/marie/marie_neutral.png +3 -0
  39. static/sprites/curated/marie/marie_sad.png +3 -0
  40. static/sprites/curated/marie/marie_smile.png +3 -0
  41. static/sprites/curated/marie/marie_surprised.png +3 -0
  42. static/sprites/curated/sakura/_facegate_mask.png +0 -0
  43. static/sprites/curated/sakura/_preframe/sakura_angry.png +3 -0
  44. static/sprites/curated/sakura/_preframe/sakura_embarrassed.png +3 -0
  45. static/sprites/curated/sakura/_preframe/sakura_laugh.png +3 -0
  46. static/sprites/curated/sakura/_preframe/sakura_neutral.png +3 -0
  47. static/sprites/curated/sakura/_preframe/sakura_sad.png +3 -0
  48. static/sprites/curated/sakura/_preframe/sakura_smile.png +3 -0
  49. static/sprites/curated/sakura/_preframe/sakura_surprised.png +3 -0
  50. static/sprites/curated/sakura/sakura_angry.png +3 -0
.gitattributes CHANGED
@@ -33,3 +33,52 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ static/bg/bg_classroom.png filter=lfs diff=lfs merge=lfs -text
37
+ static/bg/bg_hallway.png filter=lfs diff=lfs merge=lfs -text
38
+ static/bg/bg_library.png filter=lfs diff=lfs merge=lfs -text
39
+ static/bg/classroom_bg.png filter=lfs diff=lfs merge=lfs -text
40
+ static/sprites/curated/marie/_preframe/marie_angry.png filter=lfs diff=lfs merge=lfs -text
41
+ static/sprites/curated/marie/_preframe/marie_embarrassed.png filter=lfs diff=lfs merge=lfs -text
42
+ static/sprites/curated/marie/_preframe/marie_laugh.png filter=lfs diff=lfs merge=lfs -text
43
+ static/sprites/curated/marie/_preframe/marie_neutral.png filter=lfs diff=lfs merge=lfs -text
44
+ static/sprites/curated/marie/_preframe/marie_sad.png filter=lfs diff=lfs merge=lfs -text
45
+ static/sprites/curated/marie/_preframe/marie_smile.png filter=lfs diff=lfs merge=lfs -text
46
+ static/sprites/curated/marie/_preframe/marie_surprised.png filter=lfs diff=lfs merge=lfs -text
47
+ static/sprites/curated/marie/marie_angry.png filter=lfs diff=lfs merge=lfs -text
48
+ static/sprites/curated/marie/marie_base.png filter=lfs diff=lfs merge=lfs -text
49
+ static/sprites/curated/marie/marie_embarrassed.png filter=lfs diff=lfs merge=lfs -text
50
+ static/sprites/curated/marie/marie_laugh.png filter=lfs diff=lfs merge=lfs -text
51
+ static/sprites/curated/marie/marie_neutral.png filter=lfs diff=lfs merge=lfs -text
52
+ static/sprites/curated/marie/marie_sad.png filter=lfs diff=lfs merge=lfs -text
53
+ static/sprites/curated/marie/marie_smile.png filter=lfs diff=lfs merge=lfs -text
54
+ static/sprites/curated/marie/marie_surprised.png filter=lfs diff=lfs merge=lfs -text
55
+ static/sprites/curated/sakura/_preframe/sakura_angry.png filter=lfs diff=lfs merge=lfs -text
56
+ static/sprites/curated/sakura/_preframe/sakura_embarrassed.png filter=lfs diff=lfs merge=lfs -text
57
+ static/sprites/curated/sakura/_preframe/sakura_laugh.png filter=lfs diff=lfs merge=lfs -text
58
+ static/sprites/curated/sakura/_preframe/sakura_neutral.png filter=lfs diff=lfs merge=lfs -text
59
+ static/sprites/curated/sakura/_preframe/sakura_sad.png filter=lfs diff=lfs merge=lfs -text
60
+ static/sprites/curated/sakura/_preframe/sakura_smile.png filter=lfs diff=lfs merge=lfs -text
61
+ static/sprites/curated/sakura/_preframe/sakura_surprised.png filter=lfs diff=lfs merge=lfs -text
62
+ static/sprites/curated/sakura/sakura_angry.png filter=lfs diff=lfs merge=lfs -text
63
+ static/sprites/curated/sakura/sakura_base.png filter=lfs diff=lfs merge=lfs -text
64
+ static/sprites/curated/sakura/sakura_embarrassed.png filter=lfs diff=lfs merge=lfs -text
65
+ static/sprites/curated/sakura/sakura_laugh.png filter=lfs diff=lfs merge=lfs -text
66
+ static/sprites/curated/sakura/sakura_neutral.png filter=lfs diff=lfs merge=lfs -text
67
+ static/sprites/curated/sakura/sakura_sad.png filter=lfs diff=lfs merge=lfs -text
68
+ static/sprites/curated/sakura/sakura_smile.png filter=lfs diff=lfs merge=lfs -text
69
+ static/sprites/curated/sakura/sakura_surprised.png filter=lfs diff=lfs merge=lfs -text
70
+ static/sprites/curated/yuki/_preframe/yuki_angry.png filter=lfs diff=lfs merge=lfs -text
71
+ static/sprites/curated/yuki/_preframe/yuki_embarrassed.png filter=lfs diff=lfs merge=lfs -text
72
+ static/sprites/curated/yuki/_preframe/yuki_laugh.png filter=lfs diff=lfs merge=lfs -text
73
+ static/sprites/curated/yuki/_preframe/yuki_neutral.png filter=lfs diff=lfs merge=lfs -text
74
+ static/sprites/curated/yuki/_preframe/yuki_sad.png filter=lfs diff=lfs merge=lfs -text
75
+ static/sprites/curated/yuki/_preframe/yuki_smile.png filter=lfs diff=lfs merge=lfs -text
76
+ static/sprites/curated/yuki/_preframe/yuki_surprised.png filter=lfs diff=lfs merge=lfs -text
77
+ static/sprites/curated/yuki/yuki_angry.png filter=lfs diff=lfs merge=lfs -text
78
+ static/sprites/curated/yuki/yuki_base.png filter=lfs diff=lfs merge=lfs -text
79
+ static/sprites/curated/yuki/yuki_embarrassed.png filter=lfs diff=lfs merge=lfs -text
80
+ static/sprites/curated/yuki/yuki_laugh.png filter=lfs diff=lfs merge=lfs -text
81
+ static/sprites/curated/yuki/yuki_neutral.png filter=lfs diff=lfs merge=lfs -text
82
+ static/sprites/curated/yuki/yuki_sad.png filter=lfs diff=lfs merge=lfs -text
83
+ static/sprites/curated/yuki/yuki_smile.png filter=lfs diff=lfs merge=lfs -text
84
+ static/sprites/curated/yuki/yuki_surprised.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+
8
+ # Virtual environment
9
+ .venv/
10
+
11
+ # Testing
12
+ .pytest_cache/
13
+ *.coverage
14
+ htmlcov/
15
+
16
+ # IDE
17
+ .claude/
18
+ .vscode/
19
+ .idea/
20
+
21
+ # OS
22
+ .DS_Store
23
+ Thumbs.db
24
+
25
+ # Runtime
26
+ *.log
27
+ logs/
28
+
29
+ # Generated assets & runtime data (regenerable sprites/backgrounds,
30
+ # save slots, contact-sheet report) — kept out of version control.
31
+ static/
32
+ saves/
33
+ cast_report.md
34
+
35
+ # Windows alternate-data-stream / scratch artifacts
36
+ *:Zone.Identifier
37
+ *:PG$Secure
38
+ *:Avecto.Zone.Identifier
39
+ *.bak
.pytest_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Created by pytest automatically.
2
+ *
.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
.pytest_cache/v/cache/lastfailed ADDED
@@ -0,0 +1 @@
 
 
1
+ {}
.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "tests/test_engine_mock.py::TestChoiceFlow::test_apply_choice_effects",
3
+ "tests/test_engine_mock.py::TestChoiceFlow::test_choice_affection_triggers",
4
+ "tests/test_engine_mock.py::TestChoiceFlow::test_choices_set_by_model",
5
+ "tests/test_engine_mock.py::TestSaveLoad::test_cast_persistence_across_saves",
6
+ "tests/test_engine_mock.py::TestSaveLoad::test_scene_state_roundtrip",
7
+ "tests/test_engine_mock.py::TestVNTurnLoop::test_cast_lock_cleanup",
8
+ "tests/test_engine_mock.py::TestVNTurnLoop::test_expression_changes_in_narration",
9
+ "tests/test_engine_mock.py::TestVNTurnLoop::test_first_turn_returns_narration",
10
+ "tests/test_engine_mock.py::TestVNTurnLoop::test_multi_turn_preserves_state",
11
+ "tests/test_engine_mock.py::TestVNTurnLoop::test_tool_calls_parsed_in_narration",
12
+ "tests/test_trace_log.py::test_mock_model_is_not_logged",
13
+ "tests/test_trace_log.py::test_real_model_turn_is_logged",
14
+ "tests/test_trace_log.py::test_trace_disabled_by_env",
15
+ "tests/test_unit.py::TestCastLock::test_check_case_insensitive",
16
+ "tests/test_unit.py::TestCastLock::test_check_nonexistent_character",
17
+ "tests/test_unit.py::TestCastLock::test_check_valid_character",
18
+ "tests/test_unit.py::TestCastLock::test_clean_noncast_dialogue",
19
+ "tests/test_unit.py::TestCastLock::test_repair_cast_violation_flags",
20
+ "tests/test_unit.py::TestCastMember::test_character_immutability",
21
+ "tests/test_unit.py::TestCastMember::test_expression_map_completeness",
22
+ "tests/test_unit.py::TestCastMember::test_get_sprite_fallback",
23
+ "tests/test_unit.py::TestCastPipeline::test_cast_character_identity",
24
+ "tests/test_unit.py::TestCastPipeline::test_cast_to_dict_roundtrip",
25
+ "tests/test_unit.py::TestCastPipeline::test_curated_cast_has_all_expressions",
26
+ "tests/test_unit.py::TestCastPipeline::test_curated_cast_sprite_paths_exist",
27
+ "tests/test_unit.py::TestCastPipeline::test_pipeline_returns_list",
28
+ "tests/test_unit.py::TestCastPipeline::test_standard_expressions_constant",
29
+ "tests/test_unit.py::TestEnterExitCharacter::test_enter_character_adds_to_present",
30
+ "tests/test_unit.py::TestEnterExitCharacter::test_enter_character_cast_lock_violation",
31
+ "tests/test_unit.py::TestEnterExitCharacter::test_enter_character_idempotent",
32
+ "tests/test_unit.py::TestEnterExitCharacter::test_enter_character_routed",
33
+ "tests/test_unit.py::TestEnterExitCharacter::test_exit_character_cast_lock_violation",
34
+ "tests/test_unit.py::TestEnterExitCharacter::test_exit_character_not_present_no_error",
35
+ "tests/test_unit.py::TestEnterExitCharacter::test_exit_character_removes_from_present",
36
+ "tests/test_unit.py::TestEnterExitCharacter::test_exit_character_routed",
37
+ "tests/test_unit.py::TestEnterExitCharacter::test_parse_enter_character",
38
+ "tests/test_unit.py::TestEnterExitCharacter::test_parse_exit_character",
39
+ "tests/test_unit.py::TestExpressionState::test_choice_expression_changes_applied",
40
+ "tests/test_unit.py::TestExpressionState::test_invalid_expression_leaves_state",
41
+ "tests/test_unit.py::TestExpressionState::test_set_expression_updates_scene_state",
42
+ "tests/test_unit.py::TestExpressionState::test_state_banner_shows_expressions",
43
+ "tests/test_unit.py::TestSaveLoadJson::test_engine_save_load_roundtrip",
44
+ "tests/test_unit.py::TestSaveLoadJson::test_load_missing_slot_raises",
45
+ "tests/test_unit.py::TestSaveLoadJson::test_savedata_dict_roundtrip",
46
+ "tests/test_unit.py::TestSceneState::test_affection_flags",
47
+ "tests/test_unit.py::TestSceneState::test_has_affection",
48
+ "tests/test_unit.py::TestSceneState::test_initial_state",
49
+ "tests/test_unit.py::TestSceneState::test_present_characters",
50
+ "tests/test_unit.py::TestSelectiveVoicing::test_dialogue_recorded_in_scene",
51
+ "tests/test_unit.py::TestSelectiveVoicing::test_no_voice_without_expression_change",
52
+ "tests/test_unit.py::TestSelectiveVoicing::test_voices_line_after_expression_change",
53
+ "tests/test_unit.py::TestSetBackgroundTool::test_parse_set_background",
54
+ "tests/test_unit.py::TestSetBackgroundTool::test_set_background_mutation",
55
+ "tests/test_unit.py::TestSetBackgroundTool::test_set_background_requires_key",
56
+ "tests/test_unit.py::TestSetBackgroundTool::test_set_background_routed",
57
+ "tests/test_unit.py::TestToolParsing::test_loads_lenient_preserves_colon_in_values",
58
+ "tests/test_unit.py::TestToolParsing::test_no_tools_returns_empty",
59
+ "tests/test_unit.py::TestToolParsing::test_offer_choices_lenient_loose_json",
60
+ "tests/test_unit.py::TestToolParsing::test_offer_choices_with_apostrophes_routes_ok",
61
+ "tests/test_unit.py::TestToolParsing::test_parse_advance_scene",
62
+ "tests/test_unit.py::TestToolParsing::test_parse_apostrophe_in_double_quoted_arg",
63
+ "tests/test_unit.py::TestToolParsing::test_parse_apostrophe_in_plain_single_quoted_arg",
64
+ "tests/test_unit.py::TestToolParsing::test_parse_apostrophe_in_trailing_single_quoted_arg",
65
+ "tests/test_unit.py::TestToolParsing::test_parse_bare_unquoted_json_value",
66
+ "tests/test_unit.py::TestToolParsing::test_parse_choices_with_apostrophes",
67
+ "tests/test_unit.py::TestToolParsing::test_parse_multiple_tools",
68
+ "tests/test_unit.py::TestToolParsing::test_parse_offer_choices",
69
+ "tests/test_unit.py::TestToolParsing::test_parse_save_checkpoint",
70
+ "tests/test_unit.py::TestToolParsing::test_parse_set_expression",
71
+ "tests/test_unit.py::TestToolRouting::test_advance_scene_mutation",
72
+ "tests/test_unit.py::TestToolRouting::test_apply_choice_effects",
73
+ "tests/test_unit.py::TestToolRouting::test_offer_choices_sets_state",
74
+ "tests/test_unit.py::TestToolRouting::test_set_expression_invalid_character",
75
+ "tests/test_unit.py::TestToolRouting::test_set_expression_valid"
76
+ ]
README.md CHANGED
@@ -1,13 +1,55 @@
1
  ---
2
- title: Ars Fabula Vn
3
- emoji: 📊
4
  colorFrom: pink
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.17.3
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Ars Fabula VN
3
+ emoji: 🌸
4
  colorFrom: pink
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 6.16.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
+ short_description: An AI-driven visual novel — a small LLM writes every scene
12
+ models:
13
+ - google/gemma-3-4b-it
14
+ tags:
15
+ - build-small-hackathon
16
  ---
17
 
18
+ # 🌸 Ars Fabula VN
19
+
20
+ An AI-driven visual novel where every scene is written live by a **small
21
+ local-class LLM**. The model narrates, voices a three-character cast,
22
+ swaps expressions and backgrounds, and offers branching choices — through
23
+ a plain-text tool-call protocol (`[TOOL: set_expression …]`) that any
24
+ small instruct model can speak. No fine-tune, no function-calling API.
25
+
26
+ **Track:** Thousand Token Wood · **Hackathon:** Build Small
27
+
28
+ ## How to play
29
+
30
+ 1. Press **Begin** on the title screen.
31
+ 2. Read at your own pace — click / Space / Enter advances each beat.
32
+ 3. Pick a choice button, or type any action you want in the free-text bar.
33
+ 4. Save/Load slots persist for your session.
34
+
35
+ ## What's running
36
+
37
+ | Piece | On this Space (ZeroGPU) | At home |
38
+ |---|---|---|
39
+ | 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 |
40
+ | Character sprites | Curated set (pre-baked with the pipeline below) | Live ComfyUI pipeline: face-gated base gen → expression warping → background removal |
41
+ | Backgrounds | Preset library (same pipeline) | Preset + live txt2img for novel scene keys |
42
+
43
+ The engine is model-independent: the same code drives a mock model (for
44
+ tests), a llama.cpp server, or an in-process transformers model — picked
45
+ by `ARS_FABULA_BACKEND`. On an 8 GB card the LLM and the diffusion stack
46
+ can't co-reside, so the home setup stops/relaunches servers around image
47
+ bakes; on ZeroGPU none of that is needed — the GPU is granted per call.
48
+
49
+ ## Run it locally
50
+
51
+ ```bash
52
+ pip install -r requirements.txt gradio==6.16.0
53
+ python app.py # in-process model: ARS_FABULA_BACKEND=transformers
54
+ ARS_FABULA_BACKEND=mock python app.py # no model needed
55
+ ```
app.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ars-Fabula Visual Novel — Gradio entrypoint.
3
+
4
+ The VN UI is split across the `ui/` package:
5
+ ui.styles — CSS + onload JS
6
+ ui.media — data: URI image embedding
7
+ ui.bootstrap — constants, initial scene, engine construction
8
+ ui.screens — HTML builders for the canvas
9
+ ui.beats — response → paced display beats + screen render
10
+ ui.callbacks — Gradio event handlers (make_callbacks)
11
+
12
+ This module only assembles the gr.Blocks layout, wires events, and launches.
13
+
14
+ Run with:
15
+ python app.py # production (tries real model)
16
+ ARS_FABULA_BACKEND=mock python app.py # test mode (no server)
17
+ ARS_FABULA_VOICE=kokoro python app.py # voiced lines via Kokoro
18
+ """
19
+ from __future__ import annotations
20
+ import os
21
+
22
+ # ── Hugging Face Spaces / ZeroGPU ──────────────────────────────────────
23
+ # SPACE_ID is set by the Spaces runtime. There, the LLM runs in-process
24
+ # via transformers (+ @spaces.GPU); llama-server/ComfyUI don't exist, and
25
+ # their fallbacks (curated cast, preset backgrounds, mock voice) apply.
26
+ ON_SPACES = bool(os.getenv("SPACE_ID"))
27
+ if ON_SPACES:
28
+ os.environ.setdefault("ARS_FABULA_BACKEND", "transformers")
29
+ os.environ.setdefault("ARS_FABULA_MODEL", "google/gemma-3-4b-it")
30
+
31
+ import gradio as gr
32
+
33
+ from providers import get_background_provider
34
+ from ui.styles import CSS, ONLOAD_JS
35
+ from ui.bootstrap import make_initial_scene, DEFAULT_CAST, STATIC_DIR
36
+ from ui.screens import _title_screen_html
37
+ from ui.callbacks import make_callbacks
38
+
39
+
40
+ def create_ui():
41
+ """Build and return the Gradio VN app."""
42
+ bg_backend = os.getenv("ARS_FABULA_BG", "preset")
43
+ bg_provider = get_background_provider(bg_backend)
44
+ (on_start, on_choice, on_save, on_load, on_reset, on_advance,
45
+ on_free_submit, on_casting_confirm, on_casting_redo,
46
+ on_menu_load) = make_callbacks(bg_provider)
47
+
48
+ initial_scene = make_initial_scene()
49
+
50
+ with gr.Blocks(title="Ars-Fabula VN") as app:
51
+ # ── State ──────────────────────────────────────────────
52
+ scene_state = gr.State(initial_scene)
53
+ engine_ref = gr.State(None)
54
+ cast_data = gr.State(DEFAULT_CAST)
55
+ prev_bg_state = gr.State("")
56
+ beats_state = gr.State([]) # ordered display beats for this turn
57
+ beat_idx_state = gr.State(0) # which beat is currently shown
58
+ casting_state = gr.State(None) # interactive casting review state
59
+
60
+ # ── The stage: the canvas plus the free-text bar overlaid inside its
61
+ # frame. The wrapping Column is the positioned ancestor the bar
62
+ # anchors to (#stage { position: relative }), so the overlay stays
63
+ # docked in the canvas instead of flying across the viewport — the
64
+ # failure mode of an earlier absolute version with no such ancestor.
65
+ with gr.Column(elem_id="stage"):
66
+ # Initial value is the TITLE SCREEN.
67
+ vn_screen = gr.HTML(_title_screen_html())
68
+
69
+ # Free-text action bar — docked inside the canvas, in the same
70
+ # centered column as the choices (which stack just above it) and
71
+ # styled to match the choice buttons, so it feels like part of the
72
+ # interaction cluster. Revealed only at the END of a turn (JS
73
+ # toggles body.vn-playing once the last beat's text is shown or
74
+ # choices appear), not while earlier beats are still being read.
75
+ with gr.Row(elem_id="free-input-row"):
76
+ free_input = gr.Textbox(
77
+ scale=8, lines=1, max_lines=1,
78
+ placeholder="Your choice…",
79
+ label=None, show_label=False, container=False,
80
+ elem_id="free-input",
81
+ )
82
+ free_submit = gr.Button("➤", scale=1, size="sm",
83
+ elem_id="free-submit-btn")
84
+
85
+ # ── Cast Setup — MENU ONLY (renderd as HTML inside the canvas, NOT Gradio).
86
+ # Shown on the main menu after Begin → Generated; hidden once a game
87
+ # starts, restored on Reset (which returns to the title).
88
+ # Hidden bridge controls: the in-canvas HTML buttons set these via JS
89
+ # (same pattern as preset-box / player-name-box) before clicking the
90
+ # hidden generate button. ──
91
+ cs_n_0 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-n-0")
92
+ cs_c_0 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-c-0")
93
+ cs_t_0 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-t-0")
94
+ cs_n_1 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-n-1")
95
+ cs_c_1 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-c-1")
96
+ cs_t_1 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-t-1")
97
+ cs_n_2 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-n-2")
98
+ cs_c_2 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-c-2")
99
+ cs_t_2 = gr.Textbox(value="", visible="hidden", interactive=True, elem_id="cs-t-2")
100
+ cs_fast = gr.Checkbox(value=False, visible="hidden", elem_id="cs-fast")
101
+ generate_cast_btn = gr.Button("Generate", visible="hidden",
102
+ elem_id="generate-cast-btn")
103
+
104
+ # ── Hidden sinks (callback outputs the JS never reads) ───
105
+ cast_panel = gr.HTML(visible=False)
106
+ scene_info = gr.Markdown(visible=False)
107
+ narration_log = gr.Textbox(visible=False, interactive=False)
108
+
109
+ # CSS-collapsed, not visible=False — the <audio> element must mount
110
+ # for autoplay to fire.
111
+ voice_audio = gr.Audio(
112
+ value=None, autoplay=True, visible=True,
113
+ interactive=False, elem_id="voice-audio", container=False,
114
+ )
115
+
116
+ # ── Hidden JS bridges ─────────────────────────────────────
117
+ # visible="hidden" keeps these in the DOM (visible=False would remove
118
+ # them and break the JS bridge)
119
+ preset_box = gr.Textbox(value="generated", visible="hidden",
120
+ interactive=True, elem_id="preset-box")
121
+ player_name_box = gr.Textbox(value="Player", visible="hidden",
122
+ interactive=True, elem_id="player-name-box")
123
+ start_btn = gr.Button("Begin", visible="hidden",
124
+ elem_id="start-btn-hidden")
125
+ save_slot = gr.Number(value=0, minimum=0, maximum=4, step=1,
126
+ precision=0, visible="hidden",
127
+ elem_id="save-slot-hidden")
128
+ save_btn = gr.Button("Save", visible="hidden", elem_id="save-btn-hidden")
129
+ load_btn = gr.Button("Load", visible="hidden", elem_id="load-btn-hidden")
130
+ reset_btn = gr.Button("Reset", visible="hidden", elem_id="reset-btn-hidden")
131
+ save_status = gr.Markdown("", visible="hidden",
132
+ elem_id="save-status-hidden")
133
+ # Save-name bridge (the in-panel name input writes here before Save) +
134
+ # the slot-rows mirror JS copies into the open panel after a save.
135
+ save_name_box = gr.Textbox(value="", visible="hidden", interactive=True,
136
+ elem_id="save-name-box")
137
+ slots_meta = gr.HTML("", visible="hidden", elem_id="slots-meta-hidden")
138
+ # Main-menu Load: clicked by the title-screen load list via JS.
139
+ menu_load_btn = gr.Button("Menu Load", visible="hidden",
140
+ elem_id="menu-load-btn")
141
+
142
+ choice_input = gr.Textbox(visible="hidden", interactive=True,
143
+ elem_id="choice-id-box")
144
+ choice_text_input = gr.Textbox(visible="hidden", interactive=True,
145
+ elem_id="choice-text-box")
146
+ submit_choice_btn = gr.Button("Submit Choice", visible="hidden",
147
+ elem_id="submit-choice-btn")
148
+ # Hidden "next beat" trigger — clicked by the screen-click / Space-Enter
149
+ # JS hook when there is more text to read this turn.
150
+ advance_btn = gr.Button("Advance", visible="hidden",
151
+ elem_id="advance-btn")
152
+
153
+ # Hidden state for free-text fallback choice id
154
+ free_choice_id = gr.Textbox(value="__free__", visible="hidden",
155
+ elem_id="free-choice-id")
156
+
157
+ # ── Casting review bridge widgets ──────────────────────
158
+ casting_prompt_box = gr.Textbox(visible="hidden", interactive=True,
159
+ elem_id="casting-prompt-box")
160
+ casting_confirm_btn = gr.Button("Confirm", visible="hidden",
161
+ elem_id="casting-confirm-btn")
162
+ casting_redo_btn = gr.Button("Redo", visible="hidden",
163
+ elem_id="casting-redo-btn")
164
+
165
+ main_outputs = [scene_state, engine_ref, vn_screen, cast_data,
166
+ cast_panel, narration_log, scene_info,
167
+ voice_audio, prev_bg_state]
168
+ # Turn callbacks also carry the beat list + index (read-at-your-own-pace).
169
+ turn_outputs = main_outputs + [beats_state, beat_idx_state]
170
+ # Start + casting confirm carry the interactive review state as well.
171
+ start_outputs = turn_outputs + [casting_state]
172
+
173
+ # ── Event wiring ───────────────────────────────────────
174
+ # Two entry points into on_start, both reading the preset:
175
+ # • start_btn — clicked by Begin for the CURATED path (no setup step).
176
+ # • generate_cast_btn — clicked from the Cast Setup step (GENERATED).
177
+ # Cast Setup's visibility is driven entirely by JS (body.cast-setup-active
178
+ # + CSS), so no Gradio show/hide is needed here.
179
+ # show_progress="hidden" everywhere: Gradio's default queue overlay dims
180
+ # the whole canvas (.pending → opacity 0.2) and slides a dark .eta-bar
181
+ # across it via a transform — which read as the entire UI (sprites
182
+ # included) "dragging" diagonally while a turn was processing.
183
+ _start_inputs = [preset_box, casting_state, player_name_box,
184
+ cs_n_0, cs_c_0, cs_t_0,
185
+ cs_n_1, cs_c_1, cs_t_1,
186
+ cs_n_2, cs_c_2, cs_t_2,
187
+ cs_fast]
188
+ start_btn.click(fn=on_start, inputs=_start_inputs, outputs=start_outputs,
189
+ show_progress="hidden")
190
+ generate_cast_btn.click(fn=on_start, inputs=_start_inputs, outputs=start_outputs,
191
+ show_progress="hidden")
192
+
193
+ submit_choice_btn.click(
194
+ fn=on_choice,
195
+ inputs=[choice_input, choice_text_input, scene_state, engine_ref,
196
+ prev_bg_state, narration_log],
197
+ outputs=turn_outputs,
198
+ show_progress="hidden",
199
+ )
200
+
201
+ # Advance one beat: re-render the screen, bump the index, chain the
202
+ # crossfade (prev_bg), and play that beat's voiced line if any.
203
+ advance_btn.click(
204
+ fn=on_advance,
205
+ inputs=[beats_state, beat_idx_state, scene_state, prev_bg_state,
206
+ narration_log],
207
+ outputs=[vn_screen, beat_idx_state, voice_audio, scene_info, prev_bg_state],
208
+ show_progress="hidden",
209
+ )
210
+
211
+ save_btn.click(fn=on_save,
212
+ inputs=[save_slot, save_name_box, narration_log,
213
+ scene_state, engine_ref],
214
+ outputs=[save_status, slots_meta], show_progress="hidden")
215
+ load_btn.click(fn=on_load,
216
+ inputs=[save_slot, scene_state, engine_ref, prev_bg_state],
217
+ outputs=turn_outputs + [save_status],
218
+ show_progress="hidden")
219
+ menu_load_btn.click(fn=on_menu_load, inputs=[save_slot],
220
+ outputs=turn_outputs + [save_status],
221
+ show_progress="hidden")
222
+
223
+ reset_btn.click(fn=on_reset, outputs=turn_outputs, show_progress="hidden")
224
+
225
+ # ── Casting review — confirm advances to next character ──
226
+ casting_confirm_btn.click(
227
+ fn=on_casting_confirm,
228
+ inputs=[casting_state, casting_prompt_box, scene_state, engine_ref, prev_bg_state],
229
+ outputs=start_outputs,
230
+ show_progress="hidden",
231
+ )
232
+ # ── Casting review — redo regenerates current base ────
233
+ redo_outputs = [vn_screen, scene_info, casting_state, casting_prompt_box]
234
+ casting_redo_btn.click(
235
+ fn=on_casting_redo,
236
+ inputs=[casting_state, casting_prompt_box],
237
+ outputs=redo_outputs,
238
+ show_progress="hidden",
239
+ )
240
+
241
+ # ── Free-text action (fallback when no choices, or "Write your
242
+ # own action…"). Clears the box server-side via the extra output. ─
243
+ # Both the ➤ button and Enter (via JS bridge → clicks this button)
244
+ # route here; clears the box server-side via the extra output.
245
+ free_outputs = turn_outputs + [free_input]
246
+ free_submit.click(
247
+ fn=on_free_submit,
248
+ inputs=[free_input, scene_state, engine_ref, prev_bg_state,
249
+ narration_log],
250
+ outputs=free_outputs,
251
+ show_progress="hidden",
252
+ )
253
+
254
+ return app
255
+
256
+
257
+ # ═══════════════════════════════════════════════════════════════════════
258
+ # Main
259
+ # ═══════════════════════════════════════════════════════════════════════
260
+
261
+ if __name__ == "__main__":
262
+ import sys
263
+
264
+ port = int(os.getenv("GRADIO_PORT", "7861"))
265
+ if len(sys.argv) > 1:
266
+ try:
267
+ port = int(sys.argv[1])
268
+ except ValueError:
269
+ pass
270
+
271
+ if ON_SPACES:
272
+ # ZeroGPU wants the model loaded (and placed on cuda, under its
273
+ # emulation layer) at startup, not lazily inside a request.
274
+ from model_client import preload_transformers
275
+ preload_transformers()
276
+ port = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
277
+
278
+ app = create_ui()
279
+ print(f"\n🌸 Ars-Fabula VN starting on http://127.0.0.1:{port}")
280
+ print(f" Mock mode: {os.getenv('ARS_FABULA_BACKEND', 'server') == 'mock'}")
281
+ print(f" Voice: {os.getenv('ARS_FABULA_VOICE', 'mock')} · "
282
+ f"BG: {os.getenv('ARS_FABULA_BG', 'preset')}\n")
283
+ app.launch(
284
+ server_name="0.0.0.0" if ON_SPACES else "127.0.0.1",
285
+ server_port=port,
286
+ allowed_paths=[STATIC_DIR], # belt-and-braces; images are data URIs anyway
287
+ css=CSS, # Gradio 6: css/js/theme live on launch()
288
+ js=ONLOAD_JS,
289
+ theme=gr.themes.Base(
290
+ primary_hue="amber",
291
+ neutral_hue="slate",
292
+ ),
293
+ quiet=False,
294
+ share=False,
295
+ )
cast_pipeline.py ADDED
@@ -0,0 +1,1382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cast_pipeline.py — Generate or load character casts for the VN.
3
+
4
+ Pipeline (mirrors the VALIDATED ComfyUI workflow
5
+ `tools/workflows/anima_expression_sheet.json` — tested by hand on this
6
+ machine's ComfyUI install):
7
+ Anima base gen (UNETLoader anima-base-v1.0 + Qwen CLIP/VAE)
8
+ → face detect gate (face_yolov8n via Impact-Pack; retry on miss)
9
+ → FaceDetailer per expression — only the face region is
10
+ re-generated with the expression prompt (denoise 0.55; the sheet's
11
+ note suggests 0.4–0.7, lower = subtler), so body/clothes/background
12
+ stay identical and identity holds
13
+ → rembg isnet-anime matte → alpha sprites
14
+
15
+ Two modes:
16
+ 1. GENERATE (default): requires ComfyUI at localhost:8188 with the Anima
17
+ model and Impact-Pack (UltralyticsDetectorProvider, FaceDetailer, SAM).
18
+ 2. CURATED: loads pre-made sprites from static/sprites/curated/<name>/.
19
+
20
+ Same return type either way — list[CastMember] (frozen Wave 0.5 contract:
21
+ generate_cast(prefs) -> list[CastMember]) — so nothing downstream changes.
22
+ Use vn_contracts.cast_to_dict() to build SceneState's name->member dict.
23
+
24
+ Usage:
25
+ from cast_pipeline import generate_cast, load_curated_cast
26
+ from vn_contracts import cast_to_dict
27
+
28
+ cast = generate_cast({"mood": "romance", "preset": "childhood_friends"})
29
+ scene_cast = cast_to_dict(cast)
30
+ """
31
+ from __future__ import annotations
32
+ import json
33
+ import os
34
+ import sys
35
+ import time
36
+ import urllib.request
37
+ import urllib.error
38
+ import shutil
39
+ import re
40
+ from typing import Optional
41
+
42
+ from vn_contracts import Character, CastMember, STANDARD_EXPRESSIONS
43
+
44
+ # ── Constants ─────────────────────────────────────────────────────────
45
+ # Override with ARS_FABULA_COMFY if ComfyUI runs elsewhere (e.g. on the
46
+ # Windows side of WSL: start it with `--listen 0.0.0.0` and point this at
47
+ # the Windows host IP).
48
+ COMFY_HOST = os.getenv("ARS_FABULA_COMFY", "http://127.0.0.1:8188").rstrip("/")
49
+ COMFY_OUTPUT = os.path.expanduser("~/comfy/ComfyUI/output") # legacy; results now fetched via /view
50
+ PROJECT_DIR = os.path.dirname(__file__)
51
+ CURATED_DIR = os.path.join(PROJECT_DIR, "static", "sprites", "curated")
52
+ GENERATED_DIR = os.path.join(PROJECT_DIR, "static", "sprites", "generated")
53
+
54
+ # Anima-tuned (per the June 2026 ADT/LDG synthesis): pony/score_ quality
55
+ # tags DEGRADE Anima output; `absurdres` is the only quality tag that helps.
56
+ # `safe` is the SFW content rating. (Override via env if you re-tune.)
57
+ QUALITY = os.getenv("ARS_FABULA_QUALITY", "absurdres")
58
+ NEGATIVE = os.getenv("ARS_FABULA_NEGATIVE",
59
+ "worst quality, low quality, blurry, jpeg artifacts, "
60
+ "sepia, bad anatomy, ugly, deformed")
61
+
62
+ # Anima sampler/scheduler. dpm_2_ancestral won the A/B sweep on this
63
+ # install (tools/sampler_sweep.py — best subjective quality). Env-overridable
64
+ # to retest others without code edits, e.g.:
65
+ # ARS_FABULA_SAMPLER=flow_euler python app.py
66
+ SAMPLER = os.getenv("ARS_FABULA_SAMPLER", "dpm_2_ancestral")
67
+ SCHEDULER = os.getenv("ARS_FABULA_SCHEDULER", "simple")
68
+
69
+ # Fixed generation seed — every base + expression starts here, so the whole
70
+ # cast is reproducible run-to-run. Env-overridable. The face-gate / expression
71
+ # retries still bump it locally (+attempt) to self-heal a bad draw.
72
+ SEED = int(os.getenv("ARS_FABULA_SEED", "42"))
73
+
74
+ # Shared style block — prepended to EVERY prompt (cast + backgrounds) so the
75
+ # whole game shares one look. Generic style descriptors by default; add
76
+ # `@artistname` danbooru artist tags here (or via env) to narrow further.
77
+ STYLE_TAGS = os.getenv(
78
+ "ARS_FABULA_STYLE",
79
+ "official art, light novel illustration style, clean lineart, "
80
+ "soft cel shading, pastel color palette, soft lighting, "
81
+ "shiny detailed eyes, smooth shading",
82
+ )
83
+
84
+ # Full-body base canvas (taller than the old 512x768). We generate the whole
85
+ # body for CONSISTENT framing, then crop the upper portion — full-body
86
+ # composition lands the head at a consistent place/scale across characters,
87
+ # which upper-body framing did not.
88
+ BASE_WIDTH, BASE_HEIGHT = 512, 896
89
+
90
+ # Fast generation: bake the base at a LOWER RESOLUTION (same 4:7 aspect) for a
91
+ # much quicker cast. Also uses lighter FaceDetailer parameters (fewer steps,
92
+ # smaller guide/max size, euler sampler) so both the base gen and expression
93
+ # pass are faster. Toggled per run from the UI via set_fast_generation(); fast
94
+ # dims are env-overridable.
95
+ FAST_GENERATION = False
96
+ FAST_BASE_WIDTH = int(os.getenv("ARS_FABULA_FAST_WIDTH", "384"))
97
+ FAST_BASE_HEIGHT = int(os.getenv("ARS_FABULA_FAST_HEIGHT", "672"))
98
+
99
+ # Expression FaceDetailer params are now UNIFIED across all modes (fast and
100
+ # standard). The only mode difference is base canvas resolution — see
101
+ # _base_dims(). These lighter params (euler, 15 steps, guide_size=256) come
102
+ # from the user's validated expression sheet; the old heavier config
103
+ # (dpm_2_ancestral, 20 steps, guide_size=384) added cost with no quality gain.
104
+
105
+
106
+ def set_fast_generation(enabled: bool) -> None:
107
+ """Enable/disable the lower-resolution fast bake for subsequent runs."""
108
+ global FAST_GENERATION
109
+ FAST_GENERATION = bool(enabled)
110
+
111
+
112
+ def _base_dims() -> tuple[int, int]:
113
+ """Base canvas size for the current mode (fast = lower resolution)."""
114
+ if FAST_GENERATION:
115
+ return FAST_BASE_WIDTH, FAST_BASE_HEIGHT
116
+ return BASE_WIDTH, BASE_HEIGHT
117
+
118
+
119
+ def _char_dir(key: str) -> str:
120
+ """Per-character generated-sprite folder for the current mode. Fast (low-
121
+ res) bakes use a separate '<key>_fast' folder so they never collide with —
122
+ or get silently reused as — the full-resolution set, and vice versa."""
123
+ return os.path.join(GENERATED_DIR, f"{key}_fast" if FAST_GENERATION else key)
124
+
125
+
126
+ # Fraction of the (matted) image height kept for the on-screen sprite,
127
+ # measured DOWN from the head crown. ~0.62 = head + torso (waist-up).
128
+ UPPER_BODY_FRAC = float(os.getenv("ARS_FABULA_SPRITE_FRAC", "0.62"))
129
+
130
+ # Face-anchored ZOOMED framing — equal faces, cropped legs, real height gaps.
131
+ # Using the face-gate mask, every sprite is scaled so the detected FACE is the
132
+ # SAME fraction of the SCENE height (FACE_TARGET_FRAC), then composited so the
133
+ # FEET sit FEET_DROP_FACES face-heights BELOW the canvas bottom (the lower legs
134
+ # crop off-screen — the zoom). Equal faces + a shared feet baseline mean a
135
+ # character drawn taller (smaller face ⇒ more face-heights of body) still has
136
+ # their head reach higher in the frame, so implied heights survive the zoom.
137
+ # All sprites share one pixel size, so the UI's uniform height-lock scales them
138
+ # identically. Falls back to the crude alpha-crown crop when no mask exists.
139
+ #
140
+ # GEOMETRY LIMIT: the visible span is (figure-height-in-faces − FEET_DROP_FACES)
141
+ # × FACE_TARGET_FRAC of the scene. The current cast measures 7.6–8.3 faces tall,
142
+ # so 0.13 is the largest face fraction that keeps every crown on-screen with
143
+ # feet at −2 faces; raising it toward 1/5 crops heads at the canvas top.
144
+ FACE_TARGET_FRAC = float(os.getenv("ARS_FABULA_FACE_TARGET", "0.13")) # face height ÷ SCENE height
145
+ FEET_DROP_FACES = float(os.getenv("ARS_FABULA_FEET_DROP", "2.0")) # feet this many face-heights below the bottom
146
+ UI_SPRITE_FRAC = 0.86 # .vn-sprite { height: 86% } in ui/styles.py — canvas→scene factor
147
+ SPRITE_CANVAS_MULT = float(os.getenv("ARS_FABULA_SPRITE_CANVAS", "1.4")) # output canvas size ÷ base size (headroom for tall figures)
148
+
149
+ # ── Sprite pose block (researched danbooru framing/pose tags) ─────────
150
+ # Locked onto EVERY identity prompt — but ONLY the essentials: framing,
151
+ # eye contact, and a mattable background. Body attitude (slight 3/4
152
+ # angle, hand on hip, arms crossed…) is character design and belongs in
153
+ # the appearance tags; VN sprites are rarely dead-frontal.
154
+ # upper body — waist-up framing (the VN sprite standard;
155
+ # cowboy_shot = mid-thigh if taller sprites wanted)
156
+ # standing, looking at viewer — sprite stance + player eye contact
157
+ # (eye contact holds even at a slight body angle)
158
+ # solo — exactly one character
159
+ # simple/white background — clean rembg matte
160
+ # Hard angle failures (profile, from behind/above/below) are handled by
161
+ # SPRITE_NEGATIVE instead of over-constraining the positive prompt.
162
+ # NOTE: NO weighted tags — (tag:weight) emphasis is inconsistent on Anima,
163
+ # so everything here is plain comma-separated tags.
164
+ SPRITE_POSE_TAGS = ("solo, full body, standing, full length, "
165
+ "looking at viewer, calm expression, "
166
+ "simple background, white background")
167
+
168
+ # Framing failure modes, negated for BASE generation only (the FaceDetailer
169
+ # pass keeps the plain NEGATIVE — these would fight it). We now WANT full
170
+ # body, so the old full-body/legs/cowboy negatives are gone; we still kill
171
+ # wrong camera angles, multi-character, and Anima's "coomer bias"
172
+ # (exaggerated proportions, ADT #106) so the school cast stays normal.
173
+ SPRITE_NEGATIVE = (f"{NEGATIVE}, close-up, portrait, profile, from behind, "
174
+ "from above, from below, dutch angle, multiple views, "
175
+ "multiple girls, multiple boys, 2girls, 2boys, "
176
+ "text, watermark, signature, out of frame")
177
+
178
+
179
+ def _spaces_not_underscores(tags: str) -> str:
180
+ """Underscores break Anima's tag parser — normalize to spaces.
181
+ (The LLM designer can emit canonical danbooru tags like
182
+ 'looking_at_viewer'; convert them.)"""
183
+ return tags.replace("_", " ")
184
+
185
+
186
+ def build_identity_prompt(appearance_tags: str) -> str:
187
+ """Quality + style + the character's APPEARANCE tags + the locked pose.
188
+
189
+ appearance_tags should describe only the character (1girl/1boy, hair,
190
+ eyes, build, clothing, accessories, aura) — pose/framing/background
191
+ come from SPRITE_POSE_TAGS and must not be duplicated or contradicted.
192
+ """
193
+ tags = _spaces_not_underscores(appearance_tags.strip().rstrip(","))
194
+ return f"{QUALITY}, {STYLE_TAGS}. {tags}, {SPRITE_POSE_TAGS}"
195
+
196
+
197
+ def _face_anchor(identity_or_tags: str) -> str:
198
+ """Pull the must-preserve face traits (eye + hair colour) out of a
199
+ character's tags, so the FaceDetailer keeps them across expressions.
200
+
201
+ Returns e.g. "red eyes, silver hair" (either part may be missing)."""
202
+ parts = []
203
+ m = re.search(r'([a-z]+(?:\s[a-z]+)?)\s+eyes', identity_or_tags.lower())
204
+ if m:
205
+ parts.append(f"{m.group(1).strip()} eyes")
206
+ m = re.search(r'([a-z]+(?:\s[a-z]+)?)\s+hair', identity_or_tags.lower())
207
+ if m:
208
+ parts.append(f"{m.group(1).strip()} hair")
209
+ return ", ".join(parts)
210
+
211
+ # Face detector (Impact-Pack UltralyticsDetectorProvider model) — taken
212
+ # verbatim from the validated expression sheet, which confirmed it detects
213
+ # Anima-style faces on this install. If detection ever misses, try an
214
+ # anime-specific model (e.g. yolov8x6_animeface.pt) here.
215
+ ANIME_FACE_MODEL = "bbox/face_yolov8n.pt"
216
+
217
+ # How many times to regenerate the base (seed bump) if no anime face is found
218
+ FACE_GATE_RETRIES = 2
219
+
220
+ # ── Preset character templates ────────────────────────────────────────
221
+
222
+ PRESET_CHARACTERS: dict[str, dict] = {
223
+ "yuki": {
224
+ "identity_prompt": build_identity_prompt(
225
+ "1girl, yuki tanaka, long silver hair, straight hair, "
226
+ "red eyes, fair skin, flat chest, high school uniform, white shirt, "
227
+ "blue skirt, red ribbon, gentle expression, quiet reserved aura, "
228
+ "soft lighting"
229
+ ),
230
+ "personality": "Quiet and reserved but a deeply caring girl. Speaks in short sentences. "
231
+ "Notices small details about others. Opens up slowly. "
232
+ "Has a dry sense of humor that surprises people.",
233
+ "voice_id": "af_bella",
234
+ "role": "classmate",
235
+ "seed": SEED,
236
+ },
237
+ "marie": {
238
+ "identity_prompt": build_identity_prompt(
239
+ "1girl, marie, long blonde hair, drill hair, blue eyes, "
240
+ "fair skin, elegant, high school uniform, white shirt, "
241
+ "blue skirt, red ribbon, refined posture, graceful aura, "
242
+ "proud expression, ojou-sama"
243
+ ),
244
+ "personality": "A refined, well-bred ojou-sama from old money. Proud and "
245
+ "elegant, with a signature 'ohohoho' laugh behind a raised "
246
+ "hand. Competitive and a touch haughty, but genuinely caring "
247
+ "underneath — she'd never admit how much she enjoys company.",
248
+ "voice_id": "af_sarah",
249
+ "role": "ojou-sama",
250
+ "seed": SEED,
251
+ },
252
+ "sakura": {
253
+ "identity_prompt": build_identity_prompt(
254
+ "1girl, long pink hair, side ponytail, "
255
+ "green eyes, fair skin, freckles, high school uniform, white shirt, "
256
+ "pink bow, cheerful genki energy, bubbly"
257
+ ),
258
+ "personality": "Cheerful and energetic genki girl. Always smiling. "
259
+ "Best friend energy. Gives great hugs. "
260
+ "Perceptive despite her bubbly exterior.",
261
+ "voice_id": "af_sky",
262
+ "role": "best friend",
263
+ "seed": SEED,
264
+ },
265
+ "ryo": {
266
+ "identity_prompt": build_identity_prompt(
267
+ "1boy, ryo akira, short blue hair, sharp eyes, "
268
+ "tall build, high school uniform, blazer, "
269
+ "cool aura, sharp gaze, composed"
270
+ ),
271
+ "personality": "Cool and aloof on the surface. Secretly a huge romantic. "
272
+ "Gives surprisingly good advice. "
273
+ "Has a hidden soft spot for cute things.",
274
+ "voice_id": "am_adam", # Kokoro male voice
275
+ "role": "senpai",
276
+ "seed": SEED,
277
+ },
278
+ }
279
+
280
+ # Expression prompts fed to the FaceDetailer. Each is engineered to read
281
+ # DISTINCTLY from the others along three axes — eyes, mouth, brows — plus a
282
+ # signature effect, so the 7 don't blur together:
283
+ # eyes: open / soft / >_< squint / wide+shrunk / averted
284
+ # mouth: closed / slight / open grin / frown / wavy
285
+ # extra: blush / tears / anger vein / sweatdrops
286
+ # NOTE on closed eyes: the face detector runs on the open-eyed BASE image,
287
+ # THEN inpaints the expression — so closed/squint eyes in the prompt do NOT
288
+ # break detection (earlier worry was wrong). Plain tags only (no weights).
289
+ EXPRESSION_TAGS = {
290
+ # "neutral" is the DEFAULT sprite (get_sprite falls back here) AND a callable
291
+ # emotion — deadpan/stoic characters need a true flat affect. We avoid the bare
292
+ # token "neutral expression" (Anima reads it as grumpy → angry/sad drift) and use
293
+ # "expressionless" with relaxed brows + closed mouth so it stays flat, not cross.
294
+ "neutral": "expressionless, calm, looking at viewer, relaxed, closed mouth, level eyebrows",
295
+ # SMILE = Passive Contentment (Eyes wide and calm, subtle lip curve)
296
+ "smile": "gentle smile, parted lips, pleasant expression, calm eyes, looking at viewer, serene",
297
+ # LAUGH = Dynamic Joy (Eyes naturally squinting/crinkling from a wide, open-mouthed grin)
298
+ "laugh": "joyful laughter, wide open mouth, laughing expression, squinting eyes, crinkling eyes, big happy grin, radiant smile, elevated cheeks",
299
+ "sad": "sad expression, deep frown, downturned mouth, worried eyebrows, downcast eyes, casting eyes downward, distressed",
300
+ "angry": "angry glare, slanting v-shaped eyebrows, furrowed brow, gritting teeth, tight jaw, intense eyes, looking at viewer",
301
+ "surprised": "surprised expression, wide-eyed, shocked, shrunken pupils, slightly open gasping mouth, raised elevated eyebrows",
302
+ "embarrassed": "embarrassed, heavy blush, (@_@), spiral eyes, averted eyes, flying sweatdrops, nervous",
303
+ }
304
+
305
+ # Per-expression NEGATIVES, appended to the plain NEGATIVE for the
306
+ # FaceDetailer pass. Each kills that expression's observed failure mode —
307
+ # drifting back toward the base's calm face (laugh rendering as a mild
308
+ # closed-mouth smile, sad as neutral-pleasant, surprised as calm) — without
309
+ # constraining the rest of the set.
310
+ EXPRESSION_NEGATIVE_TAGS = {
311
+ "laugh": "closed mouth, expressionless, neutral expression, frown, slight smile",
312
+ "sad": "smile, grin, laughing, happy, cheerful, expressionless",
313
+ "surprised": "smile, expressionless, calm, half-closed eyes, closed eyes, closed mouth",
314
+ "angry": "smile, happy, expressionless, calm",
315
+ }
316
+
317
+ # FaceDetailer strength — the sheet's note: 0.4–0.7, lower = subtler.
318
+ EXPRESSION_DENOISE = 0.6
319
+ # The big-delta expressions need a stronger inpaint to actually commit
320
+ # (at 0.6 they often kept too much of the base's calm face — the source of
321
+ # inconsistent laugh/sad/surprised sets); 0.7 is the top of the sheet's
322
+ # recommended range, identity still held by the anchor + face-only mask.
323
+ EXPRESSION_DENOISE_OVERRIDES = {
324
+ "laugh": 0.7,
325
+ "sad": 0.68,
326
+ "surprised": 0.7,
327
+ }
328
+
329
+ # Model files (must match the names installed in ComfyUI — taken verbatim
330
+ # from the validated workflow).
331
+ UNET_NAME = "anima-base-v1.0-Q5_K_M.gguf"
332
+ CLIP_NAME = "qwen_3_06b_base.safetensors"
333
+ VAE_NAME = "qwen_image_vae.safetensors"
334
+ SAM_NAME = "sam_vit_b_01ec64.pth"
335
+
336
+
337
+ # ═══════════════════════════════════════════════════════════════════════
338
+ # Character design — concept phrase -> appearance tags (LLM or heuristic)
339
+ # ═══════════════════════════════════════════════════════════════════════
340
+
341
+ FEMALE_VOICES = ["af_bella", "af_sky", "af_nicole", "af_sarah"]
342
+ MALE_VOICES = ["am_michael", "am_adam"]
343
+
344
+ # Keyword fallbacks for when no LLM is reachable — coarse but coherent.
345
+ _CONCEPT_DESIGNS = [
346
+ (("childhood friend",), "girl",
347
+ "1girl, brown hair, medium hair, ahoge, warm brown eyes, fair skin, "
348
+ "school uniform, white shirt, red ribbon, hands behind back, "
349
+ "cheerful aura, approachable", "childhood friend"),
350
+ (("genki", "energetic", "cheerful"), "girl",
351
+ "1girl, orange hair, short hair, bright eyes, fang, school uniform, "
352
+ "loose socks, hand on hip, energetic aura, sporty", "genki classmate"),
353
+ (("shy", "bookworm", "quiet", "library"), "girl",
354
+ "1girl, dark blue hair, long hair, blunt bangs, glasses, purple eyes, "
355
+ "cardigan, school uniform, holding book, head tilt, timid aura",
356
+ "shy bookworm"),
357
+ (("cool", "aloof", "senpai", "stoic"), "boy",
358
+ "1boy, black hair, short hair, sharp grey eyes, tall, school uniform, "
359
+ "blazer, hands in pockets, composed, cool aura", "cool senpai"),
360
+ (("delinquent", "rebel", "punk"), "boy",
361
+ "1boy, blonde hair, slicked back hair, sharp eyes, open collar, "
362
+ "school uniform, untucked shirt, piercing, hands in pockets, "
363
+ "rough aura", "delinquent"),
364
+ (("class rep", "president", "strict"), "girl",
365
+ "1girl, black hair, long hair, hime cut, serious eyes, school uniform, "
366
+ "armband, own hands together, neat, dignified aura",
367
+ "class representative"),
368
+ (("gyaru", "gal",), "girl",
369
+ "1girl, blonde hair, long wavy hair, tan skin, nail polish, "
370
+ "school uniform, loose ribbon, accessories, hand on hip, "
371
+ "confident aura", "gyaru"),
372
+ (("athlete", "sporty", "club"), "boy",
373
+ "1boy, brown hair, very short hair, tan skin, athletic build, "
374
+ "school uniform, sports towel, arms crossed, energetic aura",
375
+ "athlete"),
376
+ ]
377
+
378
+ _DEFAULT_DESIGN = ("girl",
379
+ "1girl, {hair} hair, expressive eyes, fair skin, "
380
+ "school uniform, white shirt",
381
+ "classmate")
382
+ _HAIR_ROTATION = ["chestnut", "ash blonde", "teal", "auburn", "lavender", "black"]
383
+
384
+
385
+ def _stable_seed(*parts: str) -> int:
386
+ import zlib
387
+ return zlib.crc32("|".join(parts).encode("utf-8")) & 0x7FFFFFFF
388
+
389
+
390
+ def _heuristic_design(name: str, concept: str) -> dict:
391
+ """No-LLM fallback: map concept keywords to a coherent design."""
392
+ low = (concept or "").lower()
393
+ if any(k in low for k in ("boy", "male", "guy", "him", "prince", "butler")):
394
+ forced_gender = "boy"
395
+ elif any(k in low for k in ("girl", "female", "her", "princess", "maid")):
396
+ forced_gender = "girl"
397
+ else:
398
+ forced_gender = None
399
+
400
+ for keywords, gender, tags, role in _CONCEPT_DESIGNS:
401
+ if any(k in low for k in keywords):
402
+ if forced_gender and forced_gender != gender:
403
+ tags = tags.replace("1girl", "1boy") if forced_gender == "boy" \
404
+ else tags.replace("1boy", "1girl")
405
+ gender = forced_gender
406
+ return {"gender": gender, "tags": tags, "role": role,
407
+ "personality": concept or f"{name} — {role}."}
408
+
409
+ gender, tags, role = _DEFAULT_DESIGN
410
+ gender = forced_gender or gender
411
+ hair = _HAIR_ROTATION[_stable_seed(name, concept) % len(_HAIR_ROTATION)]
412
+ tags = tags.format(hair=hair)
413
+ if gender == "boy":
414
+ tags = tags.replace("1girl", "1boy")
415
+ return {"gender": gender, "tags": tags, "role": role,
416
+ "personality": concept or f"{name}, a {role}."}
417
+
418
+
419
+ def design_character(name: str, concept: str) -> dict:
420
+ """Concept phrase -> {gender, tags, personality, role}.
421
+
422
+ Uses the story LLM when reachable (CHARACTER_DESIGNER_PROMPT,
423
+ strict-JSON reply); falls back to the keyword heuristics so the
424
+ casting beat never blocks on a missing server.
425
+ """
426
+ try:
427
+ from model_client import get_model, ModelConfig
428
+ from vn_prompt import CHARACTER_DESIGNER_PROMPT
429
+ cfg = ModelConfig.from_env()
430
+ if cfg.backend != "mock":
431
+ client = get_model(cfg)
432
+ if client.health():
433
+ raw = client.generate([
434
+ {"role": "system", "content": CHARACTER_DESIGNER_PROMPT},
435
+ {"role": "user", "content": f"Name: {name}\nConcept: {concept}"},
436
+ ]) or ""
437
+ start, end = raw.find("{"), raw.rfind("}")
438
+ if start != -1 and end > start:
439
+ data = json.loads(raw[start:end + 1])
440
+ tags = str(data.get("tags", "")).strip()
441
+ if tags and ("1girl" in tags or "1boy" in tags):
442
+ return {
443
+ "gender": "boy" if "1boy" in tags else "girl",
444
+ "tags": tags,
445
+ "personality": str(data.get("personality", concept)).strip()
446
+ or concept,
447
+ "role": str(data.get("role", "classmate")).strip()
448
+ or "classmate",
449
+ }
450
+ print(f" [cast] LLM design for '{name}' unusable — using heuristics")
451
+ except Exception as e:
452
+ print(f" [cast] LLM design failed for '{name}' ({e}) — using heuristics")
453
+ return _heuristic_design(name, concept)
454
+
455
+
456
+ def build_custom_specs(cards: list[dict]) -> list[tuple[str, dict]]:
457
+ """Turn UI character cards into (cache_key, preset) specs.
458
+
459
+ Card fields: name (required), concept (regular mode), tags (advanced
460
+ mode — overrides the designer; pose block still appended).
461
+ The cache key embeds a hash of the final prompt, so editing a
462
+ character's design automatically invalidates their sprite cache.
463
+ """
464
+ import hashlib
465
+ specs: list[tuple[str, dict]] = []
466
+ voice_idx = {"girl": 0, "boy": 0}
467
+
468
+ for i, card in enumerate(cards):
469
+ name = (card.get("name") or "").strip()
470
+ if not name:
471
+ continue
472
+ concept = (card.get("concept") or "").strip()
473
+ adv_tags = (card.get("tags") or "").strip()
474
+
475
+ if adv_tags: # advanced mode: tags are authoritative
476
+ gender = "boy" if "1boy" in adv_tags else "girl"
477
+ design = {"gender": gender, "tags": adv_tags,
478
+ "personality": concept or f"{name}.",
479
+ "role": concept.split(",")[0][:24] if concept else "custom"}
480
+ else:
481
+ design = design_character(name, concept)
482
+
483
+ pool = MALE_VOICES if design["gender"] == "boy" else FEMALE_VOICES
484
+ voice = pool[voice_idx[design["gender"]] % len(pool)]
485
+ voice_idx[design["gender"]] += 1
486
+
487
+ identity_prompt = build_identity_prompt(design["tags"])
488
+ slug = "".join(ch for ch in name.lower() if ch.isalnum()) or f"char{i}"
489
+ key = f"{slug}_{hashlib.md5(identity_prompt.encode()).hexdigest()[:8]}"
490
+
491
+ specs.append((key, {
492
+ "name": name,
493
+ "identity_prompt": identity_prompt,
494
+ "personality": design["personality"],
495
+ "voice_id": voice,
496
+ "role": design["role"],
497
+ "seed": SEED,
498
+ }))
499
+ return specs
500
+
501
+
502
+ # ═══════════════════════════════════════════════════��═══════════════════
503
+ # ComfyUI helpers
504
+ # ═══════════════════════════════════════════════════════════════════════
505
+
506
+ _COMFY_SESSION = None
507
+
508
+
509
+ def _session():
510
+ """Shared requests session for ComfyUI.
511
+
512
+ trust_env=False is the important part: it makes requests IGNORE
513
+ http_proxy/https_proxy environment variables (common on corporate
514
+ machines), which otherwise hijack even 127.0.0.1 traffic and kill
515
+ POSTs with [Errno 32] Broken pipe while GETs appear to work.
516
+ """
517
+ global _COMFY_SESSION
518
+ if _COMFY_SESSION is None:
519
+ import requests
520
+ s = requests.Session()
521
+ s.trust_env = False
522
+ _COMFY_SESSION = s
523
+ return _COMFY_SESSION
524
+
525
+
526
+ def _comfy_upload(path: str, name: str) -> dict:
527
+ """Upload an image to ComfyUI's input folder.
528
+
529
+ Uses `requests` with a proper multipart encoder — the previous
530
+ hand-rolled multipart body could make aiohttp drop the connection
531
+ mid-write (the "broken pipe"). `overwrite=true` matters: without it,
532
+ re-uploading the same filename makes ComfyUI save a renamed copy and
533
+ the workflow's LoadImage silently keeps reading the OLD image.
534
+ """
535
+ with open(path, "rb") as f:
536
+ resp = _session().post(
537
+ f"{COMFY_HOST}/upload/image",
538
+ files={"image": (name, f, "image/png")},
539
+ data={"overwrite": "true"},
540
+ timeout=60,
541
+ )
542
+ if not resp.ok:
543
+ raise RuntimeError(
544
+ f"ComfyUI upload of '{name}' failed: HTTP {resp.status_code} — {resp.text[:300]}")
545
+ return resp.json()
546
+
547
+
548
+ def _comfy_submit(workflow: dict, client_id: str) -> str:
549
+ """Submit a workflow to ComfyUI and return the prompt_id.
550
+
551
+ On a 400, surfaces ComfyUI's validation error (bad node class, missing
552
+ model file, wrong input name) instead of dying with a transport error.
553
+ """
554
+ resp = _session().post(
555
+ f"{COMFY_HOST}/prompt",
556
+ json={"prompt": workflow, "client_id": client_id},
557
+ timeout=60,
558
+ )
559
+ if not resp.ok:
560
+ raise RuntimeError(
561
+ f"ComfyUI rejected workflow '{client_id}': HTTP {resp.status_code} — {resp.text[:1000]}")
562
+ return resp.json()["prompt_id"]
563
+
564
+
565
+ def _comfy_wait(prompt_id: str, timeout: int = 180) -> Optional[dict]:
566
+ """Wait for a ComfyUI prompt to finish.
567
+
568
+ Returns the first output image's info dict ({filename, subfolder, type})
569
+ or None. Doesn't rely on the 'completed' status flag (absent in some
570
+ ComfyUI versions) — the presence of outputs is the ground truth — and
571
+ prints execution errors from the history so failures aren't silent.
572
+ """
573
+ for i in range(timeout // 2):
574
+ time.sleep(2)
575
+ try:
576
+ hist = _session().get(f"{COMFY_HOST}/history/{prompt_id}", timeout=15).json()
577
+ except Exception:
578
+ continue
579
+ if prompt_id not in hist:
580
+ continue
581
+ entry = hist[prompt_id]
582
+
583
+ # Outputs present → done, regardless of what status says
584
+ for nout in (entry.get("outputs") or {}).values():
585
+ for img in nout.get("images", []):
586
+ return img
587
+
588
+ # Surface execution errors instead of timing out silently
589
+ status = entry.get("status") or {}
590
+ for msg in status.get("messages", []):
591
+ if isinstance(msg, (list, tuple)) and msg and msg[0] == "execution_error":
592
+ detail = msg[1] if len(msg) > 1 else {}
593
+ print(f" [cast] ComfyUI execution error in node "
594
+ f"{detail.get('node_type', '?')}: "
595
+ f"{detail.get('exception_message', detail)}")
596
+ return None
597
+ if status.get("status_str") == "error":
598
+ print(f" [cast] ComfyUI reported an error for prompt {prompt_id}: {status}")
599
+ return None
600
+ if status.get("completed"):
601
+ return None # finished with no image outputs
602
+ print(f" [cast] Timed out waiting for prompt {prompt_id}")
603
+ return None
604
+
605
+
606
+ def _comfy_fetch_image(img_info: dict, dest_path: str) -> Optional[str]:
607
+ """Download a generated image from ComfyUI via its /view endpoint.
608
+
609
+ This replaces copying from a guessed output directory on disk — which
610
+ breaks whenever ComfyUI lives somewhere else (or SaveImage used a
611
+ subfolder). Works regardless of where/how ComfyUI is installed.
612
+ """
613
+ try:
614
+ resp = _session().get(
615
+ f"{COMFY_HOST}/view",
616
+ params={
617
+ "filename": img_info.get("filename", ""),
618
+ "subfolder": img_info.get("subfolder", ""),
619
+ "type": img_info.get("type", "output"),
620
+ },
621
+ timeout=60,
622
+ )
623
+ if not resp.ok:
624
+ print(f" [cast] /view fetch failed: HTTP {resp.status_code} for {img_info}")
625
+ return None
626
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
627
+ with open(dest_path, "wb") as f:
628
+ f.write(resp.content)
629
+ return dest_path
630
+ except Exception as e:
631
+ print(f" [cast] /view fetch failed for {img_info}: {e}")
632
+ return None
633
+
634
+
635
+ def _comfy_health() -> bool:
636
+ """Check if ComfyUI is reachable (proxy-bypassing, like all other calls)."""
637
+ try:
638
+ return _session().get(f"{COMFY_HOST}/system_stats", timeout=3).ok
639
+ except Exception:
640
+ return False
641
+
642
+
643
+ def comfy_free_vram() -> bool:
644
+ """Unload ComfyUI's models from VRAM via its /free endpoint.
645
+
646
+ Called after a cast bake so the scene LLM has the GPU to itself.
647
+ ComfyUI reloads its models lazily on the next /prompt, so this is safe
648
+ to call between casting beats. No-op if ComfyUI isn't reachable.
649
+ """
650
+ try:
651
+ r = _session().post(f"{COMFY_HOST}/free",
652
+ json={"unload_models": True, "free_memory": True},
653
+ timeout=20)
654
+ if r.ok:
655
+ print("[vram] ComfyUI models unloaded (/free)")
656
+ return True
657
+ except Exception as e:
658
+ print(f"[vram] ComfyUI /free failed (ignored): {e}")
659
+ return False
660
+
661
+
662
+ # ═══════════════════════════════════════════════════════════════════════
663
+ # Base generation (txt2img)
664
+ # ═══════════════════════════════════════════════════════════════════════
665
+
666
+ def _generate_base(character_key: str, preset: dict, out_dir: str,
667
+ seed: Optional[int] = None) -> Optional[str]:
668
+ """Generate base character image via ComfyUI txt2img.
669
+
670
+ Returns path to the generated base PNG, or None on failure.
671
+ """
672
+ os.makedirs(out_dir, exist_ok=True)
673
+ out_path = os.path.join(out_dir, f"{character_key}_base.png")
674
+ seed = preset["seed"] if seed is None else seed
675
+ base_w, base_h = _base_dims()
676
+
677
+ workflow = {
678
+ "1": {"inputs": {"filename_prefix": f"base_{character_key}", "images": ["8", 0]}, "class_type": "SaveImage"},
679
+ "8": {"inputs": {"samples": ["19", 0], "vae": ["15", 0]}, "class_type": "VAEDecode"},
680
+ "11": {"inputs": {"text": preset["identity_prompt"], "clip": ["45", 0]}, "class_type": "CLIPTextEncode"},
681
+ "12": {"inputs": {"text": SPRITE_NEGATIVE, "clip": ["45", 0]}, "class_type": "CLIPTextEncode"},
682
+ "15": {"inputs": {"vae_name": VAE_NAME}, "class_type": "VAELoader"},
683
+ "19": {"inputs": {
684
+ "seed": seed, "steps": 30, "cfg": 4.0,
685
+ "sampler_name": SAMPLER, "scheduler": SCHEDULER, "denoise": 1.0,
686
+ "model": ["44", 0], "positive": ["11", 0], "negative": ["12", 0],
687
+ "latent_image": ["28", 0],
688
+ }, "class_type": "KSampler"},
689
+ "28": {"inputs": {"width": base_w, "height": base_h, "batch_size": 1}, "class_type": "EmptyLatentImage"},
690
+ "44": {"inputs": {"unet_name": UNET_NAME}, "class_type": "UnetLoaderGGUF"},
691
+ "45": {"inputs": {"clip_name": CLIP_NAME, "type": "stable_diffusion", "device": "default"}, "class_type": "CLIPLoader"},
692
+ }
693
+
694
+ pid = _comfy_submit(workflow, f"base_{character_key}")
695
+ result = _comfy_wait(pid)
696
+ if result and _comfy_fetch_image(result, out_path):
697
+ print(f" [cast] Generated base for {character_key} (seed={seed}): {out_path}")
698
+ return out_path
699
+ return None
700
+
701
+
702
+ # ═══════════════════════════════════════════════════════════════════════
703
+ # Anime face detection gate
704
+ # ═══════════════════════════════════════════════════════════════════════
705
+
706
+ def _detect_anime_face(base_path: str, character_key: str) -> Optional[bool]:
707
+ """Check the base image contains a detectable anime face.
708
+
709
+ Runs an Impact-Pack detector workflow with the ANIME face model and
710
+ inspects the combined detection mask: non-empty mask == face found.
711
+
712
+ Returns:
713
+ True — anime face detected
714
+ False — no face detected (caller should retry base gen)
715
+ None — gate unavailable (node/model missing); caller proceeds
716
+ """
717
+ try:
718
+ _comfy_upload(base_path, f"{character_key}_base.png")
719
+ except Exception as e:
720
+ print(f" [cast] Face gate upload failed ({e}); proceeding without gate")
721
+ return None
722
+
723
+ workflow = {
724
+ "10": {"inputs": {"image": f"{character_key}_base.png"}, "class_type": "LoadImage"},
725
+ "70": {"inputs": {"model_name": ANIME_FACE_MODEL}, "class_type": "UltralyticsDetectorProvider"},
726
+ "71": {"inputs": {
727
+ "bbox_detector": ["70", 0], "image": ["10", 0],
728
+ "threshold": 0.3, "dilation": 0, "crop_factor": 1.0,
729
+ "drop_size": 10, "labels": "all",
730
+ }, "class_type": "BboxDetectorSEGS"},
731
+ "72": {"inputs": {"segs": ["71", 0]}, "class_type": "SegsToCombinedMask"},
732
+ "73": {"inputs": {"mask": ["72", 0]}, "class_type": "MaskToImage"},
733
+ "74": {"inputs": {"filename_prefix": f"facegate_{character_key}", "images": ["73", 0]}, "class_type": "SaveImage"},
734
+ }
735
+
736
+ try:
737
+ pid = _comfy_submit(workflow, f"facegate_{character_key}")
738
+ except Exception as e:
739
+ print(f" [cast] Face gate unavailable ({e}); proceeding without gate")
740
+ return None
741
+
742
+ result = _comfy_wait(pid, timeout=60)
743
+ if not result:
744
+ print(f" [cast] Face gate produced no output; proceeding without gate")
745
+ return None
746
+
747
+ mask_path = os.path.join(_char_dir(character_key), "_facegate_mask.png")
748
+ if not _comfy_fetch_image(result, mask_path):
749
+ print(f" [cast] Face gate mask fetch failed; proceeding without gate")
750
+ return None
751
+ try:
752
+ from PIL import Image
753
+ with Image.open(mask_path) as img:
754
+ extrema = img.convert("L").getextrema()
755
+ return extrema[1] > 0 # any non-black pixel => a face was detected
756
+ except Exception as e:
757
+ print(f" [cast] Face gate mask check failed ({e}); proceeding without gate")
758
+ return None
759
+
760
+
761
+ def _generate_base_with_face_gate(character_key: str, preset: dict, out_dir: str) -> Optional[str]:
762
+ """Base gen + anime-face-detect gate, retrying with bumped seeds."""
763
+ for attempt in range(FACE_GATE_RETRIES + 1):
764
+ seed = preset["seed"] + attempt
765
+ base_path = _generate_base(character_key, preset, out_dir, seed=seed)
766
+ if not base_path:
767
+ return None
768
+ detected = _detect_anime_face(base_path, character_key)
769
+ if detected is not False: # True, or gate unavailable
770
+ if detected is True:
771
+ print(f" [cast] Anime face confirmed for {character_key}")
772
+ return base_path
773
+ print(f" [cast] No anime face in {character_key} base "
774
+ f"(attempt {attempt + 1}/{FACE_GATE_RETRIES + 1}); regenerating...")
775
+ print(f" [cast] Face gate failed for {character_key} after retries; using last base")
776
+ return base_path
777
+
778
+
779
+ # ═══════════════════════════════════════════════════════════════════════
780
+ # Expression generation via FaceDetailer (the validated expression sheet)
781
+ # ═══════════════════════════════════════════════════════════════════════
782
+
783
+ def _generate_expression(base_path: str, character_key: str, expr_name: str,
784
+ expr_tags: str, seed: int, anchor: str = "") -> Optional[str]:
785
+ """Generate a single expression with the validated FaceDetailer sheet
786
+ (tools/workflows/anima_expression_sheet.json, API form).
787
+
788
+ Only the detected face region is re-generated with the expression
789
+ prompt — body, clothes and background stay identical, which is what
790
+ preserves identity across the set. `anchor` (eye/hair colour) is folded
791
+ into the prompt so those traits don't drift across expressions.
792
+
793
+ Returns path to the expression PNG, or None on failure.
794
+ """
795
+ out_dir = _char_dir(character_key)
796
+ os.makedirs(out_dir, exist_ok=True)
797
+ out_path = os.path.join(out_dir, f"{character_key}_{expr_name}.png")
798
+
799
+ # neutral == the base itself, just copied through (base prompts are
800
+ # already neutral-faced; copying beats re-generating for identity)
801
+ if expr_name == "neutral":
802
+ shutil.copy2(base_path, out_path)
803
+ return out_path
804
+
805
+ _comfy_upload(base_path, f"{character_key}_base.png")
806
+
807
+ anchor_part = f"{anchor}, " if anchor else ""
808
+ expr_prompt = f"{QUALITY}, {STYLE_TAGS}. {anchor_part}{expr_tags}"
809
+
810
+ # Per-expression strength + negatives (see EXPRESSION_DENOISE_OVERRIDES /
811
+ # EXPRESSION_NEGATIVE_TAGS): big-delta expressions inpaint harder and
812
+ # negate their drift-back-to-calm failure mode.
813
+ denoise = EXPRESSION_DENOISE_OVERRIDES.get(expr_name, EXPRESSION_DENOISE)
814
+ expr_negative = NEGATIVE
815
+ if expr_name in EXPRESSION_NEGATIVE_TAGS:
816
+ expr_negative = f"{NEGATIVE}, {EXPRESSION_NEGATIVE_TAGS[expr_name]}"
817
+
818
+ # FaceDetailer inputs — unified across all modes (see the module-level
819
+ # comment above; only base resolution differs per mode).
820
+ # Unified FaceDetailer params from the validated expression sheet.
821
+ # Lighter than the old heavier config (guide_size 384→256, steps 20→15,
822
+ # dpm_2_ancestral→euler) with no quality loss. bbox_threshold 0.5 catches
823
+ # expressions reliably (was 0.3 with more dilation/crop to compensate).
824
+ _fd_inputs = {
825
+ "image": ["10", 0], "model": ["44", 0], "clip": ["45", 0], "vae": ["15", 0],
826
+ "guide_size": 256, "guide_size_for": True, "max_size": 384,
827
+ "seed": seed, "steps": 15, "cfg": 4.0,
828
+ "sampler_name": "euler", "scheduler": SCHEDULER,
829
+ "positive": ["11", 0], "negative": ["12", 0],
830
+ "denoise": denoise, "feather": 15,
831
+ "noise_mask": True, "force_inpaint": True,
832
+ "bbox_threshold": 0.5, "bbox_dilation": 25, "bbox_crop_factor": 2.0,
833
+ "sam_detection_hint": "center-1", "sam_dilation": 0,
834
+ "sam_threshold": 0.93, "sam_bbox_expansion": 0,
835
+ "sam_mask_hint_threshold": 0.7, "sam_mask_hint_use_negative": "False",
836
+ "drop_size": 10, "wildcard": "", "cycle": 1,
837
+ "noise_mask_feather": 20,
838
+ "bbox_detector": ["70", 0], "sam_model_opt": ["73", 0],
839
+ }
840
+
841
+ workflow = {
842
+ "10": {"inputs": {"image": f"{character_key}_base.png"}, "class_type": "LoadImage"},
843
+ "11": {"inputs": {"text": expr_prompt, "clip": ["45", 0]}, "class_type": "CLIPTextEncode"},
844
+ "12": {"inputs": {"text": expr_negative, "clip": ["45", 0]}, "class_type": "CLIPTextEncode"},
845
+ "15": {"inputs": {"vae_name": VAE_NAME}, "class_type": "VAELoader"},
846
+ "44": {"inputs": {"unet_name": UNET_NAME}, "class_type": "UnetLoaderGGUF"},
847
+ "45": {"inputs": {"clip_name": CLIP_NAME, "type": "stable_diffusion", "device": "default"}, "class_type": "CLIPLoader"},
848
+ "70": {"inputs": {"model_name": ANIME_FACE_MODEL}, "class_type": "UltralyticsDetectorProvider"},
849
+ "73": {"inputs": {"model_name": SAM_NAME, "device_mode": "AUTO"}, "class_type": "SAMLoader"},
850
+ "80": {"inputs": _fd_inputs, "class_type": "FaceDetailer"},
851
+ "1": {"inputs": {"filename_prefix": f"{character_key}_{expr_name}", "images": ["80", 0]}, "class_type": "SaveImage"},
852
+ }
853
+
854
+ pid = _comfy_submit(workflow, f"{character_key}_{expr_name}_{seed}")
855
+ result = _comfy_wait(pid, timeout=200)
856
+ if result and _comfy_fetch_image(result, out_path):
857
+ return out_path
858
+ return None
859
+
860
+
861
+ # Per-expression retry count on failure (bumped seed). 0 = no retry.
862
+ EXPRESSION_RETRIES = 1
863
+
864
+
865
+ def _generate_expression_retry(base_path: str, character_key: str,
866
+ expr_name: str, expr_tags: str,
867
+ seed: int, anchor: str = "") -> Optional[str]:
868
+ """Generate one expression, retrying with a bumped seed if it fails
869
+ (transient face-detect miss / empty output). Self-heals the occasional
870
+ 'laugh didn't generate' without re-running the whole cast."""
871
+ for attempt in range(EXPRESSION_RETRIES + 1):
872
+ path = _generate_expression(base_path, character_key, expr_name,
873
+ expr_tags, seed + attempt * 1000, anchor=anchor)
874
+ if path:
875
+ return path
876
+ if attempt < EXPRESSION_RETRIES:
877
+ print(f" {expr_name}: empty/failed — retrying with bumped seed")
878
+ return None
879
+
880
+
881
+ # ═══════════════════════════════════════════════════════════════════════
882
+ # Background removal (rembg isnet-anime)
883
+ # ═══════════════════════════════════════════════════════════════════════
884
+
885
+ _REMBG_SESSION = None
886
+
887
+
888
+ def _get_rembg_session():
889
+ """Lazily create (and cache) the isnet-anime rembg session."""
890
+ global _REMBG_SESSION
891
+ if _REMBG_SESSION is None:
892
+ from rembg import new_session
893
+ _REMBG_SESSION = new_session("isnet-anime")
894
+ return _REMBG_SESSION
895
+
896
+
897
+ _REMBG_WARNED = False
898
+
899
+
900
+ def _warn_rembg_missing(detail: str) -> None:
901
+ """One-time, prominent, actionable warning — silent failure here is why
902
+ sprites came out with white backgrounds."""
903
+ global _REMBG_WARNED
904
+ if _REMBG_WARNED:
905
+ return
906
+ _REMBG_WARNED = True
907
+ print("\n" + "!" * 64)
908
+ print(" [cast] BACKGROUND REMOVAL DID NOT RUN — sprites will keep their")
909
+ print(f" white background. Reason: {detail}")
910
+ print(" Install it INTO THIS venv (rembg + an onnxruntime):")
911
+ print(" .venv/bin/pip install rembg onnxruntime")
912
+ print(" (use onnxruntime-gpu instead for CUDA). The isnet-anime")
913
+ print(" model at ~/.u2net/isnet-anime.pth is already present.")
914
+ print("!" * 64 + "\n")
915
+
916
+
917
+ def _has_transparency(path: str) -> bool:
918
+ try:
919
+ from PIL import Image
920
+ with Image.open(path) as img:
921
+ if img.mode not in ("RGBA", "LA", "PA"):
922
+ return False
923
+ alpha = img.convert("RGBA").getchannel("A").getextrema()
924
+ return alpha[0] < 250 # some pixel is meaningfully transparent
925
+ except Exception:
926
+ return False
927
+
928
+
929
+ def _remove_bg(input_path: str) -> Optional[str]:
930
+ """Remove background using rembg with the isnet-anime matte.
931
+
932
+ Overwrites the input in-place with an RGBA PNG. Uses the rembg 2.x
933
+ session API (`new_session("isnet-anime")` — the old `model_name=`
934
+ kwarg no longer exists and would silently skip matting). Verifies the
935
+ output actually became transparent; warns loudly if not.
936
+ """
937
+ out_path = input_path
938
+ try:
939
+ from rembg import remove
940
+ except ImportError:
941
+ _warn_rembg_missing("`import rembg` failed (not installed in this venv)")
942
+ return input_path
943
+
944
+ try:
945
+ with open(input_path, "rb") as f:
946
+ img_data = f.read()
947
+ try:
948
+ result = remove(img_data, session=_get_rembg_session())
949
+ except Exception as e:
950
+ print(f" [cast] isnet-anime session failed ({e}); trying default rembg model")
951
+ result = remove(img_data)
952
+ with open(out_path, "wb") as f:
953
+ f.write(result)
954
+ if not _has_transparency(out_path):
955
+ _warn_rembg_missing("rembg ran but produced no transparency "
956
+ "(onnxruntime missing/broken, or model failed to load)")
957
+ return out_path
958
+ except Exception as e:
959
+ _warn_rembg_missing(f"rembg error: {e}")
960
+ return input_path
961
+
962
+
963
+ # ═══════════════════════════════════════════════════════════════════════
964
+ # Public API
965
+ # ═══════════════════════════════════════════════════════════════════════
966
+
967
+ def _frame_upper_body(char_dir: str, key: str,
968
+ frac: Optional[float] = None, pad: int = 12) -> None:
969
+ """Crop a character's full-body renders to a consistent waist-up sprite.
970
+
971
+ Vertical: anchored on the head crown (union alpha-bbox TOP) and a FIXED
972
+ height (frac × image height) down from it — so the head sits at the same
973
+ place and the framing is identical across the whole cast.
974
+ Horizontal: the character's union extent + padding (trims side
975
+ whitespace; widths may differ per character, which is fine — the UI
976
+ height-locks sprites).
977
+ The SAME box is applied to all 7 expressions so they don't jump on swap.
978
+ """
979
+ try:
980
+ from PIL import Image
981
+ except ImportError:
982
+ return
983
+ frac = UPPER_BODY_FRAC if frac is None else frac
984
+
985
+ paths = [os.path.join(char_dir, f"{key}_{e}.png") for e in STANDARD_EXPRESSIONS]
986
+ paths = [p for p in paths if os.path.exists(p)]
987
+ if not paths:
988
+ return
989
+
990
+ # Union alpha bbox across the set → stable crown + horizontal extent
991
+ union = None
992
+ H = W = 0
993
+ for p in paths:
994
+ with Image.open(p) as img:
995
+ rgba = img.convert("RGBA")
996
+ W, H = rgba.size
997
+ bbox = rgba.getchannel("A").getbbox()
998
+ if bbox:
999
+ union = bbox if union is None else (
1000
+ min(union[0], bbox[0]), min(union[1], bbox[1]),
1001
+ max(union[2], bbox[2]), max(union[3], bbox[3]),
1002
+ )
1003
+ if union is None:
1004
+ return
1005
+
1006
+ # Detected face height (base px), from the face-gate mask if present.
1007
+ face_h = None
1008
+ mask_path = os.path.join(char_dir, "_facegate_mask.png")
1009
+ if os.path.exists(mask_path):
1010
+ try:
1011
+ with Image.open(mask_path) as m:
1012
+ ml = m.convert("L")
1013
+ _, mH = ml.size
1014
+ fb = ml.getbbox() # bbox of the white (detected-face) region
1015
+ if fb and mH:
1016
+ face_h = (fb[3] - fb[1]) * (H / mH) # scale mask→sprite if res differs
1017
+ except Exception as e:
1018
+ print(f" [cast] face mask unusable for {key} ({e}); crude crop")
1019
+
1020
+ if face_h and face_h > 1:
1021
+ # FACE-NORMALIZED ZOOMED bake: every face = FACE_TARGET_FRAC of the
1022
+ # SCENE height (the UI shows the canvas at UI_SPRITE_FRAC of the
1023
+ # screen, so the canvas target is FACE_TARGET_FRAC / UI_SPRITE_FRAC),
1024
+ # and the FEET sit FEET_DROP_FACES face-heights BELOW the canvas
1025
+ # bottom (lower legs crop off — the zoom-in). Equal faces + shared
1026
+ # feet baseline ⇒ taller figures' heads still reach higher.
1027
+ H0 = int(H * SPRITE_CANVAS_MULT)
1028
+ face_px = H0 * FACE_TARGET_FRAC / UI_SPRITE_FRAC # target face height on canvas
1029
+ scale = face_px / face_h
1030
+ # Canvas WIDTH grows to fit the scaled figure (wide poses — drill hair,
1031
+ # full skirts — must never hit the vertical edges; a hard clip line
1032
+ # mid-screen looks broken). Heights stay uniform across the cast, which
1033
+ # is what keeps faces equal under the UI height-lock; widths may vary.
1034
+ fig_w = (union[2] - union[0]) * scale
1035
+ W0 = max(int(W * SPRITE_CANVAS_MULT), int(fig_w) + 1 + 2 * pad)
1036
+ cx = (union[0] + union[2]) / 2.0 # figure center-x (base px)
1037
+ off_x = int(round(W0 / 2.0 - cx * scale)) # center horizontally
1038
+ # union bottom (feet) lands FEET_DROP_FACES faces below the canvas edge
1039
+ off_y = int(round(H0 + FEET_DROP_FACES * face_px - union[3] * scale))
1040
+ sw, sh = max(1, int(round(W * scale))), max(1, int(round(H * scale)))
1041
+ crown_clip = -(off_y + union[1] * scale) # px of head above the canvas top
1042
+ for p in paths:
1043
+ with Image.open(p) as img:
1044
+ resized = img.convert("RGBA").resize((sw, sh), Image.LANCZOS)
1045
+ canvas = Image.new("RGBA", (W0, H0), (0, 0, 0, 0))
1046
+ canvas.paste(resized, (off_x, off_y), resized) # mask = alpha; clips overflow
1047
+ canvas.save(p)
1048
+ print(f" [cast] Framed {len(paths)} sprites for {key} "
1049
+ f"(face-normalized zoom, scale={scale:.2f}, face={face_h:.0f}px → "
1050
+ f"{FACE_TARGET_FRAC:.0%} of scene, feet at -{FEET_DROP_FACES:g} faces, {W0}x{H0})")
1051
+ if crown_clip > 1:
1052
+ print(f" [cast] WARNING: {key}'s crown overflows the canvas top by "
1053
+ f"{crown_clip:.0f}px — lower ARS_FABULA_FACE_TARGET or ARS_FABULA_FEET_DROP")
1054
+ return
1055
+
1056
+ # ── Fallback: crude alpha-crown + fixed-fraction crop (no usable mask) ──
1057
+ crown_y = max(0, union[1] - pad)
1058
+ crop_h = int(H * frac)
1059
+ bottom = min(H, crown_y + crop_h)
1060
+ left = max(0, union[0] - pad)
1061
+ right = min(W, union[2] + pad)
1062
+ box = (left, crown_y, right, bottom)
1063
+ for p in paths:
1064
+ with Image.open(p) as img:
1065
+ img.convert("RGBA").crop(box).save(p)
1066
+ print(f" [cast] Framed {len(paths)} sprites for {key} (alpha-crown crop) to {box}")
1067
+
1068
+
1069
+ # Back-compat alias: tools/recrop_sprites.py calls _autocrop_sprite_set.
1070
+ _autocrop_sprite_set = _frame_upper_body
1071
+
1072
+
1073
+ def generate_cast(preferences: Optional[dict] = None) -> list[CastMember]:
1074
+ """Generate a full cast via ComfyUI (or fall back to curated).
1075
+
1076
+ This is the main entry point for the casting beat. It:
1077
+ 1. Decides which 3-4 characters to generate based on preferences
1078
+ 2. Generates base images (anime-face-gated) + warped expression sets
1079
+ 3. Removes backgrounds for alpha compositing
1080
+ 4. Returns locked CastMember objects
1081
+
1082
+ Args:
1083
+ preferences: Optional dict with keys like "mood", "preferences",
1084
+ "preset", "characters".
1085
+
1086
+ Returns:
1087
+ list[CastMember] with expression_map filled (frozen contract).
1088
+ """
1089
+ # Decide the cast specs: custom cards take precedence over presets.
1090
+ # VRAM choreography (LLM and diffusion can't co-reside):
1091
+ # custom mode needs the design LLM first, so free ComfyUI to make room
1092
+ # for it; preset mode designs nothing and skips that step.
1093
+ if preferences and preferences.get("custom_cast"):
1094
+ comfy_free_vram() # room for the design LLM
1095
+ try: # bring the LLM up if a prior bake killed it
1096
+ from model_client import ensure_llm
1097
+ ensure_llm(timeout=300)
1098
+ except Exception as e:
1099
+ print(f"[cast] ensure_llm before design failed ({e}); designing with heuristics")
1100
+ specs = build_custom_specs(preferences["custom_cast"]) # LLM designs
1101
+ if not specs:
1102
+ specs = [(k, PRESET_CHARACTERS[k]) for k in ["yuki", "marie", "sakura"]]
1103
+ else:
1104
+ if preferences and "preset" in preferences:
1105
+ char_keys = _get_preset_chars(preferences["preset"])
1106
+ elif preferences and "characters" in preferences:
1107
+ char_keys = preferences["characters"]
1108
+ else:
1109
+ char_keys = ["yuki", "marie", "sakura"] # Default 3-character cast
1110
+ specs = [(k, PRESET_CHARACTERS[k]) for k in char_keys
1111
+ if k in PRESET_CHARACTERS]
1112
+
1113
+ # Try ComfyUI generation
1114
+ comfy_ok = _comfy_health()
1115
+ if comfy_ok:
1116
+ # Only a real bake loads diffusion models — a fully-cached reuse
1117
+ # doesn't, so don't needlessly evict the LLM in that case.
1118
+ will_bake = bool(os.getenv("ARS_FABULA_REGEN")) or \
1119
+ not all(_generated_complete(k) for k, _ in specs)
1120
+ if will_bake:
1121
+ # Evict the LLM before ComfyUI loads its models — covers both the
1122
+ # design LLM just used (custom mode) and one left resident from a
1123
+ # prior scene (Reset → regenerate).
1124
+ from model_client import release_vram
1125
+ release_vram()
1126
+ print("[cast] ComfyUI reachable — generating cast...")
1127
+ return _generate_via_comfy(specs)
1128
+
1129
+ # ComfyUI down — previously baked sprites beat placeholders
1130
+ if all(_generated_complete(k) for k, _ in specs):
1131
+ print("[cast] ComfyUI not available — using previously generated sprites.")
1132
+ return [_load_generated_member(k, p) for k, p in specs]
1133
+
1134
+ # Fall back to curated (presets) / placeholders (custom)
1135
+ print("[cast] ComfyUI not available — using curated/placeholder cast.")
1136
+ out: list[CastMember] = []
1137
+ for k, p in specs:
1138
+ if k in PRESET_CHARACTERS:
1139
+ out.extend(load_curated_cast([k]))
1140
+ else:
1141
+ out.append(_placeholder_member(k, p))
1142
+ return out
1143
+
1144
+
1145
+ def load_curated_cast(char_keys: Optional[list[str]] = None) -> list[CastMember]:
1146
+ """Load pre-made character sprites from the curated directory.
1147
+
1148
+ Each character has a folder under static/sprites/curated/<name>/
1149
+ with PNG files named <name>_<expression>.png
1150
+
1151
+ Args:
1152
+ char_keys: Optional list of character keys to load. Defaults to all.
1153
+
1154
+ Returns:
1155
+ list[CastMember] with loaded sprite paths (frozen contract).
1156
+ """
1157
+ if char_keys is None:
1158
+ char_keys = list(PRESET_CHARACTERS.keys())
1159
+
1160
+ cast: list[CastMember] = []
1161
+ for key in char_keys:
1162
+ preset = PRESET_CHARACTERS.get(key)
1163
+ if not preset:
1164
+ continue
1165
+
1166
+ member = _build_member(key, preset)
1167
+
1168
+ # Check curated folder
1169
+ curated_char_dir = os.path.join(CURATED_DIR, key)
1170
+ if os.path.isdir(curated_char_dir):
1171
+ for expr in STANDARD_EXPRESSIONS:
1172
+ expr_path = os.path.join(curated_char_dir, f"{key}_{expr}.png")
1173
+ if os.path.exists(expr_path):
1174
+ member.expression_map[expr] = expr_path
1175
+
1176
+ # If missing expressions, use generated directory
1177
+ generated_char_dir = os.path.join(GENERATED_DIR, key)
1178
+ if os.path.isdir(generated_char_dir):
1179
+ for expr in STANDARD_EXPRESSIONS:
1180
+ if expr not in member.expression_map:
1181
+ expr_path = os.path.join(generated_char_dir, f"{key}_{expr}.png")
1182
+ if os.path.exists(expr_path):
1183
+ member.expression_map[expr] = expr_path
1184
+
1185
+ # If still missing, create placeholder sprites
1186
+ for expr in STANDARD_EXPRESSIONS:
1187
+ if expr not in member.expression_map:
1188
+ placeholder = _make_placeholder_sprite(key, expr, member)
1189
+ if placeholder:
1190
+ member.expression_map[expr] = placeholder
1191
+
1192
+ cast.append(member)
1193
+
1194
+ return cast
1195
+
1196
+
1197
+ def _build_member(key: str, preset: dict) -> CastMember:
1198
+ character = Character(
1199
+ name=preset.get("name", key.capitalize()),
1200
+ identity_prompt=preset["identity_prompt"],
1201
+ seed=preset["seed"],
1202
+ personality=preset["personality"],
1203
+ voice_id=preset["voice_id"],
1204
+ role=preset["role"],
1205
+ )
1206
+ return CastMember(character=character)
1207
+
1208
+
1209
+ def _placeholder_member(key: str, preset: dict) -> CastMember:
1210
+ """A custom character with placeholder sprites (no ComfyUI, no curated art)."""
1211
+ member = _build_member(key, preset)
1212
+ for expr in STANDARD_EXPRESSIONS:
1213
+ placeholder = _make_placeholder_sprite(key, expr, member)
1214
+ if placeholder:
1215
+ member.expression_map[expr] = placeholder
1216
+ return member
1217
+
1218
+
1219
+ def _generate_via_comfy(specs: list[tuple[str, dict]]) -> list[CastMember]:
1220
+ """Generate a cast using ComfyUI. Full pipeline per character.
1221
+
1222
+ Any HTTP/ComfyUI failure for one character (broken pipe, rejected
1223
+ workflow, timeout) falls back to curated sprites (presets) or
1224
+ placeholders (custom characters) instead of crashing the batch.
1225
+ """
1226
+ cast: list[CastMember] = []
1227
+
1228
+ for key, preset in specs:
1229
+ try:
1230
+ cast.append(_generate_one_via_comfy(key, preset))
1231
+ except Exception as e:
1232
+ print(f" [cast] ComfyUI pipeline failed for {key}: {e}")
1233
+ if key in PRESET_CHARACTERS:
1234
+ print(f" [cast] Falling back to curated sprites for {key}")
1235
+ cast.extend(load_curated_cast([key]))
1236
+ else:
1237
+ print(f" [cast] Falling back to placeholder sprites for {key}")
1238
+ cast.append(_placeholder_member(key, preset))
1239
+
1240
+ return cast
1241
+
1242
+
1243
+ def _generated_complete(key: str) -> bool:
1244
+ """True if a full generated sprite set already exists on disk
1245
+ (for the current mode — fast and full are cached separately)."""
1246
+ char_dir = _char_dir(key)
1247
+ return all(os.path.exists(os.path.join(char_dir, f"{key}_{e}.png"))
1248
+ for e in STANDARD_EXPRESSIONS)
1249
+
1250
+
1251
+ def _load_generated_member(key: str, preset: dict) -> CastMember:
1252
+ """Build a CastMember from already-generated sprites on disk."""
1253
+ char_dir = _char_dir(key)
1254
+ member = _build_member(key, preset)
1255
+ for expr in STANDARD_EXPRESSIONS:
1256
+ member.expression_map[expr] = os.path.join(char_dir, f"{key}_{expr}.png")
1257
+ return member
1258
+
1259
+
1260
+ def _generate_one_via_comfy(key: str, preset: dict, base_path: str = None) -> CastMember:
1261
+ """Full ComfyUI pipeline for one character. Raises on hard failure.
1262
+
1263
+ Cached: if the character's full sprite set already exists under
1264
+ static/sprites/generated/<key>/, it is reused — the bake happens once,
1265
+ not on every casting beat. Force a re-bake by deleting that folder or
1266
+ running with ARS_FABULA_REGEN=1.
1267
+
1268
+ When *base_path* is given, step 1 (base generation) is skipped — the
1269
+ caller already ran the face-gated base gen and now only needs
1270
+ expressions baked from it. The base is copied into the char dir if
1271
+ it isn't already there.
1272
+ """
1273
+ if _generated_complete(key) and not os.getenv("ARS_FABULA_REGEN"):
1274
+ print(f" [cast] Reusing generated sprites for {key} "
1275
+ f"(delete {_char_dir(key)}/ or set ARS_FABULA_REGEN=1 to re-bake)")
1276
+ return _load_generated_member(key, preset)
1277
+
1278
+ char_dir = _char_dir(key)
1279
+ os.makedirs(char_dir, exist_ok=True)
1280
+
1281
+ # Step 1: Generate base (face-gated) — skipped if base_path provided and exists
1282
+ if base_path and os.path.exists(base_path):
1283
+ dest = os.path.join(char_dir, f"{key}_base.png")
1284
+ if os.path.abspath(base_path) != os.path.abspath(dest):
1285
+ shutil.copy2(base_path, dest)
1286
+ print(f" [cast] Using supplied base for {key}: {base_path}")
1287
+ else:
1288
+ print(f" [cast] Generating {key} (seed={preset['seed']})...")
1289
+ base_path = _generate_base_with_face_gate(key, preset, char_dir)
1290
+ if not base_path:
1291
+ raise RuntimeError("base generation produced no image")
1292
+
1293
+ # Step 2: Generate expressions via the FaceDetailer sheet.
1294
+ # The eye/hair anchor keeps those traits from drifting across the set.
1295
+ anchor = _face_anchor(preset.get("identity_prompt", ""))
1296
+ print(f" [cast] Generating expressions for {key} (anchor: {anchor or 'none'})...")
1297
+ for expr_name in STANDARD_EXPRESSIONS:
1298
+ tags = EXPRESSION_TAGS[expr_name]
1299
+ expr_path = _generate_expression_retry(base_path, key, expr_name,
1300
+ tags, preset["seed"], anchor=anchor)
1301
+ status = "OK" if expr_path else "FAILED"
1302
+ print(f" {expr_name}: {status}")
1303
+
1304
+ # Step 3: Remove backgrounds (isnet-anime matte)
1305
+ print(f" [cast] Removing backgrounds for {key}...")
1306
+ for expr_name in STANDARD_EXPRESSIONS:
1307
+ src = os.path.join(char_dir, f"{key}_{expr_name}.png")
1308
+ if os.path.exists(src):
1309
+ _remove_bg(src)
1310
+
1311
+ # Step 3.5: Crop full-body renders to a consistent waist-up sprite,
1312
+ # anchored on the head crown so the whole cast lines up.
1313
+ _frame_upper_body(char_dir, key)
1314
+
1315
+ # Step 4: Build CastMember
1316
+ member = _build_member(key, preset)
1317
+ for expr_name in STANDARD_EXPRESSIONS:
1318
+ path = os.path.join(char_dir, f"{key}_{expr_name}.png")
1319
+ if os.path.exists(path):
1320
+ member.expression_map[expr_name] = path
1321
+
1322
+ # Any expression that failed gets a placeholder so the sprite-set
1323
+ # integrity invariant (every emotion present) still holds.
1324
+ for expr_name in STANDARD_EXPRESSIONS:
1325
+ if expr_name not in member.expression_map:
1326
+ placeholder = _make_placeholder_sprite(key, expr_name, member)
1327
+ if placeholder:
1328
+ member.expression_map[expr_name] = placeholder
1329
+
1330
+ return member
1331
+
1332
+
1333
+ def _get_preset_chars(preset_name: str) -> list[str]:
1334
+ """Get character keys for a named cast preset."""
1335
+ presets = {
1336
+ "childhood_friends": ["yuki", "marie", "sakura"],
1337
+ "love_triangle": ["yuki", "marie", "ryo"],
1338
+ "girls_night": ["yuki", "marie", "sakura"], # was yuki+sakura; Marie joins the girls
1339
+ "full_cast": ["yuki", "marie", "sakura", "ryo"],
1340
+ "default": ["yuki", "marie", "sakura"],
1341
+ }
1342
+ return presets.get(preset_name, presets["default"])
1343
+
1344
+
1345
+ def _make_placeholder_sprite(char_key: str, expr: str, member: CastMember) -> Optional[str]:
1346
+ """Create a colored placeholder sprite for missing expressions."""
1347
+ char_dir = _char_dir(char_key)
1348
+ os.makedirs(char_dir, exist_ok=True)
1349
+ path = os.path.join(char_dir, f"{char_key}_{expr}.png")
1350
+
1351
+ try:
1352
+ from PIL import Image, ImageDraw
1353
+ # Base color per character
1354
+ colors = {
1355
+ "yuki": (200, 210, 230),
1356
+ "marie": (235, 225, 180),
1357
+ "sakura": (230, 200, 210),
1358
+ "ryo": (190, 200, 220),
1359
+ }
1360
+ r, g, b = colors.get(char_key, (200, 200, 200))
1361
+
1362
+ # Slight variation per expression
1363
+ expr_shifts = {
1364
+ "neutral": (0, 0, 0),
1365
+ "smile": (10, 10, 0),
1366
+ "laugh": (20, 15, 0),
1367
+ "sad": (0, 0, -20),
1368
+ "angry": (20, 0, 0),
1369
+ "surprised": (0, 10, 20),
1370
+ "embarrassed": (20, 0, 10),
1371
+ }
1372
+ dr, dg, db = expr_shifts.get(expr, (0, 0, 0))
1373
+
1374
+ img = Image.new("RGBA", (member.sprite_width, member.sprite_height),
1375
+ (min(255, r + dr), min(255, g + dg), min(255, b + db), 255))
1376
+ draw = ImageDraw.Draw(img)
1377
+ draw.text((member.sprite_width // 2, member.sprite_height // 2),
1378
+ f"{char_key}\n{expr}", fill=(255, 255, 255, 255), anchor="mm")
1379
+ img.save(path)
1380
+ return path
1381
+ except ImportError:
1382
+ return None
model_client.py ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ model_client.py — the model-independence layer.
3
+
4
+ The engine depends on the `ModelClient` interface, never on a concrete model or URL.
5
+ Swap models by changing env vars; run tests with no server via MockModel.
6
+
7
+ ARS_FABULA_BACKEND = "server" | "transformers" | "mock" (default: server)
8
+ ARS_FABULA_BASE_URL = "http://localhost:8110/v1"
9
+ ARS_FABULA_MODEL = "gemma-12b" (later: "gemma-26b-a4b", "gemma-31b")
10
+ for the transformers backend: a HF repo id,
11
+ e.g. "google/gemma-3-4b-it"
12
+ ARS_FABULA_API_KEY = "not-needed"
13
+
14
+ The "transformers" backend runs the model in-process — built for Hugging
15
+ Face Spaces / ZeroGPU, where external servers (llama-server, ComfyUI)
16
+ cannot hold a GPU. GPU time is only granted inside @spaces.GPU calls.
17
+ """
18
+ from __future__ import annotations
19
+ import os
20
+ import json
21
+ import time
22
+ from abc import ABC, abstractmethod
23
+ from dataclasses import dataclass, field
24
+ from typing import Optional, Callable
25
+
26
+ import requests
27
+
28
+
29
+ # ── Config ──────────────────────────────────────────────────────────────
30
+
31
+ @dataclass
32
+ class ModelConfig:
33
+ backend: str = "server"
34
+ base_url: str = "http://localhost:8110/v1"
35
+ model: str = "gemma-12b"
36
+ api_key: str = "not-needed"
37
+ temperature: float = 0.8
38
+ max_tokens: int = 1024
39
+ timeout: int = 120
40
+
41
+ @classmethod
42
+ def from_env(cls) -> "ModelConfig":
43
+ return cls(
44
+ backend=os.getenv("ARS_FABULA_BACKEND", "server"),
45
+ base_url=os.getenv("ARS_FABULA_BASE_URL", "http://localhost:8110/v1"),
46
+ model=os.getenv("ARS_FABULA_MODEL", "gemma-12b"),
47
+ api_key=os.getenv("ARS_FABULA_API_KEY", "not-needed"),
48
+ temperature=float(os.getenv("ARS_FABULA_TEMPERATURE", "0.8")),
49
+ max_tokens=int(os.getenv("ARS_FABULA_MAX_TOKENS", "1024")),
50
+ timeout=int(os.getenv("ARS_FABULA_TIMEOUT", "120")),
51
+ )
52
+
53
+
54
+ # ── Interface ───────────────────────────────────────────────────────────
55
+
56
+ class ModelClient(ABC):
57
+ """Minimal surface the engine needs. Any model that speaks it is swappable."""
58
+
59
+ @abstractmethod
60
+ def chat(self, messages: list[dict], tools: Optional[list] = None, **kw) -> dict:
61
+ """Return an assistant message dict: {"role","content","tool_calls"?}."""
62
+ ...
63
+
64
+ def generate(self, messages: list[dict], **kw) -> str:
65
+ """Convenience: text-only completion."""
66
+ return self.chat(messages, **kw).get("content") or ""
67
+
68
+ def health(self) -> bool:
69
+ """Check if the backend is reachable. Default False for abstract clients."""
70
+ return False
71
+
72
+
73
+ # ── Real backend: any OpenAI-compatible llama-server ────────────────────
74
+
75
+ class LlamaServerClient(ModelClient):
76
+ def __init__(self, config: Optional[ModelConfig] = None):
77
+ self.cfg = config or ModelConfig.from_env()
78
+
79
+ def chat(self, messages, tools=None, **kw) -> dict:
80
+ payload = {
81
+ "model": kw.get("model", self.cfg.model),
82
+ "messages": messages,
83
+ "temperature": kw.get("temperature", self.cfg.temperature),
84
+ "max_tokens": kw.get("max_tokens", self.cfg.max_tokens),
85
+ }
86
+ if tools:
87
+ payload["tools"] = tools
88
+ payload["tool_choice"] = kw.get("tool_choice", "auto")
89
+ resp = requests.post(
90
+ f"{self.cfg.base_url}/chat/completions",
91
+ headers={"Authorization": f"Bearer {self.cfg.api_key}",
92
+ "Content-Type": "application/json"},
93
+ json=payload, timeout=self.cfg.timeout,
94
+ )
95
+ resp.raise_for_status()
96
+ return resp.json()["choices"][0]["message"]
97
+
98
+ def health(self) -> bool:
99
+ try:
100
+ base = self.cfg.base_url.rsplit("/v1", 1)[0]
101
+ return requests.get(f"{base}/health", timeout=5).status_code == 200
102
+ except requests.RequestException:
103
+ return False
104
+
105
+
106
+ # ── Transformers backend: in-process model (HF Spaces / ZeroGPU) ────────
107
+ #
108
+ # On ZeroGPU the model must be loaded and placed on "cuda" at startup
109
+ # (a CUDA emulation layer makes that legal outside @spaces.GPU); the real
110
+ # GPU only exists inside the @spaces.GPU-decorated generate call. The
111
+ # decorator is a no-op off-Spaces, so the same code path runs anywhere
112
+ # torch + transformers are installed.
113
+
114
+ # HF repo ids tried in order when ARS_FABULA_MODEL isn't itself a repo id.
115
+ # Gemma is the project's model family but is license-gated — if the Space
116
+ # token can't access it, fall through to an ungated small model rather
117
+ # than dropping the whole demo to mock.
118
+ _HF_MODEL_CANDIDATES = [
119
+ "google/gemma-3-4b-it",
120
+ "Qwen/Qwen3-4B-Instruct-2507",
121
+ ]
122
+
123
+ _HF_STATE: dict = {"model": None, "tokenizer": None, "id": None}
124
+
125
+ try:
126
+ import spaces as _spaces
127
+ _gpu_decorator = _spaces.GPU(duration=120)
128
+ except ImportError:
129
+ _gpu_decorator = lambda f: f
130
+
131
+
132
+ def _hf_load(model_id: Optional[str] = None):
133
+ """Load the chat model once, module-wide. Returns the loaded repo id
134
+ or None if nothing could be loaded (no torch, gated repo, etc.)."""
135
+ if _HF_STATE["model"] is not None:
136
+ return _HF_STATE["id"]
137
+ try:
138
+ import torch
139
+ from transformers import AutoModelForCausalLM, AutoTokenizer
140
+ except ImportError as e:
141
+ print(f"[hf] transformers backend unavailable ({e})")
142
+ return None
143
+
144
+ candidates = [model_id] if model_id and "/" in model_id else list(_HF_MODEL_CANDIDATES)
145
+ for mid in candidates:
146
+ try:
147
+ print(f"[hf] loading {mid}…")
148
+ tok = AutoTokenizer.from_pretrained(mid)
149
+ model = AutoModelForCausalLM.from_pretrained(mid, dtype=torch.bfloat16)
150
+ if torch.cuda.is_available():
151
+ model = model.to("cuda")
152
+ model.eval()
153
+ _HF_STATE.update(model=model, tokenizer=tok, id=mid)
154
+ print(f"[hf] {mid} ready on {model.device}")
155
+ return mid
156
+ except Exception as e:
157
+ print(f"[hf] could not load {mid}: {type(e).__name__}: {e}")
158
+ return None
159
+
160
+
161
+ @_gpu_decorator
162
+ def _hf_generate(messages: list[dict], max_new_tokens: int,
163
+ temperature: float) -> str:
164
+ import torch
165
+ tok, model = _HF_STATE["tokenizer"], _HF_STATE["model"]
166
+ inputs = tok.apply_chat_template(
167
+ messages, add_generation_prompt=True,
168
+ return_tensors="pt", return_dict=True,
169
+ ).to(model.device)
170
+ with torch.inference_mode():
171
+ out = model.generate(
172
+ **inputs,
173
+ max_new_tokens=max_new_tokens,
174
+ do_sample=temperature > 0,
175
+ temperature=max(temperature, 1e-3),
176
+ top_p=0.95,
177
+ )
178
+ return tok.decode(out[0][inputs["input_ids"].shape[-1]:],
179
+ skip_special_tokens=True)
180
+
181
+
182
+ class TransformersClient(ModelClient):
183
+ """In-process HF transformers chat model (the ZeroGPU path)."""
184
+
185
+ def __init__(self, config: Optional[ModelConfig] = None):
186
+ self.cfg = config or ModelConfig.from_env()
187
+ self._loaded_id = _hf_load(self.cfg.model)
188
+
189
+ def chat(self, messages, tools=None, **kw) -> dict:
190
+ if not self.health():
191
+ raise RuntimeError("transformers backend has no loaded model")
192
+ text = _hf_generate(
193
+ messages,
194
+ max_new_tokens=kw.get("max_tokens", self.cfg.max_tokens),
195
+ temperature=kw.get("temperature", self.cfg.temperature),
196
+ )
197
+ return {"role": "assistant", "content": text}
198
+
199
+ def health(self) -> bool:
200
+ return _HF_STATE["model"] is not None
201
+
202
+
203
+ def preload_transformers() -> bool:
204
+ """Warm the in-process model at app startup (ZeroGPU wants the CUDA
205
+ placement done at module/startup time, not lazily mid-request)."""
206
+ cfg = ModelConfig.from_env()
207
+ if cfg.backend != "transformers":
208
+ return False
209
+ return _hf_load(cfg.model) is not None
210
+
211
+
212
+ # ── Mock backend: scripted, no server (for unit/CI tests) ───────────────
213
+
214
+ class MockModel(ModelClient):
215
+ """Returns programmed responses and records what it was asked.
216
+
217
+ Program with either:
218
+ MockModel(responses=["text 1", {"role":"assistant","tool_calls":[...]}, ...])
219
+ MockModel(handler=lambda messages, tools: "...") # dynamic
220
+ """
221
+ # Scripted output is not real model data — the engine skips trace logging
222
+ # for mock models so tests and mock fallbacks don't pollute the dataset.
223
+ is_mock = True
224
+
225
+ def __init__(self, responses: Optional[list] = None,
226
+ handler: Optional[Callable[[list, Optional[list]], object]] = None):
227
+ self._queue = list(responses or [])
228
+ self._handler = handler
229
+ self.calls: list[dict] = [] # every chat() invocation, for assertions
230
+ self.default = "The scene holds its breath for a moment, waiting."
231
+
232
+ def chat(self, messages, tools=None, **kw) -> dict:
233
+ self.calls.append({"messages": messages, "tools": tools, "kw": kw})
234
+ if self._handler:
235
+ out = self._handler(messages, tools)
236
+ elif self._queue:
237
+ out = self._queue.pop(0)
238
+ else:
239
+ out = self.default
240
+ if isinstance(out, str):
241
+ return {"role": "assistant", "content": out}
242
+ return out # already a message dict (may carry tool_calls)
243
+
244
+ # test helpers
245
+ def last_prompt_text(self) -> str:
246
+ """Concatenated content of the most recent call's messages."""
247
+ if not self.calls:
248
+ return ""
249
+ return "\n".join(str(m.get("content", "")) for m in self.calls[-1]["messages"])
250
+
251
+ def queue(self, *responses):
252
+ self._queue.extend(responses)
253
+
254
+
255
+ # ── Factory ─────────────────────────────────────────────────────────────
256
+
257
+ def get_model(config: Optional[ModelConfig] = None) -> ModelClient:
258
+ cfg = config or ModelConfig.from_env()
259
+ if cfg.backend == "mock":
260
+ return MockModel()
261
+ if cfg.backend == "transformers":
262
+ return TransformersClient(cfg)
263
+ return LlamaServerClient(cfg)
264
+
265
+
266
+ # ── VRAM management — swap the LLM out so the image model can load ──────
267
+ #
268
+ # The LLM and the diffusion stack don't fit in VRAM together (e.g. Gemma
269
+ # 12B IQ4_XS ~6GB on an 8GB card). llama.cpp's llama-server has NO unload
270
+ # API, so we STOP the server process before a ComfyUI bake and RELAUNCH it
271
+ # afterward, polling /health before resuming scene turns.
272
+ #
273
+ # Default behavior auto-detects a WSL-native atomic-fork llama-server (Gemma
274
+ # 4 26B-A4B MoE + MTP) or falls back to a WSL→Windows llama-server.exe.
275
+ # Everything is env-overridable:
276
+ # ARS_FABULA_LLM_MANAGE = auto (default) | wsl | win | none | ollama
277
+ # ARS_FABULA_LLM_STOP_CMD = "<shell cmd>" # custom stop
278
+ # ARS_FABULA_LLM_START_CMD = "<shell cmd>" # custom start (detached)
279
+ # ARS_FABULA_LLM_UNLOAD_URL = "http://.../unload"
280
+ # ARS_FABULA_LLAMA_EXE_WIN = C:\\...\\llama-server.exe # Windows mode
281
+ # ARS_FABULA_LLAMA_MODEL_WIN = C:\\...\\model.gguf
282
+ # ARS_FABULA_LLAMA_ARGS = "<llama-server flags>" # shared
283
+ # ARS_FABULA_LLAMA_BIN = /path/to/llama-server (WSL) # WSL mode
284
+ # ARS_FABULA_LLAMA_MODEL = /path/to/model.gguf (WSL)
285
+ # ARS_FABULA_LLAMA_DRAFTER = /path/to/mtp-drafter.gguf # MTP
286
+ # ARS_FABULA_LLAMA_LD_PATH = /path/to/lib/dir # LD_LIBRARY_PATH
287
+
288
+ _DEFAULT_LLAMA_EXE_WIN = r"C:\Users\vruizes\llama-cpp-setup\llama-server.exe"
289
+ _DEFAULT_LLAMA_MODEL_WIN = r"C:\Users\vruizes\Downloads\gemma-4-12b-it-IQ4_XS.gguf"
290
+ # 'none' reasoning profile from the launcher .bat — fastest, cleanest tool
291
+ # output for the VN (thinking tokens just add latency to bracket emission).
292
+ _DEFAULT_LLAMA_ARGS = ("-ngl 99 -fa on -ctk q8_0 -ctv q8_0 -c 16384 "
293
+ "--reasoning off -np 1 --cache-ram 0 "
294
+ "--host 0.0.0.0 --port 8110")
295
+
296
+ # ── WSL-native defaults (atomic-llama-cpp-turboquant, Gemma 4 26B-A4B) ──
297
+ _DEFAULT_LLAMA_BIN_WSL = "/mnt/c/Users/vruizes/atomic-llama-cpp-turboquant/" \
298
+ "build/bin/llama-server"
299
+ _DEFAULT_LLAMA_MODEL_WSL = "/mnt/c/Users/vruizes/Downloads/" \
300
+ "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf"
301
+ _DEFAULT_LLAMA_DRAFTER_WSL = "/mnt/c/Users/vruizes/Downloads/" \
302
+ "gemma-4-26B-A4B-it-assistant.Q4_K_M.gguf"
303
+ _DEFAULT_LLAMA_LD_PATH_WSL = "/mnt/c/Users/vruizes/" \
304
+ "atomic-llama-cpp-turboquant/build/bin"
305
+ # 32K context, CPU MoE offload (GPU can't fit 26B MoE experts), MTP drafter,
306
+ # no reasoning tokens (they add latency to bracket emission).
307
+ _DEFAULT_LLAMA_ARGS_WSL = ("--spec-type mtp --draft-block-size 3 "
308
+ "--draft-max 8 --draft-min 0 "
309
+ "-ngl 99 -ngld 99 -fa on -ctk q8_0 -ctv q8_0 "
310
+ "-c 32000 --cpu-moe --reasoning off "
311
+ "--host 0.0.0.0 --port 8110")
312
+
313
+
314
+ def _is_wsl() -> bool:
315
+ if os.name != "posix":
316
+ return False
317
+ try:
318
+ with open("/proc/version") as f:
319
+ return "microsoft" in f.read().lower()
320
+ except OSError:
321
+ return False
322
+
323
+
324
+ def _win_to_wsl(win_path: str) -> str:
325
+ """C:\\Users\\x → /mnt/c/Users/x (for existence checks from WSL)."""
326
+ p = win_path.replace("\\", "/")
327
+ if len(p) > 1 and p[1] == ":":
328
+ return f"/mnt/{p[0].lower()}{p[2:]}"
329
+ return p
330
+
331
+
332
+ def _llm_manage_mode(cfg: ModelConfig) -> str:
333
+ mode = os.getenv("ARS_FABULA_LLM_MANAGE", "auto").lower()
334
+ if mode != "auto":
335
+ return mode
336
+ # auto: a custom stop hook wins; then try WSL-native atomic fork,
337
+ # then Windows .exe via interop, then ollama.
338
+ if os.getenv("ARS_FABULA_LLM_STOP_CMD"):
339
+ return "cmd"
340
+ # Check for WSL-native atomic-fork binary first
341
+ bin_path = os.getenv("ARS_FABULA_LLAMA_BIN", _DEFAULT_LLAMA_BIN_WSL)
342
+ if _is_wsl() and os.path.exists(bin_path):
343
+ return "wsl"
344
+ exe = os.getenv("ARS_FABULA_LLAMA_EXE_WIN", _DEFAULT_LLAMA_EXE_WIN)
345
+ if _is_wsl() and os.path.exists(_win_to_wsl(exe)):
346
+ return "win"
347
+ if "11434" in cfg.base_url:
348
+ return "ollama"
349
+ return "none"
350
+
351
+
352
+ def _win_health(cfg: ModelConfig) -> bool:
353
+ try:
354
+ base = cfg.base_url.rsplit("/v1", 1)[0]
355
+ return requests.get(f"{base}/health", timeout=3).status_code == 200
356
+ except requests.RequestException:
357
+ return False
358
+
359
+
360
+ def release_vram(config: Optional[ModelConfig] = None) -> bool:
361
+ """Stop/evict the LLM so ComfyUI can load. Returns True if it acted."""
362
+ cfg = config or ModelConfig.from_env()
363
+ # mock has nothing to stop; transformers is in-process — on ZeroGPU the
364
+ # GPU is granted per-call and released automatically, so the whole
365
+ # stop/relaunch swap dance does not apply.
366
+ if cfg.backend in ("mock", "transformers"):
367
+ return False
368
+ mode = _llm_manage_mode(cfg)
369
+ if mode == "none":
370
+ return False
371
+ import subprocess
372
+
373
+ if mode == "cmd" or os.getenv("ARS_FABULA_LLM_STOP_CMD"):
374
+ cmd = os.getenv("ARS_FABULA_LLM_STOP_CMD")
375
+ if cmd:
376
+ try:
377
+ subprocess.run(cmd, shell=True, timeout=30, check=False)
378
+ print(f"[vram] LLM stopped via STOP_CMD")
379
+ return True
380
+ except Exception as e:
381
+ print(f"[vram] STOP_CMD failed: {e}")
382
+
383
+ if mode == "win":
384
+ # Kill the Windows llama-server.exe from WSL via interop
385
+ for killer in ("taskkill.exe", "/mnt/c/Windows/System32/taskkill.exe"):
386
+ try:
387
+ subprocess.run([killer, "/IM", "llama-server.exe", "/F"],
388
+ timeout=20, check=False,
389
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
390
+ print("[vram] llama-server.exe stopped (taskkill)")
391
+ # give the driver a moment to release VRAM
392
+ time.sleep(2)
393
+ return True
394
+ except Exception:
395
+ continue
396
+ print("[vram] taskkill unavailable — could not stop llama-server")
397
+ return False
398
+
399
+ if mode == "wsl":
400
+ # Kill the WSL-native atomic-fork llama-server (Linux process)
401
+ try:
402
+ subprocess.run(["pkill", "-f", "build/bin/llama-server"],
403
+ timeout=15, check=False,
404
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
405
+ print("[vram] WSL llama-server stopped (pkill build/bin/llama-server)")
406
+ time.sleep(2)
407
+ return True
408
+ except Exception as e:
409
+ print(f"[vram] pkill failed: {e}")
410
+ return False
411
+
412
+ url = os.getenv("ARS_FABULA_LLM_UNLOAD_URL")
413
+ if url:
414
+ try:
415
+ requests.post(url, json={}, timeout=15)
416
+ print(f"[vram] LLM unload URL hit: {url}")
417
+ return True
418
+ except Exception as e:
419
+ print(f"[vram] unload URL failed: {e}")
420
+
421
+ if mode == "ollama":
422
+ root = cfg.base_url.rsplit("/v1", 1)[0].rstrip("/")
423
+ try:
424
+ if requests.post(f"{root}/api/generate",
425
+ json={"model": cfg.model, "keep_alive": 0,
426
+ "prompt": "", "stream": False}, timeout=15).ok:
427
+ print(f"[vram] LLM unloaded via ollama keep_alive=0")
428
+ return True
429
+ except Exception:
430
+ pass
431
+ return False
432
+
433
+
434
+ def ensure_llm(config: Optional[ModelConfig] = None, timeout: int = 300) -> bool:
435
+ """Make sure the LLM server is up and answering /health, relaunching it
436
+ if we manage it. Returns True when healthy, False on give-up."""
437
+ cfg = config or ModelConfig.from_env()
438
+ if cfg.backend == "mock":
439
+ return False
440
+ if cfg.backend == "transformers":
441
+ # In-process model: there is no server to relaunch or poll.
442
+ return _HF_STATE["model"] is not None or preload_transformers()
443
+ if _win_health(cfg):
444
+ return True
445
+
446
+ mode = _llm_manage_mode(cfg)
447
+ import subprocess
448
+
449
+ started = False
450
+ start_cmd = os.getenv("ARS_FABULA_LLM_START_CMD")
451
+ if start_cmd:
452
+ try:
453
+ subprocess.Popen(start_cmd, shell=True,
454
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
455
+ started = True
456
+ print("[vram] LLM starting via START_CMD…")
457
+ except Exception as e:
458
+ print(f"[vram] START_CMD failed: {e}")
459
+ elif mode == "win":
460
+ exe = os.getenv("ARS_FABULA_LLAMA_EXE_WIN", _DEFAULT_LLAMA_EXE_WIN)
461
+ model = os.getenv("ARS_FABULA_LLAMA_MODEL_WIN", _DEFAULT_LLAMA_MODEL_WIN)
462
+ args = os.getenv("ARS_FABULA_LLAMA_ARGS", _DEFAULT_LLAMA_ARGS)
463
+ # Execute the Windows .exe DIRECTLY via its WSL path — no cmd.exe,
464
+ # no `start` (whose title arg gets mangled across the WSL boundary).
465
+ # The exe is a Windows program, so the model path stays a Windows
466
+ # path; Popen returns immediately and the process keeps running.
467
+ import shlex
468
+ exe_wsl = _win_to_wsl(exe)
469
+ cmd = [exe_wsl, "-m", model] + shlex.split(args)
470
+ try:
471
+ subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
472
+ stderr=subprocess.DEVNULL,
473
+ stdin=subprocess.DEVNULL,
474
+ start_new_session=True)
475
+ started = True
476
+ print(f"[vram] llama-server.exe relaunching (detached): {exe_wsl}")
477
+ except Exception as e:
478
+ print(f"[vram] direct launch failed ({e}); trying the headless .bat")
479
+ # Fallback: run the headless launcher .bat via cmd.exe /c
480
+ bat = os.getenv("ARS_FABULA_LLAMA_BAT")
481
+ if bat:
482
+ for shell in ("cmd.exe", "/mnt/c/Windows/System32/cmd.exe"):
483
+ try:
484
+ subprocess.Popen([shell, "/c", bat],
485
+ stdout=subprocess.DEVNULL,
486
+ stderr=subprocess.DEVNULL,
487
+ start_new_session=True)
488
+ started = True
489
+ print("[vram] llama-server relaunching via .bat…")
490
+ break
491
+ except Exception:
492
+ continue
493
+
494
+ elif mode == "wsl":
495
+ # Launch the WSL-native atomic-fork llama-server (Gemma 4 26B-A4B
496
+ # MoE + MTP drafter). The binary is a Linux ELF with CUDA deps, so
497
+ # LD_LIBRARY_PATH must point at its build/bin for libggml*.so etc.
498
+ bin_path = os.getenv("ARS_FABULA_LLAMA_BIN", _DEFAULT_LLAMA_BIN_WSL)
499
+ model_path = os.getenv("ARS_FABULA_LLAMA_MODEL",
500
+ _DEFAULT_LLAMA_MODEL_WSL)
501
+ drafter_path = os.getenv("ARS_FABULA_LLAMA_DRAFTER",
502
+ _DEFAULT_LLAMA_DRAFTER_WSL)
503
+ ld_path = os.getenv("ARS_FABULA_LLAMA_LD_PATH",
504
+ _DEFAULT_LLAMA_LD_PATH_WSL)
505
+ args_str = os.getenv("ARS_FABULA_LLAMA_ARGS",
506
+ _DEFAULT_LLAMA_ARGS_WSL)
507
+ import shlex
508
+ cmd = [bin_path, "-m", model_path,
509
+ "--mtp-head", drafter_path] + shlex.split(args_str)
510
+ launch_env = os.environ.copy()
511
+ launch_env["LD_LIBRARY_PATH"] = (
512
+ f"{ld_path}:{launch_env.get('LD_LIBRARY_PATH', '')}"
513
+ )
514
+ try:
515
+ subprocess.Popen(cmd, env=launch_env,
516
+ stdout=subprocess.DEVNULL,
517
+ stderr=subprocess.DEVNULL,
518
+ stdin=subprocess.DEVNULL,
519
+ start_new_session=True)
520
+ started = True
521
+ print(f"[vram] atomic-fork llama-server starting (WSL-native): "
522
+ f"{bin_path}")
523
+ except Exception as e:
524
+ print(f"[vram] WSL launch failed: {e}")
525
+
526
+ if not started:
527
+ # Nothing we could launch — wait only a short grace period in case
528
+ # the user is bringing it up by hand, then give up to mock.
529
+ print("[vram] LLM not running and no launcher configured — "
530
+ "start llama-server (or set ARS_FABULA_LLM_START_CMD). "
531
+ "Using mock until it's up.")
532
+ timeout = 6
533
+
534
+ # Poll /health until the model finishes loading
535
+ deadline = time.time() + timeout
536
+ while time.time() < deadline:
537
+ if _win_health(cfg):
538
+ print("[vram] LLM is healthy.")
539
+ return True
540
+ time.sleep(2)
541
+ return _win_health(cfg)
providers.py ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ providers.py — Swappable Background and Voice providers.
3
+
4
+ BackgroundProvider: preset library + live gen hook.
5
+ VoiceProvider: Chatterbox (emotion) + Kokoro (fast) selectable.
6
+
7
+ Both are interfaces with concrete implementations.
8
+ """
9
+ from __future__ import annotations
10
+ import os
11
+ import subprocess
12
+ import json
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass, field
15
+ from typing import Optional
16
+
17
+ # ── Asset paths ──────────────────────────────────────────────────────
18
+ ASSET_DIR = os.path.join(os.path.dirname(__file__), "static")
19
+ BG_DIR = os.path.join(ASSET_DIR, "bg")
20
+
21
+
22
+ # ═══════════════════════════════════════════════════════════════════════
23
+ # Background Provider
24
+ # ═══════════════════════════════════════════════════════════════════════
25
+
26
+ # Preset background library — maps keys to image paths or generation prompts
27
+ PRESET_BACKGROUNDS: dict[str, dict] = {
28
+ "classroom": {
29
+ "path": "bg_classroom.png",
30
+ "label": "High School Classroom",
31
+ "prompt": "sunlit Japanese high school classroom, desks in rows, blackboard, afternoon light through windows, anime style",
32
+ },
33
+ "hallway": {
34
+ "path": "bg_hallway.png",
35
+ "label": "School Hallway",
36
+ "prompt": "Japanese school hallway with lockers, shoe cubbies, afternoon sunlight, empty corridor, anime style",
37
+ },
38
+ "rooftop": {
39
+ "path": "bg_rooftop.png",
40
+ "label": "School Rooftop",
41
+ "prompt": "school rooftop at sunset, city skyline in distance, fence, water tower, sky gradient, anime style",
42
+ },
43
+ "courtyard": {
44
+ "path": "bg_courtyard.png",
45
+ "label": "Courtyard",
46
+ "prompt": "school courtyard with cherry blossom trees in full bloom, benches, students walking, spring, anime style",
47
+ },
48
+ "cafe": {
49
+ "path": "bg_cafe.png",
50
+ "label": "Café",
51
+ "prompt": "cozy cafe interior, warm lighting, wooden tables, plants, window seat, afternoon, anime style",
52
+ },
53
+ "park": {
54
+ "path": "bg_park.png",
55
+ "label": "City Park",
56
+ "prompt": "city park with green grass, trees, pond, benches, jogging path, blue sky, anime style",
57
+ },
58
+ "library": {
59
+ "path": "bg_library.png",
60
+ "label": "School Library",
61
+ "prompt": "quiet school library, tall bookshelves, reading tables, lamp light, large windows, anime style",
62
+ },
63
+ "home_room": {
64
+ "path": "bg_home_room.png",
65
+ "label": "Character's Room",
66
+ "prompt": "cozy Japanese bedroom, low bed, desk with laptop, posters on wall, evening light, anime style",
67
+ },
68
+ "gate": {
69
+ "path": "bg_gate.png",
70
+ "label": "School Gate",
71
+ "prompt": "school gate at the end of the day, cherry blossom petals falling, sunset, students leaving, anime style",
72
+ },
73
+ "sunset_hill": {
74
+ "path": "bg_sunset_hill.png",
75
+ "label": "Sunset Hill",
76
+ "prompt": "hill overlooking the city at sunset, grass, wind, two silhouettes, romantic atmosphere, anime style",
77
+ },
78
+ }
79
+
80
+
81
+ @dataclass
82
+ class BackgroundInfo:
83
+ """Information about a background image."""
84
+ key: str
85
+ label: str
86
+ path: str # absolute path to the image file
87
+ is_placeholder: bool = False
88
+
89
+
90
+ class BackgroundProvider(ABC):
91
+ """Interface for providing background images."""
92
+
93
+ @abstractmethod
94
+ def get_background(self, key: str) -> BackgroundInfo:
95
+ """Get a background image by key. Returns info with path."""
96
+ ...
97
+
98
+ @abstractmethod
99
+ def list_available(self) -> list[str]:
100
+ """List all available background keys."""
101
+ ...
102
+
103
+ def get_prompt_for_gen(self, key: str) -> str:
104
+ """Get the generation prompt for a key (for live gen hook)."""
105
+ info = PRESET_BACKGROUNDS.get(key)
106
+ return info["prompt"] if info else f"anime style background, {key}"
107
+
108
+
109
+ class PresetBackgroundProvider(BackgroundProvider):
110
+ """Uses pre-rendered background images from static/bg/."""
111
+
112
+ def get_background(self, key: str) -> BackgroundInfo:
113
+ info = PRESET_BACKGROUNDS.get(key)
114
+ if not info:
115
+ # Fallback to first available
116
+ key = list(PRESET_BACKGROUNDS.keys())[0]
117
+ info = PRESET_BACKGROUNDS[key]
118
+
119
+ path = os.path.join(BG_DIR, info["path"])
120
+ is_placeholder = not os.path.exists(path)
121
+ if is_placeholder:
122
+ path = os.path.join(BG_DIR, "_placeholder.png")
123
+ # Use a default placeholder if we have one
124
+ if not os.path.exists(path):
125
+ path = self._make_placeholder(key)
126
+
127
+ return BackgroundInfo(
128
+ key=key,
129
+ label=info["label"],
130
+ path=path,
131
+ is_placeholder=is_placeholder,
132
+ )
133
+
134
+ def list_available(self) -> list[str]:
135
+ return list(PRESET_BACKGROUNDS.keys())
136
+
137
+ def _make_placeholder(self, key: str) -> str:
138
+ """Create a colored placeholder background."""
139
+ colors = {
140
+ "classroom": (200, 190, 160),
141
+ "hallway": (180, 175, 155),
142
+ "rooftop": (200, 180, 140),
143
+ "courtyard": (160, 190, 140),
144
+ "cafe": (180, 150, 120),
145
+ "park": (140, 180, 130),
146
+ "library": (160, 150, 130),
147
+ "home_room": (170, 140, 130),
148
+ "gate": (180, 170, 140),
149
+ "sunset_hill": (200, 150, 100),
150
+ }
151
+ try:
152
+ from PIL import Image, ImageDraw, ImageFont
153
+ r, g, b = colors.get(key, (150, 150, 150))
154
+ img = Image.new("RGB", (1024, 768), (r, g, b))
155
+ draw = ImageDraw.Draw(img)
156
+ draw.text((512, 384), key, fill=(255, 255, 255), anchor="mm")
157
+ os.makedirs(BG_DIR, exist_ok=True)
158
+ path = os.path.join(BG_DIR, "_placeholder.png")
159
+ img.save(path)
160
+ return path
161
+ except ImportError:
162
+ # Fallback — write a minimal PNG manually
163
+ path = os.path.join(BG_DIR, "_placeholder.png")
164
+ if not os.path.exists(path):
165
+ self._write_minimal_png(path, r, g, b)
166
+ return path
167
+
168
+ def _write_minimal_png(self, path: str, r: int, g: int, b: int):
169
+ """Write a minimal valid 1x1 PNG as absolute last resort."""
170
+ import struct, zlib
171
+ width, height = 1024, 768
172
+ raw = b""
173
+ for y in range(height):
174
+ raw += b"\x00" + bytes([r, g, b]) * width
175
+ compressed = zlib.compress(raw)
176
+ def chunk(ctype, data):
177
+ c = ctype + data
178
+ return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
179
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
180
+ png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", compressed) + chunk(b"IEND", b"")
181
+ os.makedirs(os.path.dirname(path), exist_ok=True)
182
+ with open(path, "wb") as f:
183
+ f.write(png)
184
+
185
+
186
+ class ComfyUIBackgroundProvider(PresetBackgroundProvider):
187
+ """Extends PresetBackgroundProvider with live gen for novel keys.
188
+
189
+ Live gen happens ONLY for keys outside the preset library — and per the
190
+ plan's hard rule it should be invoked from beat boundaries (casting /
191
+ pre-staging), never mid-scene-turn.
192
+ """
193
+
194
+ def __init__(self, comfy_host: str = "http://127.0.0.1:8188"):
195
+ super().__init__()
196
+ self.comfy_host = comfy_host
197
+ self._generated_paths: dict[str, str] = {}
198
+
199
+ def get_background(self, key: str) -> BackgroundInfo:
200
+ if key in PRESET_BACKGROUNDS:
201
+ return super().get_background(key)
202
+
203
+ # Novel key — generate once, then serve from disk
204
+ if key not in self._generated_paths:
205
+ path = self._generate_background(key)
206
+ if path:
207
+ self._generated_paths[key] = path
208
+
209
+ if key in self._generated_paths and os.path.exists(self._generated_paths[key]):
210
+ return BackgroundInfo(
211
+ key=key,
212
+ label=key.replace("_", " ").title(),
213
+ path=self._generated_paths[key],
214
+ is_placeholder=False,
215
+ )
216
+
217
+ # Generation failed — fall back to preset/placeholder behavior
218
+ return super().get_background(key)
219
+
220
+ def _generate_background(self, key: str) -> Optional[str]:
221
+ """Generate a background via ComfyUI txt2img, WAIT for completion,
222
+ and copy the result into static/bg/ so it can actually be served.
223
+
224
+ Returns the local path of the generated image, or None on failure.
225
+ """
226
+ prompt_text = self.get_prompt_for_gen(key)
227
+ try:
228
+ # Reuse the cast pipeline's ComfyUI helpers and model names
229
+ # (UNETLoader + Qwen CLIP/VAE — same trio as the validated
230
+ # expression sheet; anima is a UNET-only file, so
231
+ # CheckpointLoaderSimple would fail to load it).
232
+ from cast_pipeline import (_comfy_submit, _comfy_wait,
233
+ _comfy_fetch_image,
234
+ UNET_NAME, CLIP_NAME, VAE_NAME,
235
+ SAMPLER, SCHEDULER, STYLE_TAGS)
236
+
237
+ workflow = {
238
+ "3": {"class_type": "KSampler", "inputs": {"seed": abs(hash(key)) % (2**31), "steps": 30, "cfg": 4.0, "sampler_name": SAMPLER, "scheduler": SCHEDULER, "denoise": 1.0, "model": ["44", 0], "positive": ["6", 0], "negative": ["7", 0], "latent_image": ["5", 0]}},
239
+ "44": {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": UNET_NAME}},
240
+ "45": {"class_type": "CLIPLoader", "inputs": {"clip_name": CLIP_NAME, "type": "stable_diffusion", "device": "default"}},
241
+ "15": {"class_type": "VAELoader", "inputs": {"vae_name": VAE_NAME}},
242
+ "5": {"class_type": "EmptyLatentImage", "inputs": {"width": 1024, "height": 768, "batch_size": 1}},
243
+ "6": {"class_type": "CLIPTextEncode", "inputs": {"text": f"absurdres, {STYLE_TAGS}, {prompt_text}", "clip": ["45", 0]}},
244
+ "7": {"class_type": "CLIPTextEncode", "inputs": {"text": "worst quality, low quality, blurry", "clip": ["45", 0]}},
245
+ "8": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0], "vae": ["15", 0]}},
246
+ "9": {"class_type": "SaveImage", "inputs": {"filename_prefix": f"bg_{key}", "images": ["8", 0]}},
247
+ }
248
+
249
+ pid = _comfy_submit(workflow, f"bg_{key}")
250
+ result = _comfy_wait(pid, timeout=120)
251
+ if not result:
252
+ print(f"[bg] Generation failed/timed out for '{key}'")
253
+ return None
254
+
255
+ dst = os.path.join(BG_DIR, f"bg_{key}.png")
256
+ if not _comfy_fetch_image(result, dst):
257
+ return None
258
+ print(f"[bg] Generated background '{key}' -> {dst}")
259
+ return dst
260
+ except Exception as e:
261
+ print(f"[bg] Failed to generate background '{key}': {e}")
262
+ return None
263
+
264
+
265
+ # ═══════════════════════════════════════════════════════════════════════
266
+ # Voice Provider
267
+ # ═══════════════════════════════════════════════════════════════════════
268
+
269
+ @dataclass
270
+ class VoiceLine:
271
+ """A voice line ready for playback."""
272
+ text: str
273
+ character: str
274
+ emotion: str # happy, sad, angry, surprised, neutral, embarrassed
275
+ wav_path: Optional[str] = None
276
+ duration_ms: int = 0
277
+
278
+
279
+ class VoiceProvider(ABC):
280
+ """Interface for generating voice lines."""
281
+
282
+ @abstractmethod
283
+ def speak(self, text: str, character: str, emotion: str = "neutral",
284
+ voice_id: Optional[str] = None) -> VoiceLine:
285
+ """Generate a voiced line. Returns a VoiceLine with path to WAV.
286
+
287
+ Args:
288
+ text: The line to voice.
289
+ character: Character name (for filenames/metadata).
290
+ emotion: Emotion tag (happy, sad, angry, surprised, neutral, embarrassed).
291
+ voice_id: The character's locked voice (Character.voice_id).
292
+ Providers that support per-voice synthesis MUST honor it.
293
+ """
294
+ ...
295
+
296
+ @property
297
+ @abstractmethod
298
+ def name(self) -> str:
299
+ ...
300
+
301
+
302
+ class KokoroVoiceProvider(VoiceProvider):
303
+ """Fast TTS via Kokoro (local model on port 11436 by default)."""
304
+
305
+ def __init__(self, base_url: str = "http://localhost:11436"):
306
+ self.base_url = base_url
307
+ self._name = "kokoro"
308
+
309
+ @property
310
+ def name(self) -> str:
311
+ return self._name
312
+
313
+ def speak(self, text: str, character: str, emotion: str = "neutral",
314
+ voice_id: Optional[str] = None) -> VoiceLine:
315
+ try:
316
+ import urllib.request
317
+ import json as _json
318
+ payload = _json.dumps({"text": text, "voice": voice_id or "af_bella"}).encode()
319
+ req = urllib.request.Request(
320
+ f"{self.base_url}/v1/audio/speech",
321
+ data=payload,
322
+ headers={"Content-Type": "application/json"},
323
+ )
324
+ with urllib.request.urlopen(req, timeout=30) as resp:
325
+ data = resp.read()
326
+ wav_dir = os.path.join(ASSET_DIR, "voice")
327
+ os.makedirs(wav_dir, exist_ok=True)
328
+ wav_path = os.path.join(wav_dir, f"{character}_{hash(text) % 10000}.wav")
329
+ with open(wav_path, "wb") as f:
330
+ f.write(data)
331
+ return VoiceLine(text=text, character=character, emotion=emotion,
332
+ wav_path=wav_path, duration_ms=len(data) // 32)
333
+ except Exception as e:
334
+ print(f"[voice] Kokoro failed: {e}")
335
+ return VoiceLine(text=text, character=character, emotion=emotion)
336
+
337
+
338
+ class ChatterboxVoiceProvider(VoiceProvider):
339
+ """Emotion-rich voice via Chatterbox-Turbo."""
340
+
341
+ def __init__(self, base_url: str = "http://localhost:11437"):
342
+ self.base_url = base_url
343
+ self._name = "chatterbox"
344
+
345
+ @property
346
+ def name(self) -> str:
347
+ return self._name
348
+
349
+ def speak(self, text: str, character: str, emotion: str = "neutral",
350
+ voice_id: Optional[str] = None) -> VoiceLine:
351
+ try:
352
+ import urllib.request
353
+ import json as _json
354
+ payload = _json.dumps({
355
+ "text": text,
356
+ "speaker": voice_id or character,
357
+ "emotion": emotion,
358
+ }).encode()
359
+ req = urllib.request.Request(
360
+ f"{self.base_url}/tts",
361
+ data=payload,
362
+ headers={"Content-Type": "application/json"},
363
+ )
364
+ with urllib.request.urlopen(req, timeout=30) as resp:
365
+ data = resp.read()
366
+ wav_dir = os.path.join(ASSET_DIR, "voice")
367
+ os.makedirs(wav_dir, exist_ok=True)
368
+ wav_path = os.path.join(wav_dir, f"{character}_{emotion}_{hash(text) % 10000}.wav")
369
+ with open(wav_path, "wb") as f:
370
+ f.write(data)
371
+ return VoiceLine(text=text, character=character, emotion=emotion,
372
+ wav_path=wav_path, duration_ms=len(data) // 32)
373
+ except Exception as e:
374
+ print(f"[voice] Chatterbox failed: {e}")
375
+ return VoiceLine(text=text, character=character, emotion=emotion)
376
+
377
+
378
+ class MockVoiceProvider(VoiceProvider):
379
+ """No actual TTS — returns a voice line with just metadata (for testing)."""
380
+
381
+ def __init__(self):
382
+ self._name = "mock"
383
+
384
+ @property
385
+ def name(self) -> str:
386
+ return self._name
387
+
388
+ def speak(self, text: str, character: str, emotion: str = "neutral",
389
+ voice_id: Optional[str] = None) -> VoiceLine:
390
+ return VoiceLine(
391
+ text=text,
392
+ character=character,
393
+ emotion=emotion,
394
+ duration_ms=int(len(text) * 60), # ~60ms per character
395
+ )
396
+
397
+
398
+ def get_voice_provider(backend: str = "mock") -> VoiceProvider:
399
+ """Factory: returns the appropriate VoiceProvider based on backend name.
400
+
401
+ Args:
402
+ backend: "mock", "kokoro", "chatterbox"
403
+
404
+ Returns:
405
+ VoiceProvider instance.
406
+ """
407
+ if backend == "kokoro":
408
+ return KokoroVoiceProvider()
409
+ elif backend == "chatterbox":
410
+ return ChatterboxVoiceProvider()
411
+ return MockVoiceProvider()
412
+
413
+
414
+ def get_background_provider(backend: str = "preset") -> BackgroundProvider:
415
+ """Factory: returns the appropriate BackgroundProvider.
416
+
417
+ Args:
418
+ backend: "preset", "comfyui"
419
+
420
+ Returns:
421
+ BackgroundProvider instance.
422
+ """
423
+ if backend == "comfyui":
424
+ return ComfyUIBackgroundProvider()
425
+ return PresetBackgroundProvider()
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ torch
2
+ transformers>=4.56
3
+ accelerate
4
+ spaces
5
+ requests
6
+ pillow
7
+ numpy
static/bg/_placeholder.png ADDED
static/bg/bg_cafe.png ADDED
static/bg/bg_classroom.png ADDED

Git LFS Details

  • SHA256: bd9c85536a58b40c153057884a7888fbca3d677bdcf7feac22c83e3a45f3d7ec
  • Pointer size: 131 Bytes
  • Size of remote file: 211 kB
static/bg/bg_courtyard.png ADDED
static/bg/bg_gate.png ADDED
static/bg/bg_hallway.png ADDED

Git LFS Details

  • SHA256: 37d8cbbd1d512641baf2e05cea96aa85bcfee63b096f9514b89e3a46dee65f1e
  • Pointer size: 131 Bytes
  • Size of remote file: 603 kB
static/bg/bg_home_room.png ADDED
static/bg/bg_library.png ADDED

Git LFS Details

  • SHA256: f4e0afd77c6b4693c0dea3fe1a034345e1ac3612822e327495954ad2a27e1248
  • Pointer size: 131 Bytes
  • Size of remote file: 270 kB
static/bg/bg_park.png ADDED
static/bg/bg_rooftop.png ADDED
static/bg/bg_sunset_hill.png ADDED
static/bg/classroom_bg.png ADDED

Git LFS Details

  • SHA256: bd9c85536a58b40c153057884a7888fbca3d677bdcf7feac22c83e3a45f3d7ec
  • Pointer size: 131 Bytes
  • Size of remote file: 211 kB
static/sprites/curated/marie/_facegate_mask.png ADDED
static/sprites/curated/marie/_preframe/marie_angry.png ADDED

Git LFS Details

  • SHA256: 082d70483c0d05bdc41f58c1bd146afa1b81200423c80e077820581a290e12b5
  • Pointer size: 131 Bytes
  • Size of remote file: 361 kB
static/sprites/curated/marie/_preframe/marie_embarrassed.png ADDED

Git LFS Details

  • SHA256: 01a3c7e1f32bf84d0beed8ec41919978fcb7f75b80a90bb5afc7d2450ac15086
  • Pointer size: 131 Bytes
  • Size of remote file: 362 kB
static/sprites/curated/marie/_preframe/marie_laugh.png ADDED

Git LFS Details

  • SHA256: ecf4ad2e44915da7a87688dd8b096be49a8a5af342f8fa25979ee179ca8eca86
  • Pointer size: 131 Bytes
  • Size of remote file: 362 kB
static/sprites/curated/marie/_preframe/marie_neutral.png ADDED

Git LFS Details

  • SHA256: 37ea9052905c58b62acc8262d97530efb014a43047ca7b41ec0b61483b7a191e
  • Pointer size: 131 Bytes
  • Size of remote file: 362 kB
static/sprites/curated/marie/_preframe/marie_sad.png ADDED

Git LFS Details

  • SHA256: 4c4c97af6e429b83baa2d2acfdbaf2cea948769808eaea86720ff864c2ffcb2f
  • Pointer size: 131 Bytes
  • Size of remote file: 361 kB
static/sprites/curated/marie/_preframe/marie_smile.png ADDED

Git LFS Details

  • SHA256: bbef51574fd1594eaf076e13e10722963486cdcf0b79cd11e7c76aa5bd8ade1a
  • Pointer size: 131 Bytes
  • Size of remote file: 361 kB
static/sprites/curated/marie/_preframe/marie_surprised.png ADDED

Git LFS Details

  • SHA256: d45737f7972fce06ea0242ea46c1e610eefabeeeeaaf52b5aa917e75ac2a56dd
  • Pointer size: 131 Bytes
  • Size of remote file: 362 kB
static/sprites/curated/marie/marie_angry.png ADDED

Git LFS Details

  • SHA256: 3eafdee475aa29e0220ac8f64b7dcbf9df2dc3af359475cc47e6cdcd4f28540e
  • Pointer size: 131 Bytes
  • Size of remote file: 594 kB
static/sprites/curated/marie/marie_base.png ADDED

Git LFS Details

  • SHA256: c216419dcb2376b932016ff7a80f85016077d4b6f79718c161ecbb63e2d6d26d
  • Pointer size: 131 Bytes
  • Size of remote file: 305 kB
static/sprites/curated/marie/marie_embarrassed.png ADDED

Git LFS Details

  • SHA256: 8691392cbd3e6112a7a49f03dc4e09a7e7ea7eb3d3531b75ef393bbb3d38159e
  • Pointer size: 131 Bytes
  • Size of remote file: 595 kB
static/sprites/curated/marie/marie_laugh.png ADDED

Git LFS Details

  • SHA256: c472d1f3a4f820da7f999fa1a5cb9f190892a2ffa2dd2eba326c183379202fcb
  • Pointer size: 131 Bytes
  • Size of remote file: 595 kB
static/sprites/curated/marie/marie_neutral.png ADDED

Git LFS Details

  • SHA256: 3c7be07288d554497f64f94278c1dea3f3c0b8d7e7daa6cfeea85117531b15a8
  • Pointer size: 131 Bytes
  • Size of remote file: 595 kB
static/sprites/curated/marie/marie_sad.png ADDED

Git LFS Details

  • SHA256: 0b5cdf3b94a7d5b2a1688b431014d199af975839a59793c3745b5e1c28389aaf
  • Pointer size: 131 Bytes
  • Size of remote file: 594 kB
static/sprites/curated/marie/marie_smile.png ADDED

Git LFS Details

  • SHA256: 9e758678fd3c9c6b615b2b7dba3ff2310dea3298f677d657b21a3be3aff3814d
  • Pointer size: 131 Bytes
  • Size of remote file: 594 kB
static/sprites/curated/marie/marie_surprised.png ADDED

Git LFS Details

  • SHA256: 6a5c71a52347e64180fb89606a10556d970f488a518e22b27ae741b22566be8e
  • Pointer size: 131 Bytes
  • Size of remote file: 595 kB
static/sprites/curated/sakura/_facegate_mask.png ADDED
static/sprites/curated/sakura/_preframe/sakura_angry.png ADDED

Git LFS Details

  • SHA256: e893a6009916f6b303ba13b07945b6781d0cd4e4bce78435482f8241f6035016
  • Pointer size: 131 Bytes
  • Size of remote file: 215 kB
static/sprites/curated/sakura/_preframe/sakura_embarrassed.png ADDED

Git LFS Details

  • SHA256: 39704879f941714a464b7fe60c31b1ea625e7c9b624bad63c9e318de9e29fd6f
  • Pointer size: 131 Bytes
  • Size of remote file: 216 kB
static/sprites/curated/sakura/_preframe/sakura_laugh.png ADDED

Git LFS Details

  • SHA256: a76cc1f841ae8ac5b44d48fc17f446b9a890dff3fa42a65103c5272b9ffe5110
  • Pointer size: 131 Bytes
  • Size of remote file: 216 kB
static/sprites/curated/sakura/_preframe/sakura_neutral.png ADDED

Git LFS Details

  • SHA256: d6e1c2922127fa3769a409f3954db6de4808fe92497a176dd7302070b22af07f
  • Pointer size: 131 Bytes
  • Size of remote file: 216 kB
static/sprites/curated/sakura/_preframe/sakura_sad.png ADDED

Git LFS Details

  • SHA256: b3d6bd0114cab5681ec23d2ebb06a0638446d29cbae02b0bd80bc9e3fd2f8051
  • Pointer size: 131 Bytes
  • Size of remote file: 215 kB
static/sprites/curated/sakura/_preframe/sakura_smile.png ADDED

Git LFS Details

  • SHA256: ed5877bb5c6a08056dffc09896148fd8dec020f277e1e91ff09f5f758c019370
  • Pointer size: 131 Bytes
  • Size of remote file: 215 kB
static/sprites/curated/sakura/_preframe/sakura_surprised.png ADDED

Git LFS Details

  • SHA256: 0d8089232495891aaf7472f070c48f497653f66e3c5a05a1edb876fb51dcdb62
  • Pointer size: 131 Bytes
  • Size of remote file: 215 kB
static/sprites/curated/sakura/sakura_angry.png ADDED

Git LFS Details

  • SHA256: 1f345d0375b6035ea8f3e030d214ab82580d7b124dc7cf6f1078112a7f5d8992
  • Pointer size: 131 Bytes
  • Size of remote file: 351 kB