From 5bd83bdd36a9a3edf3cfa466c06fe970a3774325 Mon Sep 17 00:00:00 2001 From: phaethix Date: Thu, 30 Jul 2026 15:14:01 +0800 Subject: [PATCH 01/13] docs: add finished-page comic pipeline design spec Capture the approved whole-page default path (schema-first plans, deterministic prompts, explicit panel fallback) without copying the external studio's agent/file workflow. Co-authored-by: Cursor --- ...026-07-30-finished-page-pipeline-design.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-finished-page-pipeline-design.md 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. From 1d0e12ca429692a4372227b6b2ef942cc3da13a3 Mon Sep 17 00:00:00 2001 From: phaethix Date: Thu, 30 Jul 2026 15:17:31 +0800 Subject: [PATCH 02/13] docs: add finished-page pipeline implementation plan Break the approved design into TDD tasks for schemas, prompt render, page planning, pipeline default path, and docs/web wiring. Co-authored-by: Cursor --- .../2026-07-30-finished-page-pipeline.md | 676 ++++++++++++++++++ 1 file changed, 676 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-finished-page-pipeline.md 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). From a9ba9bb3833be7463141af03dfa7063516280827 Mon Sep 17 00:00:00 2001 From: phaethix Date: Thu, 30 Jul 2026 15:20:29 +0800 Subject: [PATCH 03/13] feat(schemas): add finished-page plan and GeneratedPage state Co-authored-by: Cursor --- core/schemas.py | 190 +++++++++++++++++++++++++++- tests/test_schemas_finished_page.py | 61 +++++++++ 2 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 tests/test_schemas_finished_page.py 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/tests/test_schemas_finished_page.py b/tests/test_schemas_finished_page.py new file mode 100644 index 0000000..64f0ca5 --- /dev/null +++ b/tests/test_schemas_finished_page.py @@ -0,0 +1,61 @@ +# 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" From 4991221b9cec9854c96bb2b205cc2cc9efdbbcd4 Mon Sep 17 00:00:00 2001 From: phaethix Date: Thu, 30 Jul 2026 15:23:10 +0800 Subject: [PATCH 04/13] feat(config): add INKSTONE_RENDER_MODE and page size Co-authored-by: Cursor --- core/config.py | 14 ++++++++++++++ tests/test_config.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 tests/test_config.py 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/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" From 508e0e9e264161a44e3f32915688cb085aac0bc7 Mon Sep 17 00:00:00 2001 From: phaethix Date: Thu, 30 Jul 2026 15:26:10 +0800 Subject: [PATCH 05/13] feat(page_prompt): render finished-page prompts from ComicPagePlan Co-authored-by: Cursor --- core/comic/page_prompt.py | 50 +++++++++++++++++++++++++++++++++++++++ tests/test_page_prompt.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 core/comic/page_prompt.py create mode 100644 tests/test_page_prompt.py diff --git a/core/comic/page_prompt.py b/core/comic/page_prompt.py new file mode 100644 index 0000000..0855c48 --- /dev/null +++ b/core/comic/page_prompt.py @@ -0,0 +1,50 @@ +"""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) diff --git a/tests/test_page_prompt.py b/tests/test_page_prompt.py new file mode 100644 index 0000000..e022690 --- /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, 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 From fa2ba6d55e9f32d812815d242ff6948f2082a174 Mon Sep 17 00:00:00 2001 From: phaethix Date: Thu, 30 Jul 2026 15:30:21 +0800 Subject: [PATCH 06/13] feat(screenwriter): add plan_comic_pages forced tool call Co-authored-by: Cursor --- core/screenwriter.py | 44 ++++++++++++++++- tests/test_screenwriter_pages.py | 83 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 tests/test_screenwriter_pages.py diff --git a/core/screenwriter.py b/core/screenwriter.py index 7d0a35a..06d4397 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,32 @@ 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/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 From 59d5b797c4cf042bc1264cd5a594645113f70e0a Mon Sep 17 00:00:00 2001 From: phaethix Date: Thu, 30 Jul 2026 15:46:27 +0800 Subject: [PATCH 07/13] feat(pipeline): default finished-page generation with resume Add the finished_page render path to creative_comic: after extract + portraits, plan_comic_pages() replaces the storyboard/panel loop, one image is generated per ComicPagePlan and saved to pages/page_cXXXX_pYYYY.png (position-derived, not a running counter, so a stale/missing page always regenerates to the same file instead of risking collision with another page's filename on partial resume). PDF export runs ExportEngine directly over the pages dir (no LayoutEngine 2x2 collage). panel_compose keeps the existing storyboard -> panels -> LayoutEngine path unchanged behind an explicit render_mode= kwarg (falls back to config.render_mode()). - _render_fingerprint now includes render_mode + finished_page_size so switching modes (or page size) soft-invalidates rendered assets, now also clearing pages_done/stale_pages/generated.pages. No _PIPELINE_STATE_VERSION bump: that guards the structure fingerprint (extract/storyboard cache), which this change does not affect. - estimate_progress/_progress_label prefer page-based counts when state.render_mode == "finished_page"; content-policy rejections land in skipped_pages (no L3 on this path, per design). - tests/conftest.py opts the whole suite into panel_compose by default (core.config.render_mode() now defaults to finished_page), since most existing FakeChat/FakeImage fixtures assume the storyboard/panel contract; tests/test_finished_page_pipeline.py overrides this locally. Co-authored-by: Cursor --- core/pipelines/creative_comic.py | 349 ++++++++++++++++++++++++--- tests/conftest.py | 19 ++ tests/test_estimate_progress.py | 3 + tests/test_finished_page_pipeline.py | 208 ++++++++++++++++ tests/test_web_server.py | 1 + 5 files changed, 545 insertions(+), 35 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_finished_page_pipeline.py diff --git a/core/pipelines/creative_comic.py b/core/pipelines/creative_comic.py index 4a32180..bee9b9e 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,18 @@ def _stored_panel_key(state: ProjectState, chunk_index: int, panel_index: int) - return _panel_state_key(chunk_index, panel_index) +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 ``ExportEngine``'s + ``sorted(glob("page_*.png"))`` in correct reading order. + """ + return pages_dir / f"page_c{chunk_index:04d}_p{page_index:04d}.png" + + 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 +316,70 @@ 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, page_id: str) -> bool: + """True when a finished page must be (re)generated this run.""" + if page_id in state.stale_pages: + return True + if page_id in state.pages_done or page_id in state.skipped_pages: + return False + return True + + +def _mark_page_done(state: ProjectState, page_id: str) -> None: + if page_id in state.stale_pages: + state.stale_pages = [k for k in state.stale_pages if k != page_id] + if page_id not in state.pages_done: + state.pages_done.append(page_id) + + +def _page_chunk_complete(state: ProjectState, pageset: ComicPagePlanSet, output_dir: Path) -> bool: + """True when every planned page is generated or policy-skipped.""" + for plan in pageset.pages: + page_id = plan.page_id + if page_id in state.stale_pages: + return False + if page_id in state.skipped_pages: + continue + rec = state.generated.pages.get(page_id) + if ( + rec is None + or page_id 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 +) -> None: + """Record chunks_done only after every planned page is done or skipped.""" + for plan in pageset.pages: + page_id = plan.page_id + if page_id in state.stale_pages: + return + if page_id not in state.pages_done and page_id 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 +390,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 +415,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 +450,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 +545,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 +601,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 +616,7 @@ async def creative_comic( progress_callback=progress_callback, panel_keys=panel_keys, cancel_check=cancel_check, + render_mode=render_mode, ) @@ -489,6 +636,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 +652,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 +669,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 +678,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 +713,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 +754,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 +782,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) + 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 +899,100 @@ 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 + if not _page_needs_generation(state, page_id): + 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()] + try: + async with image_semaphore: + with perf.measure("page"): + out = await image.generate_single_image( + prompt, reference_image_paths=refs, size=page_size + ) + except Exception as exc: # noqa: BLE001 — preserve policy skip behavior + if not is_content_policy_rejection(exc): + raise + logger.warning("page %s skipped: content filter rejected it (%s)", page_id, exc) + if page_id not in state.skipped_pages: + state.skipped_pages.append(page_id) + if page_id in state.stale_pages: + state.stale_pages = [k for k in state.stale_pages if k != page_id] + state.save(state_path) + 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[page_id] = GeneratedPage( + local=str(local), + page_id=page_id, + unit_index=ci, + page_index=page_index, + mode="finished", + ) + _mark_page_done(state, page_id) + state.save(state_path) + _report("pages", _pct()) + + _mark_page_chunk_done_if_complete(state, key, pageset) + state.save(state_path) + _report("pages", _pct()) + continue + # ---- storyboard (only when not cached) ---- if board is None: state.stage = "storyboard" @@ -925,41 +1191,54 @@ 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 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") + if mode == "finished_page": + # No LayoutEngine collage: each page image was already saved directly + # by the loop above, in reading order (page_XX.png), so export can run + # straight over the pages directory. state.stage = "export" - _report("export", 0.95) - if output_format == "webtoon": - webtoon = pages[0] if pages else None - else: + _report("export", max(0.90, _pct())) + page_files = sorted(pages_dir.glob("page_*.png")) if pages_dir.exists() else [] + if page_files: 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, + ) + ) + + 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/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_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..1dac76a --- /dev/null +++ b/tests/test_finished_page_pipeline.py @@ -0,0 +1,208 @@ +"""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 "u1_p0001" in proj.state.generated.pages + assert "u1_p0001" in proj.state.pages_done + generated_page = proj.state.generated.pages["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 "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["u1_p0001"] + second_page = state.generated.pages["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) == {"u1_p0001", "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 "u1_p0001" in proj.state.skipped_pages + assert "u1_p0001" not in proj.state.pages_done + assert "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 diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 4ff5bac..815fa38 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)], ) From c0b9adc242a3df6bf5861ac698668e00424433ed Mon Sep 17 00:00:00 2001 From: phaethix Date: Thu, 30 Jul 2026 21:31:01 +0800 Subject: [PATCH 08/13] docs(readme): beautify README with project-native visual system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add dark ink-stone hero SVG with comic-panel motif - Add numbered section header SVGs (Proof → What → Why → How → Use) - Add pipeline flow diagram SVG - Restructure content sequence: Proof before claims - Unify color palette derived from logo (#1B212C ink / #C0412B seal red) - Replace table of contents with visual section rhythm Co-Authored-By: Claude --- README.md | 121 ++++++++++++------------- assets/readme/hero.svg | 127 +++++++++++++++++++++++++++ assets/readme/pipeline.svg | 56 ++++++++++++ assets/readme/section-features.svg | 18 ++++ assets/readme/section-gallery.svg | 18 ++++ assets/readme/section-how.svg | 18 ++++ assets/readme/section-quickstart.svg | 18 ++++ assets/readme/section-why.svg | 18 ++++ 8 files changed, 335 insertions(+), 59 deletions(-) create mode 100644 assets/readme/hero.svg create mode 100644 assets/readme/pipeline.svg create mode 100644 assets/readme/section-features.svg create mode 100644 assets/readme/section-gallery.svg create mode 100644 assets/readme/section-how.svg create mode 100644 assets/readme/section-quickstart.svg create mode 100644 assets/readme/section-why.svg diff --git a/README.md b/README.md index 52b6044..2ffcaf3 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,29 @@ 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 +

+ 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` → 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: + +| 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 +100,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 +115,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 +135,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 +146,25 @@ 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_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 + + + From b5e9d04e3b910c1ab22e02b36eb1743b5b7fc151 Mon Sep 17 00:00:00 2001 From: phaethix Date: Fri, 31 Jul 2026 00:11:53 +0800 Subject: [PATCH 09/13] fix(pipeline): exclude panel-compose pages from finished-page export When switching render modes, stale page_01.png files from LayoutEngine could pollute finished-page PDF export. Scope export to page_c*_p*.png and remove leftover panel-compose page sheets before calling ExportEngine. Co-authored-by: Cursor --- core/pipelines/creative_comic.py | 20 +++++++++++++++++--- tests/test_finished_page_pipeline.py | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/core/pipelines/creative_comic.py b/core/pipelines/creative_comic.py index bee9b9e..02391d3 100644 --- a/core/pipelines/creative_comic.py +++ b/core/pipelines/creative_comic.py @@ -213,12 +213,26 @@ def _page_asset_path(pages_dir: Path, chunk_index: int, page_index: int) -> Path 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 ``ExportEngine``'s - ``sorted(glob("page_*.png"))`` in correct reading order. + 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() @@ -1201,7 +1215,7 @@ async def _render_panel( # straight over the pages directory. state.stage = "export" _report("export", max(0.90, _pct())) - page_files = sorted(pages_dir.glob("page_*.png")) if pages_dir.exists() else [] + page_files = _prepare_finished_page_export(pages_dir) if pages_dir.exists() else [] if page_files: with perf.measure("export"): pdf = ExportEngine().export_pdf(pages_dir, out=str(output_dir / "comic.pdf")) diff --git a/tests/test_finished_page_pipeline.py b/tests/test_finished_page_pipeline.py index 1dac76a..35f3a20 100644 --- a/tests/test_finished_page_pipeline.py +++ b/tests/test_finished_page_pipeline.py @@ -206,3 +206,23 @@ def test_finished_page_content_policy_rejection_is_skipped_not_raised(tmp_path, def test_is_content_policy_rejection_still_used_by_finished_page_path(): assert is_content_policy_rejection(RuntimeError("content_policy_violation")) is True + + +@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() From 1ef1512d80214aa515cc31af2a56c6f628800b6a Mon Sep 17 00:00:00 2001 From: phaethix Date: Fri, 31 Jul 2026 00:15:34 +0800 Subject: [PATCH 10/13] feat: finished-page retry, web progress fields, docs honesty Co-authored-by: Cursor --- README.md | 3 ++ core/comic/page_prompt.py | 6 ++++ core/pipelines/creative_comic.py | 49 ++++++++++++++++++++-------- docs/ROADMAP.md | 4 +-- tests/test_finished_page_pipeline.py | 35 ++++++++++++++++++++ tests/test_web_server.py | 33 ++++++++++++++----- web/server.py | 12 +++++++ 7 files changed, 119 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 2ffcaf3..cfa2aa2 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ Inkstone is an **independent implementation, not a fork** — inspired by [`lcy3 > **An honest trade-off.** Free, cloud-only Agnes with no GPU caps how far character consistency can reach. The strongest approaches (IP-Adapter / InsightFace) need a local GPU running SDXL/Flux — incompatible with Inkstone's zero-cost premise. So Inkstone trades *perfect* consistency for *zero-cost + no-GPU + out-of-the-box*, using L1+L2+L3 as the best feasible strategy. Stated plainly, not hidden. +**Finished-page mode (default)** generates one designed comic page per image call (dynamic panels + in-image lettering). Free-tier models have ceilings on legible text and identity lock — Inkstone optimizes for page-shaped comics, not commercial print parity. If finished pages fail persistently, set `INKSTONE_RENDER_MODE=panel_compose` and re-run; the legacy panel + layout path reuses cached plans where possible. +

How it works

@@ -155,6 +157,7 @@ Inkstone is configured through environment variables (copy `.env.example` → `. | `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). | +| `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. | diff --git a/core/comic/page_prompt.py b/core/comic/page_prompt.py index 0855c48..e9f3a77 100644 --- a/core/comic/page_prompt.py +++ b/core/comic/page_prompt.py @@ -12,6 +12,7 @@ def render_finished_page_prompt( 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,", @@ -20,6 +21,11 @@ def render_finished_page_prompt( "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}") diff --git a/core/pipelines/creative_comic.py b/core/pipelines/creative_comic.py index 02391d3..6857b96 100644 --- a/core/pipelines/creative_comic.py +++ b/core/pipelines/creative_comic.py @@ -972,21 +972,44 @@ async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[s 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()] - try: - async with image_semaphore: - with perf.measure("page"): - out = await image.generate_single_image( - prompt, reference_image_paths=refs, size=page_size + 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 ) - except Exception as exc: # noqa: BLE001 — preserve policy skip behavior - if not is_content_policy_rejection(exc): + if page_id not in state.skipped_pages: + state.skipped_pages.append(page_id) + if page_id in state.stale_pages: + state.stale_pages = [k for k in state.stale_pages if k != page_id] + 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 - logger.warning("page %s skipped: content filter rejected it (%s)", page_id, exc) - if page_id not in state.skipped_pages: - state.skipped_pages.append(page_id) - if page_id in state.stale_pages: - state.stale_pages = [k for k in state.stale_pages if k != page_id] - state.save(state_path) + if out is None: continue pages_dir.mkdir(parents=True, exist_ok=True) local = _page_asset_path(pages_dir, ci, page_index) 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/tests/test_finished_page_pipeline.py b/tests/test_finished_page_pipeline.py index 35f3a20..dba1707 100644 --- a/tests/test_finished_page_pipeline.py +++ b/tests/test_finished_page_pipeline.py @@ -208,6 +208,41 @@ 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 "u1_p0001" in proj.state.pages_done + assert "u1_p0001" not in proj.state.skipped_pages + assert Path(proj.state.generated.pages["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.""" diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 815fa38..290a8ad 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -224,6 +224,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, @@ -480,11 +483,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" ) @@ -495,6 +502,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 @@ -503,9 +513,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 @@ -514,6 +528,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..9dd6133 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), } @@ -413,6 +416,9 @@ def _start_job( "skipped_chunks": [], "needs_review": [], "stale_panels": [], + "render_mode": "finished_page", + "pages_done": [], + "skipped_pages": [], "project_id": pid, "pause_reason": None, "base_elapsed": base_elapsed, @@ -517,6 +523,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 +623,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"), From 02262881a99d94967d3cfa81a8f184d455ebc506 Mon Sep 17 00:00:00 2001 From: phaethix Date: Fri, 31 Jul 2026 00:18:19 +0800 Subject: [PATCH 11/13] fix(web): seed resume fields from state.json on job start _start_job now loads render_mode, pages_done, and skipped_pages from ProjectState when resuming, matching progress seeding behavior. Co-authored-by: Cursor --- tests/test_web_server.py | 28 ++++++++++++++++++++++++++++ web/server.py | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 290a8ad..b6cbc4c 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -127,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 diff --git a/web/server.py b/web/server.py index 9dd6133..ccb2c15 100644 --- a/web/server.py +++ b/web/server.py @@ -248,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" @@ -403,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() @@ -416,9 +441,7 @@ def _start_job( "skipped_chunks": [], "needs_review": [], "stale_panels": [], - "render_mode": "finished_page", - "pages_done": [], - "skipped_pages": [], + **seeded_resume, "project_id": pid, "pause_reason": None, "base_elapsed": base_elapsed, From e61c7de7db96d6710021d5bfc01acc6a3c12b763 Mon Sep 17 00:00:00 2001 From: phaethix Date: Fri, 31 Jul 2026 00:20:13 +0800 Subject: [PATCH 12/13] style: ruff fix unused imports and format finished-page tests Co-authored-by: Cursor --- core/screenwriter.py | 4 +--- tests/test_page_prompt.py | 2 +- tests/test_schemas_finished_page.py | 8 ++++++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/core/screenwriter.py b/core/screenwriter.py index 06d4397..9af5085 100644 --- a/core/screenwriter.py +++ b/core/screenwriter.py @@ -139,9 +139,7 @@ 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: +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 = [ diff --git a/tests/test_page_prompt.py b/tests/test_page_prompt.py index e022690..6ad9d16 100644 --- a/tests/test_page_prompt.py +++ b/tests/test_page_prompt.py @@ -1,5 +1,5 @@ from core.comic.page_prompt import render_finished_page_prompt -from core.schemas import CharacterAsset, ComicPagePlan, Setting +from core.schemas import CharacterAsset, ComicPagePlan def test_prompt_includes_layout_lettering_and_identity(): diff --git a/tests/test_schemas_finished_page.py b/tests/test_schemas_finished_page.py index 64f0ca5..2d28caf 100644 --- a/tests/test_schemas_finished_page.py +++ b/tests/test_schemas_finished_page.py @@ -3,7 +3,6 @@ ComicPagePlan, ComicPagePlanSet, GeneratedPage, - PagePanelSpec, ProjectState, ) @@ -39,7 +38,12 @@ def test_page_panel_spec_and_plan_round_trip(): 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": []}]} + { + "unit_id": "0", + "pages": [ + {"page_id": "p0001", "purpose": "x", "layout_intent": "splash", "panels": []} + ], + } ) state = ProjectState(project_id="demo") state.page_cache["0"] = pageset From 09d953ffa1ba67f16f10f7ec09b75b0599fbefe2 Mon Sep 17 00:00:00 2001 From: phaethix Date: Fri, 31 Jul 2026 00:26:11 +0800 Subject: [PATCH 13/13] fix: namespace finished-page state keys and webtoon export Cross-chunk page_id collisions no longer skip generation; finished_page webtoon mode stacks page PNGs; README pipeline blurb updated. Co-authored-by: Cursor --- README.md | 2 +- core/pipelines/creative_comic.py | 89 +++++++++++++-------- tests/test_finished_page_pipeline.py | 114 ++++++++++++++++++++++++--- 3 files changed, 158 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index cfa2aa2..828335d 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Inkstone is an **independent implementation, not a fork** — inspired by [`lcy3 Pipeline: Split → Extract → Board → Paint → Export

-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. +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: diff --git a/core/pipelines/creative_comic.py b/core/pipelines/creative_comic.py index 6857b96..a32a94b 100644 --- a/core/pipelines/creative_comic.py +++ b/core/pipelines/creative_comic.py @@ -207,6 +207,11 @@ 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. @@ -345,34 +350,39 @@ def _page_reference_names(plan: ComicPagePlan) -> list[str]: return names -def _page_needs_generation(state: ProjectState, page_id: str) -> bool: +def _page_needs_generation(state: ProjectState, state_key: str) -> bool: """True when a finished page must be (re)generated this run.""" - if page_id in state.stale_pages: + if state_key in state.stale_pages: return True - if page_id in state.pages_done or page_id in state.skipped_pages: + if state_key in state.pages_done or state_key in state.skipped_pages: return False return True -def _mark_page_done(state: ProjectState, page_id: str) -> None: - if page_id in state.stale_pages: - state.stale_pages = [k for k in state.stale_pages if k != page_id] - if page_id not in state.pages_done: - state.pages_done.append(page_id) +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) -> bool: +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: - page_id = plan.page_id - if page_id in state.stale_pages: + state_key = _page_state_key(chunk_index, plan.page_id) + if state_key in state.stale_pages: return False - if page_id in state.skipped_pages: + if state_key in state.skipped_pages: continue - rec = state.generated.pages.get(page_id) + rec = state.generated.pages.get(state_key) if ( rec is None - or page_id not in state.pages_done + or state_key not in state.pages_done or not _is_within(rec.local, output_dir) or not Path(rec.local).exists() ): @@ -381,14 +391,17 @@ def _page_chunk_complete(state: ProjectState, pageset: ComicPagePlanSet, output_ def _mark_page_chunk_done_if_complete( - state: ProjectState, key: str, pageset: ComicPagePlanSet + 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: - page_id = plan.page_id - if page_id in state.stale_pages: + state_key = _page_state_key(chunk_index, plan.page_id) + if state_key in state.stale_pages: return - if page_id not in state.pages_done and page_id not in state.skipped_pages: + 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) @@ -805,7 +818,7 @@ def _pct() -> float: if ( pageset is not None and key in set(state.chunks_done) - and _page_chunk_complete(state, pageset, output_dir) + and _page_chunk_complete(state, pageset, output_dir, ci) and panel_key_filter is None ): _report("resume", _pct()) @@ -957,7 +970,8 @@ async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[s check_cancel(cancel_check) for page_index, plan in enumerate(pageset.pages): page_id = plan.page_id - if not _page_needs_generation(state, 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( @@ -986,10 +1000,10 @@ async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[s logger.warning( "page %s skipped: content filter rejected it (%s)", page_id, exc ) - if page_id not in state.skipped_pages: - state.skipped_pages.append(page_id) - if page_id in state.stale_pages: - state.stale_pages = [k for k in state.stale_pages if k != page_id] + 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 @@ -1014,18 +1028,18 @@ async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[s 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[page_id] = GeneratedPage( + 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, page_id) + _mark_page_done(state, state_key) state.save(state_path) _report("pages", _pct()) - _mark_page_chunk_done_if_complete(state, key, pageset) + _mark_page_chunk_done_if_complete(state, key, pageset, ci) state.save(state_path) _report("pages", _pct()) continue @@ -1233,16 +1247,25 @@ async def _render_panel( pages: list[str] = [] if mode == "finished_page": - # No LayoutEngine collage: each page image was already saved directly - # by the loop above, in reading order (page_XX.png), so export can run - # straight over the pages directory. + # 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: - with perf.measure("export"): - pdf = ExportEngine().export_pdf(pages_dir, out=str(output_dir / "comic.pdf")) - pages = [str(p) for p in page_files] + if output_format == "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: + 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())) diff --git a/tests/test_finished_page_pipeline.py b/tests/test_finished_page_pipeline.py index dba1707..36cfce6 100644 --- a/tests/test_finished_page_pipeline.py +++ b/tests/test_finished_page_pipeline.py @@ -97,9 +97,9 @@ def test_finished_page_mode_writes_generated_pages(tmp_path, monkeypatch): assert page_files assert proj.state.render_mode == "finished_page" - assert "u1_p0001" in proj.state.generated.pages - assert "u1_p0001" in proj.state.pages_done - generated_page = proj.state.generated.pages["u1_p0001"] + 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() @@ -149,7 +149,7 @@ def test_finished_page_resumes_after_deleted_page(tmp_path, monkeypatch): 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 "u1_p0001" in proj.state.pages_done + assert "c0000:u1_p0001" in proj.state.pages_done @patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf) @@ -165,8 +165,8 @@ def test_finished_page_filenames_are_position_stable_across_partial_resume(tmp_p state = ProjectState.load(tmp_path / "state.json") assert len(state.generated.pages) == 2 - first_page = state.generated.pages["u1_p0001"] - second_page = state.generated.pages["u2_p0001"] + 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() @@ -179,7 +179,7 @@ def test_finished_page_filenames_are_position_stable_across_partial_resume(tmp_p 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) == {"u1_p0001", "u2_p0001"} + assert set(proj.state.pages_done) == {"c0000:u1_p0001", "c0001:u2_p0001"} class RejectingPageImage(FakeImage): @@ -199,9 +199,9 @@ def test_finished_page_content_policy_rejection_is_skipped_not_raised(tmp_path, proj = asyncio.run( creative_comic(src, output_dir=str(tmp_path), chat=FakeChat(), image=RejectingPageImage()) ) - assert "u1_p0001" in proj.state.skipped_pages - assert "u1_p0001" not in proj.state.pages_done - assert "u1_p0001" not in proj.state.generated.pages + 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(): @@ -238,9 +238,9 @@ def test_finished_page_generic_failure_retries_once_with_stricter_prompt(tmp_pat assert "STRICT" in img.prompts[1] assert "STRICT" not in img.prompts[0] - assert "u1_p0001" in proj.state.pages_done - assert "u1_p0001" not in proj.state.skipped_pages - assert Path(proj.state.generated.pages["u1_p0001"].local).exists() + 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) @@ -261,3 +261,91 @@ def test_finished_page_export_ignores_stale_panel_compose_pages(tmp_path, monkey 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