Merge origin/main into feature/update-vocal_system
Browse filesIntegrates 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 +2 -2
- frontend/index.html +298 -12
- modal_app.py +96 -23
- uv.lock +12 -11
- visualnovel/config.py +0 -2
- visualnovel/engine.py +2 -2
- visualnovel/memory.py +11 -0
- visualnovel/orchestrator.py +7 -2
- visualnovel/painter.py +56 -4
- visualnovel/prompts.py +70 -3
- visualnovel/state.py +1 -1
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,.
|
| 62 |
-
.rel-fill{
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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">
|
| 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:
|
| 271 |
const rel = p.relationship ?? 0;
|
| 272 |
-
const
|
| 273 |
-
const
|
| 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">
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 376 |
$("say").value = ""; busy(true);
|
| 377 |
try {
|
| 378 |
-
const { data } = await client.predict("/turn", { player_input: text });
|
| 379 |
render(data[0]);
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ? ` Β· <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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 {
|
| 93 |
-
snapshot_download(
|
| 94 |
image_volume.commit()
|
| 95 |
-
print("SDXL-
|
| 96 |
|
| 97 |
|
| 98 |
# --------------------------------------------------------------------------- #
|
| 99 |
-
# LLM backend (llama.cpp + Qwen3-
|
| 100 |
# --------------------------------------------------------------------------- #
|
| 101 |
|
| 102 |
|
|
@@ -143,7 +155,7 @@ class ModalLLMBackend:
|
|
| 143 |
|
| 144 |
|
| 145 |
# --------------------------------------------------------------------------- #
|
| 146 |
-
# Painter backend (SDXL-
|
| 147 |
# --------------------------------------------------------------------------- #
|
| 148 |
|
| 149 |
|
|
@@ -152,20 +164,46 @@ class ModalLLMBackend:
|
|
| 152 |
gpu="A10G",
|
| 153 |
volumes={IMAGE_DIR: image_volume},
|
| 154 |
timeout=120,
|
| 155 |
-
scaledown_window=
|
| 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}/
|
| 165 |
torch_dtype=torch.float16,
|
| 166 |
variant="fp16",
|
| 167 |
).to("cuda")
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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=
|
| 189 |
height=size,
|
| 190 |
width=size,
|
| 191 |
generator=gen,
|
| 192 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
buf = io.BytesIO()
|
| 194 |
-
|
| 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=
|
|
|
|
|
|
|
| 225 |
)
|
| 226 |
img = Image.open(io.BytesIO(png))
|
| 227 |
-
img.save("
|
| 228 |
-
print(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "
|
| 2688 |
source = { registry = "https://pypi.org/simple" }
|
| 2689 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 2690 |
wheels = [
|
| 2691 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2692 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2693 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2694 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2695 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2696 |
-
{ url = "https://files.pythonhosted.org/packages/16/
|
|
|
|
| 2697 |
]
|
| 2698 |
|
| 2699 |
[[package]]
|
|
@@ -3819,7 +3820,7 @@ wheels = [
|
|
| 3819 |
|
| 3820 |
[[package]]
|
| 3821 |
name = "transformers"
|
| 3822 |
-
version = "5.
|
| 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/
|
| 3836 |
wheels = [
|
| 3837 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 108 |
if config.USE_MOCK:
|
| 109 |
return _mock_turn(state, player_input)
|
| 110 |
|
| 111 |
-
|
|
|
|
| 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 |
-
"""
|
| 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, {
|
| 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"{
|
| 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 ->
|
|
|
|
| 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 |
-
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|