Files
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

408 lines
22 KiB
Python

#!/usr/bin/env python3
"""novel-illustrator: illustrations, character cards and recaps for web novels read in
LNReader, in the style of each novel's cover.
A chapter's illustration: GLM (Ollama Cloud) picks a moment, writes the image prompt and
records what it learned (characters, their looks on the story timeline, glossary, a
summary); Qwen Image 2.1 renders it with the characters' portraits as references; GLM
checks the result and one re-roll replaces a defective image. See pipeline.py, store.py.
Endpoints (CORS open: the LNReader reader page runs on the source site's origin). "novel"
is always the canonical key from /v1/novels/resolve.
GET /reader.js script the LNReader code snippet loads
GET /gallery[?novel=] illustrations and character looks, up to the furthest chapter read
GET /v1/novels/resolve?key=&title= {"novel": canonical key}: one identity per book across sources
POST /v1/progress {novel, source, title, chapter, position}: chapter opened;
starts prefetch/pre-reading; -> {"recap_due": bool}
GET /v1/recap?novel=&position= {"recap": "..."|null} from the summaries of earlier chapters
GET /v1/settings?novel= {"every": N, "paused": bool}; every=0 is manual only
POST /v1/settings {novel, every, paused} -> settings
GET /v1/illustrations?novel=&chapter= status lookup, never starts work
POST /v1/illustrations {novel, chapter, position?, title?, chapter_title?, cover?,
genres?, text, regenerate?} -> job
GET /v1/illustrations/<id>[.webp] job status (with the placement anchor) / the image
DELETE /v1/illustrations/<id> cancel a queued job and skip the chapter for auto
GET /v1/codex?novel=&position= cards as of that chapter: characters (look there) and terms
POST /v1/characters {action: "redraw", novel, name, label, hint?} -> job
{action: "look", novel, name, label, position} -> card
{action: "merge", novel, name, into} -> {"merged": true}
GET /v1/portraits/<novel>/<look>.webp a portrait (WebP made from the PNG reference)
GET /v1/jobs/<id> status of a portrait job
GET /v1/styles?novel= {current, options: [{name, style, image}]}
POST /v1/styles {novel, action: "propose"} -> job | {novel, action: "choose", index}
GET /v1/styles/<file>.webp a style sample
GET /library request a novel (search sources, add to Kavita)
GET /v1/library/find?q=, /v1/library/jobs, POST /v1/library/fetch
proxied to the novel-fetcher service
"""
import json
import os
import re
import sys
import threading
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import gallery
import indexer
import pipeline
import store
from config import CHARACTERS, IMAGES, READER_JS, STYLES, key
MAX_BODY = 512 * 1024 # chapter text arrives in the request
FETCHER = os.environ.get("FETCHER_URL", "http://novel-fetcher:8090") # internal-only service
MAX_HINT = 300
MAX_UPLOAD = 6 * 1024 * 1024 # portrait uploads (base64 in JSON)
RECAP_AFTER = 2 * 24 * 3600 # offer a recap when a novel is reopened after two days
ID_RE = re.compile(r"^[0-9a-f]{32}$")
def text_field(req, field, required=False, limit=2000):
value = req.get(field)
if value is None or value == "":
if required:
raise ValueError(f"'{field}' is required")
return ""
if not isinstance(value, str) or (required and not value.strip()):
raise ValueError(f"'{field}' must be a string")
return value[:limit]
def position_field(req):
# 0-based chapter index in reading order; places story facts on the timeline.
position = req.get("position")
if position is not None and (type(position) is not int or position < 0):
raise ValueError("'position' must be a non-negative integer")
return position
def parse_illustration(req):
out = {field: text_field(req, field, required=True, limit=MAX_BODY) for field in ("novel", "chapter", "text")}
for field in ("title", "chapter_title", "cover", "genres"):
out[field] = text_field(req, field)
out["title"] = out["title"] or out["novel"]
out["chapter_title"] = out["chapter_title"] or out["chapter"]
out["position"] = position_field(req)
return out
def portrait_webp(novel_hash, look_file):
"""Portraits are stored as lossless PNG (they are Qwen references); phones get a small
WebP, remade whenever the PNG is redrawn."""
png = CHARACTERS / novel_hash / f"{look_file}.png"
if not png.exists():
return None
webp = png.with_suffix(".webp")
if not webp.exists() or webp.stat().st_mtime < png.stat().st_mtime:
tmp = png.parent / f"{look_file}.{threading.get_ident()}.tmp" # concurrent first requests
pipeline.to_webp(png, tmp)
tmp.replace(webp)
return webp
THUMB_WIDTHS = {"240", "480", "720"} # bounded, so thumbnails cannot fill the disk
def thumbnail(file, width):
"""A narrower WebP of an image for grids on phones, made once next to the original."""
if not file or width not in THUMB_WIDTHS:
return file
thumb = file.with_name(f"{file.stem}.w{width}.webp")
if not thumb.exists() or thumb.stat().st_mtime < file.stat().st_mtime:
from PIL import Image
tmp = file.with_name(f"{file.stem}.w{width}.{threading.get_ident()}.tmp")
with Image.open(file) as im:
im = im.convert("RGB")
im.thumbnail((int(width), int(width) * 2))
im.save(tmp, "WEBP", quality=80, method=4)
tmp.replace(thumb)
return thumb
class Handler(BaseHTTPRequestHandler):
def send(self, code, body, content_type="application/json", cache=None):
data = body if isinstance(body, bytes) else json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", cache or "no-store")
self.end_headers()
self.wfile.write(data)
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Max-Age", "86400")
self.end_headers()
def do_GET(self):
path, _, query = self.path.partition("?")
params = {k: v[0] for k, v in urllib.parse.parse_qs(query).items()}
novel = params.get("novel", "")
position = params.get("position", "")
if position and not position.isdigit():
return self.send(400, {"error": "position must be a non-negative integer"})
position = int(position) if position else None
if path == "/healthz":
return self.send(200, {"ok": True})
if path == "/reader.js":
return self.send(200, READER_JS.read_bytes(), "application/javascript", "max-age=300")
if path == "/library":
return self.send(200, gallery.library_page(), "text/html; charset=utf-8")
if path in ("/v1/library/find", "/v1/library/jobs", "/v1/library/info"):
return self.proxy(path.removeprefix("/v1/library"), query)
if path == "/v1/library/titles":
return self.proxy("/library")
if path == "/gallery":
html = gallery.wiki_page(novel) if novel else gallery.index()
return self.send(200, html, "text/html; charset=utf-8")
if path == "/v1/wiki":
if not novel:
return self.send(400, {"error": "novel is required"})
return self.send(200, gallery.wiki_data(novel))
if path == "/v1/novels/resolve":
if not params.get("key"):
return self.send(400, {"error": "key is required"})
return self.send(200, {"novel": store.resolve_novel(params["key"], params.get("title", ""))})
if path in ("/v1/settings", "/v1/codex", "/v1/recap", "/v1/illustrations") and not novel:
return self.send(400, {"error": "novel is required"})
if path == "/v1/settings":
return self.send(200, store.get_settings(novel))
if path == "/v1/codex":
# The reader's cards; the wiki calls store.codex with familiar terms included.
return self.send(200, store.codex(novel, position, familiar=False))
if path == "/v1/recap":
if position is None:
return self.send(400, {"error": "position is required"})
return self.send(200, {"recap": pipeline.recap(novel, position)})
if path == "/v1/illustrations":
# Status lookup by novel+chapter without starting any work.
if not params.get("chapter"):
return self.send(400, {"error": "chapter is required"})
return self.send(200, pipeline.chapter_status(novel, params["chapter"]))
m = re.fullmatch(r"/v1/portraits/([0-9a-f]{32})/([0-9a-f]{32}(?:-[0-9a-f]{8})?)\.webp", path)
if m:
file = thumbnail(portrait_webp(*m.groups()), params.get("w"))
if not file:
return self.send(404, {"error": "not found"})
return self.send(200, file.read_bytes(), "image/webp", "public, max-age=31536000, immutable")
if path == "/v1/styles":
if not novel:
return self.send(400, {"error": "novel is required"})
return self.send(200, pipeline.styles(novel))
m = re.fullmatch(r"/v1/styles/([0-9a-f]{32}-option\d)\.webp", path)
if m:
file = STYLES / f"{m.group(1)}.webp"
if not file.exists():
return self.send(404, {"error": "not found"})
file = thumbnail(file, params.get("w"))
return self.send(200, file.read_bytes(), "image/webp", "public, max-age=31536000, immutable")
m = re.fullmatch(r"/v1/jobs/([0-9a-f]{32})", path)
if m:
return self.send(200, {"id": m.group(1), **(pipeline.job_status(m.group(1)) or {"status": "missing"})})
m = re.fullmatch(r"/v1/illustrations/([0-9a-f]{32})(\.webp)?", path)
if not m:
return self.send(404, {"error": "not found"})
if m.group(2):
file = pipeline.image_path(m.group(1))
if not file.exists():
return self.send(404, {"error": "not ready"})
file = thumbnail(file, params.get("w"))
# Status links carry ?v=<mtime>, so a replaced image gets a new URL.
return self.send(200, file.read_bytes(), "image/webp", "public, max-age=31536000, immutable")
return self.send(200, pipeline.illustration_status(m.group(1)))
def proxy(self, path, query="", body=None):
"""Forward to the fetcher; searches ask every source and take about a minute."""
req = urllib.request.Request(f"{FETCHER}{path}" + (f"?{query}" if query else ""), body,
{"Content-Type": "application/json"} if body else {})
try:
with urllib.request.urlopen(req, timeout=240) as res:
return self.send(res.status, res.read())
except urllib.error.HTTPError as e:
return self.send(e.code, e.read())
except OSError:
return self.send(503, {"error": "the fetcher is not running"})
def do_DELETE(self):
m = re.fullmatch(r"/v1/illustrations/([0-9a-f]{32})", self.path.split("?", 1)[0])
if not m:
return self.send(404, {"error": "not found"})
job_id = m.group(1)
if not pipeline.cancel(job_id):
return self.send(409, {"error": "already painting; it cannot be stopped midway"})
if not pipeline.image_path(job_id).exists():
store.set_skipped(job_id, True)
self.send(200, pipeline.illustration_status(job_id))
def do_POST(self):
path = self.path.split("?", 1)[0]
if path == "/v1/library/fetch":
length = int(self.headers.get("Content-Length") or 0)
if not 0 < length <= 10_000:
return self.send(413, {"error": "body must be 1..10000 bytes"})
return self.proxy("/fetch", body=self.rfile.read(length))
handlers = {"/v1/illustrations": self.post_illustration, "/v1/settings": self.post_settings,
"/v1/styles": self.post_styles,
"/v1/progress": self.post_progress, "/v1/characters": self.post_character}
if path not in handlers:
return self.send(404, {"error": "not found"})
length = int(self.headers.get("Content-Length") or 0)
limit = MAX_UPLOAD * 2 if path == "/v1/characters" else MAX_BODY
if length <= 0 or length > limit:
return self.send(413, {"error": f"body must be 1..{limit} bytes"})
try:
req = json.loads(self.rfile.read(length))
if not isinstance(req, dict):
raise ValueError("body must be a JSON object")
handlers[path](req)
except (ValueError, json.JSONDecodeError) as e:
return self.send(400, {"error": str(e)})
def post_settings(self, req):
self.send(200, store.put_settings(text_field(req, "novel", required=True), req.get("every"),
req.get("paused", False), req.get("mode", "every")))
def post_illustration(self, req):
regenerate = req.get("regenerate") is True
slot = req.get("slot", 1)
if slot not in (1, 2):
raise ValueError("'slot' must be 1 or 2")
req = parse_illustration(req)
job_id = pipeline.slot_id(req["novel"], req["chapter"], slot)
store.set_skipped(job_id, False) # an explicit request overrides an earlier skip
if regenerate:
if pipeline.job_status(job_id) is None:
for suffix in (".webp", ".json"):
(IMAGES / f"{job_id}{suffix}").unlink(missing_ok=True)
store.forget_illustration(job_id)
if not pipeline.image_path(job_id).exists():
pipeline.submit(job_id, "illustration", pipeline.USER, pipeline.generate, req, slot)
else:
# Auto mode may want a second image for the chapters rated most visual.
pipeline.request_chapter(req, pipeline.USER, max(1, store.images_due(req["novel"], req["position"])))
current = pipeline.chapter_status(req["novel"], req["chapter"])
self.send(202 if current["status"] != "done" else 200, current)
def post_progress(self, req):
novel = text_field(req, "novel", required=True)
source = text_field(req, "source")
chapter = text_field(req, "chapter")
position = position_field(req)
gap, last = store.record_progress(novel, position)
recap_due = bool(gap and gap > RECAP_AFTER and position and store.summaries_before(novel, position, 1))
# Kavita calls can be slow or down; never hold up the reader for them.
threading.Thread(target=indexer.register, args=(novel, source, chapter), daemon=True).start()
if position is not None:
pipeline.drop_behind(novel, position)
prefetching = pipeline.prefetch_next(novel, source, chapter, position, text_field(req, "title"))
# Auto decisions live here (they need pre-reading's scores for "key" mode).
self.send(200, {"recap_due": recap_due, "prefetching": prefetching,
"due": store.images_due(novel, position),
"next_due": store.next_due(novel, position) if position is not None else None})
def post_styles(self, req):
novel = text_field(req, "novel", required=True)
if req.get("action") == "propose":
job_id = key(novel, "styles")
pipeline.submit(job_id, "styles", pipeline.USER, pipeline.propose_styles_job, novel)
return self.send(202, {"id": job_id, **(pipeline.job_status(job_id) or {"status": "queued"})})
if req.get("action") == "choose":
index = req.get("index")
if type(index) is not int:
raise ValueError("'index' must be an integer")
return self.send(200, pipeline.choose_style(novel, index))
raise ValueError("'action' must be propose or choose")
def post_character(self, req):
"""Character tools from the reader's cards and the wiki's Edit sheet."""
novel = text_field(req, "novel", required=True)
name = text_field(req, "name", required=True, limit=80)
action = req.get("action")
try:
if action == "merge":
into = text_field(req, "into", required=True, limit=80)
if not store.merge_characters(novel, into, name):
return self.send(404, {"error": "unknown characters"})
return self.send(200, {"merged": True})
if action == "edit":
aliases = req.get("aliases")
if aliases is not None and (not isinstance(aliases, list) or len(aliases) > 40
or not all(isinstance(a, str) and 0 < len(a) <= 80 for a in aliases)):
raise ValueError("'aliases' must be a list of up to 40 short strings")
role = req.get("role")
if role is not None and (not isinstance(role, str) or len(role) > 400):
raise ValueError("'role' must be a string of at most 400 characters")
new = store.edit_character(novel, name, text_field(req, "new_name", limit=80).strip() or None,
role, [a.strip() for a in aliases] if aliases is not None else None)
return self.send(200, {"name": new})
if action == "delete":
store.delete_character(novel, name)
return self.send(200, {"deleted": True})
if action == "split":
new_name = text_field(req, "new_name", required=True, limit=80).strip()
labels, aliases = req.get("looks"), req.get("aliases") or []
if not isinstance(labels, list) or not isinstance(aliases, list):
raise ValueError("'looks' and 'aliases' must be lists")
store.split_character(novel, name, new_name, [a for a in aliases if isinstance(a, str)],
[l for l in labels if isinstance(l, str)], text_field(req, "role", limit=400))
return self.send(200, {"name": new_name})
# Actions on one look of the character.
label = text_field(req, "label", required=True, limit=60)
with store.connect() as conn:
if label not in {l["label"] for l in store.looks_of(conn, novel, name)}:
return self.send(404, {"error": "unknown character or look"})
if action == "redraw":
hint = text_field(req, "hint", limit=MAX_HINT).strip()
job_id = key(novel, name, label, "portrait")
pipeline.submit(job_id, "portrait", pipeline.USER, pipeline.redraw_job, novel, name, label, hint)
return self.send(202, {"id": job_id, **(pipeline.job_status(job_id) or {"status": "queued"})})
if action == "upload":
image = pipeline.save_upload(novel, name, label, text_field(req, "image", required=True,
limit=MAX_UPLOAD * 2))
job_id = key(novel, name, label, "describe")
pipeline.run_now(job_id, "portrait", pipeline.describe_job, novel, name, label, image)
return self.send(202, {"id": job_id, **(pipeline.job_status(job_id) or {"status": "queued"})})
if action == "look":
position = position_field(req)
if position is None:
raise ValueError("'position' is required")
with store.connect() as conn:
store.set_timeline(conn, novel, name, position, label)
card = next((c for c in store.codex(novel, position)["characters"] if c["name"] == name), None)
return self.send(200, card or {})
if action == "edit_look":
since = req.get("since")
if since is not None and (type(since) is not int or since < 0):
raise ValueError("'since' must be a chapter position")
description = req.get("description")
if description is not None and (not isinstance(description, str) or len(description) > 600):
raise ValueError("'description' must be a string of at most 600 characters")
new = store.edit_look(novel, name, label, text_field(req, "new_label", limit=60).strip() or None,
description, since)
return self.send(200, {"label": new})
if action == "delete_look":
store.delete_look(novel, name, label, text_field(req, "into", required=True, limit=60))
return self.send(200, {"deleted": True})
except LookupError as e:
return self.send(404, {"error": str(e)})
raise ValueError("unknown action")
def log_message(self, fmt, *args):
print(f"{self.address_string()} {fmt % args}", file=sys.stderr)
if __name__ == "__main__":
store.migrate()
pipeline.resume()
threading.Thread(target=pipeline.worker, daemon=True).start()
threading.Thread(target=indexer.run, daemon=True).start()
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()