Background GLM callers on the shared Ollama account slowed each scene call to ~2 min. Pre-reading and maintain.py now yield while illustrations are queued, passing a chapter drops queued work for earlier ones, and prompts carry only the active look's description (roster 90 KB -> 17 KB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
novel-illustrator
Illustrations, character and glossary cards, and "previously on" recaps for web novels read in LNReader. Illustrations are drawn in the style of each novel's cover.
- URL:
https://illustrate.marcospaulo.dev.br(Tailscale-only;expose novel-illustrator/novel-illustrator illustrate). The gallery is at/gallery. - Deploy with
./deploy.sh. All*.pyfiles andreader.jsgo into ConfigMapnovel-illustrator-app, mounted at/app/src. The ComfyUI dispatcher (~/manifests/comfyui/scripts/generate_asset.py) and the Qwen workflow are mounted from their canonical copies, not copied into this repo.
| File | Role |
|---|---|
server.py |
HTTP API, input validation, startup |
pipeline.py |
GPU priority queue: scenes, portraits, quality check, prefetch, recaps |
store.py |
SQLite state on the story timeline |
llm.py |
GLM calls and all prompts |
indexer.py |
Background pre-reading of Kavita novels |
kavita.py |
Server-side chapter reads |
gallery.py |
Gallery front page, the /v1/wiki JSON behind the wiki, and the /library request page |
wiki.html |
The per-novel wiki (tabs, character sheets with editing, lightbox); renders /v1/wiki |
maintain-job.sh |
Runs maintain.py as a Kubernetes Job, so deploys don't interrupt it |
maintain.py |
One-off knowledge cleanup (prune non-characters, fold looks, drop generic terms) |
fetcher-start.sh |
Entrypoint of the novel-fetcher Deployment (Node) |
k8s/backup.yaml |
Nightly backup CronJob to MinIO |
reader.js |
Runs inside the LNReader reader |
One chapter's illustration
-
Style, once per novel. GLM-5.3-Flash (Ollama Cloud, vision) describes the cover's style, cached in
styles/<hash>.json. It describes only how things are drawn: medium, brushwork, palette, lighting. It never names subjects, because it's appended to every prompt and those would reappear in every scene. -
Scene. GLM picks a moment that doesn't spoil the ending and writes a prompt around the image model's weak spots:
- at most two main figures;
- calm, grounded poses;
- no contact between figures;
- medium shots with faces visible;
- restrained gore.
In the same call it returns:
anchor: a few words copied from the sentence where the moment happens;- what it learned (see Story knowledge);
- a 3-sentence
summary.
-
Cast.
[[Name]]placeholders become "the character from image N (kind)", with that character's portrait for the look they have in this chapter. Up to 4 are passed as Qwen 2.1 references. Missing portraits are drawn first (lazily). The kind label ("a dragon") stops Qwen blending a human and a creature into one figure. -
Render and check. One plain Qwen Image 2.1 pass at 1216×832, with a random seed each time.
- GLM vision then checks for clear defects: cloned or merged figures, a human with creature parts, broken limbs, a malformed foreground face or hand, extra figures, text.
- On a defect it re-rolls once and keeps the better image. Results are in
illustrations.qaandimages/<id>.json. - A check failure never blocks the image.
-
Saved as WebP (quality 85) in
images/<id>.webp, with a row inillustrations. The status URL carries?v=<mtime>, so "New image" gets a fresh URL.
GLM is the real bottleneck, not the GPU. Every background GLM user shares one Ollama Cloud account, and a busy caller made each call take 70–114 s instead of about 2 s (renders take 12–21 s). So:
- pre-reading pauses while illustrations are queued;
maintain.pywaits between characters until the queue is empty;- opening a chapter drops queued work for chapters already passed;
- prompts send full descriptions only for the active look, plus names of the 80 most recurring other characters (about 17 KB instead of 90 KB).
GPU queue. Pending jobs are persisted in the queue table and resumed in order after a restart or deploy. Failed jobs aren't retried automatically; the reader offers a retry. One job runs at a time, because the GPU is shared. The reader's own requests (USER) run before prefetches (PREFETCH), and a queued prefetch you open is moved up. On CUDA out-of-memory the service calls ComfyUI's /free and retries.
Story knowledge (on the timeline)
All tables are keyed by the canonical novel: the same book from different sources (the WeTriedTLS plugin and the Kavita copy) resolves by normalized title to one key (novels table). The first source seen becomes canonical; here it's kavita:3.
-
Characters and looks. A character has named looks, each with its own portrait (
looks). A timeline records which look is active from which chapter position (timeline). This handles:- transformations and alternate forms (Kim's reconstruction, Seo Ran's dragon and human forms);
- regression loops, where GLM returns to an earlier look instead of inventing one;
- flashbacks, which apply to the scene only.
Portraits are drawn lazily, the first time a scene or a card needs them.
-
Identity. The scene and pre-read prompts list every known character by name and aliases, and give full look details for the ones named in the chapter. GLM can add aliases it sees ("Director Kim" → Kim Young-hoon). When a duplicate still happens, the card's "Same person as…" merges it:
- the duplicate's names become aliases;
- matching look labels are merged;
- a clashing
initiallook is renamedinitial (<name>); - the timeline moves over.
-
Glossary (
terms), summaries (summaries) and recaps (recaps) are all placed by position. -
Nothing past the reader's chapter is shown: cards, their look lists and the gallery are all cut at the chapter position.
Knowledge quality
-
Prompts are strict about categories.
- A look is only a lasting visual change. Power levels, realms, injuries, one-off outfits and brief disguises don't count.
- Characters are named individuals only: never groups, descriptions or translators.
- Terms are recurring proper names only.
-
Mentions (
mentionstable) record which chapters each character name, alias or term occurs in. They're matched by script, case-sensitive, as whole words, with one compiled pattern of every name and term per novel: a single pass per chapter, about 46 ms instead of about 1.4 s with one search per name. The slow version pinned the CPU, failed the liveness probe and restarted the pod. Cards and prompts leave out anything seen in only one chapter until it recurs (unless it's first met in the current chapter). Terms seen in more than 20 chapters stop being underlined in the reader but stay in the wiki. -
maintain.py <novel> [--dry-run] [--prune-only|--looks-only|--terms-only], run inside the pod:- prunes non-characters: rules first, then one batched GLM call for the rest;
- folds inflated look lists: one GLM call per character with more than 3 looks, keeping drawn portraits and moving the timeline over;
- drops generic "terms" by a script test on the chapter texts, then recounts mentions.
Always read the
--dry-runoutput first. Run it with./maintain-job.sh <novel> [flags], which starts a Kubernetes Job, so a deploy never kills a long consolidation. Each character is committed on its own, so a GLM timeout on one is skipped and doesn't stop the run.
Auto modes
- Every N chapters (
mode=every), or Key moments (mode=key). Key moments illustrates the chapters pre-reading rated at least 7 out of 10 for illustration value (summaries.score). Unrated chapters fall back to every 5th. - Chapters rated 9 or more get a second image of a different moment (slot 2, its own anchor).
- The server decides (
/v1/progressreturnsdueandnext_due). Prefetch paints the next due chapter.
Wiki and character editing (/gallery?novel=)
- Tabs: Characters (ranked by chapters they appear in; Main = 20+ chapters), Illustrations (lightbox with previous/next), Glossary (A–Z), Story so far (summaries in groups of 10 chapters, with thumbnails), Art style. Search filters the open tab. Everything is cut at the furthest chapter read.
- Tapping a character opens a sheet with its portrait, aliases, role, appearance and look timeline. Edit changes:
- name, role and aliases (aliases need a capital; descriptive ones like "the young man" are refused);
- each look's label, appearance and first chapter; redraw (with a correction); upload your own picture (GLM then describes it, off the GPU queue); delete a look (its chapters move to another look);
- merge into another character, split looks and aliases into a new character (for two people mixed into one), delete the character.
- API:
POST /v1/characterswithaction=edit,edit_look,delete_look,upload(base64 image, at most 6 MB, verified with Pillow),split,delete,merge,redraw,look. - Images take
?w=240|480|720for resized WebP thumbnails, made once and cached next to the original.
Art style picker
On a novel's wiki page (/gallery?novel=), "Try three other styles" has GLM propose three distinct styles.
- Each one is rendered as a sample on the best-rated moment the reader has already seen, without character references, so the sample shows the style.
- "Use this style" saves it and clears portrait paths, so each portrait is redrawn in the new style when next needed.
- Existing illustrations keep their old style until "🔄 New image".
Novel requests (/library) and nightly updates
- The page searches every source (about 30 s;
/library?q=Titlestarts a search directly). Results are grouped by novel. Each source shows its chapter count, free versus locked, and latest chapter, loaded three at a time fromGET /info. The novel's cover, author, status, genres and summary come from the first source that answers. Novels already in the library are marked, and "Add" queues a download. It's served by the illustrator and proxied to thenovel-fetcherDeployment (internal Servicenovel-fetcher:8090). - The fetcher has its own pod on purpose: a pod is Ready only when all its containers are, so a failing helper must not take the illustrator offline.
- It keeps a checkout of
masi/lnreader-plugins(in-cluster Gitea) on the data volume atfetcher/, and reinstallsnode_modulesonly when the lockfile changes. - Searches and downloads run as child processes of
scripts/request-server.js. Plugins sharenode_modules(npm packages are external in the bundle), about 110 MB per child. Bundled standalone, all 165 needed 1.2 GB. - Download novels, make sure to search on alternative sites if not found and make sure they land in
/srv/hdd/filebrowser/data/library/novels/<Title>/: PVpv-hdd-novel-library, writable,Retain. Kavita picks them up by folder watching (enabled globally; the watcher starts with Kavita, and it scans a changed folder after about 10 minutes). - Nightly at 04:17 (America/Sao_Paulo),
update-novels.jsappends newly free chapters to EPUBs built from a source (urn:lnreader:<plugin>:<path>). Positions never move. Pre-reading re-checks the page count every 6 hours.
Backups
k8s/backup.yamlruns nightly at 03:23. It takes a consistent SQLite snapshot (online backup API) plus a tar ofcharacters/,styles/andimages/, and uploads them to MinIO bucketnovel-illustrator-backups/<timestamp>/andlatest/, kept 14 days.- It uses a least-privilege key in Secret
novel-illustrator-backup: MinIO usernovel-illustrator-backup, policy scoped to that one bucket. - Restore: download
latest/illustrator.dbandlatest/assets.tar(e.g.mc cpinside the minio pod). Scale the illustrator to 0, replace/srv/hdd/novel-illustrator/illustrator.db, untar the assets into the same folder, then scale back to 1. Restore-tested on 2026-09-26: integrity ok, 284 characters, 470 summaries.
Pre-reading (Kavita novels)
The first time a Kavita novel is opened, indexer.py starts reading it server-side in order, one chapter at a time:
- one text-only GLM call per chapter, about 5 s apart;
- it records characters, looks, aliases, terms and a summary;
- progress and the last error are in
index_state.
Cards and recaps are then complete even for chapters that are never illustrated. Chapters come from Kavita through the read-only reader account: Secret novel-illustrator/kavita-reader, a copy of kavita/kavita-reader. For the Kavita plugin, the chapter path is <bookId>/<page> and position equals page.
LNReader setup (once per phone)
Settings → Reader → Custom code → add a JS snippet:
var s=document.createElement('script');s.src='https://illustrate.marcospaulo.dev.br/reader.js';document.head.appendChild(s);
It applies to every source. Off the tailnet the script stays invisible. On each chapter it:
- Places the illustration inside the text, right after the anchor sentence. It falls back to below the chapter for older images or when the sentence isn't found. "🔄 New image" (tap twice) repaints it.
- Hides promo lines: short paragraphs that are only translator credits, Discord/ko-fi/Patreon plugs or bare links. The rule is a copy of
lnreader-plugins/scripts/clean-chapter.js, whichnovel fetchandnovel cleanuse for EPUBs, so keep the two in sync. Hidden text isn't sent to GLM. - Underlines known names and terms, at most once every 10 paragraphs each. Tapping one opens a card: portrait, aliases, look, role, appearance. The card's fixes are:
- 🎨 Redraw portrait, with an optional correction ("older, with a beard");
- "Look here", to set which look applies from this chapter;
- "Same person as…".
- Shows automation controls below the chapter: Auto Off / every 1, 2, 3, 5, 10 chapters, ⏸ Pause, and ✕ Cancel on a queued chapter (which marks it skipped), plus 📜 Recap.
- Reports progress (
POST /v1/progress). For a Kavita novel with Auto on, this prefetches the next due chapter, so its image is ready when you get there. After two days away, the recap opens automatically.
API
novel is always the canonical key from /v1/novels/resolve. The endpoint list is in the server.py docstring. CORS is open (*) because the reader page runs on the source site's origin or file://. Access control is the Tailscale-only ingress.
Storage and secrets
-
pv-hdd-novel-illustrator→/srv/hdd/novel-illustrator(20Gi,hdd-storage,Retain, nodekubernets). It holdsimages/,styles/,characters/andillustrator.db(SQLite, WAL mode). -
illustrator.dbandcharacters/are the only record of characters, looks, the glossary and summaries, so back those up. Images are regenerable but cost GPU time. -
To restyle a novel: delete
styles/<hash>.json, then redraw portraits from the cards. -
Secrets live only in the namespace:
ollama-cloud(api-key);kavita-reader(username,password);novel-illustrator-backup(ACCESS_KEY,SECRET_KEY).
Rotate them with
kubectl create secret ... --dry-run=client -o yaml | kubectl apply -f -, then./deploy.sh.
Performance notes
- Qwen Image 2.1 needs about 16.4 GB of VRAM loaded at once. If other GPU users leave less free, ComfyUI swaps models from the HDD on every job (about 135 s instead of about 20 s). After a reboot, the first job pays that load once.
- A scene takes about 40–110 s: GLM design, the render, the GLM check, and occasionally a re-roll or a new portrait.
- Pre-reading 882 chapters takes several hours in the background. It uses GLM only, no GPU.