diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-05 11:41:06 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-05 11:41:06 +0200 |
| commit | e3e08870d4d120145d5667a16602fc3a15167b76 (patch) | |
| tree | d9b8225775413f76282e108f7515309c3fae3f93 | |
| parent | 7017ceddad7750c54aed5a4323aec0ef3f2e119f (diff) | |
| download | fanfictioner-e3e08870d4d120145d5667a16602fc3a15167b76.tar.gz fanfictioner-e3e08870d4d120145d5667a16602fc3a15167b76.zip | |
Take series, book, chapter and title from story.md frontmatter
An optional --- frontmatter in the story file sets the names instead of
letting Gemma invent them. The script writes them into plan.md after
every draft and revision and whenever story.md changes. With a chapter
number, pages are saved as CC-NNN.png so all chapters of a book share
one folder and read in order in book-reader.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | README.md | 17 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-10-04-fanfictioner-design.md | 12 | ||||
| -rwxr-xr-x | fanfictioner | 84 |
3 files changed, 102 insertions, 11 deletions
@@ -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) |
