WillHbx Claude Sonnet 4.6 commited on
Commit
78f83ed
Β·
2 Parent(s): 08855872d74a10

Merge origin/main into feature/update-vocal_system

Browse files

Integrates action/target turn system, journal UI, painter and prompt
improvements from main while preserving Kokoro TTS voice system.
Regenerated uv.lock to include kokoro-onnx + soundfile TTS dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

app.py CHANGED
@@ -179,8 +179,8 @@ def build_server():
179
 
180
  @app.api(name="turn")
181
  @gpu
182
- def turn(player_input: str) -> dict:
183
- return ENGINE.play_turn(player_input).model_dump()
184
 
185
  @app.api(name="transcribe")
186
  @gpu
 
179
 
180
  @app.api(name="turn")
181
  @gpu
182
+ def turn(player_input: str, action: str = "talk", target: str = "") -> dict:
183
+ return ENGINE.play_turn(player_input, action=action, target=target).model_dump()
184
 
185
  @app.api(name="transcribe")
186
  @gpu
frontend/index.html CHANGED
@@ -58,8 +58,11 @@
58
  .char-card-name{font-family:var(--display); font-size:1.15rem; color:var(--gold); margin-bottom:4px}
59
  .char-card-bio{font-size:.82rem; color:var(--ink-dim); margin-bottom:10px; line-height:1.4}
60
  .rel-row{display:flex; align-items:center; gap:8px; margin-bottom:10px}
61
- .rel-bar{flex:1; height:5px; background:rgba(255,255,255,.12); border-radius:3px; overflow:hidden}
62
- .rel-fill{height:100%; border-radius:3px; transition:width .5s ease}
 
 
 
63
  .rel-val{font-size:.78rem; color:var(--ink-dim); min-width:28px; text-align:right}
64
  .traits-section{margin-bottom:8px}
65
  .traits-label{font-size:.72rem; text-transform:uppercase; letter-spacing:.8px;
@@ -98,13 +101,43 @@
98
  #place{position:absolute; top:18px; left:22px; z-index:4; font-family:var(--display);
99
  font-size:1.05rem; color:var(--ink-dim); letter-spacing:1px; text-transform:uppercase;
100
  text-shadow:0 1px 8px #000}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  /* ---- input row ---- */
102
- #inputrow{display:flex; gap:10px; margin-top:12px}
103
  #say{flex:1; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.15);
104
  color:var(--ink); border-radius:10px; padding:11px 14px; font-family:var(--body); font-size:1rem}
105
  #say:focus{outline:none; border-color:var(--gold)}
106
  button{font-family:var(--body); cursor:pointer; border:none; border-radius:10px;
107
  padding:11px 16px; color:#10131a; background:var(--gold); font-weight:600; font-size:.98rem}
 
108
  button.ghost{background:transparent; color:var(--ink); border:1px solid rgba(255,255,255,.22)}
109
  button:disabled{opacity:.45; cursor:wait}
110
  @keyframes pulse-rec{0%,100%{opacity:1}50%{opacity:.6}}
@@ -174,6 +207,90 @@
174
  #enter{width:100%; margin-top:16px; padding:14px; font-size:1.05rem}
175
  .shimmer{color:var(--ink-dim); font-style:italic}
176
  .hidden{display:none !important}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  </style>
178
  </head>
179
  <body>
@@ -185,15 +302,42 @@
185
  <div id="dialogue">
186
  <h2 id="speaker">…</h2>
187
  <div id="line"><span class="shimmer">the story is awakening…</span></div>
 
 
 
 
 
 
 
 
 
188
  <div id="inputrow">
189
  <input id="say" placeholder="say something…" autocomplete="off" />
190
- <button id="send">Speak</button>
191
  <button id="mic" class="ghost" title="hold to speak">πŸŽ™οΈ</button>
192
  <button id="tts-toggle" title="toggle voice output">πŸ”‡</button>
193
  </div>
194
  </div>
195
  </div>
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  <!-- Book intro overlay β€” shown after /start if intro_text is non-empty -->
198
  <div id="intro-book" class="hidden">
199
  <div class="book-wrap">
@@ -235,6 +379,55 @@ let client;
235
  } catch (e) { console.warn("themes endpoint not ready", e); }
236
  })();
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  // --- typewriter ---
239
  function typeOut(el, text){
240
  el.textContent = ""; let i = 0;
@@ -267,10 +460,10 @@ function render(v){
267
  const img = document.createElement("img");
268
  img.className = "sprite"; img.src = p.sprite_url; img.alt = p.name;
269
 
270
- // relation bar: 0..100 maps to 0..100%; colour shifts gold→green as affection rises
271
  const rel = p.relationship ?? 0;
272
- const relPct = Math.min(100, Math.max(0, rel)).toFixed(1);
273
- const relColor = `hsl(${40 + rel * 0.8}, 65%, 52%)`; // gold at 0, warm-green at 100
274
  const relLabel = rel > 0 ? `+${rel}` : `${rel}`;
275
 
276
  // traits rows
@@ -293,7 +486,10 @@ function render(v){
293
  card.innerHTML = `
294
  <div class="char-card-name">${p.name}</div>
295
  <div class="rel-row">
296
- <div class="rel-bar"><div class="rel-fill" style="width:${relPct}%;background:${relColor}"></div></div>
 
 
 
297
  <span class="rel-val">${relLabel}</span>
298
  </div>
299
  <div class="traits-section">
@@ -311,6 +507,9 @@ function render(v){
311
 
312
  // notifications
313
  for (const msg of (v.notifications || [])) showNotif(msg);
 
 
 
314
  }
315
 
316
  function showNotif(msg){
@@ -324,7 +523,84 @@ function showNotif(msg){
324
  }, 5000);
325
  }
326
 
327
- function busy(state){ $("send").disabled = state; $("say").disabled = state; $("mic").disabled = state; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
  // --- TTS toggle ---
330
  let ttsEnabled = true;
@@ -362,6 +638,8 @@ $("enter").addEventListener("click", async () => {
362
  const { data } = await client.predict("/start", { theme: $("theme").value, tone: $("tone").value });
363
  $("setup").classList.add("hidden");
364
  render(data[0]);
 
 
365
  // Show the book intro overlay if the engine sent an intro text
366
  if (data[0]?.intro_text) {
367
  $("book-text").textContent = data[0].intro_text;
@@ -372,12 +650,20 @@ $("enter").addEventListener("click", async () => {
372
  // --- a turn ---
373
  async function sendTurn(){
374
  const text = $("say").value.trim();
375
- if (!text) return;
 
 
376
  $("say").value = ""; busy(true);
377
  try {
378
- const { data } = await client.predict("/turn", { player_input: text });
379
  render(data[0]);
380
- } finally { busy(false); $("say").focus(); }
 
 
 
 
 
 
381
  }
382
  $("send").addEventListener("click", sendTurn);
383
  $("say").addEventListener("keydown", (e) => { if (e.key === "Enter") sendTurn(); });
 
58
  .char-card-name{font-family:var(--display); font-size:1.15rem; color:var(--gold); margin-bottom:4px}
59
  .char-card-bio{font-size:.82rem; color:var(--ink-dim); margin-bottom:10px; line-height:1.4}
60
  .rel-row{display:flex; align-items:center; gap:8px; margin-bottom:10px}
61
+ .rel-bar{flex:1; height:5px; background:rgba(255,255,255,.10); border-radius:3px; overflow:hidden; position:relative}
62
+ .rel-fill-pos{position:absolute; left:0; top:0; bottom:0; width:0;
63
+ background:var(--moss); border-radius:3px; transition:width .5s ease}
64
+ .rel-fill-neg{position:absolute; right:0; top:0; bottom:0; width:0;
65
+ background:var(--rose); border-radius:3px; transition:width .5s ease}
66
  .rel-val{font-size:.78rem; color:var(--ink-dim); min-width:28px; text-align:right}
67
  .traits-section{margin-bottom:8px}
68
  .traits-label{font-size:.72rem; text-transform:uppercase; letter-spacing:.8px;
 
101
  #place{position:absolute; top:18px; left:22px; z-index:4; font-family:var(--display);
102
  font-size:1.05rem; color:var(--ink-dim); letter-spacing:1px; text-transform:uppercase;
103
  text-shadow:0 1px 8px #000}
104
+ /* ---- target row (who to address) ---- */
105
+ #target-row{
106
+ display:flex; align-items:center; gap:6px; margin-bottom:8px; flex-wrap:wrap;
107
+ }
108
+ .target-label{font-size:.75rem; color:var(--ink-dim); letter-spacing:.4px; flex-shrink:0}
109
+ .target-chip{
110
+ font-family:var(--body); cursor:pointer;
111
+ border:1px solid rgba(255,255,255,.15); border-radius:20px;
112
+ padding:4px 12px; font-size:.83rem;
113
+ background:rgba(255,255,255,.04); color:var(--ink-dim);
114
+ transition:background .15s, border-color .15s, color .15s;
115
+ }
116
+ .target-chip:hover{background:rgba(233,166,166,.1); border-color:rgba(233,166,166,.4); color:var(--ink)}
117
+ .target-chip.active{background:rgba(233,166,166,.18); border-color:var(--rose); color:var(--rose); font-weight:600}
118
+ .target-chip:disabled{opacity:.4; cursor:wait}
119
+ /* ---- action chips ---- */
120
+ #actions{display:flex; gap:7px; margin-bottom:10px; flex-wrap:wrap}
121
+ .action-chip{
122
+ font-family:var(--body); cursor:pointer; border:1px solid rgba(255,255,255,.18);
123
+ border-radius:20px; padding:6px 13px; font-size:.88rem; font-weight:500;
124
+ background:rgba(255,255,255,.05); color:var(--ink-dim);
125
+ transition:background .15s, border-color .15s, color .15s; white-space:nowrap;
126
+ }
127
+ .action-chip:hover{background:rgba(232,193,114,.1); border-color:rgba(232,193,114,.4); color:var(--ink)}
128
+ .action-chip.active{
129
+ background:rgba(232,193,114,.18); border-color:var(--gold);
130
+ color:var(--gold); font-weight:600;
131
+ }
132
+ .action-chip:disabled{opacity:.4; cursor:wait}
133
  /* ---- input row ---- */
134
+ #inputrow{display:flex; gap:10px; margin-top:4px}
135
  #say{flex:1; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.15);
136
  color:var(--ink); border-radius:10px; padding:11px 14px; font-family:var(--body); font-size:1rem}
137
  #say:focus{outline:none; border-color:var(--gold)}
138
  button{font-family:var(--body); cursor:pointer; border:none; border-radius:10px;
139
  padding:11px 16px; color:#10131a; background:var(--gold); font-weight:600; font-size:.98rem}
140
+ #send{background:var(--moss); color:#0d1a0e; font-size:1.2rem; padding:11px 18px; min-width:46px}
141
  button.ghost{background:transparent; color:var(--ink); border:1px solid rgba(255,255,255,.22)}
142
  button:disabled{opacity:.45; cursor:wait}
143
  @keyframes pulse-rec{0%,100%{opacity:1}50%{opacity:.6}}
 
207
  #enter{width:100%; margin-top:16px; padding:14px; font-size:1.05rem}
208
  .shimmer{color:var(--ink-dim); font-style:italic}
209
  .hidden{display:none !important}
210
+ /* ---- journal button (top-right) ---- */
211
+ #journal-btn{
212
+ position:fixed; top:16px; right:16px; z-index:8;
213
+ display:flex; align-items:center; gap:7px;
214
+ background:rgba(10,13,24,.82); border:1px solid rgba(232,193,114,.30);
215
+ border-radius:10px; padding:9px 15px;
216
+ color:var(--gold); font-family:var(--display); font-size:1rem;
217
+ backdrop-filter:blur(6px); cursor:pointer;
218
+ transition:background .15s, border-color .15s, transform .15s;
219
+ position:fixed; /* re-state for specificity */
220
+ }
221
+ #journal-btn:hover{
222
+ background:rgba(232,193,114,.12); border-color:rgba(232,193,114,.55);
223
+ transform:translateY(-1px);
224
+ }
225
+ #journal-btn .jb-icon{font-size:1.15rem; line-height:1}
226
+ .je-badge{
227
+ display:none; position:absolute; top:-5px; right:-5px;
228
+ width:10px; height:10px; background:var(--rose); border-radius:50%;
229
+ border:2px solid var(--dusk-0);
230
+ }
231
+ #journal-btn.has-new .je-badge{display:block}
232
+ /* ---- journal side panel ---- */
233
+ #journal-overlay{
234
+ position:fixed; top:0; right:0; bottom:0;
235
+ width:min(400px,92vw); z-index:25;
236
+ display:flex; flex-direction:column;
237
+ background:rgba(8,10,18,.97);
238
+ border-left:1px solid rgba(232,193,114,.18);
239
+ backdrop-filter:blur(14px);
240
+ transform:translateX(105%);
241
+ transition:transform .35s cubic-bezier(.4,0,.2,1);
242
+ box-shadow:-14px 0 48px rgba(0,0,0,.55);
243
+ }
244
+ #journal-overlay.open{transform:translateX(0)}
245
+ #journal-header{
246
+ display:flex; align-items:center; justify-content:space-between;
247
+ padding:18px 20px 14px;
248
+ border-bottom:1px solid rgba(232,193,114,.13);
249
+ flex-shrink:0;
250
+ }
251
+ #journal-header h3{
252
+ font-family:var(--display); font-size:1.35rem;
253
+ color:var(--gold); margin:0; letter-spacing:.3px;
254
+ }
255
+ #journal-close{
256
+ background:transparent; border:none; color:var(--ink-dim);
257
+ font-size:1.2rem; cursor:pointer; padding:4px 9px;
258
+ border-radius:6px; transition:color .15s, background .15s;
259
+ }
260
+ #journal-close:hover{color:var(--ink); background:rgba(255,255,255,.07)}
261
+ #journal-list{
262
+ flex:1; overflow-y:auto; padding:16px 20px 28px;
263
+ scroll-behavior:smooth;
264
+ }
265
+ #journal-list::-webkit-scrollbar{width:4px}
266
+ #journal-list::-webkit-scrollbar-track{background:transparent}
267
+ #journal-list::-webkit-scrollbar-thumb{background:rgba(232,193,114,.22); border-radius:2px}
268
+ .journal-entry{margin-bottom:4px}
269
+ .je-meta{
270
+ font-size:.72rem; color:var(--ink-dim); letter-spacing:.6px;
271
+ text-transform:uppercase; margin-bottom:9px;
272
+ }
273
+ .je-meta em{font-style:normal; color:rgba(232,193,114,.55)}
274
+ .je-player{
275
+ font-size:.9rem; color:rgba(200,190,175,.6);
276
+ font-style:italic; margin-bottom:10px;
277
+ padding:6px 10px; border-left:2px solid rgba(255,255,255,.1);
278
+ border-radius:0 4px 4px 0;
279
+ }
280
+ .je-you{font-style:normal; color:rgba(210,200,185,.9); font-weight:500; margin-right:5px}
281
+ .je-speaker{
282
+ font-family:var(--display); font-size:1.1rem;
283
+ color:var(--gold); margin-bottom:3px;
284
+ }
285
+ .je-dialogue{font-size:.95rem; line-height:1.65; color:var(--ink)}
286
+ .je-sep{
287
+ text-align:center; color:rgba(232,193,114,.22);
288
+ font-size:.68rem; letter-spacing:7px; margin:18px 0;
289
+ }
290
+ .je-empty{
291
+ color:var(--ink-dim); font-style:italic; font-family:var(--display);
292
+ text-align:center; margin-top:50px; font-size:1.05rem; line-height:1.7;
293
+ }
294
  </style>
295
  </head>
296
  <body>
 
302
  <div id="dialogue">
303
  <h2 id="speaker">…</h2>
304
  <div id="line"><span class="shimmer">the story is awakening…</span></div>
305
+ <div id="target-row" class="hidden"></div>
306
+ <div id="actions">
307
+ <button class="action-chip active" data-action="talk">πŸ’¬ Talk</button>
308
+ <button class="action-chip" data-action="touch">🀝 Touch</button>
309
+ <button class="action-chip" data-action="silent">😢 Silence</button>
310
+ <button class="action-chip" data-action="give">🎁 Give</button>
311
+ <button class="action-chip" data-action="move">πŸšͺ Move</button>
312
+ <button class="action-chip" data-action="scout">πŸ‘οΈ Look around</button>
313
+ </div>
314
  <div id="inputrow">
315
  <input id="say" placeholder="say something…" autocomplete="off" />
316
+ <button id="send">βœ“</button>
317
  <button id="mic" class="ghost" title="hold to speak">πŸŽ™οΈ</button>
318
  <button id="tts-toggle" title="toggle voice output">πŸ”‡</button>
319
  </div>
320
  </div>
321
  </div>
322
 
323
+ <!-- Journal button β€” hidden until game starts -->
324
+ <button id="journal-btn" class="hidden" title="Story journal">
325
+ <span class="jb-icon">πŸ“–</span>
326
+ <span>Journal</span>
327
+ <span class="je-badge"></span>
328
+ </button>
329
+
330
+ <!-- Journal side panel -->
331
+ <div id="journal-overlay">
332
+ <div id="journal-header">
333
+ <h3>πŸ“– Story Journal</h3>
334
+ <button id="journal-close" title="Close">βœ•</button>
335
+ </div>
336
+ <div id="journal-list">
337
+ <div class="je-empty">The story has not yet begun…</div>
338
+ </div>
339
+ </div>
340
+
341
  <!-- Book intro overlay β€” shown after /start if intro_text is non-empty -->
342
  <div id="intro-book" class="hidden">
343
  <div class="book-wrap">
 
379
  } catch (e) { console.warn("themes endpoint not ready", e); }
380
  })();
381
 
382
+ // --- journal ---
383
+ let history = [];
384
+ let journalTurn = 0;
385
+
386
+ const ACTION_ICONS = { talk:"πŸ’¬", flirt:"🌸", touch:"🀝", ask:"πŸ€”", silent:"😢", give:"🎁", move:"πŸšͺ" };
387
+
388
+ function addToHistory(playerText, v, action = "talk") {
389
+ if (!v?.dialogue || !v?.speaker) return;
390
+ history.push({
391
+ turn: ++journalTurn,
392
+ place: v.place || "",
393
+ player: playerText || "",
394
+ action: action,
395
+ speaker: v.speaker,
396
+ dialogue: v.dialogue,
397
+ });
398
+ if ($("journal-overlay").classList.contains("open")) {
399
+ renderJournal();
400
+ } else {
401
+ $("journal-btn").classList.add("has-new");
402
+ }
403
+ }
404
+
405
+ function renderJournal() {
406
+ const list = $("journal-list");
407
+ if (history.length === 0) {
408
+ list.innerHTML = '<div class="je-empty">The story has not yet begun…</div>';
409
+ return;
410
+ }
411
+ list.innerHTML = history.map((e, i) => `
412
+ ${i > 0 ? '<div class="je-sep">Β· Β· Β·</div>' : ""}
413
+ <div class="journal-entry">
414
+ <div class="je-meta">Turn ${e.turn}${e.place ? ` &nbsp;Β·&nbsp; <em>${e.place}</em>` : ""}</div>
415
+ ${(e.player || e.action !== "talk") ? `<div class="je-player"><span class="je-you">${ACTION_ICONS[e.action] || "πŸ’¬"} You</span>${e.player ? ` "${e.player}"` : ""}</div>` : ""}
416
+ <div class="je-speaker">${e.speaker}</div>
417
+ <div class="je-dialogue">${e.dialogue}</div>
418
+ </div>
419
+ `).join("");
420
+ setTimeout(() => { list.scrollTop = list.scrollHeight; }, 30);
421
+ }
422
+
423
+ $("journal-btn").addEventListener("click", () => {
424
+ const open = $("journal-overlay").classList.toggle("open");
425
+ if (open) { renderJournal(); $("journal-btn").classList.remove("has-new"); }
426
+ });
427
+ $("journal-close").addEventListener("click", () => {
428
+ $("journal-overlay").classList.remove("open");
429
+ });
430
+
431
  // --- typewriter ---
432
  function typeOut(el, text){
433
  el.textContent = ""; let i = 0;
 
460
  const img = document.createElement("img");
461
  img.className = "sprite"; img.src = p.sprite_url; img.alt = p.name;
462
 
463
+ // relation bar: positive → green left→right | negative → red right→left
464
  const rel = p.relationship ?? 0;
465
+ const relPosW = rel > 0 ? rel.toFixed(1) : "0";
466
+ const relNegW = rel < 0 ? Math.abs(rel).toFixed(1) : "0";
467
  const relLabel = rel > 0 ? `+${rel}` : `${rel}`;
468
 
469
  // traits rows
 
486
  card.innerHTML = `
487
  <div class="char-card-name">${p.name}</div>
488
  <div class="rel-row">
489
+ <div class="rel-bar">
490
+ <div class="rel-fill-neg" style="width:${relNegW}%"></div>
491
+ <div class="rel-fill-pos" style="width:${relPosW}%"></div>
492
+ </div>
493
  <span class="rel-val">${relLabel}</span>
494
  </div>
495
  <div class="traits-section">
 
507
 
508
  // notifications
509
  for (const msg of (v.notifications || [])) showNotif(msg);
510
+
511
+ // target chips β€” show only when multiple characters are on stage
512
+ updateTargetRow(v.present);
513
  }
514
 
515
  function showNotif(msg){
 
523
  }, 5000);
524
  }
525
 
526
+ // --- target selector ---
527
+ let currentTarget = ""; // "" = anyone
528
+
529
+ function updateTargetRow(present) {
530
+ const row = $("target-row");
531
+ if (!present || present.length <= 1) {
532
+ row.classList.add("hidden");
533
+ currentTarget = "";
534
+ return;
535
+ }
536
+ row.classList.remove("hidden");
537
+ row.innerHTML = `
538
+ <span class="target-label">To:</span>
539
+ <button class="target-chip active" data-name="">Everyone</button>
540
+ ${present.map(p => `<button class="target-chip" data-name="${p.name}">${p.name}</button>`).join("")}
541
+ `;
542
+ row.querySelectorAll(".target-chip").forEach(btn => {
543
+ btn.addEventListener("click", () => {
544
+ if (btn.disabled) return;
545
+ row.querySelectorAll(".target-chip").forEach(b => b.classList.remove("active"));
546
+ btn.classList.add("active");
547
+ currentTarget = btn.dataset.name;
548
+ });
549
+ });
550
+ }
551
+
552
+ function resetTarget() {
553
+ currentTarget = "";
554
+ const row = $("target-row");
555
+ row.querySelectorAll(".target-chip").forEach(b => b.classList.remove("active"));
556
+ const everyone = row.querySelector('.target-chip[data-name=""]');
557
+ if (everyone) everyone.classList.add("active");
558
+ }
559
+
560
+ // --- action chips ---
561
+
562
+ const ACTION_PLACEHOLDERS = {
563
+ talk: "say something…",
564
+ flirt: "add a flirty line… (or leave blank)",
565
+ touch: "add words… (optional)",
566
+ ask: "what do you want to know about them?",
567
+ silent: "",
568
+ give: "what do you offer?",
569
+ move: "where? (leave blank and the story decides)",
570
+ scout: "name someone specific… (or leave blank)",
571
+ };
572
+ let currentAction = "talk";
573
+
574
+ document.querySelectorAll(".action-chip").forEach(btn => {
575
+ btn.addEventListener("click", () => {
576
+ if (btn.disabled) return;
577
+ document.querySelectorAll(".action-chip").forEach(b => b.classList.remove("active"));
578
+ btn.classList.add("active");
579
+ currentAction = btn.dataset.action;
580
+ if (currentAction === "silent") {
581
+ $("say").value = "";
582
+ $("say").disabled = true;
583
+ } else {
584
+ $("say").disabled = false;
585
+ $("say").placeholder = ACTION_PLACEHOLDERS[currentAction] || "say something…";
586
+ $("say").focus();
587
+ }
588
+ });
589
+ });
590
+
591
+ function resetAction() {
592
+ currentAction = "talk";
593
+ document.querySelectorAll(".action-chip").forEach(b => b.classList.remove("active"));
594
+ document.querySelector('.action-chip[data-action="talk"]').classList.add("active");
595
+ $("say").disabled = false;
596
+ $("say").placeholder = ACTION_PLACEHOLDERS.talk;
597
+ }
598
+
599
+ function busy(state){
600
+ $("send").disabled = state; $("say").disabled = state; $("mic").disabled = state;
601
+ document.querySelectorAll(".action-chip").forEach(b => b.disabled = state);
602
+ document.querySelectorAll(".target-chip").forEach(b => b.disabled = state);
603
+ }
604
 
605
  // --- TTS toggle ---
606
  let ttsEnabled = true;
 
638
  const { data } = await client.predict("/start", { theme: $("theme").value, tone: $("tone").value });
639
  $("setup").classList.add("hidden");
640
  render(data[0]);
641
+ addToHistory("", data[0]);
642
+ $("journal-btn").classList.remove("hidden");
643
  // Show the book intro overlay if the engine sent an intro text
644
  if (data[0]?.intro_text) {
645
  $("book-text").textContent = data[0].intro_text;
 
650
  // --- a turn ---
651
  async function sendTurn(){
652
  const text = $("say").value.trim();
653
+ // allow empty text for non-talk actions (the action hint is enough)
654
+ if (!text && currentAction === "talk") return;
655
+ const action = currentAction;
656
  $("say").value = ""; busy(true);
657
  try {
658
+ const { data } = await client.predict("/turn", { player_input: text, action, target: currentTarget });
659
  render(data[0]);
660
+ addToHistory(text, data[0], action);
661
+ } finally {
662
+ busy(false);
663
+ resetAction();
664
+ resetTarget();
665
+ $("say").focus();
666
+ }
667
  }
668
  $("send").addEventListener("click", sendTurn);
669
  $("say").addEventListener("keydown", (e) => { if (e.key === "Enter") sendTurn(); });
modal_app.py CHANGED
@@ -41,9 +41,12 @@ painter_image = (
41
  "diffusers==0.35.1",
42
  "transformers==4.47.1",
43
  "accelerate==1.8.1",
 
44
  "safetensors",
45
  "Pillow",
46
  "huggingface_hub",
 
 
47
  )
48
  )
49
 
@@ -65,7 +68,11 @@ app = modal.App("vn-app")
65
 
66
  GGUF_REPO = "Qwen/Qwen3-14B-GGUF"
67
  GGUF_FILE = "Qwen3-14B-Q8_0.gguf"
68
- SDXL_MODEL = "stabilityai/sdxl-turbo"
 
 
 
 
69
 
70
 
71
  # --------------------------------------------------------------------------- #
@@ -77,26 +84,31 @@ SDXL_MODEL = "stabilityai/sdxl-turbo"
77
  def download_model() -> None:
78
  from huggingface_hub import hf_hub_download
79
 
80
- print(f"Downloading {GGUF_REPO}/{GGUF_FILE} …")
81
  path = hf_hub_download(repo_id=GGUF_REPO, filename=GGUF_FILE, local_dir=LLM_DIR)
82
  llm_volume.commit()
83
- print(f"Saved β†’ {path}")
84
 
85
 
86
  @app.function(
87
  image=painter_image, volumes={IMAGE_DIR: image_volume}, timeout=900, secrets=[hf_secret]
88
  )
89
  def download_image_model() -> None:
 
 
 
 
 
90
  from huggingface_hub import snapshot_download
91
 
92
- print(f"Downloading {SDXL_MODEL} …")
93
- snapshot_download(SDXL_MODEL, local_dir=f"{IMAGE_DIR}/sdxl-turbo")
94
  image_volume.commit()
95
- print("SDXL-Turbo saved to volume.")
96
 
97
 
98
  # --------------------------------------------------------------------------- #
99
- # LLM backend (llama.cpp + Qwen3-4B on A10G)
100
  # --------------------------------------------------------------------------- #
101
 
102
 
@@ -143,7 +155,7 @@ class ModalLLMBackend:
143
 
144
 
145
  # --------------------------------------------------------------------------- #
146
- # Painter backend (SDXL-Turbo on A10G)
147
  # --------------------------------------------------------------------------- #
148
 
149
 
@@ -152,20 +164,46 @@ class ModalLLMBackend:
152
  gpu="A10G",
153
  volumes={IMAGE_DIR: image_volume},
154
  timeout=120,
155
- scaledown_window=60,
156
  )
157
  class ModalPainterBackend:
 
 
 
 
 
 
 
 
 
 
158
  @modal.enter()
159
  def load(self) -> None:
160
  import torch
161
- from diffusers import StableDiffusionXLPipeline
162
 
 
163
  self.pipe = StableDiffusionXLPipeline.from_pretrained(
164
- f"{IMAGE_DIR}/sdxl-turbo",
165
  torch_dtype=torch.float16,
166
  variant="fp16",
167
  ).to("cuda")
168
- print("[modal] SDXL-Turbo loaded on GPU")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  @modal.method()
171
  def render(
@@ -175,23 +213,40 @@ class ModalPainterBackend:
175
  seed: int,
176
  size: int,
177
  steps: int,
 
 
178
  ) -> bytes:
 
 
 
 
 
 
 
 
 
 
 
179
  import io
180
 
181
- import torch
182
-
183
- gen = torch.Generator(device="cuda").manual_seed(seed)
184
  result = self.pipe(
185
  prompt=prompt,
186
- negative_prompt=negative_prompt,
187
  num_inference_steps=steps,
188
- guidance_scale=0.0,
189
  height=size,
190
  width=size,
191
  generator=gen,
192
  )
 
 
 
 
 
 
193
  buf = io.BytesIO()
194
- result.images[0].save(buf, format="PNG")
195
  return buf.getvalue()
196
 
197
 
@@ -216,13 +271,31 @@ def smoke_painter() -> None:
216
  from PIL import Image
217
 
218
  backend = ModalPainterBackend()
 
 
219
  png = backend.render.remote(
220
- prompt="Japanese anime forest, glowing mushrooms",
221
- negative_prompt="text, watermark",
222
  seed=42,
223
  size=512,
224
- steps=2,
 
 
225
  )
226
  img = Image.open(io.BytesIO(png))
227
- img.save("smoke_painter.png")
228
- print(f"Painter smoke OK β†’ smoke_painter.png {img.size}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
 
68
 
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"
76
 
77
 
78
  # --------------------------------------------------------------------------- #
 
84
  def download_model() -> None:
85
  from huggingface_hub import hf_hub_download
86
 
87
+ print(f"Downloading {GGUF_REPO}/{GGUF_FILE} ...")
88
  path = hf_hub_download(repo_id=GGUF_REPO, filename=GGUF_FILE, local_dir=LLM_DIR)
89
  llm_volume.commit()
90
+ print(f"Saved -> {path}")
91
 
92
 
93
  @app.function(
94
  image=painter_image, volumes={IMAGE_DIR: image_volume}, timeout=900, secrets=[hf_secret]
95
  )
96
  def download_image_model() -> None:
97
+ """Download SDXL-base-1.0 weights (~6.5 GB) to the volume.
98
+
99
+ The Lightning LoRA (ByteDance/SDXL-Lightning) is small (~400 MB) and loaded
100
+ at inference time directly from HF Hub β€” no need to pre-download it.
101
+ """
102
  from huggingface_hub import snapshot_download
103
 
104
+ print(f"Downloading {SDXL_BASE_REPO} -> {IMAGE_DIR}/{SDXL_BASE_LOCAL} ...")
105
+ snapshot_download(SDXL_BASE_REPO, local_dir=f"{IMAGE_DIR}/{SDXL_BASE_LOCAL}")
106
  image_volume.commit()
107
+ print("SDXL-base saved to volume.")
108
 
109
 
110
  # --------------------------------------------------------------------------- #
111
+ # LLM backend (llama.cpp + Qwen3-14B on A10G)
112
  # --------------------------------------------------------------------------- #
113
 
114
 
 
155
 
156
 
157
  # --------------------------------------------------------------------------- #
158
+ # Painter backend (SDXL-base-1.0 + Lightning LoRA on A10G)
159
  # --------------------------------------------------------------------------- #
160
 
161
 
 
164
  gpu="A10G",
165
  volumes={IMAGE_DIR: image_volume},
166
  timeout=120,
167
+ scaledown_window=600, # keep warm 10 min β€” avoids cold-start during a play session
168
  )
169
  class ModalPainterBackend:
170
+ """SDXL-base-1.0 + ByteDance SDXL-Lightning 4-step LoRA.
171
+
172
+ Mirrors the local SdxlLightningPainter exactly:
173
+ - EulerDiscreteScheduler with trailing timestep spacing
174
+ - LoRA fused into weights (fuse_lora) for faster inference
175
+ - guidance_scale=0.0 (required by Lightning distillation)
176
+ - 4 inference steps
177
+ - Optional rembg background removal for sprites
178
+ """
179
+
180
  @modal.enter()
181
  def load(self) -> None:
182
  import torch
183
+ from diffusers import EulerDiscreteScheduler, StableDiffusionXLPipeline
184
 
185
+ print(f"[modal] Loading SDXL-base from {IMAGE_DIR}/{SDXL_BASE_LOCAL} ...")
186
  self.pipe = StableDiffusionXLPipeline.from_pretrained(
187
+ f"{IMAGE_DIR}/{SDXL_BASE_LOCAL}",
188
  torch_dtype=torch.float16,
189
  variant="fp16",
190
  ).to("cuda")
191
+
192
+ # Lightning requires EulerDiscrete with trailing timestep spacing
193
+ self.pipe.scheduler = EulerDiscreteScheduler.from_config(
194
+ self.pipe.scheduler.config, timestep_spacing="trailing"
195
+ )
196
+
197
+ # Load Lightning LoRA from HF Hub (~400 MB, fast)
198
+ print(f"[modal] Loading Lightning LoRA from {LIGHTNING_LORA_REPO} ...")
199
+ self.pipe.load_lora_weights(LIGHTNING_LORA_REPO, weight_name=LIGHTNING_LORA_FILE)
200
+ self.pipe.fuse_lora() # bake into weights for faster inference
201
+
202
+ # Fix fp16 VAE instability ("weird pixels" artifact)
203
+ self.pipe.upcast_vae()
204
+
205
+ self.torch = torch
206
+ print("[modal] SDXL-Lightning ready on GPU")
207
 
208
  @modal.method()
209
  def render(
 
213
  seed: int,
214
  size: int,
215
  steps: int,
216
+ guidance_scale: float = 0.0,
217
+ remove_bg: bool = False,
218
  ) -> bytes:
219
+ """Generate an image and return PNG bytes.
220
+
221
+ Args:
222
+ prompt: Positive prompt.
223
+ negative_prompt: Negative prompt.
224
+ seed: RNG seed for reproducibility.
225
+ size: Square image side in pixels.
226
+ steps: Inference steps (4 for Lightning).
227
+ guidance_scale: CFG scale β€” 0.0 for Lightning sprites, >1.0 for backdrops.
228
+ remove_bg: Run rembg on the output (sprites only).
229
+ """
230
  import io
231
 
232
+ gen = self.torch.Generator(device="cuda").manual_seed(seed)
 
 
233
  result = self.pipe(
234
  prompt=prompt,
235
+ negative_prompt=negative_prompt or None,
236
  num_inference_steps=steps,
237
+ guidance_scale=guidance_scale,
238
  height=size,
239
  width=size,
240
  generator=gen,
241
  )
242
+ img = result.images[0]
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()
249
+ img.save(buf, format="PNG") # PNG supports RGBA transparency
250
  return buf.getvalue()
251
 
252
 
 
271
  from PIL import Image
272
 
273
  backend = ModalPainterBackend()
274
+
275
+ # Backdrop test
276
  png = backend.render.remote(
277
+ prompt="Japanese anime forest, glowing mushrooms, painterly background",
278
+ negative_prompt="text, watermark, characters, person",
279
  seed=42,
280
  size=512,
281
+ steps=4,
282
+ guidance_scale=0.0,
283
+ remove_bg=False,
284
  )
285
  img = Image.open(io.BytesIO(png))
286
+ img.save("smoke_backdrop.png")
287
+ print(f"Backdrop smoke OK -> smoke_backdrop.png {img.size}")
288
+
289
+ # Sprite test (with rembg background removal)
290
+ png_sprite = backend.render.remote(
291
+ prompt="anime girl, school uniform, happy expression, white background",
292
+ negative_prompt="text, watermark, scenery, complex background",
293
+ seed=7,
294
+ size=512,
295
+ steps=4,
296
+ guidance_scale=0.0,
297
+ remove_bg=True,
298
+ )
299
+ img_sprite = Image.open(io.BytesIO(png_sprite))
300
+ img_sprite.save("smoke_sprite.png")
301
+ print(f"Sprite smoke OK -> smoke_sprite.png {img_sprite.size} mode={img_sprite.mode}")
uv.lock CHANGED
@@ -2684,16 +2684,17 @@ wheels = [
2684
 
2685
  [[package]]
2686
  name = "protobuf"
2687
- version = "4.25.9"
2688
  source = { registry = "https://pypi.org/simple" }
2689
- sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/d08c41a8c004e1d437ef467e7c4f9c3295cd784eba48ed5d1d01f94b1dad/protobuf-4.25.9.tar.gz", hash = "sha256:b0dc7e7c68de8b1ce831dacb12fb407e838edbb8b6cc0dc3a2a6b4cbf6de9cff", size = 381040, upload-time = "2026-03-25T23:09:36.423Z" }
2690
  wheels = [
2691
- { url = "https://files.pythonhosted.org/packages/a8/e9/59435bd04bdd46cb38c42a336b22f9843e8e586ff83c35a5423f8b14704e/protobuf-4.25.9-cp310-abi3-win32.whl", hash = "sha256:bde396f568b0b46fc8fbfe9f02facf25b6755b2578a3b8ac61e74b9d69499e03", size = 392879, upload-time = "2026-03-25T23:09:21.32Z" },
2692
- { url = "https://files.pythonhosted.org/packages/f3/16/42a5c7f1001783d2b5bfcecde10127f09010f78982c86ae409122ce3ece6/protobuf-4.25.9-cp310-abi3-win_amd64.whl", hash = "sha256:3683c05154252206f7cb2d371626514b3708199d9bcf683b503dabf3a2e38e06", size = 413900, upload-time = "2026-03-25T23:09:23.589Z" },
2693
- { url = "https://files.pythonhosted.org/packages/56/5b/0074a0a9eb01f3d1c4648ca5e81b22090c811b210b61df9018ac6d6c5cda/protobuf-4.25.9-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:9560813560e6ee72c11ca8873878bdb7ee003c96a57ebb013245fe84e2540904", size = 394826, upload-time = "2026-03-25T23:09:25.194Z" },
2694
- { url = "https://files.pythonhosted.org/packages/54/aa/b2dba856f64c36b2a06c67be1472de98cca07a2322d0f0cbf03279a40e5b/protobuf-4.25.9-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:999146ef02e7fa6a692477badd1528bcd7268df211852a3df2d834ba2b480791", size = 294191, upload-time = "2026-03-25T23:09:26.613Z" },
2695
- { url = "https://files.pythonhosted.org/packages/a8/5c/53f18822017b8bda6bd8bb4e02048e911fdc79a3dafdc83ab994fe922a84/protobuf-4.25.9-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:438c636de8fb706a0de94a12a268ef1ae8f5ba5ae655a7671fcda5968ba3c9be", size = 295178, upload-time = "2026-03-25T23:09:27.839Z" },
2696
- { url = "https://files.pythonhosted.org/packages/16/28/d5065b212685875d3924bcdb3201cbf467cb4d58a18aa19a8dfd99ea80a9/protobuf-4.25.9-py3-none-any.whl", hash = "sha256:d49b615e7c935194ac161f0965699ac84df6112c378e05ec53da65d2e4cbb6d4", size = 156822, upload-time = "2026-03-25T23:09:34.957Z" },
 
2697
  ]
2698
 
2699
  [[package]]
@@ -3819,7 +3820,7 @@ wheels = [
3819
 
3820
  [[package]]
3821
  name = "transformers"
3822
- version = "5.10.2"
3823
  source = { registry = "https://pypi.org/simple" }
3824
  dependencies = [
3825
  { name = "huggingface-hub" },
@@ -3832,9 +3833,9 @@ dependencies = [
3832
  { name = "tqdm" },
3833
  { name = "typer" },
3834
  ]
3835
- sdist = { url = "https://files.pythonhosted.org/packages/8d/38/d5f978bd5091019e89aef29b9a831f5cd70f2598963a3ead8b9570cab592/transformers-5.10.2.tar.gz", hash = "sha256:f9a44b9c8ca9ab1156b467f574d832ea066284299c2fd0ed84641ccb592751fc", size = 8799687, upload-time = "2026-06-04T18:43:49.119Z" }
3836
  wheels = [
3837
- { url = "https://files.pythonhosted.org/packages/73/6f/e1564b0cc182afa05e219a8e09a8e770ffaab879b6b824b56c819bd221da/transformers-5.10.2-py3-none-any.whl", hash = "sha256:8a669db546f82c7c3618cb46ceb0f0afd89292bc70f319c058f8332ec63e268d", size = 11003830, upload-time = "2026-06-04T18:43:45.303Z" },
3838
  ]
3839
 
3840
  [[package]]
 
2684
 
2685
  [[package]]
2686
  name = "protobuf"
2687
+ version = "6.33.6"
2688
  source = { registry = "https://pypi.org/simple" }
2689
+ sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" }
2690
  wheels = [
2691
+ { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" },
2692
+ { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" },
2693
+ { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" },
2694
+ { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" },
2695
+ { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" },
2696
+ { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" },
2697
+ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" },
2698
  ]
2699
 
2700
  [[package]]
 
3820
 
3821
  [[package]]
3822
  name = "transformers"
3823
+ version = "5.9.0"
3824
  source = { registry = "https://pypi.org/simple" }
3825
  dependencies = [
3826
  { name = "huggingface-hub" },
 
3833
  { name = "tqdm" },
3834
  { name = "typer" },
3835
  ]
3836
+ sdist = { url = "https://files.pythonhosted.org/packages/51/58/7f843608f2e8421f86bb97060b54649be6239ec612b82bf9d41e65c26c00/transformers-5.9.0.tar.gz", hash = "sha256:25997cb8fa6053533171634b6162d7df54346530ec2aa9b42bb834e63668c842", size = 8642240, upload-time = "2026-05-20T14:50:49.278Z" }
3837
  wheels = [
3838
+ { url = "https://files.pythonhosted.org/packages/02/ca/2eaa5359f2ccb8c2e1656bc26305ad0cf438aa392ce4b29ae67a315c186e/transformers-5.9.0-py3-none-any.whl", hash = "sha256:1d19509bcff7028ebc6b277d71caa712e8353778463d38764237d14b42b52788", size = 10787648, upload-time = "2026-05-20T14:50:45.337Z" },
3839
  ]
3840
 
3841
  [[package]]
visualnovel/config.py CHANGED
@@ -71,8 +71,6 @@ LLM_GGUF_FILE = os.getenv("VN_LLM_GGUF_FILE", "Qwen3-14B-Q4_K_M.gguf")
71
  IMAGE_MODEL = os.getenv("VN_IMAGE_MODEL", "stabilityai/sdxl-turbo")
72
  # Path or HF repo of YOUR fine-tuned anime-style LoRA (the Well-Tuned bonus). Empty = none.
73
  IMAGE_LORA = os.getenv("VN_IMAGE_LORA", "")
74
- # sdxl β†’ SDXL-Turbo (1–2 steps) | lightning β†’ SDXL-Lightning (4 steps, better quality)
75
- IMAGE_BACKEND = os.getenv("VN_IMAGE_BACKEND", "sdxl")
76
 
77
  WHISPER_SIZE = os.getenv(
78
  "VN_WHISPER_SIZE", "large-v3-turbo"
 
71
  IMAGE_MODEL = os.getenv("VN_IMAGE_MODEL", "stabilityai/sdxl-turbo")
72
  # Path or HF repo of YOUR fine-tuned anime-style LoRA (the Well-Tuned bonus). Empty = none.
73
  IMAGE_LORA = os.getenv("VN_IMAGE_LORA", "")
 
 
74
 
75
  WHISPER_SIZE = os.getenv(
76
  "VN_WHISPER_SIZE", "large-v3-turbo"
visualnovel/engine.py CHANGED
@@ -56,7 +56,7 @@ class Engine:
56
  def transcribe(self, audio_path: str) -> str:
57
  return self.stt.transcribe(audio_path)
58
 
59
- def play_turn(self, player_input: str, audio_path: str | None = None) -> ViewState:
60
  assert self.state is not None, "call start() first"
61
  # turn_num matches what the tracer logs (post-increment value)
62
  turn_num = self.state.turn_index + 1
@@ -67,7 +67,7 @@ class Engine:
67
  player_input = self.stt.transcribe(audio_path) or player_input
68
 
69
  with collector.measure("llm_direct", turn=turn_num):
70
- out = orchestrator.direct_turn(self.llm, self.state, player_input)
71
 
72
  with collector.measure("apply_directives", turn=turn_num):
73
  effects = state.apply_directives(self.state, out)
 
56
  def transcribe(self, audio_path: str) -> str:
57
  return self.stt.transcribe(audio_path)
58
 
59
+ def play_turn(self, player_input: str, action: str = "talk", target: str = "", audio_path: str | None = None) -> ViewState:
60
  assert self.state is not None, "call start() first"
61
  # turn_num matches what the tracer logs (post-increment value)
62
  turn_num = self.state.turn_index + 1
 
67
  player_input = self.stt.transcribe(audio_path) or player_input
68
 
69
  with collector.measure("llm_direct", turn=turn_num):
70
+ out = orchestrator.direct_turn(self.llm, self.state, player_input, action=action, target=target)
71
 
72
  with collector.measure("apply_directives", turn=turn_num):
73
  effects = state.apply_directives(self.state, out)
visualnovel/memory.py CHANGED
@@ -23,6 +23,17 @@ def assemble_context(state: GameState, player_input: str) -> str:
23
  f"- description: {state.scene.description}\n- mood: {state.scene.mood}"
24
  )
25
  parts.append(f"SPIRITS ON STAGE:\n{characters.present_sheets_block(state)}")
 
 
 
 
 
 
 
 
 
 
 
26
  if state.flags:
27
  parts.append("FACTS: " + ", ".join(f"{k}={v}" for k, v in state.flags.items()))
28
 
 
23
  f"- description: {state.scene.description}\n- mood: {state.scene.mood}"
24
  )
25
  parts.append(f"SPIRITS ON STAGE:\n{characters.present_sheets_block(state)}")
26
+
27
+ # Characters the player has already met but who are currently off-stage.
28
+ # The LLM can bring them back via new_character (same id) β€” data is preserved.
29
+ off_stage = [
30
+ f" - {ch.name} (id: {ch.id}, rel: {ch.relationship:+d})"
31
+ for cid, ch in state.characters.items()
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
  if state.flags:
38
  parts.append("FACTS: " + ", ".join(f"{k}={v}" for k, v in state.flags.items()))
39
 
visualnovel/orchestrator.py CHANGED
@@ -65,6 +65,7 @@ def init_world(llm: LLMBackend, setup: SetupForm) -> tuple[GameState, DirectorOu
65
  init = InitOutput.model_validate(raw)
66
 
67
  first = init.first_character
 
68
  scene = Scene(
69
  id=_slug(init.scene.place),
70
  place=init.scene.place,
@@ -104,11 +105,15 @@ def init_world(llm: LLMBackend, setup: SetupForm) -> tuple[GameState, DirectorOu
104
  # --------------------------------------------------------------------------- #
105
  # direct_turn
106
  # --------------------------------------------------------------------------- #
107
- def direct_turn(llm: LLMBackend, state: GameState, player_input: str) -> DirectorOutput:
 
 
 
108
  if config.USE_MOCK:
109
  return _mock_turn(state, player_input)
110
 
111
- context = memory.assemble_context(state, player_input)
 
112
  raw = llm.complete_json(
113
  messages=[
114
  {"role": "system", "content": prompts.DIRECTOR_SYSTEM},
 
65
  init = InitOutput.model_validate(raw)
66
 
67
  first = init.first_character
68
+ first.id = _slug(first.id) or _slug(first.name) # sanitize LLM-supplied id (Windows-safe)
69
  scene = Scene(
70
  id=_slug(init.scene.place),
71
  place=init.scene.place,
 
105
  # --------------------------------------------------------------------------- #
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)
114
 
115
+ formatted = prompts.format_player_turn(player_input, action, target)
116
+ context = memory.assemble_context(state, formatted)
117
  raw = llm.complete_json(
118
  messages=[
119
  {"role": "system", "content": prompts.DIRECTOR_SYSTEM},
visualnovel/painter.py CHANGED
@@ -56,26 +56,45 @@ class Painter(Protocol):
56
  # --------------------------------------------------------------------------- #
57
  # Prompt composition (shared)
58
  # --------------------------------------------------------------------------- #
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def _backdrop_style(style_guide: str) -> str:
60
- """Strip character-triggering tokens from the style guide for backdrop renders."""
61
  skip = {"expressive eyes", "clean linework"}
62
  tokens = [t.strip() for t in style_guide.split(",") if t.strip() not in skip]
63
- return ", ".join(tokens)
64
 
65
 
66
  def backdrop_prompt(state: GameState) -> str:
67
  style = _backdrop_style(state.style_guide)
 
68
  return (
69
  f"background art, empty scenic environment, wide establishing shot, no humans, "
70
- f"uninhabited location, {state.scene.description}, {state.scene.mood} atmosphere, "
71
  f"{style}, no text"
72
  )
73
 
74
 
75
  def sprite_prompt(state: GameState, ch: Character) -> str:
 
 
76
  return (
77
  f"vtuber character design, solo, single character, full-body, centered, "
78
- f"{state.style_guide}, {ch.appearance}, {ch.mood} expression, "
79
  f"pure white background, simple background, no scenery, no text"
80
  )
81
 
@@ -299,6 +318,38 @@ class ModalPainter(PainterBase):
299
 
300
  self._backend = modal.Cls.from_name("vn-app", "ModalPainterBackend")()
301
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  def _render(self, prompt: str, seed: int, size: int,
303
  negative_prompt: str = "", guidance_scale: float = 0.0):
304
  import io # noqa: PLC0415
@@ -312,6 +363,7 @@ class ModalPainter(PainterBase):
312
  size=size,
313
  steps=config.IMAGE_STEPS,
314
  guidance_scale=guidance_scale,
 
315
  )
316
  return Image.open(io.BytesIO(png_bytes))
317
 
 
56
  # --------------------------------------------------------------------------- #
57
  # Prompt composition (shared)
58
  # --------------------------------------------------------------------------- #
59
+
60
+ # CLIP hard limit is 77 tokens (~4 chars/token). Keep variable parts short so the
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:
70
+ """Return at most *n* words from *text* (space-split, no tokenizer needed)."""
71
+ words = text.split()
72
+ return " ".join(words[:n]) if len(words) > n else text
73
+
74
+
75
  def _backdrop_style(style_guide: str) -> str:
76
+ """Keep only the most impactful style tokens and strip character-specific ones."""
77
  skip = {"expressive eyes", "clean linework"}
78
  tokens = [t.strip() for t in style_guide.split(",") if t.strip() not in skip]
79
+ return _w(", ".join(tokens), _STYLE_WORDS)
80
 
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, "
88
  f"{style}, no text"
89
  )
90
 
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, "
98
  f"pure white background, simple background, no scenery, no text"
99
  )
100
 
 
318
 
319
  self._backend = modal.Cls.from_name("vn-app", "ModalPainterBackend")()
320
 
321
+ def sprite(self, state: GameState, ch: Character) -> Path:
322
+ """Override to request server-side background removal via rembg."""
323
+ import io # noqa: PLC0415
324
+
325
+ from PIL import Image # noqa: PLC0415
326
+
327
+ prompt = sprite_prompt(state, ch)
328
+ neg = _SPRITE_NEGATIVE
329
+ seed = ch.sprite_seed
330
+ kind, key = "sprite", f"{ch.id}.{ch.mood}"
331
+
332
+ # Include remove_bg in the cache key so backdrop/sprite hashes never collide.
333
+ h = hashlib.sha1(f"{kind}|{prompt}|{neg}|{seed}|rembg".encode()).hexdigest()[:12]
334
+ path = config.CACHE_DIR / f"{kind}_{key}_{h}.png"
335
+ if path.exists():
336
+ collector.record_cache(hit=True)
337
+ return path
338
+
339
+ collector.record_cache(hit=False)
340
+ png_bytes = self._backend.render.remote(
341
+ prompt=prompt,
342
+ negative_prompt=neg,
343
+ seed=seed,
344
+ size=config.IMAGE_SIZE,
345
+ steps=config.IMAGE_STEPS,
346
+ guidance_scale=0.0,
347
+ remove_bg=True,
348
+ )
349
+ img = Image.open(io.BytesIO(png_bytes))
350
+ img.save(path)
351
+ return path
352
+
353
  def _render(self, prompt: str, seed: int, size: int,
354
  negative_prompt: str = "", guidance_scale: float = 0.0):
355
  import io # noqa: PLC0415
 
363
  size=size,
364
  steps=config.IMAGE_STEPS,
365
  guidance_scale=guidance_scale,
366
+ remove_bg=False,
367
  )
368
  return Image.open(io.BytesIO(png_bytes))
369
 
visualnovel/prompts.py CHANGED
@@ -35,14 +35,23 @@ This turn:
35
  Rules:
36
  - Stay true to each character's established personality, speech quirks, and appearance.
37
  A character only reacts to what they could realistically know.
38
- - Player moves to a new place -> set scene_change.
 
39
  - Player meets someone new -> set new_character with a vivid one_line, a STABLE appearance
40
  (used for every illustration), a distinct voice, 2-3 personality traits, and a secret/goal.
41
  Maximum ~3 characters on stage at once.
42
- - Romantic tension and friction build via relationship_delta for the speaking character.
43
- - When affection is high enough, allow confessions, hand-holding moments, blushing reactions.
 
 
 
 
 
 
44
  - If a detail is missing, invent something charming and consistent β€” never contradict an
45
  established fact.
 
 
46
  - Output MUST be a single JSON object matching the schema. Nothing else.
47
  """
48
 
@@ -76,6 +85,64 @@ exact wording. Neutral narrator tone, plain prose, no lists. Output ONLY the sum
76
  """
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  # --------------------------------------------------------------------------- #
80
  # Helpers
81
  # --------------------------------------------------------------------------- #
 
35
  Rules:
36
  - Stay true to each character's established personality, speech quirks, and appearance.
37
  A character only reacts to what they could realistically know.
38
+ - Player moves to a new place -> ALWAYS emit scene_change. The current character stays
39
+ behind. Their goodbye is their last line β€” they never follow unless the player invites them.
40
  - Player meets someone new -> set new_character with a vivid one_line, a STABLE appearance
41
  (used for every illustration), a distinct voice, 2-3 personality traits, and a secret/goal.
42
  Maximum ~3 characters on stage at once.
43
+ - Relationship_delta ranges from -100 to +100. Romantic warmth builds it up; coldness,
44
+ insults, or rejection bring it down. React authentically to negative moments β€” hurt, cold,
45
+ quietly withdrawing. Do NOT loop the same response β€” escalate or shift the dynamic.
46
+ - If a character explicitly says goodbye, storms off, or leaves for good -> emit
47
+ exit_character with their exact id to remove them from the stage.
48
+ - With multiple characters on stage, they may react to each other (banter, jealousy, a
49
+ knowing glance) β€” but never more than 2 NPC-to-NPC exchanges in a row. After that the
50
+ next line MUST address the player directly.
51
  - If a detail is missing, invent something charming and consistent β€” never contradict an
52
  established fact.
53
+ - NEVER repeat a line of dialogue verbatim from the recent exchanges. Every response must
54
+ move the scene forward β€” emotionally, narratively, or tonally.
55
  - Output MUST be a single JSON object matching the schema. Nothing else.
56
  """
57
 
 
85
  """
86
 
87
 
88
+ # --------------------------------------------------------------------------- #
89
+ # Player action hints
90
+ # These are injected into the context so the Weaver knows what kind of action
91
+ # the player is taking, independently of the text they typed.
92
+ # --------------------------------------------------------------------------- #
93
+ _ACTION_HINTS: dict[str, str] = {
94
+ # move absorbs "leave" β€” destination optional, the LLM picks freely if blank
95
+ "move": (
96
+ "*The wanderer moves to a different location. "
97
+ "If a destination was named, go there; otherwise pick a fitting place freely. "
98
+ "EMIT scene_change. "
99
+ "The current character stays behind β€” they do NOT follow. "
100
+ "Their farewell is their last line; the wanderer is already walking away.*"
101
+ ),
102
+ "touch": (
103
+ "*The wanderer gently touches the character β€” "
104
+ "react emotionally (surprise, warmth, or shy flustering). "
105
+ "adjust relationship_delta (positive if welcomed, negative if unwanted).*"
106
+ ),
107
+ "give": (
108
+ "*The wanderer offers something to the character β€” "
109
+ "react warmly or curiously, adjust relationship_delta.*"
110
+ ),
111
+ "flirt": (
112
+ "*The wanderer flirts directly β€” bold, romantic, slightly daring. "
113
+ "The character reacts with flustering, excitement, or playful deflection. "
114
+ "Apply a significant positive relationship_delta.*"
115
+ ),
116
+ "ask": (
117
+ "*The wanderer asks a personal question about the character β€” "
118
+ "they reveal something about their personality, past, or hidden feelings. "
119
+ "This may unlock a trait; apply a small positive relationship_delta.*"
120
+ ),
121
+ "silent": (
122
+ "*The wanderer says nothing β€” just watches in silence. "
123
+ "The character fills the silence: nervous rambling, a shy glance, "
124
+ "or a quiet moment of shared understanding. "
125
+ "No relationship_delta unless the silence clearly resonates (then small positive).*"
126
+ ),
127
+ "scout": (
128
+ "*The wanderer looks around with curiosity, taking in the surroundings. "
129
+ "This is a natural moment for someone new to appear β€” a passerby, a student, "
130
+ "a stranger who catches the eye. EMIT new_character to introduce them. "
131
+ "If the player named a specific person (e.g. 'Emily'), use that name and invent "
132
+ "a fitting personality for them. The existing character may notice too and react.*"
133
+ ),
134
+ }
135
+
136
+
137
+ def format_player_turn(player_input: str, action: str = "talk", target: str = "") -> str:
138
+ """Combine target + action hint + player text into a single context line."""
139
+ hint = _ACTION_HINTS.get(action, "")
140
+ text = player_input.strip()
141
+ target_hint = f"*The wanderer addresses {target} specifically β€” only {target} should reply.*\n" if target else ""
142
+ parts = [p for p in [target_hint, hint, text] if p]
143
+ return "\n".join(parts) or "…"
144
+
145
+
146
  # --------------------------------------------------------------------------- #
147
  # Helpers
148
  # --------------------------------------------------------------------------- #
visualnovel/state.py CHANGED
@@ -52,7 +52,7 @@ def apply_directives(state: GameState, out: DirectorOutput) -> list[str]:
52
  # new character arrives
53
  if d.new_character:
54
  nc = d.new_character
55
- cid = nc.id or _slug(nc.name)
56
  if cid not in state.characters:
57
  state.characters[cid] = Character(
58
  id=cid,
 
52
  # new character arrives
53
  if d.new_character:
54
  nc = d.new_character
55
+ cid = _slug(nc.id) or _slug(nc.name) # always sanitize β€” LLM ids can contain invalid chars
56
  if cid not in state.characters:
57
  state.characters[cid] = Character(
58
  id=cid,