WillHbx commited on
Commit
541815d
Β·
1 Parent(s): 2c5e1c7

chore(reformat): reformat existing code with ruff.

Browse files
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", # 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,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 = "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,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 = "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,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 = 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.
 
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(msg.encode(sys.stdout.encoding or "utf-8", errors="replace").decode(sys.stdout.encoding or "utf-8", errors="replace"))
 
 
 
 
 
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("KNOWN BUT OFF STAGE (can return β€” use their existing id):\n" + "\n".join(off_stage))
 
 
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"This turn MUST be their heartfelt confession. Emit ending(kind=\"warm\") "
43
- f"with a poetic 2-4 sentence epilogue. Also emit set_music=\"romantic\"."
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"They storm off with a final cutting line. MUST emit exit_character=\"{ch.id}\". "
49
- f"If the stage becomes empty after this, also emit ending(kind=\"defeat\") "
50
- f"with a dark 2-4 sentence epilogue. Emit set_music=\"sad\"."
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
- "Emit ending(kind=\"defeat\") with a melancholic epilogue. Emit set_music=\"sad\"."
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"CURRENT BGM: \"{current_music}\" β€” leave set_music null unless a MAJOR tonal "
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, state: GameState, player_input: str,
110
- action: str = "talk", target: str = "",
 
 
 
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 = 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,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 = _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,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 = _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,7 +122,10 @@ class PainterBase:
122
  def backdrop(self, state: GameState) -> Path:
123
  prompt = backdrop_prompt(state)
124
  return self._cached(
125
- kind="bg", key=state.scene.id, prompt=prompt, seed=state.scene.backdrop_seed,
 
 
 
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", key=f"{ch.id}.{ch.mood}", prompt=prompt, seed=ch.sprite_seed,
 
 
 
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": ("cherry blossom park at golden sunset, petals falling softly, "
142
- "empty bench under blooming trees, warm amber light filtering through branches",
143
- "romantic warm"),
144
- "bittersweet": ("misty autumn street at twilight, fallen leaves on cobblestones, "
145
- "distant lamplight, empty path fading into soft fog",
146
- "melancholic gentle"),
147
- "strange": ("surreal moonlit garden, glowing silver motes drifting upward, "
148
- "impossible geometry, dreamlike luminous plants",
149
- "mysterious ethereal"),
150
- "defeat": ("rain-soaked empty park bench at night, fallen leaves in puddles, "
151
- "single dim lamppost, deserted street receding into darkness",
152
- "desolate cold"),
 
 
 
 
 
 
 
 
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 ** 31)
160
  return self._cached(
161
- kind="ending", key=kind, prompt=prompt, seed=seed,
 
 
 
162
  negative_prompt=_BACKDROP_NEGATIVE,
163
  guidance_scale=config.BACKDROP_GUIDANCE,
164
  )
165
 
166
  # -- internals --
167
- def _cached(self, *, kind: str, key: str, prompt: str, seed: int,
168
- negative_prompt: str = "", guidance_scale: float = 0.0,
169
- transform=None) -> Path:
 
 
 
 
 
 
 
 
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(prompt, seed, config.IMAGE_SIZE,
179
- negative_prompt=negative_prompt, guidance_scale=guidance_scale)
 
 
 
 
 
180
  if transform is not None:
181
  img = transform(img)
182
  img.save(path)
183
  return path
184
 
185
- def _render(self, prompt: str, seed: int, size: int,
186
- negative_prompt: str = "", guidance_scale: float = 0.0):
 
 
 
 
 
 
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(self, prompt: str, seed: int, size: int,
195
- negative_prompt: str = "", guidance_scale: float = 0.0):
 
 
 
 
 
 
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(self, prompt: str, seed: int, size: int,
269
- negative_prompt: str = "", guidance_scale: float = 0.0):
 
 
 
 
 
 
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(self, prompt: str, seed: int, size: int,
325
- negative_prompt: str = "", guidance_scale: float = 0.0):
 
 
 
 
 
 
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(self, prompt: str, seed: int, size: int,
383
- negative_prompt: str = "", guidance_scale: float = 0.0):
 
 
 
 
 
 
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 = f"*The wanderer addresses {target} specifically β€” only {target} should reply.*\n" if target else ""
 
 
 
 
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
- source: str # character id who holds the feeling
92
- target: str # character id the feeling is directed at
93
- delta: int # change to apply (-100..100)
 
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 # current -100..100
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(default_factory=list) # all met chars incl. off-stage
 
 
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 = None # active BGM track name (calm|romantic|dramatic|mystery|sad|joyful)
 
 
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, NPCRelDelta, Scene
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
- ("af_heart", "f", frozenset({"warm", "bright", "breathless", "tender", "sweet", "laughs", "light"})),
36
- ("af_bella", "f", frozenset({"soft", "gentle", "quiet", "shy", "delicate", "whisper", "whispered"})),
37
- ("af_sarah", "f", frozenset({"clear", "professional", "calm", "steady", "cool", "composed"})),
38
- ("af_sky", "f", frozenset({"airy", "bubbly", "cheerful", "energetic", "lively", "peppy"})),
39
- ("af_jessica", "f", frozenset({"bold", "confident", "sharp", "spirited", "assertive", "direct"})),
40
- ("af_nicole", "f", frozenset({"rich", "smooth", "soothing", "mature", "warm", "velvet"})),
41
- ("af_nova", "f", frozenset({"crisp", "precise", "intelligent", "focused", "analytical"})),
42
- ("af_river", "f", frozenset({"flowing", "peaceful", "serene", "dreamy", "drifts", "trails"})),
43
- ("af_kore", "f", frozenset({"strong", "powerful", "serious", "stern", "commanding"})),
44
- ("af_aoede", "f", frozenset({"melodic", "musical", "lyrical", "poetic", "ethereal", "sings"})),
45
- ("af_alloy", "f", frozenset({"neutral", "balanced", "modern", "even"})),
 
 
 
 
 
 
 
 
 
 
 
 
46
  # ── British Female ─────────────────────────────────────────────────────
47
- ("bf_alice", "f", frozenset({"british", "english", "uk", "posh", "accent", "clipped"})),
48
- ("bf_emma", "f", frozenset({"british", "english", "uk", "homely", "friendly", "warm"})),
49
- ("bf_isabella", "f", frozenset({"british", "english", "uk", "refined", "elegant", "sophisticated"})),
50
- ("bf_lily", "f", frozenset({"british", "english", "uk", "gentle", "soft", "sweet"})),
 
 
 
 
51
  # ── American Male ─────────────────────────────────────────────────────
52
- ("am_adam", "m", frozenset({"confident", "deep", "steady", "bass", "strong", "rich"})),
53
- ("am_echo", "m", frozenset({"resonant", "full", "commanding", "measured"})),
54
- ("am_eric", "m", frozenset({"friendly", "casual", "warm", "approachable", "cheerful"})),
55
- ("am_fenrir", "m", frozenset({"gruff", "rough", "intense", "dark", "rugged", "growl", "harsh"})),
56
- ("am_liam", "m", frozenset({"young", "energetic", "bright", "enthusiastic", "lively"})),
 
 
 
 
57
  ("am_michael", "m", frozenset({"mature", "calm", "authoritative", "professorial", "quiet"})),
58
- ("am_onyx", "m", frozenset({"smooth", "velvet", "baritone", "low", "silky", "suave"})),
59
- ("am_puck", "m", frozenset({"playful", "mischievous", "quick", "witty", "impish", "teasing"})),
60
  # ── British Male ──────────────────────────────────────────────────────
61
- ("bm_daniel", "m", frozenset({"british", "english", "uk", "smooth", "refined", "crisp"})),
62
- ("bm_fable", "m", frozenset({"british", "english", "uk", "storyteller", "dramatic", "expressive"})),
63
- ("bm_george", "m", frozenset({"british", "english", "uk", "distinguished", "formal", "noble"})),
64
- ("bm_lewis", "m", frozenset({"british", "english", "uk", "young", "casual", "easy"})),
 
 
 
 
65
  ]
66
 
67
- _MALE_GENDER_HINTS = {"man", "male", "his", "him", "he", "boy", "lad", "gentleman"}
68
  _FEMALE_GENDER_HINTS = {"woman", "female", "her", "she", "girl", "lady"}
69
 
70
- _MODEL_PATH = config.MODELS_DIR / "kokoro" / "kokoro-v1.0.onnx"
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 = bool(words & _MALE_GENDER_HINTS)
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 = [vid for vid, g, _ in _VOICE_PROFILES if g == "f" and vid in self._available_voices]
144
- voice_id = fallback[seed % len(fallback)] if fallback else next(iter(self._available_voices))
 
 
 
 
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: