aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-05 13:56:32 +0200
committerDanilo M. <danix@danix.xyz>2026-10-05 13:56:32 +0200
commitf15665670b5f2923bb8ec84929b6bc9b7f29a53a (patch)
tree8f566728a182a046d9f59e16d1d5446131aae8f3
parentc07c3df46fddad7a91fe0c3038400f14f632ed0d (diff)
downloadfanfictioner-f15665670b5f2923bb8ec84929b6bc9b7f29a53a.tar.gz
fanfictioner-f15665670b5f2923bb8ec84929b6bc9b7f29a53a.zip
Revise the plan when the story changes, take style from frontmatter
An edited story only re-applied its frontmatter, so plan.md kept describing the old story. The body plan.md was drafted from is now kept in story.seen; when the story body differs, ask whether Gemma should revise the plan to match, start a new draft, or keep the plan. A style: frontmatter key now overrides the Style: line, so the art style can be set before Gemma drafts anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--README.md7
-rwxr-xr-xfanfictioner62
2 files changed, 53 insertions, 16 deletions
diff --git a/README.md b/README.md
index ff3cec0..0de61a3 100644
--- a/README.md
+++ b/README.md
@@ -23,14 +23,19 @@ instead of letting Gemma invent them; every key is optional:
book: Life as Daniel
chapter: 1
title: Meet Daniel
+ style: shoujo manga, soft pastel cel shading
---
`series` and `book` name the library folders, `title` is the lettering on the
-chapter's page 1. With `chapter`, pages are saved as
+chapter's page 1, `style` is the art style of every page and reference sheet. 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.
+Editing the story after plan.md exists asks whether Gemma should revise the
+plan to match it, draft a new one (the old one is kept as plan.md.bak), or
+keep the plan as it is. Editing only the frontmatter is applied without Gemma.
+
fanfictioner story.md [--base zimage|krea2]
fanfictioner --selftest
diff --git a/fanfictioner b/fanfictioner
index 23c06b9..8a4f164 100755
--- a/fanfictioner
+++ b/fanfictioner
@@ -136,7 +136,7 @@ def parse_front(text):
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())]
+ keys = [k for k in ("Series", "Book", "Chapter", "Style") 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()))]
@@ -543,12 +543,54 @@ def strip_fences(s):
GEMMA_ERRS = (OSError, ValueError, KeyError, http.client.HTTPException)
+def revise(md, meta, wd, ins):
+ """Gemma rewrites the plan; returns the new plan.md text, or None when it fails or is invalid."""
+ try:
+ 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}")
+ return None
+ nerrs = validate_plan(parse_plan(new))
+ if nerrs:
+ (wd / "plan.rejected.md").write_text(new)
+ log(f"Gemma revision rejected ({'; '.join(nerrs)}): {ins}")
+ print("revision invalid, plan.md kept, see plan.rejected.md", *nerrs, sep="\n ")
+ return None
+ (wd / "plan.md.bak").write_text(md)
+ log(f"Gemma revision accepted: {ins}")
+ return new
+
+
def plan_stage(story, wd):
md_path, js_path = wd / "plan.md", wd / "plan.json"
+ seen = wd / "story.seen" # the story body plan.md was last drafted or revised from
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 md_path.exists() and (seen.read_text() != body if seen.exists()
+ else story.stat().st_mtime > md_path.stat().st_mtime):
+ try:
+ k = input("\nstory changed since plan.md was drafted: "
+ "[r]evise plan with Gemma [n]ew draft [k]eep plan [q]uit > ").strip().lower()
+ except EOFError:
+ k = "q"
+ if k == "n":
+ os.replace(md_path, wd / "plan.md.bak")
+ log("story changed, plan.md moved to plan.md.bak for a new draft")
+ elif k == "r":
+ check_llm()
+ new = revise(md_path.read_text(), meta, wd, "the story changed, update the plan to match "
+ f"this new story and keep whatever still fits:\n\n{body}")
+ if new:
+ md_path.write_text(new)
+ seen.write_text(body)
+ elif k == "k":
+ seen.write_text(body)
+ log("story changed, plan.md kept")
+ else:
+ sys.exit("quit, run again to resume")
if not md_path.exists():
check_llm()
wd.mkdir(exist_ok=True)
@@ -557,6 +599,7 @@ def plan_stage(story, wd):
except GEMMA_ERRS as e:
sys.exit(f"Gemma failed: {e}")
md_path.write_text(apply_front(draft, meta))
+ seen.write_text(body)
log("plan.md drafted by Gemma")
while True:
md = md_path.read_text()
@@ -588,21 +631,8 @@ def plan_stage(story, wd):
ins = input("instruction for Gemma: ")
except EOFError:
continue
- try:
- 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
- nerrs = validate_plan(parse_plan(new))
- if nerrs:
- (wd / "plan.rejected.md").write_text(new)
- log(f"Gemma revision rejected ({'; '.join(nerrs)}): {ins}")
- print("revision invalid, plan.md kept, see plan.rejected.md", *nerrs, sep="\n ")
- else:
- (wd / "plan.md.bak").write_text(md)
+ if new := revise(md, meta, wd, ins):
md_path.write_text(new)
- log(f"Gemma revision accepted: {ins}")
elif k == "q":
sys.exit(0)
@@ -715,6 +745,8 @@ def selftest():
"Framing: wide shot, eye level, landscape\nTitle: Gemma Title"), meta)
assert apply_front(forced, meta) == forced
assert apply_front(SAMPLE_PLAN, {}) == SAMPLE_PLAN
+ styled = parse_plan(apply_front(SAMPLE_PLAN, {"style": "ink manga"}))
+ assert styled["style"] == "ink manga" and validate_plan(styled) == [], styled
fp = parse_plan(forced)
assert (fp["title"], fp["series"], fp["book"], fp["chapter"]) == ("Rooftop", "A new life", "Life as Daniel", "1"), fp
assert fp["pages"][0]["lettering"] == "Meet Daniel" and fp["pages"][1]["lettering"] == "", fp["pages"]