aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-05 14:35:33 +0200
committerDanilo M. <danix@danix.xyz>2026-10-05 14:35:33 +0200
commit45ab8ec26c95f580bfcacf401ffb5ea8717e3c2e (patch)
treee68ca1b76ab8d05b69376c0c19c1f2957e703703
parent5ecfc3540437625d12cefac18a941acde63bc72e (diff)
downloadfanfictioner-45ab8ec26c95f580bfcacf401ffb5ea8717e3c2e.tar.gz
fanfictioner-45ab8ec26c95f580bfcacf401ffb5ea8717e3c2e.zip
Show a live preview of the candidate being rendered
sd-cli writes a latent projection of the current candidate every step (--preview proj, no extra VRAM). While it runs, a magick display window shows that preview scaled up to 640 px and redraws on each change; it closes when sd-cli exits. display -update drops -resize on reload, so the poll loop writes the enlarged copy itself. Under Hyprland a runtime window rule (hyprctl eval) floats the window so the tiling layout leaves its size alone; the config is not touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--README.md3
-rwxr-xr-xfanfictioner39
2 files changed, 38 insertions, 4 deletions
diff --git a/README.md b/README.md
index 3e57d6d..a29613f 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,8 @@ image stage. Pages land in the book-reader library as `NNN.png`.
- llama-server in router mode on `localhost:8181` serving `Gemma4-12B-qat-mtp`
- `sd-cli` with Vulkan, models under `/data/LLM-models/SD`
- ImageMagick 7 (`magick`), `$EDITOR`, `notify-send` (libnotify), and kitty for
- inline previews (elsewhere candidates open with `xdg-open`)
+ inline previews (elsewhere candidates open with `xdg-open`); a live preview of
+ the candidate being rendered opens in `magick display`, floated under Hyprland
- `~/.config/book-reader.conf` with a `LIBRARY="..."` line
## Usage
diff --git a/fanfictioner b/fanfictioner
index 89a3fec..063f349 100755
--- a/fanfictioner
+++ b/fanfictioner
@@ -376,8 +376,31 @@ def progress(start, log):
print(f"\r{int(time.time() - start):4d}s {last.strip()}"[:width].ljust(width), end="", flush=True)
-def run_sd(args, log):
- """Run sd-cli with output in log; return an error string or None."""
+def scale_preview(src, dst):
+ """sd-cli's preview is latent-sized (1/8 of the image): enlarge it, swapping dst in atomically."""
+ tmp = dst.with_name("tmp_" + dst.name)
+ if subprocess.run(["magick", str(src), "-resize", "640x640", str(tmp)], capture_output=True).returncode == 0:
+ os.replace(tmp, dst)
+
+
+def open_preview(f):
+ """Window showing f, redrawn by magick display whenever f changes (display -update drops
+ -resize on reload, hence scale_preview). Floated under Hyprland through a runtime rule,
+ so the tiling layout neither resizes it nor reflows."""
+ try:
+ if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
+ subprocess.run(["hyprctl", "eval", 'hl.window_rule({name = "fanfictioner-preview", '
+ 'match = { title = "^fanfictioner preview$" }, float = true})'], capture_output=True)
+ return subprocess.Popen(["magick", "display", "-update", "1", "-title", "fanfictioner preview", str(f)],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ except OSError:
+ return None
+
+
+def run_sd(args, log, preview=None):
+ """Run sd-cli with output in log; return an error string or None.
+ With preview, a live view of that file is open while sd-cli runs."""
+ viewer, seen = None, None
with open(log, "w") as f:
f.write(shlex.join(args) + "\n")
f.flush()
@@ -390,6 +413,11 @@ def run_sd(args, log):
while proc.poll() is None:
time.sleep(2)
progress(start, log)
+ if preview and preview.exists() and preview.stat().st_mtime != seen:
+ seen = preview.stat().st_mtime
+ view = preview.with_name("view_" + preview.name)
+ scale_preview(preview, view)
+ viewer = viewer or (view.exists() and open_preview(view)) or None
if proc.poll() is None and \
mem_available_kb(Path("/proc/meminfo").read_text()) < MIN_RAM_KB:
proc.kill()
@@ -401,6 +429,8 @@ def run_sd(args, log):
raise
finally:
print()
+ if viewer:
+ viewer.terminate()
if proc.returncode or "[ERROR" in Path(log).read_text(errors="replace"):
return f"sd-cli failed (exit {proc.returncode})"
return None
@@ -441,7 +471,10 @@ def generate(d, profile, prompt, size, refs=()):
seed = random.randint(0, 2**31 - 4)
log = d / f"{seed}.log"
print(f"sd-cli {profile}, seed {seed} ...", flush=True)
- err = run_sd(sd_args(profile, prompt, d / f"{seed}_%d.png", size, seed, refs), log)
+ preview = d / "preview.png" # rewritten every step, one candidate after the other
+ preview.unlink(missing_ok=True)
+ err = run_sd(sd_args(profile, prompt, d / f"{seed}_%d.png", size, seed, refs)
+ + ["--preview", "proj", "--preview-path", str(preview)], log, preview)
files = [d / f"{seed}_{i}.png" for i in (1, 2, 3)]
if not err and not all(f.exists() for f in files):
err = "sd-cli wrote no output"