- Local-first, open-source novel → series comic generator.
- Built on the free Agnes multimodal API — no GPU, no paid key, grind your novel into comics.
+
@@ -19,32 +12,40 @@
> *"Grind your novel into comics."*
-**Inkstone** is a local-first, open-source **novel → series comic** generator. It reads a local `txt` novel and, through the free **Agnes** multimodal API, produces comic pages with cross-panel character consistency — exported as **PDF / PNG**. No GPU, no paid plan: just one free API key. Image generation sits behind a pluggable `ImageProvider` (Agnes by default; `PROVIDER=openai_compat` targets Agnes-protocol-compatible gateways — see Configuration).
+**Inkstone** reads a local `txt` novel and, through the free **Agnes** multimodal API, produces comic pages with cross-panel character consistency — exported as **PDF / PNG**. No GPU, no paid plan: just one free API key.
-## Table of Contents
+---
-- [Features](#features)
-- [Why Inkstone](#why-inkstone)
-- [Quick Start](#quick-start)
-- [Gallery](#gallery)
-- [Web UI](#web-ui)
-- [Configuration](#configuration)
-- [How it works](#how-it-works)
-- [Resources](#resources)
-- [Contributing](#contributing)
+
+
+
----
+
+
+
+
+
-## Features
+These panels were generated from the bundled sample (`examples/scene1.txt`) with the default L1+L2 pipeline — no L3, straight from the model. Consistency here is "medium" by design: the honest ceiling of a free, no-GPU model. More samples, including the full webtoon strip, are at:
+
+
- **Free & local-first** — one `AGNES_API_KEY`, no GPU, no paid plan.
- **Agnes-native multimodal** — `agnes-2.0-flash` for scripting, `agnes-image-2.1-flash` for t2i and i2i consistency.
-- **Pluggable `ImageProvider`** — Agnes by default; `PROVIDER=openai_compat` for Agnes-protocol-compatible `/images/generations` gateways (i2i uses `extra_body.image`). Vanilla OpenAI/Gemini need a small `_build_payload` subclass.
-- **Character consistency engine** — L1 prompt hard-description + L2 reference img2img, the robust path under no-GPU. An optional L3 PIL/OpenCV face overlay exists but is **off by default** (it deforms stylized faces when pose/lighting differ, so it's opt-in via `INKSTONE_L3=1`).
+- **Pluggable `ImageProvider`** — Agnes by default; `PROVIDER=openai_compat` for Agnes-protocol-compatible `/images/generations` gateways (i2i uses `extra_body.image`).
+- **Character consistency engine** — L1 prompt hard-description + L2 reference img2img, the robust path under no-GPU. An optional L3 PIL/OpenCV face overlay is **off by default** (opt-in via `INKSTONE_L3=1`).
- **Reliability layer** — token-bucket rate limiting, exponential-backoff retries, and error collection against 429/503.
- **Resumable long-form runs** — chapter-split generation with a persisted `state.json` checkpoint.
-## Why Inkstone
+
+
+
Every novel-to-comic tool we surveyed in 2026 runs on a **paid** model — Gemini, OpenAI, Doubao, Wenxin, or Claude. Inkstone is built differently: it is **Agnes-native and zero-cost**, the one combination no other open-source generator occupies.
@@ -57,7 +58,31 @@ 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.
-## Quick Start
+**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.
+
+
+
+
+
+
+
+
+
+A `txt` novel is split into segments → characters & scenes are extracted with `agnes-2.0-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.
+
+The core challenge — **cross-panel character consistency without a GPU** — is handled by a layered strategy:
+
+| Layer | Strategy | Default |
+|-------|----------|:-------:|
+| **L1** | Appearance-derived prompt hard-description | ✅ on |
+| **L2** | Reference img2img from prior panels | ✅ on |
+| **L3** | PIL/OpenCV face overlay | ❌ off (opt-in) |
+
+Backed by a reliability layer (rate limiting, retries, and `state.json` resumption). Alias variants are flagged for **human merge/dismiss** (never silent); merges mark affected panels stale for selective redraw.
+
+
+
+
> **Prerequisites:** Python 3.10+, [conda](https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html), and a free [Agnes](https://agnes-ai.com) API key (Free Access tier).
@@ -77,7 +102,7 @@ cp .env.example .env
# then open .env and set AGNES_API_KEY=sk-xxx
```
-Generate your first panel — the runnable example lives at `examples/first_panel.py`:
+Generate your first panel:
```console
$ python examples/first_panel.py # run from the repo root
@@ -92,26 +117,16 @@ $ python -m pytest
# pipeline resume and content-safety behavior without real API calls
```
-> **Status:** M1–M4 (provider foundation, comic pipeline, long-form hardening and open-source release) have shipped. See the [Roadmap](docs/ROADMAP.md) for released capability, local prototypes and the approved long-form plan.
-
### One-click launch
-Prefer a single command? The launcher sets up the environment, installs deps, loads your key from `.env`, and runs the generator:
-
```bash
./scripts/start.sh # runs examples/scene1.txt -> comic_out (page PDF)
./scripts/start.sh examples/sample_novel.txt --format webtoon
```
-On Windows use `.\scripts\start.ps1`. Both reuse an already-active `venv`/`conda` env instead of nesting one. Sample inputs ship in `examples/` (`scene1.txt`, `sample_novel.txt`).
-
-## Gallery
-
-Sample panels and the full webtoon strip are rendered live on the GitHub Pages site:
+On Windows use `.\scripts\start.ps1`. Sample inputs ship in `examples/` (`scene1.txt`, `sample_novel.txt`).
-**[https://phaethix.github.io/inkstone/](https://phaethix.github.io/inkstone/)**
-
-They were generated from the bundled sample (`examples/scene1.txt`) with the default L1+L2 pipeline — no L3, straight from the model. Consistency here is "medium" by design — the honest ceiling of a free, no-GPU model. L1 prompt hard-description + L2 reference img2img carry identity; the optional L3 face overlay is off by default because it deforms stylized faces.
+> **Status:** M1–M4 (provider foundation, comic pipeline, long-form hardening and open-source release) have shipped. See the [Roadmap](docs/ROADMAP.md) for released capability, local prototypes and the approved long-form plan.
## Web UI
@@ -122,15 +137,9 @@ AGNES_API_KEY=sk-xxx python web/server.py
# open http://127.0.0.1:8000
```
-Paste a novel, optionally set a **project id** (resume the same `comic_out//`), pick webtoon/page, and hit Generate. The backend runs an **unattended supervisor** around `creative_comic`: timeouts and free-tier 503s are retried automatically with backoff until the comic finishes, or until the wall-clock deadline (`INKSTONE_RUN_DEADLINE_HOURS`, default **24h**) pauses the job with progress saved — same project id continues later. Artifacts land under `comic_out//`. After generation, the UI surfaces **alias review** (merge / dismiss — never silent), **skipped** panels with retry, and **redraw affected** after a merge.
-
-The key is read from the environment or `.env`.
+Paste a novel, optionally set a **project id** (resume the same `comic_out//`), pick webtoon/page, and hit Generate. The backend runs an **unattended supervisor** around `creative_comic`: timeouts and free-tier 503s are retried automatically with backoff until the comic finishes, or until the wall-clock deadline (`INKSTONE_RUN_DEADLINE_HOURS`, default **24h**) pauses the job with progress saved. Artifacts land under `comic_out//`. After generation, the UI surfaces **alias review** (merge / dismiss — never silent), **skipped** panels with retry, and **redraw affected** after a merge.
-**The same `web/index.html` is also deployed to GitHub Pages.** The SPA auto-detects
-whether a backend is present: locally it generates your comic; on the static site it
-runs in *demo mode* and showcases a real generated sample with the **identical interface
-and operations**. What you run locally is what's on the page — see
-`https://phaethix.github.io/inkstone/` (goes live once Pages is enabled in repo settings).
+**The same `web/index.html` is also deployed to GitHub Pages.** The SPA auto-detects whether a backend is present: locally it generates your comic; on the static site it runs in *demo mode* with the identical interface. See `https://phaethix.github.io/inkstone/`.
## Configuration
@@ -139,29 +148,26 @@ Inkstone is configured through environment variables (copy `.env.example` → `.
| Variable | Required | Default | Description |
|----------|:---:|---|---|
| `AGNES_API_KEY` | ✅ | — | Free Access tier key; the only thing ordinary users need. |
-| `AGNES_RATE_LIMIT` | | `20` | Free-tier text + image-1K RPM ceiling (× 0.8 safety factor). Official free: text 20, image 1K 20, 2K 10, 3K/4K 1. |
+| `AGNES_RATE_LIMIT` | | `20` | Free-tier text + image-1K RPM ceiling (× 0.8 safety factor). |
| `AGNES_IMAGE_2K_RPM` | | `10` | Free-tier image 2K RPM (max side ≤2048). |
| `AGNES_IMAGE_3K_RPM` | | `1` | Free-tier image 3K/4K RPM. |
| `AGNES_IMAGE_I2I_MODEL` | | `agnes-image-2.1-flash` | Model used for consistency img2img. |
-| `AGNES_IMAGE_MAX_RETRIES` | | `5` | Image calls retry this many times; the free image tier is often 503 "Service busy". |
+| `AGNES_IMAGE_MAX_RETRIES` | | `5` | Image calls retry this many times. |
| `AGNES_IMAGE_RETRY_BASE_DELAY` | | `5.0` | Image retry backoff base in seconds. |
-| `PROVIDER` | | `agnes` | `openai_compat` routes image/chat to configured OpenAI-style base URLs. Image i2i still uses the **Agnes** `extra_body.image` protocol — not vanilla OpenAI Images. |
+| `PROVIDER` | | `agnes` | `openai_compat` routes to configured OpenAI-style base URLs. |
| `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). Off by default — it tends to deform stylized faces, so consistency relies on L1+L2. |
-| `INKSTONE_FONT_PATH` | | (auto) | TrueType/OpenType font for dialogue bubbles. Auto-discovers PingFang / YaHei / Noto CJK; set this if Chinese dialogue renders as □□□. |
-| `INKSTONE_WEBTOON_MAX_PIXELS` | | `200000000` | Refuse single-strip webtoon compose above this pixel budget (use `--format page` instead). `0` disables. |
-| `INKSTONE_UI_HOST` / `INKSTONE_UI_PORT` | | `127.0.0.1` / `8000` | Web UI bind address. Binding a non-loopback host exposes an **unauthenticated** API to the network — only do this on trusted LANs. |
-
-PDF export prefers the optional `manga2pdf` CLI for two-page / right-to-left manga layout. If it is missing, Inkstone falls back to a plain multi-page PDF and logs a warning (`pip install manga2pdf` to restore the gallery layout).
-
-## How it works
+| `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_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. |
-A `txt` novel is split into segments → characters & scenes are extracted with `agnes-2.0-flash` → storyboard prompts are generated → the `ImageProvider` (Agnes by default) paints each panel → panels are laid out and exported to PDF/PNG. The core challenge — **cross-panel character consistency without a GPU** — is handled by a layered strategy (L1 Appearance-derived prompt hard-description + L2 reference img2img; optional L3 face overlay is off by default), backed by a reliability layer (rate limiting, retries, and `state.json` resumption). Alias variants are flagged for **human merge/dismiss** (never silent); merges mark affected panels stale for selective redraw. Longer-form adaptation work is tracked in the [roadmap](docs/ROADMAP.md).
+PDF export prefers the optional `manga2pdf` CLI for two-page / right-to-left manga layout (`pip install manga2pdf` to restore the gallery layout).
## Resources
- **Implementation status** — [docs/ROADMAP.md](docs/ROADMAP.md)
-- **Colab CLI (close the laptop)** — [docs/guides/colab-cli.md](docs/guides/colab-cli.md) + [`scripts/colab_run.sh`](scripts/colab_run.sh)
+- **Colab CLI** — [docs/guides/colab-cli.md](docs/guides/colab-cli.md) + [`scripts/colab_run.sh`](scripts/colab_run.sh)
- **Historical plans / specs** — [docs/superpowers/](docs/superpowers/)
- **Contributing guide** — [CONTRIBUTING.md](CONTRIBUTING.md)
- **Issues & feedback** — [GitHub Issues](https://github.com/phaethix/inkstone/issues)
diff --git a/assets/readme/hero.svg b/assets/readme/hero.svg
new file mode 100644
index 0000000..66263c8
--- /dev/null
+++ b/assets/readme/hero.svg
@@ -0,0 +1,127 @@
+
diff --git a/assets/readme/pipeline.svg b/assets/readme/pipeline.svg
new file mode 100644
index 0000000..5f144ad
--- /dev/null
+++ b/assets/readme/pipeline.svg
@@ -0,0 +1,56 @@
+
diff --git a/assets/readme/section-features.svg b/assets/readme/section-features.svg
new file mode 100644
index 0000000..827947e
--- /dev/null
+++ b/assets/readme/section-features.svg
@@ -0,0 +1,18 @@
+
diff --git a/assets/readme/section-gallery.svg b/assets/readme/section-gallery.svg
new file mode 100644
index 0000000..f96387c
--- /dev/null
+++ b/assets/readme/section-gallery.svg
@@ -0,0 +1,18 @@
+
diff --git a/assets/readme/section-how.svg b/assets/readme/section-how.svg
new file mode 100644
index 0000000..6524ab9
--- /dev/null
+++ b/assets/readme/section-how.svg
@@ -0,0 +1,18 @@
+
diff --git a/assets/readme/section-quickstart.svg b/assets/readme/section-quickstart.svg
new file mode 100644
index 0000000..22d85a2
--- /dev/null
+++ b/assets/readme/section-quickstart.svg
@@ -0,0 +1,18 @@
+
diff --git a/assets/readme/section-why.svg b/assets/readme/section-why.svg
new file mode 100644
index 0000000..97e9131
--- /dev/null
+++ b/assets/readme/section-why.svg
@@ -0,0 +1,18 @@
+
diff --git a/core/comic/page_prompt.py b/core/comic/page_prompt.py
new file mode 100644
index 0000000..e9f3a77
--- /dev/null
+++ b/core/comic/page_prompt.py
@@ -0,0 +1,56 @@
+"""Deterministic finished-page image prompts from ComicPagePlan."""
+
+from __future__ import annotations
+
+from core.comic.identity import ensure_character_l1
+from core.schemas import CharacterAsset, ComicPagePlan, Setting
+
+
+def render_finished_page_prompt(
+ plan: ComicPagePlan,
+ *,
+ characters_by_name: dict[str, CharacterAsset],
+ settings_by_name: dict[str, Setting],
+ style_guide: str = "",
+ strict: bool = False,
+) -> str:
+ lines: list[str] = [
+ "Finished readable manga/comic page, A4 portrait single image,",
+ "dynamic panel layout with gutters (not a flat labeled grid collage),",
+ "clean black ink line art, soft cel shading, flat colors,",
+ "speech bubbles, caption boxes, and SFX lettered legibly in-image,",
+ "do not cover faces, hands, or key action with text.",
+ ]
+ if strict:
+ lines.append(
+ "STRICT: render every CAPTION, DIALOGUE, and SFX string exactly as "
+ "specified; high-contrast legible lettering; do not omit any text."
+ )
+ if style_guide:
+ lines.append(f"Style: {style_guide}")
+ lines.append(f"Page purpose: {plan.purpose}")
+ lines.append(f"Layout intent: {plan.layout_intent}")
+ for i, panel in enumerate(plan.panels, start=1):
+ lines.append(
+ f"Panel {i} ({panel.panel_id}): role={panel.role}, shape={panel.shape_hint}, "
+ f"shot={panel.shot}, action={panel.action}"
+ )
+ if panel.setting_ref:
+ setting = settings_by_name.get(panel.setting_ref)
+ scene = getattr(setting, "scene_prompt", "") if setting else ""
+ lines.append(f" setting={panel.setting_ref}: {scene}".rstrip(": "))
+ for name in panel.characters:
+ asset = characters_by_name.get(name)
+ if asset:
+ ensure_character_l1(asset)
+ if asset.l1_prompt:
+ lines.append(f" character {name}: {asset.l1_prompt}")
+ if panel.caption:
+ lines.append(f" CAPTION (exact): {panel.caption}")
+ if panel.dialogue:
+ lines.append(f" DIALOGUE (exact): {panel.dialogue}")
+ if panel.sfx:
+ lines.append(f" SFX (exact): {panel.sfx}")
+ if panel.lettering_notes:
+ lines.append(f" lettering: {panel.lettering_notes}")
+ return "\n".join(lines)
diff --git a/core/config.py b/core/config.py
index 07f62d8..8f62e74 100644
--- a/core/config.py
+++ b/core/config.py
@@ -19,6 +19,8 @@
ENV_RUN_DEADLINE_HOURS = "INKSTONE_RUN_DEADLINE_HOURS"
ENV_SUPERVISOR_BACKOFF_BASE = "INKSTONE_SUPERVISOR_BACKOFF_BASE"
ENV_SUPERVISOR_BACKOFF_CAP = "INKSTONE_SUPERVISOR_BACKOFF_CAP"
+ENV_RENDER_MODE = "INKSTONE_RENDER_MODE"
+ENV_PAGE_SIZE = "INKSTONE_PAGE_SIZE"
def _get(name: str, default: str = "") -> str:
@@ -127,6 +129,18 @@ def supervisor_backoff_cap() -> float:
return env_float(ENV_SUPERVISOR_BACKOFF_CAP, 300.0, minimum=0.1)
+def render_mode() -> str:
+ raw = _get(ENV_RENDER_MODE, "finished_page").strip().lower()
+ if raw in {"panel_compose", "panel", "compose"}:
+ return "panel_compose"
+ return "finished_page"
+
+
+def finished_page_size() -> str:
+ raw = _get(ENV_PAGE_SIZE, "1024x1536").strip()
+ return raw or "1024x1536"
+
+
class ImageConfig:
"""Aggregated image-generation configuration."""
diff --git a/core/pipelines/creative_comic.py b/core/pipelines/creative_comic.py
index 4a32180..a32a94b 100644
--- a/core/pipelines/creative_comic.py
+++ b/core/pipelines/creative_comic.py
@@ -46,12 +46,17 @@
from core.comic.export import ExportEngine
from core.comic.identity import ensure_character_l1, merge_settings, suggestion_from_alias
from core.comic.layout import LayoutEngine, PanelImage
+from core.comic.page_prompt import render_finished_page_prompt
from core.comic.segmentation import detect_character_aliases, merge_characters, segment_text
-from core.config import ImageConfig, l3_enabled, page_script_enabled
+from core.config import ImageConfig, finished_page_size, l3_enabled, page_script_enabled
+from core.config import render_mode as config_render_mode
from core.perf import PerfCollector
from core.pipelines.cancel import check_cancel
from core.schemas import (
ChunkCache,
+ ComicPagePlan,
+ ComicPagePlanSet,
+ GeneratedPage,
GeneratedPanel,
ModelSnapshot,
PageScript,
@@ -63,6 +68,7 @@
from core.screenwriter import (
extract_story_elements,
is_content_policy_rejection,
+ plan_comic_pages,
plan_page_script,
plan_storyboard,
)
@@ -81,6 +87,10 @@ class ComicProject:
webtoon: str | None = None
+# Bumped only when the *structure* fingerprint (extract/storyboard cache) must
+# invalidate. Adding render_mode/finished_page_size only affects the render
+# fingerprint (below), which already has its own independent invalidation path
+# (``_soft_invalidate_render``), so no bump was needed for this change.
_PIPELINE_STATE_VERSION = "2026-07-22.3"
@@ -116,6 +126,8 @@ def _render_fingerprint(
snapshot: ModelSnapshot,
panel_continuity: bool,
l3_enabled: bool,
+ render_mode: str = "finished_page",
+ page_size: str = "1024x1536",
) -> str:
payload = json.dumps(
{
@@ -123,6 +135,8 @@ def _render_fingerprint(
"model_snapshot": snapshot.model_dump(),
"panel_continuity": panel_continuity,
"l3_enabled": l3_enabled,
+ "render_mode": render_mode,
+ "page_size": page_size,
},
ensure_ascii=False,
sort_keys=True,
@@ -193,6 +207,37 @@ def _stored_panel_key(state: ProjectState, chunk_index: int, panel_index: int) -
return _panel_state_key(chunk_index, panel_index)
+def _page_state_key(chunk_index: int, page_id: str) -> str:
+ """Return the pipeline-owned identity for one (chunk, page_id) position."""
+ return f"c{chunk_index:04d}:{page_id}"
+
+
+def _page_asset_path(pages_dir: Path, chunk_index: int, page_index: int) -> Path:
+ """Deterministic ``page_XX.png``-style path for one (chunk, page) position.
+
+ Position-derived (not a running counter) so regenerating a stale/missing
+ page always rewrites the same file — a counter would risk colliding with
+ an unrelated page's filename when only some pages are redone on resume.
+ The ``page_`` prefix and zero-padding keep ``_finished_page_files`` in
+ correct reading order.
+ """
+ return pages_dir / f"page_c{chunk_index:04d}_p{page_index:04d}.png"
+
+
+def _finished_page_files(pages_dir: Path) -> list[Path]:
+ """Return finished-page assets only, excluding panel-compose ``page_NN.png`` leftovers."""
+ return sorted(p for p in pages_dir.glob("page_*.png") if p.match("page_c*_p*.png"))
+
+
+def _prepare_finished_page_export(pages_dir: Path) -> list[Path]:
+ """Collect finished-page assets and drop stale panel-compose ``page_NN.png`` files."""
+ finished = _finished_page_files(pages_dir)
+ for stale in pages_dir.glob("page_*.png"):
+ if not stale.match("page_c*_p*.png"):
+ stale.unlink()
+ return finished
+
+
def _asset_path(root: Path, directory: str, identifier: str) -> Path:
"""Map any model-facing identifier to a portable, collision-safe asset path."""
digest = hashlib.sha256(identifier.encode("utf-8")).hexdigest()
@@ -290,6 +335,78 @@ def _mark_chunk_done_if_complete(
state.chunks_done.append(key)
+def _page_reference_names(plan: ComicPagePlan) -> list[str]:
+ """Ordered unique character names for a page's portrait refs (L2 only; no L3 here)."""
+ names: list[str] = []
+ seen: set[str] = set()
+ candidates = list(plan.reference_characters) + [
+ name for panel in plan.panels for name in panel.characters
+ ]
+ for name in candidates:
+ if not name or name in seen:
+ continue
+ seen.add(name)
+ names.append(name)
+ return names
+
+
+def _page_needs_generation(state: ProjectState, state_key: str) -> bool:
+ """True when a finished page must be (re)generated this run."""
+ if state_key in state.stale_pages:
+ return True
+ if state_key in state.pages_done or state_key in state.skipped_pages:
+ return False
+ return True
+
+
+def _mark_page_done(state: ProjectState, state_key: str) -> None:
+ if state_key in state.stale_pages:
+ state.stale_pages = [k for k in state.stale_pages if k != state_key]
+ if state_key not in state.pages_done:
+ state.pages_done.append(state_key)
+
+
+def _page_chunk_complete(
+ state: ProjectState,
+ pageset: ComicPagePlanSet,
+ output_dir: Path,
+ chunk_index: int,
+) -> bool:
+ """True when every planned page is generated or policy-skipped."""
+ for plan in pageset.pages:
+ state_key = _page_state_key(chunk_index, plan.page_id)
+ if state_key in state.stale_pages:
+ return False
+ if state_key in state.skipped_pages:
+ continue
+ rec = state.generated.pages.get(state_key)
+ if (
+ rec is None
+ or state_key not in state.pages_done
+ or not _is_within(rec.local, output_dir)
+ or not Path(rec.local).exists()
+ ):
+ return False
+ return True
+
+
+def _mark_page_chunk_done_if_complete(
+ state: ProjectState,
+ key: str,
+ pageset: ComicPagePlanSet,
+ chunk_index: int,
+) -> None:
+ """Record chunks_done only after every planned page is done or skipped."""
+ for plan in pageset.pages:
+ state_key = _page_state_key(chunk_index, plan.page_id)
+ if state_key in state.stale_pages:
+ return
+ if state_key not in state.pages_done and state_key not in state.skipped_pages:
+ return
+ if key not in state.chunks_done:
+ state.chunks_done.append(key)
+
+
def _soft_invalidate_render(state: ProjectState) -> None:
"""Drop render-owned assets while keeping structural cache (extract/storyboard).
@@ -300,6 +417,9 @@ def _soft_invalidate_render(state: ProjectState) -> None:
state.stale_panels = []
state.generated.panels = {}
state.generated.portraits = {}
+ state.pages_done = []
+ state.stale_pages = []
+ state.generated.pages = {}
for asset in state.characters.values():
asset.portrait_local = None
@@ -322,6 +442,21 @@ def _reconcile_state(state: ProjectState, state_path: Path, output_dir: Path) ->
state.panels_done.remove(panel_id)
changed = True
+ invalid_pages = [
+ page_id
+ for page_id, generated in state.generated.pages.items()
+ if not _is_within(generated.local, output_dir) or not Path(generated.local).is_file()
+ ]
+ for page_id in invalid_pages:
+ state.generated.pages.pop(page_id, None)
+ changed = True
+ stale_pages_done = [
+ page_id for page_id in state.pages_done if page_id not in state.generated.pages
+ ]
+ for page_id in stale_pages_done:
+ state.pages_done.remove(page_id)
+ changed = True
+
orphan_portraits = set(state.generated.portraits) - set(state.characters)
for name in orphan_portraits:
state.generated.portraits.pop(name, None)
@@ -342,6 +477,11 @@ def _reconcile_state(state: ProjectState, state_path: Path, output_dir: Path) ->
logger.warning(
"reconcile: removed %d invalid panel record(s)", len(invalid_panels) + len(stale_done)
)
+ if invalid_pages or stale_pages_done:
+ logger.warning(
+ "reconcile: removed %d invalid page record(s)",
+ len(invalid_pages) + len(stale_pages_done),
+ )
state.save(state_path)
@@ -432,13 +572,45 @@ def panel_progress_counts(state: ProjectState, total_chunks: int | None = None)
return finished, planned_i
+def page_progress_counts(state: ProjectState, total_chunks: int | None = None) -> tuple[int, int]:
+ """Return ``(finished, planned)`` finished-page counts for progress display."""
+ if total_chunks is None:
+ keys = {int(k) for k in state.page_cache if str(k).isdigit()}
+ keys.update(int(k) for k in state.skipped_chunks if str(k).isdigit())
+ total_chunks = (max(keys) + 1) if keys else 1
+ total_chunks = max(1, total_chunks)
+
+ planned = 0
+ accounted: set[str] = set()
+ for key in state.skipped_chunks:
+ accounted.add(str(key))
+ page_counts: list[int] = []
+ for key, pageset in state.page_cache.items():
+ n = len(pageset.pages)
+ planned += n
+ page_counts.append(n)
+ accounted.add(str(key))
+
+ avg = sum(page_counts) / len(page_counts) if page_counts else float(_DEFAULT_PANELS_PER_CHUNK)
+ for i in range(total_chunks):
+ if str(i) not in accounted:
+ planned += avg
+
+ finished = len(state.pages_done) + len(state.skipped_pages)
+ planned_i = max(finished, int(round(planned)))
+ return finished, planned_i
+
+
def estimate_progress(state: ProjectState, total_chunks: int | None = None) -> float:
- """Estimate overall completion in ``[0, 1)`` from checkpointed panels.
+ """Estimate overall completion in ``[0, 1)`` from checkpointed panels/pages.
Used so a resume does not reset the UI progress bar to near-zero. Reserves
the last 10% for layout/export.
"""
- finished, planned = panel_progress_counts(state, total_chunks)
+ if state.render_mode == "finished_page":
+ finished, planned = page_progress_counts(state, total_chunks)
+ else:
+ finished, planned = panel_progress_counts(state, total_chunks)
if planned <= 0:
return 0.0
return min(0.95, 0.9 * finished / planned)
@@ -456,6 +628,7 @@ async def creative_comic(
progress_callback: Callable[[str, float | None], None] | None = None,
panel_keys: list[str] | None = None,
cancel_check: Callable[[], bool] | None = None,
+ render_mode: str | None = None,
) -> ComicProject:
"""Generate a project while holding its process-level mutation lock."""
with _project_lock(Path(output_dir)):
@@ -470,6 +643,7 @@ async def creative_comic(
progress_callback=progress_callback,
panel_keys=panel_keys,
cancel_check=cancel_check,
+ render_mode=render_mode,
)
@@ -489,6 +663,7 @@ async def _creative_comic(
progress_callback: Callable[[str, float | None], None] | None = None,
panel_keys: list[str] | None = None,
cancel_check: Callable[[], bool] | None = None,
+ render_mode: str | None = None,
) -> ComicProject:
"""Generate a comic from ``source_txt`` into ``output_dir``.
@@ -504,9 +679,14 @@ async def _creative_comic(
generation. ``percent`` is ``None`` when the stage has no reliable
completion percentage; otherwise it ranges from 0.0 to 1.0.
panel_keys: when set, only these panel state keys are (re)generated;
- layout/export still run over the full project.
+ layout/export still run over the full project. Ignored in
+ ``finished_page`` mode.
cancel_check: optional callable polled at chunk/panel checkpoints;
raises ``PipelineCancelled`` when it returns true.
+ render_mode: ``"finished_page"`` (default, one prompt/image per page,
+ no LayoutEngine collage) or ``"panel_compose"`` (legacy
+ storyboard -> panels -> LayoutEngine grid path). Falls back to
+ ``core.config.render_mode()`` when unset.
Returns:
A ``ComicProject`` with the final state, produced page paths, and PDF.
@@ -516,6 +696,8 @@ async def _creative_comic(
(output_dir / "assets" / "portraits").mkdir(parents=True, exist_ok=True)
pages_dir = output_dir / "pages"
panel_key_filter = set(panel_keys) if panel_keys is not None else None
+ mode = render_mode or config_render_mode()
+ page_size = finished_page_size()
perf = PerfCollector()
@@ -523,6 +705,9 @@ def _progress_label(stage: str) -> str:
if stage in ("panel", "panels"):
done, planned = panel_progress_counts(state, total_chunks)
return f"panels {done}/{planned}"
+ if stage in ("page_plan", "page", "pages"):
+ done, planned = page_progress_counts(state, total_chunks)
+ return f"pages {done}/{planned}"
return stage
def _report(stage: str, percent: float | None = None) -> None:
@@ -555,6 +740,8 @@ def _report(stage: str, percent: float | None = None) -> None:
snapshot=snapshot,
panel_continuity=image_config.panel_continuity,
l3_enabled=l3_on,
+ render_mode=mode,
+ page_size=page_size,
)
def _fresh_state() -> ProjectState:
@@ -594,6 +781,7 @@ def _fresh_state() -> ProjectState:
logger.info("render fingerprint changed: ignoring panel_keys filter, redrawing all panels")
panel_key_filter = None
state.project_id = project_id
+ state.render_mode = mode
image_semaphore = asyncio.Semaphore(image_config.image_concurrency)
engine = ConsistencyEngine()
@@ -621,10 +809,21 @@ def _pct() -> float:
cached = state.chunk_cache.get(key)
board = cached.storyboard if cached else None
elements = cached.elements if cached else None
+ pageset = state.page_cache.get(key)
- # Fully planned chunk: cached, marked done, every panel present on disk.
- # Re-running reuses the cache so the billable chat API is never re-called.
- if (
+ # Fully planned chunk: cached, marked done, every panel/page present on
+ # disk. Re-running reuses the cache so the billable chat API is never
+ # re-called.
+ if mode == "finished_page":
+ if (
+ pageset is not None
+ and key in set(state.chunks_done)
+ and _page_chunk_complete(state, pageset, output_dir, ci)
+ and panel_key_filter is None
+ ):
+ _report("resume", _pct())
+ continue
+ elif (
board is not None
and key in set(state.chunks_done)
and _chunk_complete(state, board, output_dir, ci)
@@ -727,6 +926,124 @@ async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[s
state.save(state_path)
_report("portrait", _pct())
+ if mode == "finished_page":
+ if pageset is None:
+ state.stage = "page_plan"
+ try:
+ with perf.measure("page_plan"):
+ pageset = await plan_comic_pages(chunk, elements, chat=chat)
+ except Exception as exc: # noqa: BLE001 — content rejections must not abort the run
+ if is_content_policy_rejection(exc):
+ logger.warning(
+ "chunk %s skipped: content filter rejected page plan (%s)", ci, exc
+ )
+ if key not in state.skipped_chunks:
+ state.skipped_chunks.append(key)
+ state.save(state_path)
+ _report("skip", _pct())
+ continue
+ raise
+ state.page_cache[key] = pageset
+ state.save(state_path)
+ _report("page_plan", _pct())
+
+ # Model-structured output sometimes reuses page_id across chunks.
+ # State keys are the raw page_id, so remint duplicates rather than
+ # silently overwriting an earlier page's generated record.
+ seen_page_ids: set[str] = set()
+ for plan in pageset.pages:
+ if plan.page_id in seen_page_ids:
+ original = plan.page_id
+ n = 2
+ while f"{original}__{n}" in seen_page_ids:
+ n += 1
+ plan.page_id = f"{original}__{n}"
+ logger.warning(
+ "duplicate page_id %r in page plan chunk %s; remapped to %r",
+ original,
+ ci,
+ plan.page_id,
+ )
+ seen_page_ids.add(plan.page_id)
+
+ state.stage = "pages"
+ check_cancel(cancel_check)
+ for page_index, plan in enumerate(pageset.pages):
+ page_id = plan.page_id
+ state_key = _page_state_key(ci, page_id)
+ if not _page_needs_generation(state, state_key):
+ continue
+ check_cancel(cancel_check)
+ prompt = render_finished_page_prompt(
+ plan,
+ characters_by_name=state.characters,
+ settings_by_name=state.settings,
+ style_guide=effective_style,
+ )
+ refs = [
+ state.characters[name].portrait_local
+ for name in _page_reference_names(plan)
+ if name in state.characters and state.characters[name].portrait_local
+ ]
+ refs = [ref for ref in refs if _is_within(ref, output_dir) and Path(ref).is_file()]
+ stricter_attempted = False
+ while True:
+ try:
+ async with image_semaphore:
+ with perf.measure("page"):
+ out = await image.generate_single_image(
+ prompt, reference_image_paths=refs, size=page_size
+ )
+ break
+ except Exception as exc: # noqa: BLE001 — preserve policy skip behavior
+ if is_content_policy_rejection(exc):
+ logger.warning(
+ "page %s skipped: content filter rejected it (%s)", page_id, exc
+ )
+ if state_key not in state.skipped_pages:
+ state.skipped_pages.append(state_key)
+ if state_key in state.stale_pages:
+ state.stale_pages = [k for k in state.stale_pages if k != state_key]
+ state.save(state_path)
+ out = None
+ break
+ if not stricter_attempted:
+ stricter_attempted = True
+ prompt = render_finished_page_prompt(
+ plan,
+ characters_by_name=state.characters,
+ settings_by_name=state.settings,
+ style_guide=effective_style,
+ strict=True,
+ )
+ logger.warning(
+ "page %s image failed (%s); retrying once with stricter prompt",
+ page_id,
+ exc,
+ )
+ continue
+ raise
+ if out is None:
+ continue
+ pages_dir.mkdir(parents=True, exist_ok=True)
+ local = _page_asset_path(pages_dir, ci, page_index)
+ await asyncio.to_thread(out.save, str(local))
+ state.generated.pages[state_key] = GeneratedPage(
+ local=str(local),
+ page_id=page_id,
+ unit_index=ci,
+ page_index=page_index,
+ mode="finished",
+ )
+ _mark_page_done(state, state_key)
+ state.save(state_path)
+ _report("pages", _pct())
+
+ _mark_page_chunk_done_if_complete(state, key, pageset, ci)
+ state.save(state_path)
+ _report("pages", _pct())
+ continue
+
# ---- storyboard (only when not cached) ----
if board is None:
state.stage = "storyboard"
@@ -925,41 +1242,63 @@ async def _render_panel(
state.save(state_path)
_report("panels", _pct())
- state.stage = "layout"
- _report("layout", max(0.90, _pct()))
- items = _ordered_generated_panels(state)
- panel_imgs = []
- for panel_id, generated in items:
- if not _is_within(generated.local, output_dir) or not Path(generated.local).exists():
- logger.warning("layout: panel %s missing or outside project; omitting", panel_id)
- continue
- panel_imgs.append(
- PanelImage(
- Image.open(generated.local),
- dialogue=generated.dialogue,
- caption=generated.caption,
- sfx=generated.sfx,
- )
- )
-
pdf: str | None = None
webtoon: str | None = None
pages: list[str] = []
- if panel_imgs:
- with perf.measure("layout"):
- engine_layout = LayoutEngine()
+
+ if mode == "finished_page":
+ # No LayoutEngine collage for page mode: each page image was already
+ # saved directly by the loop above. Webtoon mode stacks those finished
+ # pages into a single vertical strip.
+ state.stage = "export"
+ _report("export", max(0.90, _pct()))
+ page_files = _prepare_finished_page_export(pages_dir) if pages_dir.exists() else []
+ if page_files:
if output_format == "webtoon":
- pages = engine_layout.compose(panel_imgs, pages_dir, layout_mode="webtoon")
+ panel_imgs = [PanelImage(Image.open(p)) for p in page_files]
+ with perf.measure("layout"):
+ webtoon_paths = LayoutEngine().compose(
+ panel_imgs, pages_dir, layout_mode="webtoon"
+ )
+ webtoon = webtoon_paths[0] if webtoon_paths else None
+ pages = webtoon_paths
else:
- pages = engine_layout.compose(panel_imgs, pages_dir, layout_mode="page")
+ with perf.measure("export"):
+ pdf = ExportEngine().export_pdf(pages_dir, out=str(output_dir / "comic.pdf"))
+ pages = [str(p) for p in page_files]
+ else:
+ state.stage = "layout"
+ _report("layout", max(0.90, _pct()))
+ items = _ordered_generated_panels(state)
+ panel_imgs = []
+ for panel_id, generated in items:
+ if not _is_within(generated.local, output_dir) or not Path(generated.local).exists():
+ logger.warning("layout: panel %s missing or outside project; omitting", panel_id)
+ continue
+ panel_imgs.append(
+ PanelImage(
+ Image.open(generated.local),
+ dialogue=generated.dialogue,
+ caption=generated.caption,
+ sfx=generated.sfx,
+ )
+ )
- state.stage = "export"
- _report("export", 0.95)
- if output_format == "webtoon":
- webtoon = pages[0] if pages else None
- else:
- with perf.measure("export"):
- pdf = ExportEngine().export_pdf(pages_dir, out=str(output_dir / "comic.pdf"))
+ if panel_imgs:
+ with perf.measure("layout"):
+ engine_layout = LayoutEngine()
+ if output_format == "webtoon":
+ pages = engine_layout.compose(panel_imgs, pages_dir, layout_mode="webtoon")
+ else:
+ pages = engine_layout.compose(panel_imgs, pages_dir, layout_mode="page")
+
+ state.stage = "export"
+ _report("export", 0.95)
+ if output_format == "webtoon":
+ webtoon = pages[0] if pages else None
+ else:
+ with perf.measure("export"):
+ pdf = ExportEngine().export_pdf(pages_dir, out=str(output_dir / "comic.pdf"))
state.save(state_path)
_report("done", 1.0)
diff --git a/core/schemas.py b/core/schemas.py
index 6390305..56ffbea 100644
--- a/core/schemas.py
+++ b/core/schemas.py
@@ -258,8 +258,19 @@ def decode_tool_arguments(args_raw: Any) -> dict:
_COMIC_STYLE_HINT = "manhua/comic style: clean black ink line art, soft cel shading, flat colors"
-# Six resumable pipeline stages.
-Stage = Literal["extract", "storyboard", "portraits", "panels", "layout", "export"]
+# Resumable pipeline stages.
+Stage = Literal[
+ "extract",
+ "storyboard",
+ "page_plan",
+ "portraits",
+ "panels",
+ "pages",
+ "layout",
+ "export",
+]
+
+RenderMode = Literal["finished_page", "panel_compose"]
# LLM sometimes fuses ``"field": "X"`` into a single key ``field:"X”…`` / ``field: X``.
_FUSED_FIELD_KEY = re.compile(
@@ -663,6 +674,147 @@ def _coerce_size(cls, value: Any) -> Any:
return coerce_size(value)
+class PagePanelSpec(BaseModel):
+ """A single panel slot within a finished-page plan."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ panel_id: str
+ role: str = "action"
+ shape_hint: str = "rect"
+ shot: str = ""
+ action: str = ""
+ characters: list[str] = Field(default_factory=list)
+ setting_ref: str = ""
+ dialogue: str | None = None
+ caption: str | None = None
+ sfx: str | None = None
+ lettering_notes: str = ""
+
+ @model_validator(mode="before")
+ @classmethod
+ def _ensure_panel_id(cls, value: Any) -> Any:
+ """Repair fused keys and guarantee a non-empty ``panel_id``."""
+ value = coerce_jsonish(value)
+ if not isinstance(value, dict):
+ return value
+ out = repair_fused_keys(
+ value,
+ {
+ "panel_id",
+ "role",
+ "shape_hint",
+ "shot",
+ "action",
+ "setting_ref",
+ "dialogue",
+ "caption",
+ "sfx",
+ "lettering_notes",
+ },
+ stash_value_into="action",
+ )
+ return ensure_str_field(
+ out,
+ "panel_id",
+ aliases=("id", "panel", "panelId"),
+ default="panel",
+ )
+
+ @field_validator(
+ "panel_id",
+ "role",
+ "shape_hint",
+ "shot",
+ "action",
+ "setting_ref",
+ "lettering_notes",
+ mode="before",
+ )
+ @classmethod
+ def _coerce_text(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+ @field_validator("characters", mode="before")
+ @classmethod
+ def _coerce_characters(cls, value: Any) -> Any:
+ return coerce_str_list(value)
+
+ @field_validator("dialogue", "caption", "sfx", mode="before")
+ @classmethod
+ def _coerce_lettering(cls, value: Any) -> Any:
+ text = coerce_dialogue(value)
+ if isinstance(text, str):
+ text = text.strip() or None
+ return text
+
+
+class ComicPagePlan(BaseModel):
+ """A single finished-page layout plan."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ page_id: str
+ purpose: str = ""
+ layout_intent: str = ""
+ panels: list[PagePanelSpec] = Field(default_factory=list)
+ reference_characters: list[str] = Field(default_factory=list)
+ setting_refs: list[str] = Field(default_factory=list)
+
+ @model_validator(mode="before")
+ @classmethod
+ def _ensure_page_id(cls, value: Any) -> Any:
+ """Repair fused keys and guarantee a non-empty ``page_id``."""
+ value = coerce_jsonish(value)
+ if not isinstance(value, dict):
+ return value
+ out = repair_fused_keys(
+ value,
+ {"page_id", "purpose", "layout_intent"},
+ stash_value_into="purpose",
+ )
+ return ensure_str_field(
+ out,
+ "page_id",
+ aliases=("id", "page", "pageId"),
+ default="page",
+ )
+
+ @field_validator("page_id", "purpose", "layout_intent", mode="before")
+ @classmethod
+ def _coerce_text(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+ @field_validator("panels", mode="before")
+ @classmethod
+ def _coerce_panels(cls, value: Any) -> Any:
+ return coerce_model_list(value, PagePanelSpec)
+
+ @field_validator("reference_characters", "setting_refs", mode="before")
+ @classmethod
+ def _coerce_name_lists(cls, value: Any) -> Any:
+ return coerce_str_list(value)
+
+
+class ComicPagePlanSet(BaseModel):
+ """Finished-page plans for one storyboard chunk (unit)."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ unit_id: str = ""
+ pages: list[ComicPagePlan] = Field(default_factory=list)
+
+ @field_validator("unit_id", mode="before")
+ @classmethod
+ def _coerce_unit_id(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+ @field_validator("pages", mode="before")
+ @classmethod
+ def _coerce_pages(cls, value: Any) -> Any:
+ return coerce_model_list(value, ComicPagePlan)
+
+
class Storyboard(BaseModel):
"""Return payload of the ``plan_storyboard`` forced function call (one chunk)."""
@@ -781,6 +933,34 @@ class ModelSnapshot(BaseModel):
i2i: str = ""
+class GeneratedPage(BaseModel):
+ """A generated finished-page image and its story position."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ local: str
+ page_id: str = ""
+ unit_index: int = 0
+ page_index: int = 0
+ mode: Literal["finished", "composed_fallback"] = "finished"
+ dialogue: str | None = None
+ caption: str | None = None
+ sfx: str | None = None
+
+ @field_validator("page_id", mode="before")
+ @classmethod
+ def _coerce_page_id(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+ @field_validator("dialogue", "caption", "sfx", mode="before")
+ @classmethod
+ def _coerce_lettering(cls, value: Any) -> Any:
+ text = coerce_dialogue(value)
+ if isinstance(text, str):
+ text = text.strip() or None
+ return text
+
+
class GeneratedPanel(BaseModel):
"""A generated panel and the immutable storyboard position that produced it."""
@@ -812,6 +992,7 @@ class GeneratedAssets(BaseModel):
portraits: dict[str, str] = Field(default_factory=dict)
panels: dict[str, GeneratedPanel] = Field(default_factory=dict)
+ pages: dict[str, GeneratedPage] = Field(default_factory=dict)
def _now_iso() -> str:
@@ -852,6 +1033,11 @@ class ProjectState(BaseModel):
# Per-chunk cache of extraction + storyboard results so a resume reuses them
# instead of re-calling the (billable) chat API for already-planned chunks.
chunk_cache: dict[str, ChunkCache] = Field(default_factory=dict)
+ render_mode: RenderMode = "finished_page"
+ page_cache: dict[str, ComicPagePlanSet] = Field(default_factory=dict)
+ pages_done: list[str] = Field(default_factory=list)
+ stale_pages: list[str] = Field(default_factory=list)
+ skipped_pages: list[str] = Field(default_factory=list)
generated: GeneratedAssets = Field(default_factory=GeneratedAssets)
errors: str = "logs/errors.jsonl"
active_elapsed_seconds: float = 0.0
diff --git a/core/screenwriter.py b/core/screenwriter.py
index 7d0a35a..9af5085 100644
--- a/core/screenwriter.py
+++ b/core/screenwriter.py
@@ -13,7 +13,13 @@
import requests
from core.api import get_chat_provider
-from core.schemas import PageScript, Storyboard, StoryElements, to_tool_schema
+from core.schemas import (
+ ComicPagePlanSet,
+ PageScript,
+ Storyboard,
+ StoryElements,
+ to_tool_schema,
+)
logger = logging.getLogger(__name__)
@@ -29,7 +35,9 @@
"Lettering: put narration/time-place in caption, spoken lines in dialogue, "
"and onomatopoeia in sfx — leave a field null when unused. "
"Art-direction fields (style_guide, scene_prompt, action, l1_prompt) may stay "
- "in English when that helps image models."
+ "in English when that helps image models. "
+ "When planning finished pages, describe manga geometry (splash/inset/diagonal), "
+ "never only 2x2 or 3x2 grids."
)
EXTRACT_TOOL = to_tool_schema(
@@ -42,6 +50,12 @@
"plan_storyboard",
"Plan the comic panels for one chunk of text as structured data.",
)
+PAGE_PLAN_TOOL = to_tool_schema(
+ ComicPagePlanSet,
+ "plan_comic_pages",
+ "Plan finished comic pages for one text unit: per-page purpose, "
+ "dynamic layout_intent, and panel specs with source-language lettering.",
+)
# Optional local scrub list. Empty by default: content policy is enforced by the
# system prompt + upstream provider filters. Callers may pass a non-empty
@@ -125,6 +139,30 @@ async def plan_storyboard(text: str, elements: StoryElements, *, chat=None) -> S
return Storyboard.model_validate(args)
+async def plan_comic_pages(text: str, elements: StoryElements, *, chat=None) -> ComicPagePlanSet:
+ """Plan finished readable pages for ``text`` given ``elements``."""
+ chat = chat or get_chat_provider()
+ messages = [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {
+ "role": "user",
+ "content": (
+ f"{sanitize_text(text)}\n\n"
+ f"Known elements:\n{elements.model_dump_json()}\n\n"
+ "Plan finished readable pages (not a flat 2x2 collage). "
+ "Each page needs purpose, layout_intent, and panels with "
+ "caption/dialogue/sfx in the source language."
+ ),
+ },
+ ]
+ args = await chat.chat_function_call(
+ messages,
+ [PAGE_PLAN_TOOL],
+ _tool_choice("plan_comic_pages"),
+ )
+ return ComicPagePlanSet.model_validate(args)
+
+
PAGE_SCRIPT_TOOL = to_tool_schema(
PageScript,
"plan_page_script",
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index a3c5d55..2e421f7 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -32,10 +32,10 @@ When completing a change:
| Area | Status | Notes |
|---|---|---|
-| Core TXT → comic pipeline | Released | Segmentation, extraction, portraits, storyboard, panels, layout, PDF / Webtoon export, `state.json` resume |
+| Core TXT → comic pipeline | Released | Segmentation, extraction, portraits, storyboard, panels, layout, PDF / Webtoon export, `state.json` resume. **Default render mode is `finished_page`** (one image per comic page); `panel_compose` is the explicit legacy fallback (`INKSTONE_RENDER_MODE=panel_compose`). |
| Providers and reliability | Released | Agnes + OpenAI-compatible routing, rate limit, retry and JSONL error collection. **Local temp:** default Agnes `BASE_URL` is `apihub.agnes-ai.cn` (domestic reachability); revert to `.com` or make env-configurable when access stabilizes (`TODO(temp)` in `core/api/chat_provider.py` / `agnes_image.py`). |
| Cross-chapter identity | Released | L1/L2 consistency, alias review, stale-only redraw; L3 is experimental and off by default |
-| Web UI and unattended supervisor | Released | Local browser UI, cancel, retry, review, deadline pause / resume |
+| Web UI and unattended supervisor | Released | Local browser UI, cancel, retry, review, deadline pause / resume; job/project JSON exposes `render_mode`, `pages_done`, `skipped_pages` |
| Colab operations | Released | Background jobs, download progress, alias adopt after 404/401 |
| Page-PDF recovery and source-language dialogue prompt | Released | Existing panels can be re-exported to PDF; new runs request dialogue in source language |
| Density estimate (D1) | Prototype on `main` | CLI estimator only; A/B/C labels match product brief; does not constrain generate |
diff --git a/docs/superpowers/plans/2026-07-30-finished-page-pipeline.md b/docs/superpowers/plans/2026-07-30-finished-page-pipeline.md
new file mode 100644
index 0000000..1521f26
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-30-finished-page-pipeline.md
@@ -0,0 +1,676 @@
+# Finished-Page Comic Pipeline Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the default Inkstone output a sequence of finished A4-portrait manga pages (dynamic panels + in-image lettering) bound into an LTR flip PDF, with the existing per-panel + `LayoutEngine` path kept only as explicit fallback.
+
+**Architecture:** Schema-first `ComicPagePlan` via forced tool call → pure `render_finished_page_prompt` → one image call per page → `generated.pages` → `ExportEngine` on `pages/`. Do not copy `codex-novel-to-comic-studio` files or agent workflows. Panel compose remains behind `render_mode=panel_compose` / `INKSTONE_RENDER_MODE=panel_compose`.
+
+**Tech Stack:** Python ≥3.10, Pydantic v2, existing ChatProvider/ImageProvider, Pillow, pytest, ruff.
+
+**Spec:** `docs/superpowers/specs/2026-07-30-finished-page-pipeline-design.md`
+
+## Global Constraints
+
+- English code, comments, commits (CONTRIBUTING).
+- No new hard dependencies.
+- Do not plagiarize the studio repo; borrow concepts only.
+- Default product mode: `finished_page`. Existing panel-path tests must opt into `panel_compose`.
+- No L3 face-swap on finished pages.
+- Honest skips for content-policy rejects (`skipped_pages`).
+- TDD: failing test → implement → pass → commit per task.
+- Keep old `state.json` with only `generated.panels` loadable; do not force-migrate assets.
+
+## File map
+
+| File | Responsibility |
+|---|---|
+| `core/schemas.py` | `PagePanelSpec`, `ComicPagePlan`, `ComicPagePlanSet`, `GeneratedPage`; extend `GeneratedAssets`, `ProjectState`, `Stage` |
+| `core/config.py` | `INKSTONE_RENDER_MODE`, `INKSTONE_PAGE_SIZE`, helpers |
+| `core/comic/page_prompt.py` | **New.** Deterministic finished-page prompt render |
+| `core/screenwriter.py` | `plan_comic_pages` + tool schema |
+| `core/pipelines/creative_comic.py` | Mode branch, page cache/resume, fingerprints, export wiring |
+| `core/comic/export.py` | Unchanged API if pages land as `page_NN.png`; verify ordering |
+| `web/server.py` | Expose `pages_done` / mode in job/project JSON |
+| `docs/ROADMAP.md`, `README.md` | Honesty + default mode notes |
+| `tests/test_schemas_finished_page.py` | Schema/state round-trip |
+| `tests/test_page_prompt.py` | Prompt renderer contracts |
+| `tests/test_screenwriter_pages.py` | plan_comic_pages with fake chat |
+| `tests/test_finished_page_pipeline.py` | Pipeline happy path + fallback flag |
+
+---
+
+### Task 1: Schemas and state fields
+
+**Files:**
+- Modify: `core/schemas.py`
+- Create: `tests/test_schemas_finished_page.py`
+
+**Interfaces:**
+- Produces: `PagePanelSpec`, `ComicPagePlan`, `ComicPagePlanSet`, `GeneratedPage`, `RenderMode = Literal["finished_page", "panel_compose"]`
+- Extends: `Stage` with `"page_plan"` | `"pages"`; `GeneratedAssets.pages`; `ProjectState` fields below
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/test_schemas_finished_page.py
+from core.schemas import (
+ ComicPagePlan,
+ ComicPagePlanSet,
+ GeneratedPage,
+ PagePanelSpec,
+ ProjectState,
+)
+
+
+def test_page_panel_spec_and_plan_round_trip():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p0001",
+ "purpose": "Establish the station and introduce 福贵",
+ "layout_intent": "Wide establishing top; diagonal inset reaction bottom-right",
+ "panels": [
+ {
+ "panel_id": "1",
+ "role": "establishing",
+ "shape_hint": "wide",
+ "shot": "wide",
+ "action": "福贵 walks through dusk streets",
+ "characters": ["福贵"],
+ "setting_ref": "城中街道",
+ "caption": "傍晚,街灯初上。",
+ "dialogue": None,
+ "sfx": "沙沙",
+ }
+ ],
+ "reference_characters": ["福贵"],
+ "setting_refs": ["城中街道"],
+ }
+ )
+ assert plan.page_id == "p0001"
+ assert plan.panels[0].caption == "傍晚,街灯初上。"
+ assert plan.panels[0].dialogue is None
+
+
+def test_comic_page_plan_set_and_generated_page_on_state():
+ pageset = ComicPagePlanSet.model_validate(
+ {"unit_id": "0", "pages": [{"page_id": "p0001", "purpose": "x", "layout_intent": "splash", "panels": []}]}
+ )
+ state = ProjectState(project_id="demo")
+ state.page_cache["0"] = pageset
+ state.generated.pages["p0001"] = GeneratedPage(
+ local="pages/page_01.png",
+ page_id="p0001",
+ unit_index=0,
+ page_index=0,
+ mode="finished",
+ caption="傍晚,街灯初上。",
+ )
+ state.pages_done.append("p0001")
+ state.render_mode = "finished_page"
+ state.stage = "pages"
+ blob = state.model_dump_json()
+ loaded = ProjectState.model_validate_json(blob)
+ assert loaded.page_cache["0"].pages[0].page_id == "p0001"
+ assert loaded.generated.pages["p0001"].mode == "finished"
+ assert loaded.render_mode == "finished_page"
+```
+
+- [ ] **Step 2: Run tests — expect fail**
+
+Run: `.venv/bin/python -m pytest tests/test_schemas_finished_page.py -v`
+Expected: import / attribute errors for missing types/fields.
+
+- [ ] **Step 3: Implement schemas**
+
+In `core/schemas.py`:
+
+1. Extend `Stage`:
+```python
+Stage = Literal[
+ "extract",
+ "storyboard",
+ "page_plan",
+ "portraits",
+ "panels",
+ "pages",
+ "layout",
+ "export",
+]
+```
+
+2. Add after `Panel` / near storyboard models (keep coercers consistent with `Panel`):
+
+```python
+RenderMode = Literal["finished_page", "panel_compose"]
+
+
+class PagePanelSpec(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+ panel_id: str
+ role: str = "action"
+ shape_hint: str = "rect"
+ shot: str = ""
+ action: str = ""
+ characters: list[str] = Field(default_factory=list)
+ setting_ref: str = ""
+ dialogue: str | None = None
+ caption: str | None = None
+ sfx: str | None = None
+ lettering_notes: str = ""
+ # validators: coerce_str / coerce_str_list / coerce_dialogue like Panel
+
+
+class ComicPagePlan(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+ page_id: str
+ purpose: str = ""
+ layout_intent: str = ""
+ panels: list[PagePanelSpec] = Field(default_factory=list)
+ reference_characters: list[str] = Field(default_factory=list)
+ setting_refs: list[str] = Field(default_factory=list)
+
+
+class ComicPagePlanSet(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+ unit_id: str = ""
+ pages: list[ComicPagePlan] = Field(default_factory=list)
+
+
+class GeneratedPage(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+ local: str
+ page_id: str = ""
+ unit_index: int = 0
+ page_index: int = 0
+ mode: Literal["finished", "composed_fallback"] = "finished"
+ dialogue: str | None = None
+ caption: str | None = None
+ sfx: str | None = None
+```
+
+3. Extend `GeneratedAssets`:
+```python
+pages: dict[str, GeneratedPage] = Field(default_factory=dict)
+```
+
+4. Extend `ProjectState`:
+```python
+render_mode: RenderMode = "finished_page"
+page_cache: dict[str, ComicPagePlanSet] = Field(default_factory=dict)
+pages_done: list[str] = Field(default_factory=list)
+stale_pages: list[str] = Field(default_factory=list)
+skipped_pages: list[str] = Field(default_factory=list)
+```
+
+Use the same `coerce_*` / `repair_fused_keys` patterns as `Panel` for resilience.
+
+- [ ] **Step 4: Run tests — expect pass**
+
+Run: `.venv/bin/python -m pytest tests/test_schemas_finished_page.py -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/schemas.py tests/test_schemas_finished_page.py
+git commit -m "feat(schemas): add finished-page plan and GeneratedPage state"
+```
+
+---
+
+### Task 2: Config for render mode and page size
+
+**Files:**
+- Modify: `core/config.py`
+- Modify: `tests/test_config.py` (or create if missing helpers tests live elsewhere — prefer extend existing config tests)
+
+**Interfaces:**
+- Produces: `ENV_RENDER_MODE = "INKSTONE_RENDER_MODE"`, `render_mode() -> RenderMode`, `ENV_PAGE_SIZE`, `finished_page_size() -> str` default `"1024x1536"`
+
+- [ ] **Step 1: Failing test**
+
+```python
+def test_render_mode_defaults_finished_page(monkeypatch):
+ monkeypatch.delenv("INKSTONE_RENDER_MODE", raising=False)
+ from core.config import render_mode
+ assert render_mode() == "finished_page"
+
+
+def test_render_mode_panel_compose(monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "panel_compose")
+ from core.config import render_mode
+ assert render_mode() == "panel_compose"
+
+
+def test_finished_page_size_default(monkeypatch):
+ monkeypatch.delenv("INKSTONE_PAGE_SIZE", raising=False)
+ from core.config import finished_page_size
+ assert finished_page_size() == "1024x1536"
+```
+
+- [ ] **Step 2: Run — expect fail**
+
+- [ ] **Step 3: Implement**
+
+```python
+ENV_RENDER_MODE = "INKSTONE_RENDER_MODE"
+ENV_PAGE_SIZE = "INKSTONE_PAGE_SIZE"
+
+
+def render_mode() -> str:
+ raw = _get(ENV_RENDER_MODE, "finished_page").strip().lower()
+ if raw in {"panel_compose", "panel", "compose"}:
+ return "panel_compose"
+ return "finished_page"
+
+
+def finished_page_size() -> str:
+ raw = _get(ENV_PAGE_SIZE, "1024x1536").strip()
+ return raw or "1024x1536"
+```
+
+- [ ] **Step 4: Tests pass**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git commit -m "feat(config): add INKSTONE_RENDER_MODE and page size"
+```
+
+---
+
+### Task 3: Deterministic finished-page prompt renderer
+
+**Files:**
+- Create: `core/comic/page_prompt.py`
+- Create: `tests/test_page_prompt.py`
+
+**Interfaces:**
+- Consumes: `ComicPagePlan`, `CharacterAsset` dict, `Setting` dict, `style_guide: str`
+- Produces: `render_finished_page_prompt(...) -> str`
+
+- [ ] **Step 1: Failing tests**
+
+```python
+from core.comic.page_prompt import render_finished_page_prompt
+from core.schemas import CharacterAsset, ComicPagePlan, Setting
+
+
+def test_prompt_includes_layout_lettering_and_identity():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p0001",
+ "purpose": "Hook: map moves",
+ "layout_intent": "Wide top archive; diagonal window attack; reaction close-up",
+ "panels": [
+ {
+ "panel_id": "1",
+ "role": "establishing",
+ "shape_hint": "wide",
+ "action": "Mira leans over glowing map",
+ "characters": ["Mira"],
+ "caption": "MIDNIGHT AT THE SKY ARCHIVE",
+ "dialogue": "This map is moving.",
+ "sfx": None,
+ "lettering_notes": "caption top-left; bubble near Mira, not on face",
+ }
+ ],
+ "reference_characters": ["Mira"],
+ }
+ )
+ chars = {"Mira": CharacterAsset(name="Mira", l1_prompt="young woman, dark hair, blue pendant")}
+ text = render_finished_page_prompt(
+ plan,
+ characters_by_name=chars,
+ settings_by_name={},
+ style_guide="manhua comic style",
+ )
+ assert "A4 portrait" in text or "portrait comic page" in text.lower()
+ assert "MIDNIGHT AT THE SKY ARCHIVE" in text
+ assert "This map is moving." in text
+ assert "young woman, dark hair" in text
+ assert "2x2" not in text.lower() # renderer must not collapse intent to grid slogan
+ assert "Wide top archive" in text
+```
+
+- [ ] **Step 2: Run — expect fail (module missing)**
+
+- [ ] **Step 3: Implement `core/comic/page_prompt.py`**
+
+```python
+"""Deterministic finished-page image prompts from ComicPagePlan."""
+
+from __future__ import annotations
+
+from core.comic.identity import ensure_character_l1
+from core.schemas import CharacterAsset, ComicPagePlan, Setting
+
+
+def render_finished_page_prompt(
+ plan: ComicPagePlan,
+ *,
+ characters_by_name: dict[str, CharacterAsset],
+ settings_by_name: dict[str, Setting],
+ style_guide: str = "",
+) -> str:
+ lines: list[str] = [
+ "Finished readable manga/comic page, A4 portrait single image,",
+ "dynamic panel layout with gutters (not a flat labeled grid collage),",
+ "clean black ink line art, soft cel shading, flat colors,",
+ "speech bubbles, caption boxes, and SFX lettered legibly in-image,",
+ "do not cover faces, hands, or key action with text.",
+ ]
+ if style_guide:
+ lines.append(f"Style: {style_guide}")
+ lines.append(f"Page purpose: {plan.purpose}")
+ lines.append(f"Layout intent: {plan.layout_intent}")
+ for i, panel in enumerate(plan.panels, start=1):
+ lines.append(
+ f"Panel {i} ({panel.panel_id}): role={panel.role}, shape={panel.shape_hint}, "
+ f"shot={panel.shot}, action={panel.action}"
+ )
+ if panel.setting_ref:
+ setting = settings_by_name.get(panel.setting_ref)
+ scene = getattr(setting, "scene_prompt", "") if setting else ""
+ lines.append(f" setting={panel.setting_ref}: {scene}".rstrip(": "))
+ for name in panel.characters:
+ asset = characters_by_name.get(name)
+ if asset:
+ ensure_character_l1(asset)
+ if asset.l1_prompt:
+ lines.append(f" character {name}: {asset.l1_prompt}")
+ if panel.caption:
+ lines.append(f" CAPTION (exact): {panel.caption}")
+ if panel.dialogue:
+ lines.append(f" DIALOGUE (exact): {panel.dialogue}")
+ if panel.sfx:
+ lines.append(f" SFX (exact): {panel.sfx}")
+ if panel.lettering_notes:
+ lines.append(f" lettering: {panel.lettering_notes}")
+ return "\n".join(lines)
+```
+
+Tune wording so tests pass; keep function pure (no I/O).
+
+- [ ] **Step 4: Tests pass**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git commit -m "feat(page_prompt): render finished-page prompts from ComicPagePlan"
+```
+
+---
+
+### Task 4: `plan_comic_pages` screenwriter tool
+
+**Files:**
+- Modify: `core/screenwriter.py`
+- Create: `tests/test_screenwriter_pages.py`
+
+**Interfaces:**
+- Produces: `async def plan_comic_pages(text, elements, *, chat=None) -> ComicPagePlanSet`
+- Tool name: `plan_comic_pages`
+
+- [ ] **Step 1: Failing test with fake chat**
+
+```python
+import pytest
+from core.schemas import StoryElements
+from core.screenwriter import plan_comic_pages
+
+
+class FakeChat:
+ async def chat_function_call(self, messages, tools, tool_choice):
+ assert tools[0]["function"]["name"] == "plan_comic_pages"
+ return {
+ "unit_id": "0",
+ "pages": [
+ {
+ "page_id": "p0001",
+ "purpose": "open on the street",
+ "layout_intent": "tall walking strip left; plaza wide right",
+ "panels": [
+ {
+ "panel_id": "1",
+ "role": "establishing",
+ "action": "walks",
+ "characters": ["福贵"],
+ "caption": "傍晚,街灯初上。",
+ }
+ ],
+ "reference_characters": ["福贵"],
+ }
+ ],
+ }
+
+
+@pytest.mark.asyncio
+async def test_plan_comic_pages_validates_tool_payload():
+ elements = StoryElements(characters=[], settings=[], style_guide="manhua")
+ out = await plan_comic_pages("傍晚……", elements, chat=FakeChat())
+ assert out.pages[0].page_id == "p0001"
+ assert out.pages[0].panels[0].caption == "傍晚,街灯初上。"
+```
+
+- [ ] **Step 2: Run — expect fail**
+
+- [ ] **Step 3: Implement**
+
+Add imports for new schemas. Extend `SYSTEM_PROMPT` with one sentence: when planning finished pages, describe manga geometry (splash/inset/diagonal), never only `2x2`/`3x2`.
+
+```python
+PAGE_PLAN_TOOL = to_tool_schema(
+ ComicPagePlanSet,
+ "plan_comic_pages",
+ "Plan finished comic pages for one text unit: per-page purpose, "
+ "dynamic layout_intent, and panel specs with source-language lettering.",
+)
+
+
+async def plan_comic_pages(text: str, elements: StoryElements, *, chat=None) -> ComicPagePlanSet:
+ chat = chat or get_chat_provider()
+ messages = [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {
+ "role": "user",
+ "content": (
+ f"{sanitize_text(text)}\n\n"
+ f"Known elements:\n{elements.model_dump_json()}\n\n"
+ "Plan finished readable pages (not a flat 2x2 collage). "
+ "Each page needs purpose, layout_intent, and panels with "
+ "caption/dialogue/sfx in the source language."
+ ),
+ },
+ ]
+ args = await chat.chat_function_call(
+ messages,
+ [PAGE_PLAN_TOOL],
+ _tool_choice("plan_comic_pages"),
+ )
+ return ComicPagePlanSet.model_validate(args)
+```
+
+- [ ] **Step 4: Tests pass** (use whatever asyncio marker the repo already uses; if sync-style tests dominate, mirror `tests/test_screenwriter.py` patterns)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git commit -m "feat(screenwriter): add plan_comic_pages forced tool call"
+```
+
+---
+
+### Task 5: Pipeline finished-page path + fingerprints
+
+**Files:**
+- Modify: `core/pipelines/creative_comic.py`
+- Create: `tests/test_finished_page_pipeline.py`
+- Modify: existing creative_comic tests to set `render_mode="panel_compose"` (or env) so defaults do not break them
+
+**Interfaces:**
+- Consumes: `plan_comic_pages`, `render_finished_page_prompt`, `render_mode()`, `finished_page_size()`
+- Produces: pages under `output_dir/pages/page_XX.png`, `state.generated.pages`, `pages_done`
+- Update `_render_fingerprint` to include `render_mode` and page size
+- Bump `_PIPELINE_STATE_VERSION` if structure fingerprint must invalidate old caches (document in commit)
+
+- [ ] **Step 1: Failing integration test**
+
+```python
+@pytest.mark.asyncio
+async def test_finished_page_mode_writes_generated_pages(tmp_path, monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ # Fake chat returns elements + page plan; fake image returns solid PNG bytes via ImageOutput
+ # Assert: (tmp_path / "pages").glob("page_*.png") non-empty
+ # Assert: state.generated.pages and state.render_mode == "finished_page"
+ # Assert: ExportEngine can build comic.pdf from pages dir (optional in this test)
+```
+
+Mirror fixtures from `tests/test_creative_comic.py` (FakeChat / FakeImage). Prefer minimal: one chunk, one page, one image call.
+
+- [ ] **Step 2: Run — expect fail / still panel-only behavior**
+
+- [ ] **Step 3: Implement pipeline branch**
+
+Pseudocode inside `_creative_comic` after extract/portraits (finished mode skips `plan_storyboard` panel loop by default; still may keep storyboard optional later — **YAGNI: skip storyboard when finished_page**):
+
+```python
+mode = render_mode()
+state.render_mode = mode # persist
+
+if mode == "finished_page":
+ # for each chunk unit:
+ # cache hit page_cache[key] or plan_comic_pages(...)
+ # for each ComicPagePlan in set.pages:
+ # if page_id in pages_done and not stale: continue
+ # prompt = render_finished_page_prompt(...)
+ # refs = portrait paths for reference_characters
+ # out = await image.generate_single_image(prompt, refs, size=finished_page_size())
+ # save to pages/page_{n:02d}.png (global page counter)
+ # state.generated.pages[page_id] = GeneratedPage(...)
+ # pages_done.append(page_id); state.save
+ # export: ExportEngine().export_pdf(pages_dir) without LayoutEngine collage
+else:
+ # existing storyboard → panels → LayoutEngine path
+```
+
+Handle `is_content_policy_rejection` → `skipped_pages`.
+No L3 on this path.
+Progress callbacks: stages `page_plan`, `pages`, `export`.
+Update `estimate_progress` to prefer `pages_done` when `render_mode=="finished_page"`.
+
+- [ ] **Step 4: Opt existing tests into panel compose**
+
+At top of panel-era tests or via fixture:
+```python
+monkeypatch.setenv("INKSTONE_RENDER_MODE", "panel_compose")
+```
+Or pass an explicit kwarg `render_mode=` on `creative_comic` if you add the parameter (preferred for tests):
+
+```python
+async def creative_comic(..., render_mode: str | None = None):
+ mode = render_mode or config_render_mode()
+```
+
+- [ ] **Step 5: Full related pytest pass**
+
+Run:
+`.venv/bin/python -m pytest tests/test_finished_page_pipeline.py tests/test_creative_comic.py tests/test_schemas_finished_page.py -q`
+
+- [ ] **Step 6: Commit**
+
+```bash
+git commit -m "feat(pipeline): default finished-page generation with resume"
+```
+
+---
+
+### Task 6: Fallback `panel_compose` from page plans (optional bridge) + export honesty
+
+**Files:**
+- Modify: `core/pipelines/creative_comic.py`
+- Modify: `tests/test_finished_page_pipeline.py`
+- Modify: `docs/ROADMAP.md`, `README.md` (short honesty notes)
+- Modify: `web/server.py` project/job payload to include `pages_done`, `render_mode`
+
+**Interfaces:**
+- When finished-page image fails after one stricter retry **or** mode is `panel_compose` with only `page_cache` present: convert `PagePanelSpec` → temporary `Panel` list → existing panel renderer → `LayoutEngine` → mark pages `mode="composed_fallback"` if synthesizing page records
+
+Keep v1 fallback simple:
+
+- `panel_compose` mode: **keep current storyboard path** (already works).
+- Finished-page failure: record skip or retry once; do **not** auto-build a full compose bridge in v1 unless cheap — spec allows fallback, but YAGNI says: env switch to `panel_compose` for recovery is enough for v1 if auto-bridge is large.
+
+**v1 decision (lock):**
+- Auto compose-from-`PagePanelSpec` is **out of v1** if it balloons.
+- v1 fallback = set `INKSTONE_RENDER_MODE=panel_compose` and re-run (storyboard path).
+- Document this in README.
+- Still implement one stricter finished-page re-prompt on generic failure before skip/raise per existing error classes.
+
+- [ ] **Step 1: Test stricter retry called once** (mock image fail then succeed)
+
+- [ ] **Step 2: Implement retry-once + README/ROADMAP notes**
+
+- [ ] **Step 3: Web JSON fields**
+
+```python
+"render_mode": state.render_mode,
+"pages_done": list(state.pages_done),
+"skipped_pages": list(state.skipped_pages),
+```
+
+- [ ] **Step 4: pytest + ruff**
+
+```bash
+.venv/bin/ruff check core tests
+.venv/bin/ruff format --check core tests
+.venv/bin/python -m pytest -q
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git commit -m "feat: finished-page retry, web progress fields, docs honesty"
+```
+
+---
+
+### Task 7: Verify against spec + smoke
+
+- [ ] **Step 1: Spec coverage checklist**
+
+Confirm each spec bullet has a task artifact:
+
+| Spec item | Task |
+|---|---|
+| ComicPagePlan schemas / state | T1 |
+| render_mode config | T2 |
+| Deterministic prompt render | T3 |
+| plan_comic_pages tool | T4 |
+| Finished-page pipeline + resume | T5 |
+| Fingerprints include mode/size | T5 |
+| Export pages → PDF | T5 (ExportEngine reuse) |
+| Fallback honesty / panel_compose | T6 |
+| No L3 on pages | T5 |
+| Web progress | T6 |
+| README honesty | T6 |
+
+- [ ] **Step 2: Full CI-equivalent locally**
+
+```bash
+.venv/bin/ruff check .
+.venv/bin/ruff format --check core tests web utils scripts
+.venv/bin/python -m pytest -q
+```
+
+- [ ] **Step 3: Final commit if any stragglers; do not push unless asked**
+
+---
+
+## Plan self-review
+
+1. **Spec coverage:** All §1–§3 requirements mapped; auto `PagePanelSpec→compose` deferred explicitly as YAGNI with documented env fallback (matches “explicit fallback”, avoids half-copied studio compose).
+2. **Placeholders:** None intentionally left; implementers must not invent studio file trees.
+3. **Type consistency:** `ComicPagePlan` / `GeneratedPage` / `render_mode` names stable across tasks.
+4. **Risk:** Agnes may not honor `1024x1536` — if provider rejects, catch and fall back to `1024x1024` once inside image call wrapper (add in Task 5 if tests with fake provider do not cover; real probe optional).
diff --git a/docs/superpowers/specs/2026-07-30-finished-page-pipeline-design.md b/docs/superpowers/specs/2026-07-30-finished-page-pipeline-design.md
new file mode 100644
index 0000000..49ddcff
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-30-finished-page-pipeline-design.md
@@ -0,0 +1,166 @@
+# Design: Finished-page comic pipeline (default)
+
+**Date:** 2026-07-30
+**Status:** Draft for review (sections §1–§3 approved in chat)
+**Product ask:** Every bound page should read like a designed manga page (dynamic panels + in-art lettering), so LTR PDF page-flips feel like a comic — not a 2×2 collage of isolated shots.
+**Inspiration:** Conceptual only from `codex-novel-to-comic-studio` (whole-page finished output). **Do not** copy that repo’s agent/file layout, skill markdown contracts, or compose helpers. Inkstone stays automated, schema-first, and resumable.
+
+## §1 Goals, non-goals, pipeline skeleton
+
+### Goals
+
+- Default output unit is one **finished A4-portrait comic page** (dynamic panel geometry + captions / dialogue / SFX rendered in-image).
+- Bind finished pages directly into a left-to-right flip PDF.
+- Keep Inkstone strengths: Pydantic tool schemas as single source of truth, provider injection, atomic `state.json` resume, honest skip on content-policy rejects.
+
+### Non-goals (v1)
+
+- Multi-agent / multi-approval markdown workflows.
+- Human visual-bible approval gates as a required path.
+- CBZ-first distribution (PDF remains primary; CBZ can follow later).
+- Copying the reference project’s directory tree or `compose.py`.
+- Promoting legacy D2 `PageScript` into the finished-page contract.
+
+### Default pipeline
+
+```text
+segment → extract / portraits (keep)
+ → plan pages (new: per-page story job + panel map + lettering)
+ → render page prompt (deterministic from schema)
+ → generate finished page (one image call per page; character refs)
+ → bind PDF (page images in order)
+fallback (explicit): per-panel generate + LayoutEngine lettering
+```
+
+### Differentiation vs the reference studio
+
+| Studio pattern | Inkstone choice |
+|---|---|
+| Free-form director brief `.md` as primary artifact | Structured `ComicPagePlan` via forced tool call; prompt is a pure render |
+| Heavy human approval checkpoints | Automated resume; optional UI mode switch only |
+| Panel compose as rare fallback (simple grid) | Keep existing panel + `LayoutEngine` as **explicit** fallback / debug mode |
+| Agent skill swarm | Single pipeline module + small pure helpers |
+
+## §2 Data model and state
+
+### New schemas (primary contract)
+
+**`PagePanelSpec`** — in-page panel (planning only; not a separate image by default):
+
+- `panel_id`
+- `role`: establishing / action / reaction / inset / splash / …
+- `shape_hint`: wide / tall / diagonal / inset / bleed (prompt geometry, **not** pixel boxes)
+- `shot`, `action`, `characters`, `setting_ref`
+- `dialogue` / `caption` / `sfx` (same language as source)
+- `lettering_notes`: placement hints (avoid covering faces / key action)
+
+**`ComicPagePlan`** — one page:
+
+- `page_id` (stable, e.g. `p0003`)
+- `purpose`: story job + page-turn hook
+- `layout_intent`: manga geometry in natural language; **forbid** “2x2-only” plans
+- `panels: list[PagePanelSpec]` (typically 3–6; splash allowed)
+- `reference_characters` / `setting_refs` for L2 refs
+
+**`ComicPagePlanSet`** — one planning unit (v1 aligned to current text chunks):
+
+- `unit_id`
+- `pages: list[ComicPagePlan]`
+
+**`GeneratedPage`** — artifact:
+
+- `local` path under `pages/`
+- `page_id`, `unit_index`, `page_index`
+- lettering snapshot from plan (for QC / fallback)
+- `mode`: `finished` | `composed_fallback`
+
+### `ProjectState` additions
+
+- `page_cache: dict[str, ComicPagePlanSet]` (billable plan cache, like `chunk_cache`)
+- `generated.pages: dict[str, GeneratedPage]`
+- `pages_done` / `stale_pages` / `skipped_pages`
+- `render_mode`: `finished_page` (default) | `panel_compose`
+- `stage` extended through `page_plan` → `pages` → `export`
+
+### Relationship to legacy models
+
+- Keep `Storyboard` / `GeneratedPanel` / `LayoutEngine` for `panel_compose` and finished-page failure fallback.
+- Do **not** elevate D2 `PageScript` / coverage into this path.
+- Old projects (panel-era `state.json`) remain readable on the panel path; new runs default to finished pages. No forced in-place migration of old assets.
+
+### Fingerprints
+
+- `structure_fingerprint`: include page-plan semantics (and density later if wired).
+- `render_fingerprint`: include finished-page mode, page size target, model snapshot.
+- Switching `render_mode` soft-invalidates page/panel renders; cached plans may be reused when structure is unchanged.
+
+## §3 Generation, consistency, fallback, export
+
+### Page planning (chat, forced tool call)
+
+Input: source unit text + extracted characters/settings (+ style).
+Output: `ComicPagePlanSet` validated by Pydantic.
+Cache under `page_cache[unit_key]` so resume does not re-pay for planning.
+
+Schema / system reminders must require:
+
+- dynamic panel geometry and reading path
+- source-language lettering
+- per-page purpose and page-turn hook
+- no layout that is only a flat labeled `2x2` / `3x2` grid with no intent
+
+### Prompt render (pure, offline)
+
+`render_finished_page_prompt(plan, characters, settings, style) -> str`
+
+Deterministically expands the plan into an image prompt: panel map, shots, exact lettering strings, text-safety rules, A4 portrait framing, style guide, identity locks from L1 descriptions.
+This function is the sole authority for the finished-page image prompt (same spirit as `ConsistencyEngine.build_panel_prompt` for panels).
+
+### Finished-page image (default)
+
+- One `generate_single_image` call per page.
+- Prefer portrait sizes supported by the provider (e.g. `1024x1536` when available); normalize/bind at export if needed.
+- References: portraits for `reference_characters` (+ critical setting refs when available). Reuse L1/L2; **no L3 face-swap on full pages** (seam risk).
+- Write `pages/page_XX.png` and `GeneratedPage(mode="finished")`.
+- Resume granularity: `page_id` (parallel to today’s panel keys).
+
+### Failure policy
+
+1. Content-policy reject → `skipped_pages` (honest; no blind retry loop).
+2. Optional light QC (e.g. required dialogue substring missing from plan metadata only — **do not** OCR unless we later add it) → one stricter re-prompt.
+3. Still failing, or `INKSTONE_RENDER_MODE=panel_compose` / `render_mode=panel_compose` → fallback: generate from `PagePanelSpec` list via existing panel pipeline + `LayoutEngine`, mark `mode="composed_fallback"`.
+
+### Binding
+
+- Default PDF = ordered finished (or fallback-composed) page images; LTR flip.
+- Webtoon: stack page images, or keep panel-strip behavior under `panel_compose`.
+- Default path must **not** assemble a 2×2 collage before export.
+
+### Web / CLI
+
+- Default `finished_page`.
+- Progress should expose page completion, not only panel counts.
+- Keep an explicit “panel compose” mode for A/B and recovery.
+
+### Honesty
+
+Free-tier image models have ceilings on in-image text and identity. Finished-page mode optimizes for **page-shaped comics**; it does not claim commercial print parity with stronger closed image2 stacks. Document this in README when shipping.
+
+## Implementation sketch (not a plan yet)
+
+Ordered work once this spec is approved and an implementation plan exists:
+
+1. Schemas + state fields + fingerprint hooks + tests.
+2. `plan_comic_pages` screenwriter tool + cache wiring.
+3. `render_finished_page_prompt` + unit tests (golden-ish string contracts).
+4. Pipeline branch in `creative_comic` for finished pages; env/config for mode.
+5. Export bind from `generated.pages`; UI progress.
+6. Fallback path + docs/ROADMAP honesty notes.
+
+## Spec self-review
+
+- [x] No TBD/placeholder sections left in approved scope.
+- [x] No contradiction: finished-page is default; panel path is explicit fallback only.
+- [x] Scope capped: no studio file tree, no required human bible gates, no D2 promotion.
+- [x] Consistency: L3 excluded on full pages; L1/L2 retained.
+- [x] Old projects: no forced migration.
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..202e4e0
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,19 @@
+"""tests/conftest.py — shared pytest fixtures."""
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _default_render_mode(monkeypatch):
+ """Default tests to the pre-existing storyboard/panel/LayoutEngine path.
+
+ ``core.config.render_mode()`` now defaults to ``finished_page``, but most
+ of the existing pipeline test suite (FakeChat/FakeImage fixtures) was
+ written against the ``panel_compose`` (storyboard -> panels -> LayoutEngine)
+ contract. Opting the whole suite into ``panel_compose`` here avoids
+ touching every ``creative_comic(...)`` call site; tests that specifically
+ exercise the finished-page path (e.g. ``tests/test_finished_page_pipeline.py``)
+ override this within the test body via
+ ``monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")``.
+ """
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "panel_compose")
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..4d6fdf9
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,19 @@
+def test_render_mode_defaults_finished_page(monkeypatch):
+ monkeypatch.delenv("INKSTONE_RENDER_MODE", raising=False)
+ from core.config import render_mode
+
+ assert render_mode() == "finished_page"
+
+
+def test_render_mode_panel_compose(monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "panel_compose")
+ from core.config import render_mode
+
+ assert render_mode() == "panel_compose"
+
+
+def test_finished_page_size_default(monkeypatch):
+ monkeypatch.delenv("INKSTONE_PAGE_SIZE", raising=False)
+ from core.config import finished_page_size
+
+ assert finished_page_size() == "1024x1536"
diff --git a/tests/test_estimate_progress.py b/tests/test_estimate_progress.py
index 47e7f87..e558285 100644
--- a/tests/test_estimate_progress.py
+++ b/tests/test_estimate_progress.py
@@ -22,6 +22,7 @@ def test_panel_progress_counts_matches_estimate():
)
state = ProjectState(
project_id="p",
+ render_mode="panel_compose",
chunk_cache={"0": ChunkCache(storyboard=board)},
panels_done=[f"c0000-p{i:04d}" for i in range(5)],
)
@@ -40,6 +41,7 @@ def test_estimate_progress_scales_with_panels_done():
)
state = ProjectState(
project_id="p",
+ render_mode="panel_compose",
chunk_cache={"0": ChunkCache(storyboard=board)},
panels_done=[f"c0000-p{i:04d}" for i in range(5)],
)
@@ -54,6 +56,7 @@ def test_estimate_progress_nonzero_on_partial_project():
)
state = ProjectState(
project_id="p",
+ render_mode="panel_compose",
skipped_chunks=["0", "1"],
chunk_cache={"2": ChunkCache(storyboard=board)},
panels_done=[f"c0002-p{i:04d}" for i in range(10)],
diff --git a/tests/test_finished_page_pipeline.py b/tests/test_finished_page_pipeline.py
new file mode 100644
index 0000000..36cfce6
--- /dev/null
+++ b/tests/test_finished_page_pipeline.py
@@ -0,0 +1,351 @@
+"""tests/test_finished_page_pipeline.py — finished-page orchestration (fakes, no network)."""
+
+import asyncio
+from pathlib import Path
+from unittest.mock import patch
+
+from PIL import Image
+
+from core.api import ChatProvider, ImageProvider
+from core.pipelines.creative_comic import creative_comic
+from core.schemas import ProjectState
+from core.screenwriter import is_content_policy_rejection
+
+
+class FakeImageOutput:
+ def __init__(self):
+ self.fmt = "b64"
+ self.data = ""
+ self.ext = "png"
+
+ def save(self, path):
+ Image.new("RGB", (20, 30), (90, 90, 90)).save(path)
+
+
+class FakeImage(ImageProvider):
+ def __init__(self):
+ self.calls = 0
+
+ async def generate_single_image(self, prompt, reference_image_paths=None, size=None, **kw):
+ self.calls += 1
+ return FakeImageOutput()
+
+
+class FakeChat(ChatProvider):
+ def __init__(self):
+ self.calls = 0
+ self.page_plan_calls = 0
+
+ async def chat_function_call(self, messages, tools, tool_choice, **kw):
+ self.calls += 1
+ name = tool_choice["function"]["name"]
+ if name == "extract_story_elements":
+ return {
+ "characters": [
+ {
+ "name": "福贵",
+ "l1_prompt": "a middle-aged farmer",
+ "portrait_prompt": "portrait of a farmer",
+ }
+ ],
+ "settings": [{"name": "村口", "scene_prompt": "village entrance at dusk"}],
+ "style_guide": "manhua",
+ }
+ if name == "plan_comic_pages":
+ self.page_plan_calls += 1
+ return {
+ "unit_id": str(self.page_plan_calls),
+ "pages": [
+ {
+ "page_id": f"u{self.page_plan_calls}_p0001",
+ "purpose": "establish the village entrance",
+ "layout_intent": "wide establishing top, inset reaction bottom-right",
+ "panels": [
+ {
+ "panel_id": "1",
+ "role": "establishing",
+ "shape_hint": "wide",
+ "shot": "wide",
+ "action": "福贵 walks through the village entrance",
+ "characters": ["福贵"],
+ "setting_ref": "村口",
+ "caption": "傍晚,村口。",
+ }
+ ],
+ "reference_characters": ["福贵"],
+ "setting_refs": ["村口"],
+ }
+ ],
+ }
+ return {}
+
+
+def _fake_export_pdf(self, page_dir, out="comic.pdf", layout="TwoPageRight", direction="R2L"):
+ # Stand-in for the manga2pdf CLI (not installed in test/CI envs).
+ Path(out).write_bytes(b"%PDF-1.4 fake")
+ return out
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_mode_writes_generated_pages(tmp_path, monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ src = "第一章\n福贵在村口。"
+ chat, img = FakeChat(), FakeImage()
+ proj = asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=chat, image=img))
+
+ page_files = sorted((tmp_path / "pages").glob("page_*.png"))
+ assert page_files
+
+ assert proj.state.render_mode == "finished_page"
+ assert "c0000:u1_p0001" in proj.state.generated.pages
+ assert "c0000:u1_p0001" in proj.state.pages_done
+ generated_page = proj.state.generated.pages["c0000:u1_p0001"]
+ assert generated_page.mode == "finished"
+ assert Path(generated_page.local).exists()
+
+ assert proj.pdf and Path(proj.pdf).exists()
+ assert proj.pages == [str(p) for p in page_files]
+
+ assert img.calls == 2 # 1 portrait + 1 page
+ assert chat.calls == 2 # extract + plan_comic_pages
+
+ # Resume: state.json already has the page recorded, so nothing regenerates.
+ chat2, img2 = FakeChat(), FakeImage()
+ proj2 = asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=chat2, image=img2))
+ assert img2.calls == 0
+ assert chat2.calls == 0
+ assert proj2.state.pages_done == proj.state.pages_done
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_kwarg_overrides_env_default(tmp_path, monkeypatch):
+ # Env says panel_compose; explicit kwarg should win.
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "panel_compose")
+ src = "第一章\n福贵在村口。"
+ chat, img = FakeChat(), FakeImage()
+ proj = asyncio.run(
+ creative_comic(
+ src, output_dir=str(tmp_path), chat=chat, image=img, render_mode="finished_page"
+ )
+ )
+ assert proj.state.render_mode == "finished_page"
+ assert sorted((tmp_path / "pages").glob("page_*.png"))
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_resumes_after_deleted_page(tmp_path, monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ src = "第一章\n福贵在村口。"
+ asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=FakeChat(), image=FakeImage()))
+
+ state = ProjectState.load(tmp_path / "state.json")
+ deleted = Path(next(iter(state.generated.pages.values())).local)
+ assert deleted.exists()
+ deleted.unlink()
+
+ chat2, img2 = FakeChat(), FakeImage()
+ proj = asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=chat2, image=img2))
+
+ assert img2.calls == 1 # only the missing page regenerates; portrait reused
+ assert chat2.calls == 0 # page plan reused from page_cache
+ assert deleted.exists()
+ assert "c0000:u1_p0001" in proj.state.pages_done
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_filenames_are_position_stable_across_partial_resume(tmp_path, monkeypatch):
+ """Regenerating one deleted page must not collide with / corrupt another page's file.
+
+ Position-derived filenames (not a running counter) guarantee this: a
+ counter would renumber on resume and could overwrite an unrelated page.
+ """
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ src = "第一章\n福贵在村口。\n第二章\n福贵在读书。"
+ asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=FakeChat(), image=FakeImage()))
+
+ state = ProjectState.load(tmp_path / "state.json")
+ assert len(state.generated.pages) == 2
+ first_page = state.generated.pages["c0000:u1_p0001"]
+ second_page = state.generated.pages["c0001:u2_p0001"]
+ second_path = Path(second_page.local)
+ second_bytes_before = second_path.read_bytes()
+
+ Path(first_page.local).unlink()
+
+ chat2, img2 = FakeChat(), FakeImage()
+ proj = asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=chat2, image=img2))
+
+ assert img2.calls == 1 # only the deleted page regenerates
+ assert Path(first_page.local).exists() # regenerated at the *same* path
+ assert second_path.exists()
+ assert second_path.read_bytes() == second_bytes_before # untouched, not overwritten
+ assert set(proj.state.pages_done) == {"c0000:u1_p0001", "c0001:u2_p0001"}
+
+
+class RejectingPageImage(FakeImage):
+ """The finished-page image is rejected; the portrait call still succeeds."""
+
+ async def generate_single_image(self, prompt, reference_image_paths=None, size=None, **kw):
+ self.calls += 1
+ if "Finished readable manga/comic page" in prompt:
+ raise RuntimeError("Agnes image error: content_policy_violation: page rejected")
+ return FakeImageOutput()
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_content_policy_rejection_is_skipped_not_raised(tmp_path, monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ src = "第一章\n福贵在村口。"
+ proj = asyncio.run(
+ creative_comic(src, output_dir=str(tmp_path), chat=FakeChat(), image=RejectingPageImage())
+ )
+ assert "c0000:u1_p0001" in proj.state.skipped_pages
+ assert "c0000:u1_p0001" not in proj.state.pages_done
+ assert "c0000:u1_p0001" not in proj.state.generated.pages
+
+
+def test_is_content_policy_rejection_still_used_by_finished_page_path():
+ assert is_content_policy_rejection(RuntimeError("content_policy_violation")) is True
+
+
+class FailOncePageImage(FakeImage):
+ """First finished-page image call fails generically; second succeeds."""
+
+ def __init__(self):
+ super().__init__()
+ self.page_attempts = 0
+ self.prompts: list[str] = []
+
+ async def generate_single_image(self, prompt, reference_image_paths=None, size=None, **kw):
+ self.calls += 1
+ if "Finished readable manga/comic page" in prompt:
+ self.page_attempts += 1
+ self.prompts.append(prompt)
+ if self.page_attempts == 1:
+ raise RuntimeError("transient image provider failure")
+ return FakeImageOutput()
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_generic_failure_retries_once_with_stricter_prompt(tmp_path, monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ src = "第一章\n福贵在村口。"
+ img = FailOncePageImage()
+ proj = asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=FakeChat(), image=img))
+
+ assert img.page_attempts == 2
+ assert len(img.prompts) == 2
+ assert "STRICT" in img.prompts[1]
+ assert "STRICT" not in img.prompts[0]
+
+ assert "c0000:u1_p0001" in proj.state.pages_done
+ assert "c0000:u1_p0001" not in proj.state.skipped_pages
+ assert Path(proj.state.generated.pages["c0000:u1_p0001"].local).exists()
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_export_ignores_stale_panel_compose_pages(tmp_path, monkeypatch):
+ """panel_compose writes page_01.png; finished_page must not pick those up on export."""
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ pages_dir = tmp_path / "pages"
+ pages_dir.mkdir(parents=True)
+ Image.new("RGB", (20, 30), (255, 0, 0)).save(pages_dir / "page_01.png")
+
+ src = "第一章\n福贵在村口。"
+ proj = asyncio.run(
+ creative_comic(src, output_dir=str(tmp_path), chat=FakeChat(), image=FakeImage())
+ )
+
+ exported = [Path(p).name for p in proj.pages]
+ assert exported
+ assert "page_01.png" not in exported
+ assert all(name.startswith("page_c") and "_p" in name for name in exported)
+ assert not (pages_dir / "page_01.png").exists()
+
+
+class CollidingPageIdChat(FakeChat):
+ """Both chunks plan the same page_id — state keys must be chunk-namespaced."""
+
+ async def chat_function_call(self, messages, tools, tool_choice, **kw):
+ self.calls += 1
+ name = tool_choice["function"]["name"]
+ if name == "extract_story_elements":
+ return {
+ "characters": [
+ {
+ "name": "福贵",
+ "l1_prompt": "a middle-aged farmer",
+ "portrait_prompt": "portrait of a farmer",
+ }
+ ],
+ "settings": [{"name": "村口", "scene_prompt": "village entrance at dusk"}],
+ "style_guide": "manhua",
+ }
+ if name == "plan_comic_pages":
+ self.page_plan_calls += 1
+ return {
+ "unit_id": str(self.page_plan_calls),
+ "pages": [
+ {
+ "page_id": "p0001",
+ "purpose": "establish the village entrance",
+ "layout_intent": "wide establishing top, inset reaction bottom-right",
+ "panels": [
+ {
+ "panel_id": "1",
+ "role": "establishing",
+ "shape_hint": "wide",
+ "shot": "wide",
+ "action": "福贵 walks through the village entrance",
+ "characters": ["福贵"],
+ "setting_ref": "村口",
+ "caption": "傍晚,村口。",
+ }
+ ],
+ "reference_characters": ["福贵"],
+ "setting_refs": ["村口"],
+ }
+ ],
+ }
+ return {}
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_cross_chunk_page_id_collision(tmp_path, monkeypatch):
+ """Two chunks with the same page_id must both generate distinct pages."""
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ src = "第一章\n福贵在村口。\n第二章\n福贵在读书。"
+ img = FakeImage()
+ proj = asyncio.run(
+ creative_comic(src, output_dir=str(tmp_path), chat=CollidingPageIdChat(), image=img)
+ )
+
+ page_files = sorted((tmp_path / "pages").glob("page_c*_p*.png"))
+ assert len(page_files) == 2
+ assert img.calls == 3 # 1 portrait + 2 pages (same page_id, different chunks)
+
+ assert set(proj.state.generated.pages) == {"c0000:p0001", "c0001:p0001"}
+ assert set(proj.state.pages_done) == {"c0000:p0001", "c0001:p0001"}
+ assert len(proj.pages) == 2
+ assert proj.pdf and Path(proj.pdf).exists()
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_webtoon_stacks_page_images(tmp_path, monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ src = "第一章\n福贵在村口。\n第二章\n福贵在读书。"
+ proj = asyncio.run(
+ creative_comic(
+ src,
+ output_dir=str(tmp_path),
+ chat=FakeChat(),
+ image=FakeImage(),
+ output_format="webtoon",
+ )
+ )
+
+ assert proj.pdf is None
+ assert proj.webtoon and Path(proj.webtoon).exists()
+ assert proj.webtoon.endswith("webtoon.png")
+ assert proj.pages == [proj.webtoon]
+ assert len(list((tmp_path / "pages").glob("page_c*_p*.png"))) == 2
diff --git a/tests/test_page_prompt.py b/tests/test_page_prompt.py
new file mode 100644
index 0000000..6ad9d16
--- /dev/null
+++ b/tests/test_page_prompt.py
@@ -0,0 +1,39 @@
+from core.comic.page_prompt import render_finished_page_prompt
+from core.schemas import CharacterAsset, ComicPagePlan
+
+
+def test_prompt_includes_layout_lettering_and_identity():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p0001",
+ "purpose": "Hook: map moves",
+ "layout_intent": "Wide top archive; diagonal window attack; reaction close-up",
+ "panels": [
+ {
+ "panel_id": "1",
+ "role": "establishing",
+ "shape_hint": "wide",
+ "action": "Mira leans over glowing map",
+ "characters": ["Mira"],
+ "caption": "MIDNIGHT AT THE SKY ARCHIVE",
+ "dialogue": "This map is moving.",
+ "sfx": None,
+ "lettering_notes": "caption top-left; bubble near Mira, not on face",
+ }
+ ],
+ "reference_characters": ["Mira"],
+ }
+ )
+ chars = {"Mira": CharacterAsset(name="Mira", l1_prompt="young woman, dark hair, blue pendant")}
+ text = render_finished_page_prompt(
+ plan,
+ characters_by_name=chars,
+ settings_by_name={},
+ style_guide="manhua comic style",
+ )
+ assert "A4 portrait" in text or "portrait comic page" in text.lower()
+ assert "MIDNIGHT AT THE SKY ARCHIVE" in text
+ assert "This map is moving." in text
+ assert "young woman, dark hair" in text
+ assert "2x2" not in text.lower() # renderer must not collapse intent to grid slogan
+ assert "Wide top archive" in text
diff --git a/tests/test_schemas_finished_page.py b/tests/test_schemas_finished_page.py
new file mode 100644
index 0000000..2d28caf
--- /dev/null
+++ b/tests/test_schemas_finished_page.py
@@ -0,0 +1,65 @@
+# tests/test_schemas_finished_page.py
+from core.schemas import (
+ ComicPagePlan,
+ ComicPagePlanSet,
+ GeneratedPage,
+ ProjectState,
+)
+
+
+def test_page_panel_spec_and_plan_round_trip():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p0001",
+ "purpose": "Establish the station and introduce 福贵",
+ "layout_intent": "Wide establishing top; diagonal inset reaction bottom-right",
+ "panels": [
+ {
+ "panel_id": "1",
+ "role": "establishing",
+ "shape_hint": "wide",
+ "shot": "wide",
+ "action": "福贵 walks through dusk streets",
+ "characters": ["福贵"],
+ "setting_ref": "城中街道",
+ "caption": "傍晚,街灯初上。",
+ "dialogue": None,
+ "sfx": "沙沙",
+ }
+ ],
+ "reference_characters": ["福贵"],
+ "setting_refs": ["城中街道"],
+ }
+ )
+ assert plan.page_id == "p0001"
+ assert plan.panels[0].caption == "傍晚,街灯初上。"
+ assert plan.panels[0].dialogue is None
+
+
+def test_comic_page_plan_set_and_generated_page_on_state():
+ pageset = ComicPagePlanSet.model_validate(
+ {
+ "unit_id": "0",
+ "pages": [
+ {"page_id": "p0001", "purpose": "x", "layout_intent": "splash", "panels": []}
+ ],
+ }
+ )
+ state = ProjectState(project_id="demo")
+ state.page_cache["0"] = pageset
+ state.generated.pages["p0001"] = GeneratedPage(
+ local="pages/page_01.png",
+ page_id="p0001",
+ unit_index=0,
+ page_index=0,
+ mode="finished",
+ caption="傍晚,街灯初上。",
+ )
+ state.pages_done.append("p0001")
+ state.render_mode = "finished_page"
+ state.stage = "pages"
+ blob = state.model_dump_json()
+ loaded = ProjectState.model_validate_json(blob)
+ assert loaded.page_cache["0"].pages[0].page_id == "p0001"
+ assert loaded.generated.pages["p0001"].mode == "finished"
+ assert loaded.render_mode == "finished_page"
diff --git a/tests/test_screenwriter_pages.py b/tests/test_screenwriter_pages.py
new file mode 100644
index 0000000..6739a1f
--- /dev/null
+++ b/tests/test_screenwriter_pages.py
@@ -0,0 +1,83 @@
+"""tests/test_screenwriter_pages.py — plan_comic_pages parsing (no net)."""
+
+import asyncio
+
+from core.api import ChatProvider
+from core.schemas import ComicPagePlanSet, StoryElements
+from core.screenwriter import PAGE_PLAN_TOOL, plan_comic_pages
+
+
+class FakeChat(ChatProvider):
+ def __init__(self, payload):
+ self.payload = payload
+
+ async def chat_function_call(self, messages, tools, tool_choice, **kwargs):
+ assert tools[0]["function"]["name"] == "plan_comic_pages"
+ return self.payload
+
+
+def test_plan_comic_pages_validates_tool_payload():
+ payload = {
+ "unit_id": "0",
+ "pages": [
+ {
+ "page_id": "p0001",
+ "purpose": "open on the street",
+ "layout_intent": "tall walking strip left; plaza wide right",
+ "panels": [
+ {
+ "panel_id": "1",
+ "role": "establishing",
+ "action": "walks",
+ "characters": ["福贵"],
+ "caption": "傍晚,街灯初上。",
+ }
+ ],
+ "reference_characters": ["福贵"],
+ }
+ ],
+ }
+ elements = StoryElements(characters=[], settings=[], style_guide="manhua")
+ out = asyncio.run(plan_comic_pages("傍晚……", elements, chat=FakeChat(payload)))
+ assert isinstance(out, ComicPagePlanSet)
+ assert out.unit_id == "0"
+ assert out.pages[0].page_id == "p0001"
+ assert out.pages[0].panels[0].caption == "傍晚,街灯初上。"
+
+
+def test_plan_comic_pages_instructs_finished_layout_not_flat_grid():
+ seen: dict = {}
+
+ class CaptureChat(ChatProvider):
+ async def chat_function_call(self, messages, tools, tool_choice, **kwargs):
+ seen["messages"] = messages
+ return {
+ "unit_id": "0",
+ "pages": [
+ {
+ "page_id": "p0001",
+ "purpose": "street scene",
+ "layout_intent": "splash with inset",
+ "panels": [{"panel_id": "1", "action": "walks", "caption": "傍晚。"}],
+ }
+ ],
+ }
+
+ text = "傍晚,街灯初上。"
+ asyncio.run(plan_comic_pages(text, StoryElements(), chat=CaptureChat()))
+ blob = " ".join(m["content"] for m in seen["messages"])
+ assert "finished" in blob.lower() or "readable pages" in blob.lower()
+ assert "2x2" in blob or "flat" in blob.lower()
+
+
+def test_page_plan_tool_schema_present():
+ assert PAGE_PLAN_TOOL["type"] == "function"
+ assert PAGE_PLAN_TOOL["function"]["name"] == "plan_comic_pages"
+ assert "parameters" in PAGE_PLAN_TOOL["function"]
+
+
+def test_system_prompt_mentions_manga_geometry():
+ from core.screenwriter import SYSTEM_PROMPT
+
+ assert "splash" in SYSTEM_PROMPT.lower() or "diagonal" in SYSTEM_PROMPT.lower()
+ assert "2x2" in SYSTEM_PROMPT
diff --git a/tests/test_web_server.py b/tests/test_web_server.py
index 4ff5bac..b6cbc4c 100644
--- a/tests/test_web_server.py
+++ b/tests/test_web_server.py
@@ -117,6 +117,7 @@ def test_seed_job_progress_from_checkpoint(tmp_path, monkeypatch):
)
state = ProjectState(
project_id=project_id,
+ render_mode="panel_compose",
chunk_cache={"0": ChunkCache(storyboard=board)},
panels_done=[f"c0000-p{i:04d}" for i in range(5)],
)
@@ -126,6 +127,34 @@ def test_seed_job_progress_from_checkpoint(tmp_path, monkeypatch):
assert progress > 0.3
+def test_start_job_seeds_resume_fields_from_checkpoint(tmp_path, monkeypatch):
+ monkeypatch.setattr(server, "OUTPUT_DIR", tmp_path)
+ monkeypatch.setattr(
+ server.threading,
+ "Thread",
+ lambda *args, **kwargs: type("NoopThread", (), {"start": lambda self: None})(),
+ )
+ project_id = "resume2"
+ out = tmp_path / project_id
+ out.mkdir()
+ ProjectState(
+ project_id=project_id,
+ render_mode="panel_compose",
+ pages_done=["p0001", "p0002"],
+ skipped_pages=["p0003"],
+ ).save(out / "state.json")
+ job_id, _ = server._start_job("text", "page", None, project_id=project_id)
+ try:
+ job = server.JOBS[job_id]
+ assert job["render_mode"] == "panel_compose"
+ assert job["pages_done"] == ["p0001", "p0002"]
+ assert job["skipped_pages"] == ["p0003"]
+ assert job["stage"] == "resume"
+ finally:
+ with server.JOBS_LOCK:
+ server.JOBS.pop(job_id, None)
+
+
def test_seed_job_timing_from_checkpoint(tmp_path, monkeypatch):
import web.server as server
from core.schemas import ProjectState
@@ -223,6 +252,9 @@ def _base_job(**overrides) -> dict:
"skipped_chunks": [],
"needs_review": [],
"stale_panels": [],
+ "render_mode": "finished_page",
+ "pages_done": [],
+ "skipped_pages": [],
"pause_reason": None,
"elapsed_seconds": 1,
"remaining_seconds": None,
@@ -479,11 +511,15 @@ def test_list_projects_from_disk(tmp_path, monkeypatch):
]:
out = tmp_path / project_id
out.mkdir()
- ProjectState(
- project_id=project_id,
- stage=stage,
- panels_done=panels,
- ).save(out / "state.json")
+ kwargs = {
+ "project_id": project_id,
+ "stage": stage,
+ "panels_done": panels,
+ }
+ if project_id == "alpha":
+ kwargs["pages_done"] = ["p0001"]
+ kwargs["skipped_pages"] = ["p0002"]
+ ProjectState(**kwargs).save(out / "state.json")
(tmp_path / "alpha" / "timing.json").write_text(
'{"active_elapsed_seconds": 10}', encoding="utf-8"
)
@@ -494,6 +530,9 @@ def test_list_projects_from_disk(tmp_path, monkeypatch):
by_id = {p["id"]: p for p in projects}
assert by_id["alpha"]["stage"] == "panels"
assert by_id["alpha"]["panels_done"] == ["c0000-p0000"]
+ assert by_id["alpha"]["render_mode"] == "finished_page"
+ assert by_id["alpha"]["pages_done"] == ["p0001"]
+ assert by_id["alpha"]["skipped_pages"] == ["p0002"]
assert by_id["alpha"]["has_timing"] is True
assert by_id["beta"]["has_timing"] is False
@@ -502,9 +541,13 @@ def test_get_projects_endpoint(tmp_path, monkeypatch):
monkeypatch.setattr(server, "OUTPUT_DIR", tmp_path)
out = tmp_path / "webproj"
out.mkdir()
- ProjectState(project_id="webproj", stage="export", panels_done=["c0000-p0001"]).save(
- out / "state.json"
- )
+ ProjectState(
+ project_id="webproj",
+ stage="export",
+ render_mode="finished_page",
+ panels_done=["c0000-p0001"],
+ pages_done=["p0001"],
+ ).save(out / "state.json")
with _running_server() as httpd:
status, payload = _request(httpd.server_port, "/api/projects", "GET")
assert status == 200
@@ -513,6 +556,9 @@ def test_get_projects_endpoint(tmp_path, monkeypatch):
"id": "webproj",
"stage": "export",
"panels_done": ["c0000-p0001"],
+ "render_mode": "finished_page",
+ "pages_done": ["p0001"],
+ "skipped_pages": [],
"has_timing": False,
}
]
diff --git a/web/server.py b/web/server.py
index 45d9b84..ccb2c15 100644
--- a/web/server.py
+++ b/web/server.py
@@ -207,6 +207,9 @@ def _state_snapshot(state: ProjectState) -> dict:
"skipped_chunks": list(state.skipped_chunks),
"needs_review": [s.model_dump() for s in state.needs_review],
"stale_panels": list(state.stale_panels),
+ "render_mode": state.render_mode,
+ "pages_done": list(state.pages_done),
+ "skipped_pages": list(state.skipped_pages),
}
@@ -245,18 +248,42 @@ def _fill_job_from_paused(job: dict, paused: PausedRun) -> None:
]
-def _seed_job_progress(project_id: str) -> tuple[float, str]:
- """Seed UI progress from an existing checkpoint so resume does not flash 0%."""
+def _load_checkpoint_state(project_id: str) -> ProjectState | None:
+ """Load project state from disk, or None if missing or unreadable."""
state_path = OUTPUT_DIR / project_id / "state.json"
if not state_path.is_file():
- return 0.0, "init"
+ return None
try:
- state = ProjectState.load(state_path)
+ return ProjectState.load(state_path)
except Exception: # noqa: BLE001
+ return None
+
+
+def _seed_job_progress(project_id: str) -> tuple[float, str]:
+ """Seed UI progress from an existing checkpoint so resume does not flash 0%."""
+ state = _load_checkpoint_state(project_id)
+ if state is None:
return 0.0, "init"
return estimate_progress(state), "resume"
+def _seed_job_resume_fields(project_id: str) -> dict:
+ """Seed render_mode/pages_done/skipped_pages from checkpoint when resuming."""
+ state = _load_checkpoint_state(project_id)
+ if state is None:
+ return {
+ "render_mode": "finished_page",
+ "pages_done": [],
+ "skipped_pages": [],
+ }
+ snapshot = _state_snapshot(state)
+ return {
+ "render_mode": snapshot["render_mode"],
+ "pages_done": snapshot["pages_done"],
+ "skipped_pages": snapshot["skipped_pages"],
+ }
+
+
def _timing_path(project_id: str) -> Path:
"""Web-layer-owned timing file for a project (kept out of state.json)."""
return OUTPUT_DIR / project_id / "timing.json"
@@ -400,6 +427,7 @@ def _start_job(
pid = validate_project_id(project_id) if project_id else uuid.uuid4().hex[:12]
job_id = uuid.uuid4().hex[:12]
seeded_progress, seeded_stage = _seed_job_progress(pid)
+ seeded_resume = _seed_job_resume_fields(pid)
base_elapsed = _seed_job_timing(pid)
with JOBS_LOCK:
_purge_expired_jobs_locked()
@@ -413,6 +441,7 @@ def _start_job(
"skipped_chunks": [],
"needs_review": [],
"stale_panels": [],
+ **seeded_resume,
"project_id": pid,
"pause_reason": None,
"base_elapsed": base_elapsed,
@@ -517,6 +546,9 @@ def list_projects() -> list[dict]:
"id": entry.name,
"stage": state.stage,
"panels_done": list(state.panels_done),
+ "render_mode": state.render_mode,
+ "pages_done": list(state.pages_done),
+ "skipped_pages": list(state.skipped_pages),
"has_timing": (entry / "timing.json").is_file(),
}
)
@@ -614,6 +646,9 @@ def do_GET(self) -> None:
"skipped_chunks": job.get("skipped_chunks", []),
"needs_review": job.get("needs_review", []),
"stale_panels": job.get("stale_panels", []),
+ "render_mode": job.get("render_mode", "finished_page"),
+ "pages_done": job.get("pages_done", []),
+ "skipped_pages": job.get("skipped_pages", []),
"project_id": job.get("project_id"),
"pause_reason": job.get("pause_reason"),
"elapsed_seconds": job.get("elapsed_seconds"),