aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-05 11:31:18 +0200
committerDanilo M. <danix@danix.xyz>2026-10-05 11:31:18 +0200
commit7017ceddad7750c54aed5a4323aec0ef3f2e119f (patch)
treeb2ce978ec7aa27a4bbdde1720af648981e42c397
parent5237176a9bcb2d0115dc2697c688884e53abd780 (diff)
downloadfanfictioner-7017ceddad7750c54aed5a4323aec0ef3f2e119f.tar.gz
fanfictioner-7017ceddad7750c54aed5a4323aec0ef3f2e119f.zip
Log run events to run.log in the work dir
Every generation, pick with its seed, regeneration, prompt edit, skip, quit and plan decision is appended with a timestamp, so a pick can be traced back to its seed and a session can be reviewed afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--README.md5
-rwxr-xr-xfanfictioner37
2 files changed, 41 insertions, 1 deletions
diff --git a/README.md b/README.md
index 978a3db..dd83a60 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,10 @@ image stage. Pages land in the book-reader library as `NNN.png`.
fanfictioner --selftest
Work files go to a directory named after the story (`stories/rooftop.md` ->
-`stories/rooftop/`): `plan.md`, `plan.json`, `refs/`, `cand/`. Quit any time
+`stories/rooftop/`): `plan.md`, `plan.json`, `refs/`, `cand/` and `run.log`.
+`run.log` records every generation, pick (with its seed), regeneration, prompt
+edit, skip and plan decision with a timestamp. Each sd-cli call also leaves its
+own log next to its candidates in `cand/`. Quit any time
with `q` or Ctrl-C; the next run resumes from what is on disk.
Plan review keys: `c` confirm, `e` edit in `$EDITOR`, `g` ask Gemma to revise,
diff --git a/fanfictioner b/fanfictioner
index e1f5af4..fc0ad3d 100755
--- a/fanfictioner
+++ b/fanfictioner
@@ -335,6 +335,16 @@ def run_sd(args, log):
return None
+LOG = None # wd / "run.log", set by main
+
+
+def log(msg):
+ """Append one timestamped event to run.log, once the work dir exists."""
+ if LOG and LOG.parent.is_dir():
+ with open(LOG, "a") as f:
+ f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n")
+
+
def editor(path):
subprocess.run([*shlex.split(os.environ.get("EDITOR") or "vi"), str(path)])
@@ -376,8 +386,10 @@ def show(files, seed, d):
def review(d, profile, prompt, size, refs=(), skip=False):
"""Generate, show, ask. Returns the picked file, or None when the user skips."""
keys = "1-3 pick, r regen, p edit prompt" + (", s skip edit" if skip else "") + ", q quit"
+ stage = f"{d.parent.name}/{d.name}"
while True:
files, seed, err = generate(d, profile, prompt, size, refs)
+ log(f"{stage} {profile} seed {seed}" + (f" FAILED: {err}" if err else ""))
if not err:
show(files, seed, d)
while True:
@@ -386,17 +398,22 @@ def review(d, profile, prompt, size, refs=(), skip=False):
except EOFError:
k = "q"
if k in ("1", "2", "3") and not err:
+ log(f"{stage} pick {k}, seed {seed + int(k) - 1}, {files[int(k) - 1].name}")
return files[int(k) - 1]
if k == "r":
+ log(f"{stage} regenerate")
break
if k == "p":
(d / "prompt.txt").write_text(prompt)
editor(d / "prompt.txt")
prompt = (d / "prompt.txt").read_text().strip() or prompt
+ log(f"{stage} prompt edited: {prompt}")
break
if k == "s" and skip:
+ log(f"{stage} skip edit")
return None
if k == "q":
+ log(f"{stage} quit")
sys.exit("quit, run again to resume")
@@ -450,6 +467,7 @@ def plan_stage(story, wd):
except GEMMA_ERRS as e:
sys.exit(f"Gemma failed: {e}")
md_path.write_text(draft)
+ log("plan.md drafted by Gemma")
while True:
md = md_path.read_text()
plan = parse_plan(md)
@@ -468,6 +486,7 @@ def plan_stage(story, wd):
tmp = js_path.with_name("plan.json.tmp")
tmp.write_text(json.dumps(pj, indent=2) + "\n")
os.replace(tmp, js_path)
+ log(f"plan confirmed: {len(pj['characters'])} characters, {len(pj['pages'])} pages")
return pj
elif k == "e":
editor(md_path)
@@ -485,10 +504,12 @@ def plan_stage(story, wd):
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)
md_path.write_text(new)
+ log(f"Gemma revision accepted: {ins}")
elif k == "q":
sys.exit(0)
@@ -504,6 +525,7 @@ def refs_stage(chars, wd):
print(f"\n== reference sheet: {c['name']}")
pick = review(wd / "cand" / f"ref_{c['name']}", "zimage", c["turnaround_prompt"], SIZES["turnaround"])
keep(pick, wd / "refs" / f"{c['name']}.png")
+ log(f"reference sheet {c['name']} kept")
def page_stage(p, wd, out, base):
@@ -526,6 +548,7 @@ def page_stage(p, wd, out, base):
keep(pick or scene, nxt)
scene = nxt
keep(scene, out)
+ log(f"page {p['n']} -> {out}")
print(f"page {p['n']} -> {out}")
@@ -666,6 +689,15 @@ def selftest():
assert run_sd(["sh", "-c", "echo '[ERROR] boom'"], t / "e.log") == "sd-cli failed (exit 0)"
assert run_sd(["false"], t / "f.log") == "sd-cli failed (exit 1)"
assert run_sd(["/nonexistent-sd-cli"], t / "n.log").startswith("cannot run /nonexistent-sd-cli")
+ global LOG
+ LOG = t / "nowd" / "run.log"
+ log("before the work dir exists")
+ LOG = t / "run.log"
+ log("one")
+ log("two")
+ lines = LOG.read_text().splitlines()
+ LOG = None
+ assert [x[20:] for x in lines] == ["one", "two"] and not (t / "nowd").exists(), lines
print("selftest ok")
@@ -691,14 +723,19 @@ def main():
lib = None
if not lib:
sys.exit(f"set LIBRARY in {CONF}")
+ global LOG
wd = a.story.with_suffix("")
+ LOG = wd / "run.log"
+ log(f"start, base model {a.base}")
pj = plan_stage(a.story, wd)
chars, pages = todo(pj, wd, lib)
refs_stage(chars, wd)
for p in pages:
page_stage(p, wd, page_path(lib, pj, p["n"]), a.base)
+ log("done")
print(f"\ndone: {page_path(lib, pj, 1).parent}")
except (KeyboardInterrupt, EOFError):
+ log("interrupted")
sys.exit("\ninterrupted, run again to resume")