aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md17
-rw-r--r--docs/superpowers/specs/2026-10-04-fanfictioner-design.md12
-rwxr-xr-xfanfictioner84
3 files changed, 102 insertions, 11 deletions
diff --git a/README.md b/README.md
index dd83a60..4ce0704 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,23 @@ image stage. Pages land in the book-reader library as `NNN.png`.
## Usage
+A story file is free prose. An optional frontmatter at the top sets the names
+instead of letting Gemma invent them; every key is optional:
+
+ ---
+ series: A new life
+ book: Life as Daniel
+ chapter: 1
+ chapter_title: Intro
+ title: Meet Daniel
+ ---
+
+`series` and `book` name the library folders, `title` is the cover lettering,
+`chapter_title` heads plan.md. With `chapter`, pages are saved as
+`01-001.png`, `01-002.png`, so every chapter of a book (one story file each)
+reads as one continuous book in book-reader. These values always override what
+Gemma writes in plan.md.
+
fanfictioner story.md [--base zimage|krea2]
fanfictioner --selftest
diff --git a/docs/superpowers/specs/2026-10-04-fanfictioner-design.md b/docs/superpowers/specs/2026-10-04-fanfictioner-design.md
index 9a34ef6..d762243 100644
--- a/docs/superpowers/specs/2026-10-04-fanfictioner-design.md
+++ b/docs/superpowers/specs/2026-10-04-fanfictioner-design.md
@@ -49,10 +49,18 @@ Work dir: a directory named after the story file, next to it
- `refs/<Name>.png`, chosen turnaround sheet per character
- `cand/<stage>/…`, candidates, montages and sd-cli logs
-Output: `$LIBRARY/<Series>/<Book>/NNN.png` (zero-padded page number), where
-`LIBRARY` is read from `~/.config/book-reader.conf` (shell `KEY="value"` lines).
+Output: `$LIBRARY/<Series>/<Book>/NNN.png` (zero-padded page number), or
+`CC-NNN.png` when the story sets a chapter number, so all chapters of a book
+share its folder and read in order. `LIBRARY` is
+read from `~/.config/book-reader.conf` (shell `KEY="value"` lines).
Series and Book come from plan.md.
+story.md may start with a `---` frontmatter (`series`, `book`, `chapter`,
+`chapter_title`, `title`, all optional). The script writes these into plan.md
+after every draft and revision and whenever story.md changes, overriding
+Gemma: Series/Book/Chapter header lines, the `# ` heading, and page 1's
+`Title:`. plan.json is rebuilt when plan.md or story.md is newer than it.
+
Resume is derived from disk, no state file: a character with `refs/<Name>.png`
is skipped; a page whose `NNN.png` exists in the library is skipped.
diff --git a/fanfictioner b/fanfictioner
index fc0ad3d..267b9dc 100755
--- a/fanfictioner
+++ b/fanfictioner
@@ -111,7 +111,45 @@ def load_conf(path=CONF):
def page_path(lib, pj, n):
- return Path(lib) / pj["series"] / pj["book"] / f"{n:03d}.png"
+ """Chapters share one book folder: 01-001.png, 01-002.png, 02-001.png sort in reading order."""
+ name = f"{pj['chapter']:02d}-{n:03d}.png" if pj.get("chapter") else f"{n:03d}.png"
+ return Path(lib) / pj["series"] / pj["book"] / name
+
+
+def parse_front(text):
+ """story.md frontmatter between --- lines -> ({key: value}, story body)."""
+ m = re.match(r"---\n(.*?)\n---\n?(.*)", text, re.S)
+ if not m:
+ return {}, text
+ meta = {}
+ for line in m[1].splitlines():
+ k, sep, v = line.partition(":")
+ if sep and v.strip():
+ meta[k.strip().lower()] = v.strip().strip("\"'")
+ return meta, m[2]
+
+
+def apply_front(md, meta):
+ """Force the story's frontmatter into plan.md, whatever Gemma wrote there."""
+ keys = [k for k in ("Series", "Book", "Chapter") if meta.get(k.lower())]
+ drop = keys + (["Title"] if meta.get("title") else [])
+ lines = [x for x in md.splitlines()
+ if not (drop and re.match(r"[-*\s]*\**(%s)\**:" % "|".join(drop), x.strip()))]
+ top = next((i for i, x in enumerate(lines) if x.startswith("# ")), None)
+ if top is None:
+ lines.insert(0, "# " + (meta.get("chapter_title") or meta.get("title") or "Untitled"))
+ top = 0
+ elif meta.get("chapter_title"):
+ lines[top] = "# " + meta["chapter_title"]
+ lines[top + 1:top + 1] = [f"{k}: {meta[k.lower()]}" for k in keys]
+ if meta.get("title"):
+ p1 = next((i for i, x in enumerate(lines) if re.match(r"###\s+1\.", x.strip())), None)
+ if p1 is not None:
+ end = next((i for i in range(p1 + 1, len(lines)) if lines[i].startswith("#")), len(lines))
+ while end > p1 + 1 and not lines[end - 1].strip():
+ end -= 1
+ lines.insert(end, f"Title: {meta['title']}")
+ return "\n".join(lines) + "\n"
def todo(pj, wd, lib):
@@ -131,7 +169,8 @@ def keep(src, dst):
def parse_plan(text):
- plan = {"title": "", "series": "", "book": "", "style": "", "characters": {}, "pronouns": {}, "pages": []}
+ plan = {"title": "", "series": "", "book": "", "chapter": "", "style": "", "characters": {}, "pronouns": {},
+ "pages": []}
section = cur = cname = None
for line in text.splitlines():
s = line.strip()
@@ -151,7 +190,7 @@ def parse_plan(text):
"heading": "" if m else head, "characters": None, "pose": "", "framing": "", "model": "",
"lettering": ""}
plan["pages"].append(cur)
- elif section is None and (m := re.match(r"[-*\s]*\**(Series|Book|Style)\**:\**\s*(.*)", s)):
+ elif section is None and (m := re.match(r"[-*\s]*\**(Series|Book|Chapter|Style)\**:\**\s*(.*)", s)):
plan[m[1].lower()] = m[2].strip().strip("*").strip()
elif section == "characters" and cur is not None and \
(m := re.match(r"[-*\s]*\**Pronoun\**:\**\s*(.*)", s)):
@@ -189,6 +228,8 @@ def validate_plan(plan):
errs += [f'bad {k} name "{plan[k]}"' for k in ("series", "book")
if plan[k] and (("/" in plan[k]) or plan[k] in (".", ".."))]
errs += [f'bad character name "{c}"' for c in plan["characters"] if "/" in c or c in (".", "..")]
+ if plan["chapter"] and not plan["chapter"].isdigit():
+ errs.append(f'Chapter must be a number, not "{plan["chapter"]}"')
errs += [f"character {c}: Pronoun must be she, he or they" for c in plan["characters"]
if plan["pronouns"].get(c) not in OBJ]
ns = [p["n"] for p in plan["pages"]]
@@ -288,7 +329,7 @@ def build_json(plan):
pages.append({"n": p["n"], "orientation": "landscape" if "landscape" in p["framing"].lower() else "portrait",
"model": p["model"], "base_prompt": " ".join(x for x in parts if x), "edits": edits})
return {"title": plan["title"], "series": plan["series"], "book": plan["book"],
- "characters": chars, "pages": pages}
+ "chapter": int(plan["chapter"]) if plan["chapter"] else None, "characters": chars, "pages": pages}
def sd_args(profile, prompt, out, size, seed, refs=()):
@@ -457,19 +498,24 @@ GEMMA_ERRS = (OSError, ValueError, KeyError, http.client.HTTPException)
def plan_stage(story, wd):
md_path, js_path = wd / "plan.md", wd / "plan.json"
- if js_path.exists() and md_path.exists() and js_path.stat().st_mtime >= md_path.stat().st_mtime:
+ if js_path.exists() and md_path.exists() and \
+ js_path.stat().st_mtime >= max(md_path.stat().st_mtime, story.stat().st_mtime):
return json.loads(js_path.read_text())
+ meta, body = parse_front(story.read_text())
if not md_path.exists():
check_llm()
wd.mkdir(exist_ok=True)
try:
- draft = strip_fences(chat(DRAFT_SYS, story.read_text())) + "\n"
+ draft = strip_fences(chat(DRAFT_SYS, body))
except GEMMA_ERRS as e:
sys.exit(f"Gemma failed: {e}")
- md_path.write_text(draft)
+ md_path.write_text(apply_front(draft, meta))
log("plan.md drafted by Gemma")
while True:
md = md_path.read_text()
+ if apply_front(md, meta) != md: # story.md frontmatter wins over plan.md
+ md = apply_front(md, meta)
+ md_path.write_text(md)
plan = parse_plan(md)
errs = validate_plan(plan)
print(f"\n{md}\n--- {md_path}: {len(plan['characters'])} characters, {len(plan['pages'])} pages")
@@ -496,8 +542,8 @@ def plan_stage(story, wd):
except EOFError:
continue
try:
- new = strip_fences(chat(
- DRAFT_SYS, f"Current plan:\n\n{md}\n\nRevise it: {ins}\nReturn the complete new plan.")) + "\n"
+ new = apply_front(strip_fences(chat(
+ DRAFT_SYS, f"Current plan:\n\n{md}\n\nRevise it: {ins}\nReturn the complete new plan.")), meta)
except GEMMA_ERRS as e:
print(f"Gemma failed: {e}")
continue
@@ -609,6 +655,25 @@ def selftest():
assert "page 2: Framing must say portrait or landscape" in validate_plan(
parse_plan(SAMPLE_PLAN.replace("wide shot, portrait", "wide shot")))
+ meta, body = parse_front('---\nseries: A new life\nbook: "Life as Daniel"\nchapter: 1\n'
+ 'chapter_title: Intro\ntitle: Meet Daniel\n---\nThis is the story.\n')
+ assert meta == {"series": "A new life", "book": "Life as Daniel", "chapter": "1",
+ "chapter_title": "Intro", "title": "Meet Daniel"} and body == "This is the story.\n", meta
+ assert parse_front("no frontmatter") == ({}, "no frontmatter")
+ forced = apply_front(SAMPLE_PLAN.replace("Framing: wide shot, eye level, landscape",
+ "Framing: wide shot, eye level, landscape\nTitle: Gemma Title"), meta)
+ assert apply_front(forced, meta) == forced
+ assert apply_front(SAMPLE_PLAN, {}) == SAMPLE_PLAN
+ fp = parse_plan(forced)
+ assert (fp["title"], fp["series"], fp["book"], fp["chapter"]) == ("Intro", "A new life", "Life as Daniel", "1"), fp
+ assert fp["pages"][0]["lettering"] == "Meet Daniel" and fp["pages"][1]["lettering"] == "", fp["pages"]
+ assert fp["pages"][0]["framing"] == "wide shot, eye level, landscape" and validate_plan(fp) == []
+ assert forced.count("Title:") == 1 and forced.startswith("# Intro\nSeries: A new life\nBook: Life as Daniel\n"
+ "Chapter: 1\nStyle:"), forced
+ assert build_json(fp)["chapter"] == 1 and build_json(plan)["chapter"] is None
+ assert 'Chapter must be a number, not "one"' in validate_plan(
+ parse_plan(SAMPLE_PLAN.replace("Book: Book One", "Book: Book One\nChapter: one")))
+
pj = build_json(plan)
assert (pj["title"], pj["series"], pj["book"]) == ("Rooftop", "Test Series", "Book One")
assert [c["name"] for c in pj["characters"]] == ["Mara", "Jon", "Kit"]
@@ -672,6 +737,7 @@ def selftest():
"pages": [{"n": 1}, {"n": 2}]}
lib = t / "lib"
assert page_path(lib, pj, 7) == lib / "S" / "B" / "007.png"
+ assert page_path(lib, dict(pj, chapter=2), 7) == lib / "S" / "B" / "02-007.png"
(t / "refs").mkdir()
(t / "refs" / "Mara.png").touch()
page_path(lib, pj, 1).parent.mkdir(parents=True)