chore(reformat): reformat existing code with ruff.
Browse files- app.py +13 -0
- modal_app.py +5 -4
- visualnovel/config.py +2 -2
- visualnovel/llm.py +6 -1
- visualnovel/memory.py +10 -8
- visualnovel/orchestrator.py +5 -2
- visualnovel/painter.py +96 -36
- visualnovel/prompts.py +5 -1
- visualnovel/schemas.py +12 -6
- visualnovel/state.py +1 -1
- visualnovel/tts.py +60 -34
app.py
CHANGED
|
@@ -15,6 +15,19 @@ Run modes
|
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
# ββ Mode selection: must run BEFORE any visualnovel import ββββββββββββββββββ
|
| 19 |
# config.py reads os.getenv() at import time via load_dotenv(), so we must
|
| 20 |
# set the env vars first.
|
|
|
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
+
# ββ Compatibility shim: must run BEFORE gradio is imported ββββββββββββββββββ
|
| 19 |
+
# Gradio 6.15.2 calls spaces.gradio_auto_wrap() in block_function.py, but the
|
| 20 |
+
# spaces==0.50.4 package injected by HF's ZeroGPU infrastructure does not have
|
| 21 |
+
# that attribute yet. A no-op is safe: every GPU endpoint already has @spaces.GPU
|
| 22 |
+
# applied manually below, so ZeroGPU allocation is still correct.
|
| 23 |
+
try:
|
| 24 |
+
import spaces as _spaces
|
| 25 |
+
|
| 26 |
+
if not hasattr(_spaces, "gradio_auto_wrap"):
|
| 27 |
+
_spaces.gradio_auto_wrap = lambda fn: fn
|
| 28 |
+
except ImportError:
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
# ββ Mode selection: must run BEFORE any visualnovel import ββββββββββββββββββ
|
| 32 |
# config.py reads os.getenv() at import time via load_dotenv(), so we must
|
| 33 |
# set the env vars first.
|
modal_app.py
CHANGED
|
@@ -41,12 +41,12 @@ painter_image = (
|
|
| 41 |
"diffusers==0.35.1",
|
| 42 |
"transformers==4.47.1",
|
| 43 |
"accelerate==1.8.1",
|
| 44 |
-
"peft",
|
| 45 |
"safetensors",
|
| 46 |
"Pillow",
|
| 47 |
"huggingface_hub",
|
| 48 |
-
"rembg",
|
| 49 |
-
"onnxruntime",
|
| 50 |
)
|
| 51 |
)
|
| 52 |
|
|
@@ -69,7 +69,7 @@ app = modal.App("vn-app")
|
|
| 69 |
GGUF_REPO = "Qwen/Qwen3-14B-GGUF"
|
| 70 |
GGUF_FILE = "Qwen3-14B-Q8_0.gguf"
|
| 71 |
# Painter: SDXL-base-1.0 + ByteDance Lightning LoRA (matches local SdxlLightningPainter)
|
| 72 |
-
SDXL_BASE_REPO
|
| 73 |
SDXL_BASE_LOCAL = "sdxl-base"
|
| 74 |
LIGHTNING_LORA_REPO = "ByteDance/SDXL-Lightning"
|
| 75 |
LIGHTNING_LORA_FILE = "sdxl_lightning_4step_lora.safetensors"
|
|
@@ -243,6 +243,7 @@ class ModalPainterBackend:
|
|
| 243 |
|
| 244 |
if remove_bg:
|
| 245 |
from rembg import remove # noqa: PLC0415
|
|
|
|
| 246 |
img = remove(img) # returns RGBA PIL image
|
| 247 |
|
| 248 |
buf = io.BytesIO()
|
|
|
|
| 41 |
"diffusers==0.35.1",
|
| 42 |
"transformers==4.47.1",
|
| 43 |
"accelerate==1.8.1",
|
| 44 |
+
"peft", # required by diffusers load_lora_weights / fuse_lora
|
| 45 |
"safetensors",
|
| 46 |
"Pillow",
|
| 47 |
"huggingface_hub",
|
| 48 |
+
"rembg", # background removal for sprites
|
| 49 |
+
"onnxruntime", # rembg runtime dep
|
| 50 |
)
|
| 51 |
)
|
| 52 |
|
|
|
|
| 69 |
GGUF_REPO = "Qwen/Qwen3-14B-GGUF"
|
| 70 |
GGUF_FILE = "Qwen3-14B-Q8_0.gguf"
|
| 71 |
# Painter: SDXL-base-1.0 + ByteDance Lightning LoRA (matches local SdxlLightningPainter)
|
| 72 |
+
SDXL_BASE_REPO = "stabilityai/stable-diffusion-xl-base-1.0"
|
| 73 |
SDXL_BASE_LOCAL = "sdxl-base"
|
| 74 |
LIGHTNING_LORA_REPO = "ByteDance/SDXL-Lightning"
|
| 75 |
LIGHTNING_LORA_FILE = "sdxl_lightning_4step_lora.safetensors"
|
|
|
|
| 243 |
|
| 244 |
if remove_bg:
|
| 245 |
from rembg import remove # noqa: PLC0415
|
| 246 |
+
|
| 247 |
img = remove(img) # returns RGBA PIL image
|
| 248 |
|
| 249 |
buf = io.BytesIO()
|
visualnovel/config.py
CHANGED
|
@@ -75,7 +75,7 @@ LLM_GGUF_FILE = os.getenv("VN_LLM_GGUF_FILE", "Qwen3-14B-Q4_K_M.gguf")
|
|
| 75 |
|
| 76 |
# Defaults are backend-aware so the app works without an explicit .env override.
|
| 77 |
_lightning_model = "stabilityai/stable-diffusion-xl-base-1.0"
|
| 78 |
-
_lightning_lora
|
| 79 |
IMAGE_MODEL = os.getenv(
|
| 80 |
"VN_IMAGE_MODEL",
|
| 81 |
_lightning_model if IMAGE_BACKEND == "lightning" else "stabilityai/sdxl-turbo",
|
|
@@ -98,7 +98,7 @@ TTS_BACKEND = os.getenv("VN_TTS_BACKEND", "mock") # mock | kokoro
|
|
| 98 |
CONTEXT_TOKEN_BUDGET = int(os.getenv("VN_CTX_BUDGET", "3500")) # the "Thousand Token Wood"
|
| 99 |
RECENT_TURNS_K = int(os.getenv("VN_RECENT_K", "6"))
|
| 100 |
COMPACT_EVERY = int(os.getenv("VN_COMPACT_EVERY", "8")) # also compacts when over budget
|
| 101 |
-
IMAGE_SIZE
|
| 102 |
_default_steps = "4" if IMAGE_BACKEND == "lightning" else "2"
|
| 103 |
IMAGE_STEPS = int(os.getenv("VN_IMAGE_STEPS", _default_steps))
|
| 104 |
# Backdrops use a non-zero guidance so the negative prompt (no people) is actually applied.
|
|
|
|
| 75 |
|
| 76 |
# Defaults are backend-aware so the app works without an explicit .env override.
|
| 77 |
_lightning_model = "stabilityai/stable-diffusion-xl-base-1.0"
|
| 78 |
+
_lightning_lora = "ByteDance/SDXL-Lightning::sdxl_lightning_4step_lora.safetensors"
|
| 79 |
IMAGE_MODEL = os.getenv(
|
| 80 |
"VN_IMAGE_MODEL",
|
| 81 |
_lightning_model if IMAGE_BACKEND == "lightning" else "stabilityai/sdxl-turbo",
|
|
|
|
| 98 |
CONTEXT_TOKEN_BUDGET = int(os.getenv("VN_CTX_BUDGET", "3500")) # the "Thousand Token Wood"
|
| 99 |
RECENT_TURNS_K = int(os.getenv("VN_RECENT_K", "6"))
|
| 100 |
COMPACT_EVERY = int(os.getenv("VN_COMPACT_EVERY", "8")) # also compacts when over budget
|
| 101 |
+
IMAGE_SIZE = int(os.getenv("VN_IMAGE_SIZE", "768"))
|
| 102 |
_default_steps = "4" if IMAGE_BACKEND == "lightning" else "2"
|
| 103 |
IMAGE_STEPS = int(os.getenv("VN_IMAGE_STEPS", _default_steps))
|
| 104 |
# Backdrops use a non-zero guidance so the negative prompt (no people) is actually applied.
|
visualnovel/llm.py
CHANGED
|
@@ -23,7 +23,12 @@ from typing import Any, Protocol
|
|
| 23 |
|
| 24 |
def _safe_print(msg: str) -> None:
|
| 25 |
"""Print to stdout, replacing unencodable chars (cp1252 on Windows terminals)."""
|
| 26 |
-
print(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
from . import config
|
| 29 |
|
|
|
|
| 23 |
|
| 24 |
def _safe_print(msg: str) -> None:
|
| 25 |
"""Print to stdout, replacing unencodable chars (cp1252 on Windows terminals)."""
|
| 26 |
+
print(
|
| 27 |
+
msg.encode(sys.stdout.encoding or "utf-8", errors="replace").decode(
|
| 28 |
+
sys.stdout.encoding or "utf-8", errors="replace"
|
| 29 |
+
)
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
|
| 33 |
from . import config
|
| 34 |
|
visualnovel/memory.py
CHANGED
|
@@ -32,35 +32,37 @@ def assemble_context(state: GameState, player_input: str) -> str:
|
|
| 32 |
if cid not in state.scene.present
|
| 33 |
]
|
| 34 |
if off_stage:
|
| 35 |
-
parts.append(
|
|
|
|
|
|
|
| 36 |
|
| 37 |
# ββ Ending milestone hints β injected when a character hits Β±100 ββββββββββ
|
| 38 |
for ch in state.characters.values():
|
| 39 |
if ch.relationship >= 100 and ch.id in state.scene.present:
|
| 40 |
parts.append(
|
| 41 |
f"*** ROMANCE PEAK: {ch.name}'s affection is at MAX (100/100). ***\n"
|
| 42 |
-
f
|
| 43 |
-
f
|
| 44 |
)
|
| 45 |
elif ch.relationship <= -100 and ch.id in state.scene.present:
|
| 46 |
parts.append(
|
| 47 |
f"*** RELATIONSHIP BROKEN: {ch.name} is at MIN (-100/100). ***\n"
|
| 48 |
-
f
|
| 49 |
-
f
|
| 50 |
-
f
|
| 51 |
)
|
| 52 |
# If scene already empty and not yet ended β nudge for defeat
|
| 53 |
if not state.scene.present and state.beat != "ended" and state.characters:
|
| 54 |
parts.append(
|
| 55 |
"*** THE STAGE IS EMPTY β the wanderer is completely alone. ***\n"
|
| 56 |
-
|
| 57 |
)
|
| 58 |
|
| 59 |
# Surface the current music track prominently so the LLM doesn't switch it needlessly.
|
| 60 |
current_music = state.flags.get("current_music")
|
| 61 |
if current_music:
|
| 62 |
parts.append(
|
| 63 |
-
f
|
| 64 |
f"shift justifies a change (max once every 6-8 turns)."
|
| 65 |
)
|
| 66 |
|
|
|
|
| 32 |
if cid not in state.scene.present
|
| 33 |
]
|
| 34 |
if off_stage:
|
| 35 |
+
parts.append(
|
| 36 |
+
"KNOWN BUT OFF STAGE (can return β use their existing id):\n" + "\n".join(off_stage)
|
| 37 |
+
)
|
| 38 |
|
| 39 |
# ββ Ending milestone hints β injected when a character hits Β±100 ββββββββββ
|
| 40 |
for ch in state.characters.values():
|
| 41 |
if ch.relationship >= 100 and ch.id in state.scene.present:
|
| 42 |
parts.append(
|
| 43 |
f"*** ROMANCE PEAK: {ch.name}'s affection is at MAX (100/100). ***\n"
|
| 44 |
+
f'This turn MUST be their heartfelt confession. Emit ending(kind="warm") '
|
| 45 |
+
f'with a poetic 2-4 sentence epilogue. Also emit set_music="romantic".'
|
| 46 |
)
|
| 47 |
elif ch.relationship <= -100 and ch.id in state.scene.present:
|
| 48 |
parts.append(
|
| 49 |
f"*** RELATIONSHIP BROKEN: {ch.name} is at MIN (-100/100). ***\n"
|
| 50 |
+
f'They storm off with a final cutting line. MUST emit exit_character="{ch.id}". '
|
| 51 |
+
f'If the stage becomes empty after this, also emit ending(kind="defeat") '
|
| 52 |
+
f'with a dark 2-4 sentence epilogue. Emit set_music="sad".'
|
| 53 |
)
|
| 54 |
# If scene already empty and not yet ended β nudge for defeat
|
| 55 |
if not state.scene.present and state.beat != "ended" and state.characters:
|
| 56 |
parts.append(
|
| 57 |
"*** THE STAGE IS EMPTY β the wanderer is completely alone. ***\n"
|
| 58 |
+
'Emit ending(kind="defeat") with a melancholic epilogue. Emit set_music="sad".'
|
| 59 |
)
|
| 60 |
|
| 61 |
# Surface the current music track prominently so the LLM doesn't switch it needlessly.
|
| 62 |
current_music = state.flags.get("current_music")
|
| 63 |
if current_music:
|
| 64 |
parts.append(
|
| 65 |
+
f'CURRENT BGM: "{current_music}" β leave set_music null unless a MAJOR tonal '
|
| 66 |
f"shift justifies a change (max once every 6-8 turns)."
|
| 67 |
)
|
| 68 |
|
visualnovel/orchestrator.py
CHANGED
|
@@ -106,8 +106,11 @@ def init_world(llm: LLMBackend, setup: SetupForm) -> tuple[GameState, DirectorOu
|
|
| 106 |
# direct_turn
|
| 107 |
# --------------------------------------------------------------------------- #
|
| 108 |
def direct_turn(
|
| 109 |
-
llm: LLMBackend,
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
) -> DirectorOutput:
|
| 112 |
if config.USE_MOCK:
|
| 113 |
return _mock_turn(state, player_input)
|
|
|
|
| 106 |
# direct_turn
|
| 107 |
# --------------------------------------------------------------------------- #
|
| 108 |
def direct_turn(
|
| 109 |
+
llm: LLMBackend,
|
| 110 |
+
state: GameState,
|
| 111 |
+
player_input: str,
|
| 112 |
+
action: str = "talk",
|
| 113 |
+
target: str = "",
|
| 114 |
) -> DirectorOutput:
|
| 115 |
if config.USE_MOCK:
|
| 116 |
return _mock_turn(state, player_input)
|
visualnovel/painter.py
CHANGED
|
@@ -61,9 +61,9 @@ class Painter(Protocol):
|
|
| 61 |
# fixed prefix + suffix stay within budget. Rough budgets per prompt:
|
| 62 |
# backdrop : 15 (prefix) + 18 (desc) + 5 (mood) + 12 (style) + 2 (suffix) β 52
|
| 63 |
# sprite : 12 (prefix) + 10 (style) + 20 (appearance) + 5 (mood+suffix) β 47
|
| 64 |
-
_DESC_WORDS
|
| 65 |
-
_STYLE_WORDS = 12
|
| 66 |
-
_APP_WORDS
|
| 67 |
|
| 68 |
|
| 69 |
def _w(text: str, n: int) -> str:
|
|
@@ -81,7 +81,7 @@ def _backdrop_style(style_guide: str) -> str:
|
|
| 81 |
|
| 82 |
def backdrop_prompt(state: GameState) -> str:
|
| 83 |
style = _backdrop_style(state.style_guide)
|
| 84 |
-
desc
|
| 85 |
return (
|
| 86 |
f"background art, empty scenic environment, wide establishing shot, no humans, "
|
| 87 |
f"uninhabited location, {desc}, {state.scene.mood} atmosphere, "
|
|
@@ -91,7 +91,7 @@ def backdrop_prompt(state: GameState) -> str:
|
|
| 91 |
|
| 92 |
def sprite_prompt(state: GameState, ch: Character) -> str:
|
| 93 |
style = _w(state.style_guide, _STYLE_WORDS)
|
| 94 |
-
app
|
| 95 |
return (
|
| 96 |
f"vtuber character design, solo, single character, full-body, centered, "
|
| 97 |
f"{style}, {app}, {ch.mood} expression, "
|
|
@@ -122,7 +122,10 @@ class PainterBase:
|
|
| 122 |
def backdrop(self, state: GameState) -> Path:
|
| 123 |
prompt = backdrop_prompt(state)
|
| 124 |
return self._cached(
|
| 125 |
-
kind="bg",
|
|
|
|
|
|
|
|
|
|
| 126 |
negative_prompt=_BACKDROP_NEGATIVE,
|
| 127 |
guidance_scale=config.BACKDROP_GUIDANCE,
|
| 128 |
)
|
|
@@ -130,7 +133,10 @@ class PainterBase:
|
|
| 130 |
def sprite(self, state: GameState, ch: Character) -> Path:
|
| 131 |
prompt = sprite_prompt(state, ch)
|
| 132 |
return self._cached(
|
| 133 |
-
kind="sprite",
|
|
|
|
|
|
|
|
|
|
| 134 |
negative_prompt=_SPRITE_NEGATIVE,
|
| 135 |
)
|
| 136 |
|
|
@@ -138,35 +144,54 @@ class PainterBase:
|
|
| 138 |
"""Generate (and cache) a special ending illustration based on the ending kind."""
|
| 139 |
style = _backdrop_style(state.style_guide)
|
| 140 |
_ENDING_DESCS: dict[str, tuple[str, str]] = {
|
| 141 |
-
"warm":
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
}
|
| 154 |
desc, mood = _ENDING_DESCS.get(kind, _ENDING_DESCS["warm"])
|
| 155 |
prompt = (
|
| 156 |
f"background art, empty scenic environment, wide establishing shot, no humans, "
|
| 157 |
f"uninhabited location, {desc}, {mood} atmosphere, {style}, no text"
|
| 158 |
)
|
| 159 |
-
seed = (state.seed * 1234567 + sum(ord(c) for c in kind)) % (2
|
| 160 |
return self._cached(
|
| 161 |
-
kind="ending",
|
|
|
|
|
|
|
|
|
|
| 162 |
negative_prompt=_BACKDROP_NEGATIVE,
|
| 163 |
guidance_scale=config.BACKDROP_GUIDANCE,
|
| 164 |
)
|
| 165 |
|
| 166 |
# -- internals --
|
| 167 |
-
def _cached(
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
h = hashlib.sha1(
|
| 171 |
f"{kind}|{prompt}|{negative_prompt}|{seed}|{guidance_scale}".encode()
|
| 172 |
).hexdigest()[:12]
|
|
@@ -175,15 +200,26 @@ class PainterBase:
|
|
| 175 |
collector.record_cache(hit=True)
|
| 176 |
return path
|
| 177 |
collector.record_cache(hit=False)
|
| 178 |
-
img = self._render(
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
if transform is not None:
|
| 181 |
img = transform(img)
|
| 182 |
img.save(path)
|
| 183 |
return path
|
| 184 |
|
| 185 |
-
def _render(
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
raise NotImplementedError
|
| 188 |
|
| 189 |
|
|
@@ -191,8 +227,14 @@ class PainterBase:
|
|
| 191 |
# Mock β labelled placeholder, no models
|
| 192 |
# --------------------------------------------------------------------------- #
|
| 193 |
class MockPainter(PainterBase):
|
| 194 |
-
def _render(
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
from PIL import Image, ImageDraw # noqa: PLC0415
|
| 197 |
|
| 198 |
# deterministic colour from the seed so the same entity keeps the same placeholder
|
|
@@ -265,8 +307,14 @@ class SdxlTurboPainter(PainterBase):
|
|
| 265 |
transform=remove, # removes background β transparent PNG
|
| 266 |
)
|
| 267 |
|
| 268 |
-
def _render(
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
gen = self.torch.Generator(device=self.device).manual_seed(seed)
|
| 271 |
result = self.pipe(
|
| 272 |
prompt=prompt,
|
|
@@ -321,8 +369,14 @@ class SdxlLightningPainter(PainterBase):
|
|
| 321 |
transform=remove, # removes background β transparent PNG
|
| 322 |
)
|
| 323 |
|
| 324 |
-
def _render(
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
gen = self.torch.Generator(device=self.device).manual_seed(seed)
|
| 327 |
result = self.pipe(
|
| 328 |
prompt=prompt,
|
|
@@ -379,8 +433,14 @@ class ModalPainter(PainterBase):
|
|
| 379 |
img.save(path)
|
| 380 |
return path
|
| 381 |
|
| 382 |
-
def _render(
|
| 383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
import io # noqa: PLC0415
|
| 385 |
|
| 386 |
from PIL import Image # noqa: PLC0415
|
|
|
|
| 61 |
# fixed prefix + suffix stay within budget. Rough budgets per prompt:
|
| 62 |
# backdrop : 15 (prefix) + 18 (desc) + 5 (mood) + 12 (style) + 2 (suffix) β 52
|
| 63 |
# sprite : 12 (prefix) + 10 (style) + 20 (appearance) + 5 (mood+suffix) β 47
|
| 64 |
+
_DESC_WORDS = 18 # scene description budget
|
| 65 |
+
_STYLE_WORDS = 12 # style_guide budget in prompts
|
| 66 |
+
_APP_WORDS = 20 # character appearance budget
|
| 67 |
|
| 68 |
|
| 69 |
def _w(text: str, n: int) -> str:
|
|
|
|
| 81 |
|
| 82 |
def backdrop_prompt(state: GameState) -> str:
|
| 83 |
style = _backdrop_style(state.style_guide)
|
| 84 |
+
desc = _w(state.scene.description, _DESC_WORDS)
|
| 85 |
return (
|
| 86 |
f"background art, empty scenic environment, wide establishing shot, no humans, "
|
| 87 |
f"uninhabited location, {desc}, {state.scene.mood} atmosphere, "
|
|
|
|
| 91 |
|
| 92 |
def sprite_prompt(state: GameState, ch: Character) -> str:
|
| 93 |
style = _w(state.style_guide, _STYLE_WORDS)
|
| 94 |
+
app = _w(ch.appearance, _APP_WORDS)
|
| 95 |
return (
|
| 96 |
f"vtuber character design, solo, single character, full-body, centered, "
|
| 97 |
f"{style}, {app}, {ch.mood} expression, "
|
|
|
|
| 122 |
def backdrop(self, state: GameState) -> Path:
|
| 123 |
prompt = backdrop_prompt(state)
|
| 124 |
return self._cached(
|
| 125 |
+
kind="bg",
|
| 126 |
+
key=state.scene.id,
|
| 127 |
+
prompt=prompt,
|
| 128 |
+
seed=state.scene.backdrop_seed,
|
| 129 |
negative_prompt=_BACKDROP_NEGATIVE,
|
| 130 |
guidance_scale=config.BACKDROP_GUIDANCE,
|
| 131 |
)
|
|
|
|
| 133 |
def sprite(self, state: GameState, ch: Character) -> Path:
|
| 134 |
prompt = sprite_prompt(state, ch)
|
| 135 |
return self._cached(
|
| 136 |
+
kind="sprite",
|
| 137 |
+
key=f"{ch.id}.{ch.mood}",
|
| 138 |
+
prompt=prompt,
|
| 139 |
+
seed=ch.sprite_seed,
|
| 140 |
negative_prompt=_SPRITE_NEGATIVE,
|
| 141 |
)
|
| 142 |
|
|
|
|
| 144 |
"""Generate (and cache) a special ending illustration based on the ending kind."""
|
| 145 |
style = _backdrop_style(state.style_guide)
|
| 146 |
_ENDING_DESCS: dict[str, tuple[str, str]] = {
|
| 147 |
+
"warm": (
|
| 148 |
+
"cherry blossom park at golden sunset, petals falling softly, "
|
| 149 |
+
"empty bench under blooming trees, warm amber light filtering through branches",
|
| 150 |
+
"romantic warm",
|
| 151 |
+
),
|
| 152 |
+
"bittersweet": (
|
| 153 |
+
"misty autumn street at twilight, fallen leaves on cobblestones, "
|
| 154 |
+
"distant lamplight, empty path fading into soft fog",
|
| 155 |
+
"melancholic gentle",
|
| 156 |
+
),
|
| 157 |
+
"strange": (
|
| 158 |
+
"surreal moonlit garden, glowing silver motes drifting upward, "
|
| 159 |
+
"impossible geometry, dreamlike luminous plants",
|
| 160 |
+
"mysterious ethereal",
|
| 161 |
+
),
|
| 162 |
+
"defeat": (
|
| 163 |
+
"rain-soaked empty park bench at night, fallen leaves in puddles, "
|
| 164 |
+
"single dim lamppost, deserted street receding into darkness",
|
| 165 |
+
"desolate cold",
|
| 166 |
+
),
|
| 167 |
}
|
| 168 |
desc, mood = _ENDING_DESCS.get(kind, _ENDING_DESCS["warm"])
|
| 169 |
prompt = (
|
| 170 |
f"background art, empty scenic environment, wide establishing shot, no humans, "
|
| 171 |
f"uninhabited location, {desc}, {mood} atmosphere, {style}, no text"
|
| 172 |
)
|
| 173 |
+
seed = (state.seed * 1234567 + sum(ord(c) for c in kind)) % (2**31)
|
| 174 |
return self._cached(
|
| 175 |
+
kind="ending",
|
| 176 |
+
key=kind,
|
| 177 |
+
prompt=prompt,
|
| 178 |
+
seed=seed,
|
| 179 |
negative_prompt=_BACKDROP_NEGATIVE,
|
| 180 |
guidance_scale=config.BACKDROP_GUIDANCE,
|
| 181 |
)
|
| 182 |
|
| 183 |
# -- internals --
|
| 184 |
+
def _cached(
|
| 185 |
+
self,
|
| 186 |
+
*,
|
| 187 |
+
kind: str,
|
| 188 |
+
key: str,
|
| 189 |
+
prompt: str,
|
| 190 |
+
seed: int,
|
| 191 |
+
negative_prompt: str = "",
|
| 192 |
+
guidance_scale: float = 0.0,
|
| 193 |
+
transform=None,
|
| 194 |
+
) -> Path:
|
| 195 |
h = hashlib.sha1(
|
| 196 |
f"{kind}|{prompt}|{negative_prompt}|{seed}|{guidance_scale}".encode()
|
| 197 |
).hexdigest()[:12]
|
|
|
|
| 200 |
collector.record_cache(hit=True)
|
| 201 |
return path
|
| 202 |
collector.record_cache(hit=False)
|
| 203 |
+
img = self._render(
|
| 204 |
+
prompt,
|
| 205 |
+
seed,
|
| 206 |
+
config.IMAGE_SIZE,
|
| 207 |
+
negative_prompt=negative_prompt,
|
| 208 |
+
guidance_scale=guidance_scale,
|
| 209 |
+
)
|
| 210 |
if transform is not None:
|
| 211 |
img = transform(img)
|
| 212 |
img.save(path)
|
| 213 |
return path
|
| 214 |
|
| 215 |
+
def _render(
|
| 216 |
+
self,
|
| 217 |
+
prompt: str,
|
| 218 |
+
seed: int,
|
| 219 |
+
size: int,
|
| 220 |
+
negative_prompt: str = "",
|
| 221 |
+
guidance_scale: float = 0.0,
|
| 222 |
+
):
|
| 223 |
raise NotImplementedError
|
| 224 |
|
| 225 |
|
|
|
|
| 227 |
# Mock β labelled placeholder, no models
|
| 228 |
# --------------------------------------------------------------------------- #
|
| 229 |
class MockPainter(PainterBase):
|
| 230 |
+
def _render(
|
| 231 |
+
self,
|
| 232 |
+
prompt: str,
|
| 233 |
+
seed: int,
|
| 234 |
+
size: int,
|
| 235 |
+
negative_prompt: str = "",
|
| 236 |
+
guidance_scale: float = 0.0,
|
| 237 |
+
):
|
| 238 |
from PIL import Image, ImageDraw # noqa: PLC0415
|
| 239 |
|
| 240 |
# deterministic colour from the seed so the same entity keeps the same placeholder
|
|
|
|
| 307 |
transform=remove, # removes background β transparent PNG
|
| 308 |
)
|
| 309 |
|
| 310 |
+
def _render(
|
| 311 |
+
self,
|
| 312 |
+
prompt: str,
|
| 313 |
+
seed: int,
|
| 314 |
+
size: int,
|
| 315 |
+
negative_prompt: str = "",
|
| 316 |
+
guidance_scale: float = 0.0,
|
| 317 |
+
):
|
| 318 |
gen = self.torch.Generator(device=self.device).manual_seed(seed)
|
| 319 |
result = self.pipe(
|
| 320 |
prompt=prompt,
|
|
|
|
| 369 |
transform=remove, # removes background β transparent PNG
|
| 370 |
)
|
| 371 |
|
| 372 |
+
def _render(
|
| 373 |
+
self,
|
| 374 |
+
prompt: str,
|
| 375 |
+
seed: int,
|
| 376 |
+
size: int,
|
| 377 |
+
negative_prompt: str = "",
|
| 378 |
+
guidance_scale: float = 0.0,
|
| 379 |
+
):
|
| 380 |
gen = self.torch.Generator(device=self.device).manual_seed(seed)
|
| 381 |
result = self.pipe(
|
| 382 |
prompt=prompt,
|
|
|
|
| 433 |
img.save(path)
|
| 434 |
return path
|
| 435 |
|
| 436 |
+
def _render(
|
| 437 |
+
self,
|
| 438 |
+
prompt: str,
|
| 439 |
+
seed: int,
|
| 440 |
+
size: int,
|
| 441 |
+
negative_prompt: str = "",
|
| 442 |
+
guidance_scale: float = 0.0,
|
| 443 |
+
):
|
| 444 |
import io # noqa: PLC0415
|
| 445 |
|
| 446 |
from PIL import Image # noqa: PLC0415
|
visualnovel/prompts.py
CHANGED
|
@@ -175,7 +175,11 @@ def format_player_turn(player_input: str, action: str = "talk", target: str = ""
|
|
| 175 |
"""Combine target + action hint + player text into a single context line."""
|
| 176 |
hint = _ACTION_HINTS.get(action, "")
|
| 177 |
text = player_input.strip()
|
| 178 |
-
target_hint =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
parts = [p for p in [target_hint, hint, text] if p]
|
| 180 |
return "\n".join(parts) or "β¦"
|
| 181 |
|
|
|
|
| 175 |
"""Combine target + action hint + player text into a single context line."""
|
| 176 |
hint = _ACTION_HINTS.get(action, "")
|
| 177 |
text = player_input.strip()
|
| 178 |
+
target_hint = (
|
| 179 |
+
f"*The wanderer addresses {target} specifically β only {target} should reply.*\n"
|
| 180 |
+
if target
|
| 181 |
+
else ""
|
| 182 |
+
)
|
| 183 |
parts = [p for p in [target_hint, hint, text] if p]
|
| 184 |
return "\n".join(parts) or "β¦"
|
| 185 |
|
visualnovel/schemas.py
CHANGED
|
@@ -88,19 +88,21 @@ class Ending(BaseModel):
|
|
| 88 |
|
| 89 |
class NPCRelDelta(BaseModel):
|
| 90 |
"""One directed bond update between two NPCs."""
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
| 94 |
note: str = "" # human label: "jealousy", "camaraderie", "rivalry", "fondness", "protects", β¦
|
| 95 |
|
| 96 |
|
| 97 |
class NPCBond(BaseModel):
|
| 98 |
"""A directed NPCβNPC bond for the frontend graph."""
|
|
|
|
| 99 |
source_id: str
|
| 100 |
source_name: str
|
| 101 |
target_id: str
|
| 102 |
target_name: str
|
| 103 |
-
value: int
|
| 104 |
note: str = ""
|
| 105 |
|
| 106 |
|
|
@@ -183,7 +185,9 @@ class ViewState(BaseModel):
|
|
| 183 |
place: str = ""
|
| 184 |
backdrop_url: str | None = None
|
| 185 |
present: list[SpritePresence] = Field(default_factory=list)
|
| 186 |
-
known_characters: list[SpritePresence] = Field(
|
|
|
|
|
|
|
| 187 |
beat: Beat = "opening"
|
| 188 |
ended: bool = False
|
| 189 |
ending_text: str | None = None
|
|
@@ -192,5 +196,7 @@ class ViewState(BaseModel):
|
|
| 192 |
notifications: list[str] = Field(default_factory=list) # popup messages this turn
|
| 193 |
intro_text: str = "" # shown once in the book overlay at game start; empty on subsequent turns
|
| 194 |
audio_b64: str | None = None # data:audio/wav;base64,... β absent when TTS is mocked
|
| 195 |
-
current_music: str | None =
|
|
|
|
|
|
|
| 196 |
npc_bonds: list[NPCBond] = Field(default_factory=list) # all directed NPCβNPC bonds
|
|
|
|
| 88 |
|
| 89 |
class NPCRelDelta(BaseModel):
|
| 90 |
"""One directed bond update between two NPCs."""
|
| 91 |
+
|
| 92 |
+
source: str # character id who holds the feeling
|
| 93 |
+
target: str # character id the feeling is directed at
|
| 94 |
+
delta: int # change to apply (-100..100)
|
| 95 |
note: str = "" # human label: "jealousy", "camaraderie", "rivalry", "fondness", "protects", β¦
|
| 96 |
|
| 97 |
|
| 98 |
class NPCBond(BaseModel):
|
| 99 |
"""A directed NPCβNPC bond for the frontend graph."""
|
| 100 |
+
|
| 101 |
source_id: str
|
| 102 |
source_name: str
|
| 103 |
target_id: str
|
| 104 |
target_name: str
|
| 105 |
+
value: int # current -100..100
|
| 106 |
note: str = ""
|
| 107 |
|
| 108 |
|
|
|
|
| 185 |
place: str = ""
|
| 186 |
backdrop_url: str | None = None
|
| 187 |
present: list[SpritePresence] = Field(default_factory=list)
|
| 188 |
+
known_characters: list[SpritePresence] = Field(
|
| 189 |
+
default_factory=list
|
| 190 |
+
) # all met chars incl. off-stage
|
| 191 |
beat: Beat = "opening"
|
| 192 |
ended: bool = False
|
| 193 |
ending_text: str | None = None
|
|
|
|
| 196 |
notifications: list[str] = Field(default_factory=list) # popup messages this turn
|
| 197 |
intro_text: str = "" # shown once in the book overlay at game start; empty on subsequent turns
|
| 198 |
audio_b64: str | None = None # data:audio/wav;base64,... β absent when TTS is mocked
|
| 199 |
+
current_music: str | None = (
|
| 200 |
+
None # active BGM track name (calm|romantic|dramatic|mystery|sad|joyful)
|
| 201 |
+
)
|
| 202 |
npc_bonds: list[NPCBond] = Field(default_factory=list) # all directed NPCβNPC bonds
|
visualnovel/state.py
CHANGED
|
@@ -14,7 +14,7 @@ import re
|
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
from . import config
|
| 17 |
-
from .schemas import Character, DirectorOutput, GameState,
|
| 18 |
|
| 19 |
_CLAMP = (-100, 100)
|
| 20 |
|
|
|
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
from . import config
|
| 17 |
+
from .schemas import Character, DirectorOutput, GameState, Scene
|
| 18 |
|
| 19 |
_CLAMP = (-100, 100)
|
| 20 |
|
visualnovel/tts.py
CHANGED
|
@@ -32,46 +32,68 @@ from . import config
|
|
| 32 |
# ---------------------------------------------------------------------------
|
| 33 |
_VOICE_PROFILES: list[tuple[str, str, frozenset[str]]] = [
|
| 34 |
# ββ American Female ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
-
(
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
(
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
# ββ British Female βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
-
("bf_alice",
|
| 48 |
-
("bf_emma",
|
| 49 |
-
(
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
# ββ American Male βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
-
("am_adam",
|
| 53 |
-
("am_echo",
|
| 54 |
-
("am_eric",
|
| 55 |
-
(
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
("am_michael", "m", frozenset({"mature", "calm", "authoritative", "professorial", "quiet"})),
|
| 58 |
-
("am_onyx",
|
| 59 |
-
("am_puck",
|
| 60 |
# ββ British Male ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
-
("bm_daniel",
|
| 62 |
-
(
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
]
|
| 66 |
|
| 67 |
-
_MALE_GENDER_HINTS
|
| 68 |
_FEMALE_GENDER_HINTS = {"woman", "female", "her", "she", "girl", "lady"}
|
| 69 |
|
| 70 |
-
_MODEL_PATH
|
| 71 |
_VOICES_PATH = config.MODELS_DIR / "kokoro" / "voices-v1.0.bin"
|
| 72 |
-
_KOKORO_BASE_URL =
|
| 73 |
-
"https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0"
|
| 74 |
-
)
|
| 75 |
|
| 76 |
|
| 77 |
def _ensure_kokoro_models() -> None:
|
|
@@ -97,7 +119,7 @@ def _pick_voice(voice_description: str, sprite_seed: int) -> str:
|
|
| 97 |
words = set(voice_description.lower().split())
|
| 98 |
|
| 99 |
# Detect gender from description
|
| 100 |
-
is_male
|
| 101 |
is_female = bool(words & _FEMALE_GENDER_HINTS)
|
| 102 |
# If ambiguous, default to female (most anime VN characters)
|
| 103 |
target_gender = "m" if is_male and not is_female else "f"
|
|
@@ -140,8 +162,12 @@ class KokoroTTS:
|
|
| 140 |
voice_id = _pick_voice(voice_description, seed)
|
| 141 |
# Fallback if the chosen voice isn't in this model build
|
| 142 |
if voice_id not in self._available_voices:
|
| 143 |
-
fallback = [
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
cache_key = hashlib.sha256(f"{text}\x00{voice_id}\x00{seed}".encode()).hexdigest()
|
| 147 |
if cache_key in self._cache:
|
|
|
|
| 32 |
# ---------------------------------------------------------------------------
|
| 33 |
_VOICE_PROFILES: list[tuple[str, str, frozenset[str]]] = [
|
| 34 |
# ββ American Female ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
(
|
| 36 |
+
"af_heart",
|
| 37 |
+
"f",
|
| 38 |
+
frozenset({"warm", "bright", "breathless", "tender", "sweet", "laughs", "light"}),
|
| 39 |
+
),
|
| 40 |
+
(
|
| 41 |
+
"af_bella",
|
| 42 |
+
"f",
|
| 43 |
+
frozenset({"soft", "gentle", "quiet", "shy", "delicate", "whisper", "whispered"}),
|
| 44 |
+
),
|
| 45 |
+
("af_sarah", "f", frozenset({"clear", "professional", "calm", "steady", "cool", "composed"})),
|
| 46 |
+
("af_sky", "f", frozenset({"airy", "bubbly", "cheerful", "energetic", "lively", "peppy"})),
|
| 47 |
+
(
|
| 48 |
+
"af_jessica",
|
| 49 |
+
"f",
|
| 50 |
+
frozenset({"bold", "confident", "sharp", "spirited", "assertive", "direct"}),
|
| 51 |
+
),
|
| 52 |
+
("af_nicole", "f", frozenset({"rich", "smooth", "soothing", "mature", "warm", "velvet"})),
|
| 53 |
+
("af_nova", "f", frozenset({"crisp", "precise", "intelligent", "focused", "analytical"})),
|
| 54 |
+
("af_river", "f", frozenset({"flowing", "peaceful", "serene", "dreamy", "drifts", "trails"})),
|
| 55 |
+
("af_kore", "f", frozenset({"strong", "powerful", "serious", "stern", "commanding"})),
|
| 56 |
+
("af_aoede", "f", frozenset({"melodic", "musical", "lyrical", "poetic", "ethereal", "sings"})),
|
| 57 |
+
("af_alloy", "f", frozenset({"neutral", "balanced", "modern", "even"})),
|
| 58 |
# ββ British Female βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
("bf_alice", "f", frozenset({"british", "english", "uk", "posh", "accent", "clipped"})),
|
| 60 |
+
("bf_emma", "f", frozenset({"british", "english", "uk", "homely", "friendly", "warm"})),
|
| 61 |
+
(
|
| 62 |
+
"bf_isabella",
|
| 63 |
+
"f",
|
| 64 |
+
frozenset({"british", "english", "uk", "refined", "elegant", "sophisticated"}),
|
| 65 |
+
),
|
| 66 |
+
("bf_lily", "f", frozenset({"british", "english", "uk", "gentle", "soft", "sweet"})),
|
| 67 |
# ββ American Male βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
+
("am_adam", "m", frozenset({"confident", "deep", "steady", "bass", "strong", "rich"})),
|
| 69 |
+
("am_echo", "m", frozenset({"resonant", "full", "commanding", "measured"})),
|
| 70 |
+
("am_eric", "m", frozenset({"friendly", "casual", "warm", "approachable", "cheerful"})),
|
| 71 |
+
(
|
| 72 |
+
"am_fenrir",
|
| 73 |
+
"m",
|
| 74 |
+
frozenset({"gruff", "rough", "intense", "dark", "rugged", "growl", "harsh"}),
|
| 75 |
+
),
|
| 76 |
+
("am_liam", "m", frozenset({"young", "energetic", "bright", "enthusiastic", "lively"})),
|
| 77 |
("am_michael", "m", frozenset({"mature", "calm", "authoritative", "professorial", "quiet"})),
|
| 78 |
+
("am_onyx", "m", frozenset({"smooth", "velvet", "baritone", "low", "silky", "suave"})),
|
| 79 |
+
("am_puck", "m", frozenset({"playful", "mischievous", "quick", "witty", "impish", "teasing"})),
|
| 80 |
# ββ British Male ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
("bm_daniel", "m", frozenset({"british", "english", "uk", "smooth", "refined", "crisp"})),
|
| 82 |
+
(
|
| 83 |
+
"bm_fable",
|
| 84 |
+
"m",
|
| 85 |
+
frozenset({"british", "english", "uk", "storyteller", "dramatic", "expressive"}),
|
| 86 |
+
),
|
| 87 |
+
("bm_george", "m", frozenset({"british", "english", "uk", "distinguished", "formal", "noble"})),
|
| 88 |
+
("bm_lewis", "m", frozenset({"british", "english", "uk", "young", "casual", "easy"})),
|
| 89 |
]
|
| 90 |
|
| 91 |
+
_MALE_GENDER_HINTS = {"man", "male", "his", "him", "he", "boy", "lad", "gentleman"}
|
| 92 |
_FEMALE_GENDER_HINTS = {"woman", "female", "her", "she", "girl", "lady"}
|
| 93 |
|
| 94 |
+
_MODEL_PATH = config.MODELS_DIR / "kokoro" / "kokoro-v1.0.onnx"
|
| 95 |
_VOICES_PATH = config.MODELS_DIR / "kokoro" / "voices-v1.0.bin"
|
| 96 |
+
_KOKORO_BASE_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0"
|
|
|
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
def _ensure_kokoro_models() -> None:
|
|
|
|
| 119 |
words = set(voice_description.lower().split())
|
| 120 |
|
| 121 |
# Detect gender from description
|
| 122 |
+
is_male = bool(words & _MALE_GENDER_HINTS)
|
| 123 |
is_female = bool(words & _FEMALE_GENDER_HINTS)
|
| 124 |
# If ambiguous, default to female (most anime VN characters)
|
| 125 |
target_gender = "m" if is_male and not is_female else "f"
|
|
|
|
| 162 |
voice_id = _pick_voice(voice_description, seed)
|
| 163 |
# Fallback if the chosen voice isn't in this model build
|
| 164 |
if voice_id not in self._available_voices:
|
| 165 |
+
fallback = [
|
| 166 |
+
vid for vid, g, _ in _VOICE_PROFILES if g == "f" and vid in self._available_voices
|
| 167 |
+
]
|
| 168 |
+
voice_id = (
|
| 169 |
+
fallback[seed % len(fallback)] if fallback else next(iter(self._available_voices))
|
| 170 |
+
)
|
| 171 |
|
| 172 |
cache_key = hashlib.sha256(f"{text}\x00{voice_id}\x00{seed}".encode()).hexdigest()
|
| 173 |
if cache_key in self._cache:
|