masi 975eb44707 fix: reader-facing illustrations no longer starve behind background GLM work
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>
2026-09-26 16:54:13 -03:00

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 *.py files and reader.js go into ConfigMap novel-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

  1. 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.

  2. 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.
  3. 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.

  4. 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.qa and images/<id>.json.
    • A check failure never blocks the image.
  5. Saved as WebP (quality 85) in images/<id>.webp, with a row in illustrations. 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.py waits 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 initial look is renamed initial (<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 (mentions table) 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-run output 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/progress returns due and next_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/characters with action = 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|720 for 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=Title starts 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 from GET /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 the novel-fetcher Deployment (internal Service novel-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 at fetcher/, and reinstalls node_modules only when the lockfile changes.
  • Searches and downloads run as child processes of scripts/request-server.js. Plugins share node_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>/: PV pv-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.js appends 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.yaml runs nightly at 03:23. It takes a consistent SQLite snapshot (online backup API) plus a tar of characters/, styles/ and images/, and uploads them to MinIO bucket novel-illustrator-backups/<timestamp>/ and latest/, kept 14 days.
  • It uses a least-privilege key in Secret novel-illustrator-backup: MinIO user novel-illustrator-backup, policy scoped to that one bucket.
  • Restore: download latest/illustrator.db and latest/assets.tar (e.g. mc cp inside 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, which novel fetch and novel clean use 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, node kubernets). It holds images/, styles/, characters/ and illustrator.db (SQLite, WAL mode).

  • illustrator.db and characters/ 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.
S
Description
Chapter illustrations for LNReader (GLM + Qwen Image 2.1)
Readme 804 KiB
Languages
Python 69.8%
HTML 15%
JavaScript 13.1%
Shell 2.1%