diff --git a/README.md b/README.md index 52b6044..828335d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,5 @@

- Inkstone -

- -

Inkstone

- -

- 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. + Inkstone — novel to series comic generator

@@ -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) +

+ Gallery — see what Inkstone makes +

---- +

+ Generated comic panel 1 + Generated comic panel 4 + Generated comic panel 7 +

-## 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: + +

+ phaethix.github.io/inkstone +

+ +

+ What Inkstone does +

- **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 +

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

+ How it works +

+ +

+ Pipeline: Split → Extract → Board → Paint → Export +

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

+ Quick Start +

> **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 @@ + + Inkstone — novel to series comic generator + Inkstone reads a txt novel and produces comic pages with cross-panel character consistency using the free Agnes multimodal API. No GPU, no paid key. + + + + + + + + + + + + + + + + + + NOVEL → SERIES COMIC + + + + + + + + Inkstone + + + + + Grind your novel into comics. + + + + + Free Agnes multimodal API. No GPU, no paid key, no paid plan. + + + + + + AGNES-NATIVE + + + ZERO-COST + + + LOCAL-FIRST + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Inkstone pipeline — novel to comic + Five-stage pipeline: split the novel, extract characters and scenes, generate storyboard prompts, paint panels with the image provider, and lay out then export to PDF or PNG. + + + + + + + + + + + + FROM NOVEL TO COMIC PAGES + + + + + + + + + 01 + SPLIT + + + + + + 02 + EXTRACT + + + + + + 03 + BOARD + + + + + + 04 + PAINT + + + + + + 05 + EXPORT + + + 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 @@ + + Features + Section one: what Inkstone can do. + + + + + + + + + 02 · WHAT IT DOES + + What Inkstone can do + 02 + + + 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 @@ + + Gallery — real comic output + Section one: see what Inkstone produces before reading how it works. + + + + + + + + + 01 · PROOF + + See what it makes + 01 + + + 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 @@ + + How it works + Section four: the pipeline from novel to comic pages. + + + + + + + + + 04 · HOW IT WORKS + + Pipeline from novel to comic + 04 + + + 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 @@ + + Quick Start + Section three: get Inkstone running in three steps. + + + + + + + + + 05 · QUICK START + + Get running in three steps + 05 + + + 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 @@ + + Why Inkstone + Section two: the honest trade-off that sets Inkstone apart. + + + + + + + + + 03 · WHY INKSTONE + + The honest trade-off + 03 + + + 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"),