Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3ce7b27
fix: fall back to 1024x1024 when finished-page size is rejected
phaethix Jul 31, 2026
8ab4029
docs: spec and plan for deferred finished-page lettering
phaethix Jul 31, 2026
ff654d7
feat: add LetteringBox and finished_lettered page fields
phaethix Jul 31, 2026
0fd9a8b
feat: add finished-page lettering language lock helpers
phaethix Jul 31, 2026
ea58b02
feat: default finished-page prompts to blank lettering
phaethix Jul 31, 2026
a60058c
feat: overlay deferred lettering onto blank finished pages
phaethix Jul 31, 2026
054a6ce
feat: lock finished-page plan lettering language with one retry
phaethix Jul 31, 2026
ed33ee6
feat: wire deferred lettering into finished-page pipeline
phaethix Jul 31, 2026
b96a06f
chore: ruff format lettering_lang
phaethix Jul 31, 2026
4d41033
docs: document deferred lettering for finished pages
phaethix Jul 31, 2026
d3b1417
fix: gate deferred lettering fingerprint to finished_page mode
phaethix Jul 31, 2026
aea0bf4
fix: shrink-wrap lettering chrome and harden metaphorical names
phaethix Jul 31, 2026
abecf86
fix: shrink-wrap lettering chrome and harden metaphorical names
phaethix Jul 31, 2026
339cecf
fix: keep deferred lettering in bounds and strip pinyin glosses
phaethix Aug 1, 2026
1b3bf8b
fix: stop metaphorical names like 虎妞 from rendering as animals
phaethix Aug 1, 2026
892db03
docs: add visual bible design for character and color consistency
phaethix Aug 2, 2026
88cd9b0
docs: add visual bible implementation plan
phaethix Aug 2, 2026
d173eeb
feat: add VisualBible schema models on ProjectState
phaethix Aug 2, 2026
a3a43e0
feat: add visual bible hash, reconcile apply, and ref helpers
phaethix Aug 2, 2026
34c91fc
feat: add reconcile_visual_bible screenwriter tool
phaethix Aug 2, 2026
f2f6c62
feat: inject visual bible style color and locks into page prompts
phaethix Aug 2, 2026
be2287f
feat: wire visual bible reconcile into finished_page pipeline
phaethix Aug 2, 2026
0764746
fix: install visual bible from reconcile canons on first create
phaethix Aug 2, 2026
d5aa94b
feat: sync character L1 prompts from visual bible canons
phaethix Aug 2, 2026
a1f51b6
fix: Visual Bible reconcile bootstrap, alias rewrite, and palette locks
phaethix Aug 2, 2026
e4711e0
docs: add visual bible v2 hardening spec and plan
phaethix Aug 2, 2026
f44e24a
feat: guard visual bible merges and require identity locks
phaethix Aug 2, 2026
f16d02f
test: cover portrait_key repair and use canon_face in stage locks
phaethix Aug 2, 2026
b526a01
feat: sanitize polluted visual bible state to bible_v2
phaethix Aug 2, 2026
89fd012
feat: backfill empty panel characters from refs and action
phaethix Aug 2, 2026
9684425
feat: wire bible_v2 sanitize, backfill, and page prompt locks
phaethix Aug 2, 2026
93dedb9
fix: resolve alias roles in sanitize and stop outfit=style_guide
phaethix Aug 2, 2026
817865f
fix: silence ruff B023/E501/F401 for CI lint
phaethix Aug 2, 2026
d1228da
chore: ruff format visual bible and pipeline files
phaethix Aug 2, 2026
9330df5
fix: count visual-bible reconcile in d2 pipeline chat asserts
phaethix Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Inkstone is an **independent implementation, not a fork** — inspired by [`lcy3

> **An honest trade-off.** Free, cloud-only Agnes with no GPU caps how far character consistency can reach. The strongest approaches (IP-Adapter / InsightFace) need a local GPU running SDXL/Flux — incompatible with Inkstone's zero-cost premise. So Inkstone trades *perfect* consistency for *zero-cost + no-GPU + out-of-the-box*, using L1+L2+L3 as the best feasible strategy. Stated plainly, not hidden.

**Finished-page mode (default)** generates one designed comic page per image call (dynamic panels + in-image lettering). Free-tier models have ceilings on legible text and identity lock — Inkstone optimizes for page-shaped comics, not commercial print parity. If finished pages fail persistently, set `INKSTONE_RENDER_MODE=panel_compose` and re-run; the legacy panel + layout path reuses cached plans where possible.
**Finished-page mode (default)** generates whole-page art with empty lettering chrome; Inkstone overlays caption/dialogue/sfx using real fonts (CJK-capable). This avoids model-painted glyph distortion. Set `INKSTONE_RENDER_MODE=panel_compose` for the legacy per-panel path. Unlettered art is cached under `pages/blank/` so resume can re-letter without another image API call.

<p align="center">
<img src="assets/readme/section-how.svg" width="100%" alt="How it works" />
Expand All @@ -68,7 +68,7 @@ Inkstone is an **independent implementation, not a fork** — inspired by [`lcy3
<img src="assets/readme/pipeline.svg" width="100%" alt="Pipeline: Split → Extract → Board → Paint → Export" />
</p>

A `txt` novel is split into segments → characters & scenes are extracted with `agnes-2.5-flash` → **finished-page mode (default)** plans one comic page per image call (dynamic panels + in-image lettering) → each page is painted directly → pages are bound to PDF or stacked into a webtoon PNG. For the legacy path, set `INKSTONE_RENDER_MODE=panel_compose`: storyboard prompts → per-panel generation → `LayoutEngine` grid layout → export.
A `txt` novel is split into segments → characters & scenes are extracted with `agnes-2.5-flash` → **finished-page mode (default)** plans one comic page per image call (blank lettering chrome on the art) → each page is painted → Inkstone overlays text with real fonts → pages are bound to PDF or stacked into a webtoon PNG. For the legacy path, set `INKSTONE_RENDER_MODE=panel_compose`: storyboard prompts → per-panel generation → `LayoutEngine` grid layout → export.

The core challenge — **cross-panel character consistency without a GPU** — is handled by a layered strategy:

Expand Down Expand Up @@ -159,6 +159,7 @@ Inkstone is configured through environment variables (copy `.env.example` → `.
| `OPENAI_COMPAT_*` | | — | Base URL / key / models when `PROVIDER=openai_compat`. |
| `INKSTONE_L3` | | `0` | Enable the experimental L3 PIL/OpenCV face overlay (`1` to turn on). |
| `INKSTONE_RENDER_MODE` | | `finished_page` | Default: one finished comic page per image call. Set `panel_compose` to use the legacy storyboard → panel → layout path (recovery when finished pages fail). |
| `INKSTONE_PAGE_SIZE` | | `1024x1536` | Finished-page image size. If the provider rejects the size, Inkstone retries once with `1024x1024`. |
| `INKSTONE_FONT_PATH` | | (auto) | TrueType/OpenType font for dialogue bubbles. |
| `INKSTONE_WEBTOON_MAX_PIXELS` | | `200000000` | Refuse single-strip webtoon compose above this pixel budget. `0` disables. |
| `INKSTONE_UI_HOST` / `INKSTONE_UI_PORT` | | `127.0.0.1` / `8000` | Web UI bind address. |
Expand Down
71 changes: 71 additions & 0 deletions core/comic/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
Appearance,
CharacterAliasSuggestion,
CharacterAsset,
ComicPagePlan,
ProjectState,
Setting,
)
Expand All @@ -22,6 +23,72 @@
"substring match",
)

# Chinese literary nicknames often embed animal/plant glyphs metaphorically
# (虎妞, 凤姐). Image models literalize those glyphs unless prompts forbid it.
_ANIMAL_METAPHOR_CHARS = frozenset("虎龙凤豹狼狮猴蛇鹤狐兔熊鹰")

_HUMAN_LOCK = (
"human person only — the name is metaphorical; not an animal, no animal head, "
"no fur, no tail, no snout (not a tiger/dragon/phoenix creature)"
)


def name_suggests_animal_metaphor(name: str) -> bool:
"""True when ``name`` contains a common animal-metaphor ideograph."""
return any(ch in _ANIMAL_METAPHOR_CHARS for ch in name or "")


def metaphor_identity_lock_line(name: str) -> str:
"""One-line human-only lock for finished-page / portrait prompts."""
return (
f"- {name}: human person with a normal human face; Chinese nickname only — "
f"NOT a literal animal; NO tiger/dragon head, NO fur, NO snout, NO anthropomorphic beast"
)


def metaphor_names_on_page(
plan: ComicPagePlan,
characters_by_name: dict[str, CharacterAsset],
) -> list[str]:
"""Collect metaphorical animal-glyph names referenced on a finished page."""
found: set[str] = set()
for name in plan.reference_characters or []:
if name_suggests_animal_metaphor(name):
found.add(name)
for panel in plan.panels:
for name in panel.characters:
if name_suggests_animal_metaphor(name):
found.add(name)
action = panel.action or ""
for name in characters_by_name:
if name_suggests_animal_metaphor(name) and name in action:
found.add(name)
return sorted(found)


def harden_human_identity_prompt(name: str, prompt: str) -> str:
"""Prefix an anti-literalization lock for metaphorical animal names.

Non-metaphor names are returned unchanged. Idempotent if the lock is already
present.
"""
text = (prompt or "").strip()
if not name_suggests_animal_metaphor(name):
return text
lower = text.lower()
if "metaphorical" in lower and "not an animal" in lower and "human character" in lower:
return text
core = text
if core.startswith(name):
core = core[len(name) :].lstrip(" ,;—-")
lead = (
f"human character — {name} is a metaphorical Chinese nickname only "
f"(NOT a literal tiger/dragon/animal); draw a normal human face and body"
)
if core:
return f"{lead}; {core}; {_HUMAN_LOCK}"
return f"{lead}; {_HUMAN_LOCK}"


def build_l1_from_appearance(
name: str,
Expand Down Expand Up @@ -72,6 +139,10 @@ def ensure_character_l1(char: CharacterAsset) -> CharacterAsset:
char.l1_prompt = derived
elif not (char.l1_prompt or "").strip() and derived:
char.l1_prompt = derived
if char.l1_prompt:
char.l1_prompt = harden_human_identity_prompt(char.name, char.l1_prompt)
if char.portrait_prompt:
char.portrait_prompt = harden_human_identity_prompt(char.name, char.portrait_prompt)
return char


Expand Down
142 changes: 142 additions & 0 deletions core/comic/lettering_lang.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Detect source lettering script and validate ComicPagePlan lettering fields."""

from __future__ import annotations

import re
from typing import Literal

from core.comic.fonts import text_requires_cjk
from core.schemas import ComicPagePlan, PagePanelSpec

Script = Literal["cjk", "latin", "mixed", "unknown"]

_LETTER_RE = re.compile(r"[A-Za-z\u00C0-\u024F\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]")

# Parenthetical glosses that are mostly latin / pinyin (incl. tone marks + curly quotes).
_PINYIN_PAREN_RE = re.compile(
r"[((][^))]*[A-Za-zĀÁǍÀĒÉĚÈĪÍǏÌŌÓǑÒŪÚǓÙÜǖǘǚǜāáǎàēéěèīíǐìōóǒòūúǔùüǖǘǚǜ][^))]*[))]"
)

_MAX_CAPTION_CHARS = 48
_MAX_DIALOGUE_CHARS = 36
_MAX_SFX_CHARS = 16


def source_lettering_script(text: str) -> Script:
letters = _LETTER_RE.findall(text or "")
if not letters:
return "unknown"
cjk = sum(1 for ch in letters if text_requires_cjk(ch))
latin = len(letters) - cjk
if cjk and not latin:
return "cjk"
if latin and not cjk:
return "latin"
ratio = cjk / len(letters)
if ratio >= 0.15:
return "cjk"
if ratio <= 0.05:
return "latin"
return "mixed"


def strip_pinyin_glosses(text: str) -> str:
"""Remove parenthetical pinyin / latin pronunciation glosses."""
cleaned = _PINYIN_PAREN_RE.sub("", text or "")
cleaned = re.sub(r"[ \t]{2,}", " ", cleaned)
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
return cleaned.strip(" \t,;;、")


def truncate_lettering(text: str, *, kind: str) -> str:
"""Keep lettering short enough for overlay chrome."""
limits = {
"caption": _MAX_CAPTION_CHARS,
"dialogue": _MAX_DIALOGUE_CHARS,
"sfx": _MAX_SFX_CHARS,
}
limit = limits.get(kind, _MAX_DIALOGUE_CHARS)
text = (text or "").strip()
if len(text) <= limit:
return text
return text[: max(1, limit - 1)].rstrip(",,、;; ") + "…"


def sanitize_lettering_text(text: str | None, *, kind: str, script: Script) -> str | None:
"""Clean one lettering field for overlay / storage."""
if not text or not str(text).strip():
return None
cleaned = str(text).strip()
if script == "cjk":
cleaned = strip_pinyin_glosses(cleaned)
# Drop leftover latin-only tails after CJK (e.g. broken gloss remnants).
if text_requires_cjk(cleaned):
cleaned = re.sub(r"[A-Za-zĀ-žā-ž][A-Za-zĀ-žā-ž\s,.\-']{2,}$", "", cleaned).strip()
cleaned = truncate_lettering(cleaned, kind=kind)
return cleaned or None


def sanitize_plan_lettering(plan: ComicPagePlan, script: Script) -> ComicPagePlan:
"""Sanitize all caption/dialogue/sfx on a page plan."""
panels: list[PagePanelSpec] = []
for panel in plan.panels:
data = panel.model_dump()
for kind in ("caption", "dialogue", "sfx"):
data[kind] = sanitize_lettering_text(getattr(panel, kind), kind=kind, script=script)
panels.append(PagePanelSpec.model_validate(data))
kept_kinds = {
(p.panel_id, kind)
for p in panels
for kind in ("caption", "dialogue", "sfx")
if getattr(p, kind)
}
boxes = [b for b in plan.lettering_boxes if (b.panel_id, b.kind) in kept_kinds]
return plan.model_copy(update={"panels": panels, "lettering_boxes": boxes})


def _field_mismatch(text: str | None, script: Script) -> bool:
if not text or not _LETTER_RE.search(text):
return False
has_cjk = text_requires_cjk(text)
if script == "cjk":
# Pure latin, or mostly latin gloss without enough CJK, is a mismatch.
if not has_cjk:
return True
# Chinese + heavy pinyin still counts as polluted for mismatch retry.
latin = sum(
1
for ch in text
if ("A" <= ch <= "Z") or ("a" <= ch <= "z") or ("\u00c0" <= ch <= "\u024f")
)
cjk = sum(1 for ch in text if text_requires_cjk(ch))
return latin >= max(8, cjk // 2) and _PINYIN_PAREN_RE.search(text) is not None
if script == "latin":
return has_cjk and sum(1 for ch in text if text_requires_cjk(ch)) >= max(
1, len(_LETTER_RE.findall(text)) // 2
)
return False


def lettering_field_mismatches(plan: ComicPagePlan, script: Script) -> list[tuple[str, str, str]]:
out: list[tuple[str, str, str]] = []
if script not in ("cjk", "latin"):
return out
for panel in plan.panels:
for kind in ("caption", "dialogue", "sfx"):
val = getattr(panel, kind)
if _field_mismatch(val, script):
out.append((panel.panel_id, kind, val or ""))
return out


def strip_mismatched_lettering(plan: ComicPagePlan, script: Script) -> ComicPagePlan:
bad = {(p, k) for p, k, _ in lettering_field_mismatches(plan, script)}
panels: list[PagePanelSpec] = []
for panel in plan.panels:
data = panel.model_dump()
for kind in ("caption", "dialogue", "sfx"):
if (panel.panel_id, kind) in bad:
data[kind] = None
panels.append(PagePanelSpec.model_validate(data))
boxes = [b for b in plan.lettering_boxes if (b.panel_id, b.kind) not in bad]
return plan.model_copy(update={"panels": panels, "lettering_boxes": boxes})
Loading