Install the whole backend from one script; queue log; UI fixes

install_comfyui.sh becomes install_backend.sh and now builds everything the app
needs, not just the speech half:

- Step 7 runs normalizer/setup.sh --model, which fetches both models: the open
  inflection model first, so the install gets as far as it can before anything
  can ask for credentials, then the gated fine-tune.
- A missing Hugging Face account is no longer fatal. Accepting a licence is the
  one thing an installer cannot do, so the rest installs, the verification
  reports "fallback only (gated model missing)", and the summary prints the
  three commands that finish it.
- --skip-normalizer, disk requirement 35 -> 55 GB, removal instructions and the
  step list updated to match.

Queue logging — queue.log in the first queued video's job folder, which for a
single-video run is its own:

- Records what went wrong rather than what happened: warnings, tracebacks,
  ffmpeg errors, guard rejections, clips cut off mid-word or that crashed the
  backend, and the --regen line carrying their numbers.
- ComfyUI failures *and* restarts, so the log shows the recovery rather than
  reading as though the run simply stopped. GUI-initiated backend actions go in
  too; they never pass through a job's pipe.
- A failure also dumps the job's last 15 lines. Keyword matching alone is not
  enough — ffmpeg says "Invalid data found when processing input" and nothing
  that looks like an error.

UI:

- The output pane follows new output only while already at the bottom. Scroll up
  and it stays there; scroll back down and it resumes. Reading an earlier
  warning during a run was impossible while every ffmpeg progress frame yanked
  the view to the end.
- The ComfyUI button's status dot is drawn as its own canvas item, so it can be
  green (answering) or amber (holds the port, does not answer). A "." inside the
  label could only ever be the same grey as the words.

comfyui.log is capped at 120,000 lines, trimmed on backend start and every five
minutes. Rewritten in place, never replaced: tee holds it open in append mode,
so swapping the inode would leave the backend writing to an unlinked file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 09:48:20 +02:00
co-authored by Claude Opus 5
parent e201aadc3e
commit 97866e6623
8 changed files with 421 additions and 69 deletions
+190 -18
View File
@@ -16,6 +16,7 @@ Needs tkinter (sudo apt install python3-tk). It also runs under
to X11 bitmaps, so the window looks dated there; the UI detects this and says so.
"""
import collections
import configparser
import os
import queue
@@ -24,6 +25,7 @@ import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path
try:
@@ -61,6 +63,10 @@ PLUS = "#8f8f8f"
LOG_BG = "#242424"
LOG_FG = "#d0d0d0"
# Status dot on the ComfyUI button.
COMFY_UP = "#4caf50" # answering
COMFY_BUSY = "#d9a441" # holds the port but does not answer
# Rounding radius, in pixels, for fields and buttons.
RADIUS = 9
@@ -113,6 +119,7 @@ class RoundButton(tk.Canvas):
highlightthickness=0, bd=0)
self._shape = round_rect(self, 1, 1, w - 1, h - 1, radius, fill=bg, outline="")
self._label = self.create_text(w / 2, h / 2, text=text, fill=fg, font=f)
self._dot = None
self.bind("<Enter>", lambda _: self._paint(self._hover))
self.bind("<Leave>", lambda _: self._paint(self._bg))
self.bind("<Button-1>", self._click)
@@ -131,6 +138,35 @@ class RoundButton(tk.Canvas):
def set_text(self, text):
self.itemconfigure(self._label, text=text)
if self._dot is not None:
self._place_dot()
def set_indicator(self, colour):
"""A status dot beside the label, or None for no dot.
Drawn as its own canvas item rather than a coloured character in the
text: a canvas text item has one fill colour for the whole string, so
a "●" in the label can only ever be the same grey as the words.
"""
if colour is None:
if self._dot is not None:
self.delete(self._dot)
self._dot = None
self.coords(self._label, int(self["width"]) / 2,
int(self["height"]) / 2)
return
if self._dot is None:
self._dot = self.create_oval(0, 0, 0, 0, fill=colour, outline="")
self.itemconfigure(self._dot, fill=colour)
self._place_dot()
def _place_dot(self):
"""Keep label+dot centred together as the text changes length."""
w, h, gap, r = int(self["width"]), int(self["height"]), 5, 4
self.coords(self._label, w / 2 - (gap + r), h / 2)
x1, _y1, x2, _y2 = self.bbox(self._label)
cx, cy = x2 + gap + r, h / 2
self.coords(self._dot, cx - r, cy - r, cx + r, cy + r)
class RoundField(tk.Frame):
@@ -925,7 +961,7 @@ class JobBlock(tk.Frame):
except ValueError as exc:
messagebox.showerror("Cannot run", str(exc))
return
self.app.enqueue([(self.label(), mode, cmd)])
self.app.enqueue([(self.label(), mode, cmd, self.job_folder())])
def ask_regen(self):
raw = SimplePrompt(self.app, "Regenerate clips",
@@ -1036,7 +1072,9 @@ class LektorUI(tk.Tk):
self.blocks = []
self._group_seq = 0
self._syncing = False
self.jobs = [] # pending [(label, mode, cmd), …]
self.jobs = [] # pending [(label, mode, cmd, job_folder), …]
self.log_path = None # queue.log for the batch being run
self._job_tail = collections.deque(maxlen=self.TAIL_LINES)
self.proc = None
self.running = False
self.out_q = queue.Queue()
@@ -1099,11 +1137,12 @@ class LektorUI(tk.Tk):
RoundButton(top, "Queue dictionary", self.open_queue_dictionary,
bg="#3a3a3a", fg=MUTED, hover="#4a4a4a", font=(F, 9),
padx=13, pady=6).pack(side="right", padx=(0, 8))
self.comfy_btn = RoundButton(top, "ComfyUI", self.toggle_comfy,
self.comfy_btn = RoundButton(top, "Start ComfyUI", self.toggle_comfy,
bg="#3a3a3a", fg=MUTED, hover="#4a4a4a",
font=(F, 9), padx=13, pady=6)
self.comfy_btn.pack(side="right", padx=(0, 8))
self.after(500, self._refresh_comfy_button)
self.after(60_000, self._trim_backend_log)
# scrollable list of job blocks
mid = tk.Frame(self, bg=BG)
@@ -1202,6 +1241,22 @@ class LektorUI(tk.Tk):
# of the work is in lektor_ai_comfy so the terminal script and this button
# behave identically.
def _trim_backend_log(self):
"""Keep comfyui.log bounded while the backend runs.
Trimming only at startup is not enough: ComfyUI can stay up for days,
and it is the running backend that fills the file. Every five minutes,
and the call is cheap — it returns on a stat when the file is small.
"""
try:
dropped = core.trim_comfy_log()
if dropped:
self.log(f"Trimmed {core.COMFY_LOG.name}: dropped {dropped} old "
f"line(s), keeping the newest {core.COMFY_LOG_MAX_LINES}.")
except Exception as exc: # noqa: BLE001
self.log(f"Could not trim {core.COMFY_LOG.name}: {exc}")
self.after(300_000, self._trim_backend_log)
def _comfy_state(self):
"""(running, healthy) — separate, because a backend can hold the port
and the GPU while no longer answering."""
@@ -1217,11 +1272,16 @@ class LektorUI(tk.Tk):
def _refresh_comfy_button(self):
running, healthy = self._comfy_state()
if healthy:
self.comfy_btn.set_text("ComfyUI ●")
self.comfy_btn.set_text("ComfyUI")
self.comfy_btn.set_indicator(COMFY_UP)
elif running:
self.comfy_btn.set_text("ComfyUI ◍") # up but not answering
# Holding the port but not answering — the state that strands the
# GPU, so it gets its own colour rather than looking healthy.
self.comfy_btn.set_text("ComfyUI")
self.comfy_btn.set_indicator(COMFY_BUSY)
else:
self.comfy_btn.set_text("Start ComfyUI")
self.comfy_btn.set_indicator(None)
self.after(5000, self._refresh_comfy_button)
def toggle_comfy(self):
@@ -1240,17 +1300,17 @@ class LektorUI(tk.Tk):
def _comfy_worker(self, running):
try:
if running:
self.log("Stopping ComfyUI…")
self.log_both("Stopping ComfyUI…")
core.stop_comfy_backend()
self.log("Starting ComfyUI…")
self.log_both("Starting ComfyUI…")
if not core.start_comfy_backend(core.COMFY_START_SCRIPT):
self.log("Could not start ComfyUI — see the terminal window.")
self.log_both("Could not start ComfyUI — see the terminal window.")
return
if core.wait_for_backend(180.0):
self.log("ComfyUI is up.")
self.log_both("ComfyUI is up.")
else:
self.log(f"ComfyUI did not answer within 180s. "
f"Its output is in {core.COMFY_LOG}.")
self.log_both(f"ComfyUI did not answer within 180s. "
f"Its output is in {core.COMFY_LOG}.")
finally:
self.after(0, lambda: self.comfy_btn.set_enabled(True))
self.after(0, self._refresh_comfy_button)
@@ -1353,13 +1413,83 @@ class LektorUI(tk.Tk):
jobs = []
for b in self.blocks:
try:
jobs.append((b.label(), "generate", b.build_command("generate")))
jobs.append((b.label(), "generate", b.build_command("generate"),
b.job_folder()))
except ValueError as exc:
messagebox.showerror("Cannot start", f"{b.label()}:\n\n{exc}")
return
self.enqueue(jobs)
LOG_NAME = "queue.log"
# What counts as an issue. The pipeline is deliberately chatty on stdout —
# every clip, every placement — and none of that belongs in a file you open
# to find out what went wrong overnight. Matched case-insensitively because
# the interesting lines come from three different sources: this script,
# Python tracebacks, and ffmpeg, which capitalises nothing.
ISSUE_RE = re.compile(
r"error|warning|traceback|exception|fail|"
r"cannot|could not|not found|no such|invalid|denied|"
r"crash|killed comfyui|died|went away|unreachable|did not|"
# Backend lifecycle: a restart is not an error, but a log that shows
# the crash without the recovery reads like the run simply stopped.
r"start(?:ing|ed) comfyui|stopping comfyui|comfyui is back|"
r"comfyui is up|not answering|resubmitting|waiting for it|"
r"rejected |safety check|skipping this clip|mid-word|"
# The --regen line carries the clip numbers, and it follows its summary
# on a line of its own — without it the log says three clips are broken
# but not which three.
r"--regen|"
r"^\s*!", re.IGNORECASE)
# Kept per job so a failure can be explained even when its last words match
# nothing above — an exit code with no context is not worth logging.
TAIL_LINES = 15
def _open_log(self, jobs):
"""Point the log at the first job of this batch.
One rule covers both cases the same way: the file goes in the first
queued video's folder, which for a single-video run is simply its own.
"""
folder = next((f for _l, _m, _c, f in jobs if f is not None), None)
if folder is None:
self.log_path = None
return
self.log_path = folder / self.LOG_NAME
try:
self.log_path.parent.mkdir(parents=True, exist_ok=True)
with self.log_path.open("a", encoding="utf-8") as fh:
fh.write(f"\n===== queue started {time.strftime('%Y-%m-%d %H:%M:%S')}"
f" — {len(jobs)} job(s) =====\n")
except OSError as exc:
self.log(f"Could not open {self.log_path}: {exc}")
self.log_path = None
def _log_tail(self):
"""The job's last lines, so a failure is explained even when its error
text matches no keyword — ffmpeg in particular says things like
"Invalid data found when processing input" and nothing else."""
if not self.log_path or not self._job_tail:
return
self._log_issue(f" last {len(self._job_tail)} line(s) before it stopped:")
for line in self._job_tail:
self._log_issue(f" | {line}")
def _log_issue(self, text):
"""Append one line. Appends rather than truncates, so the file is a
record across runs and a crash never costs the previous session."""
if not self.log_path:
return
try:
with self.log_path.open("a", encoding="utf-8") as fh:
fh.write(f"{time.strftime('%H:%M:%S')} {text.rstrip()}\n")
except OSError:
self.log_path = None # a broken log must not stop a run
def enqueue(self, jobs):
if not self.running:
self._open_log(jobs)
self.jobs.extend(jobs)
self.log(f"Queued {len(jobs)} job{'' if len(jobs) == 1 else 's'} "
f"({len(self.jobs)} pending).")
@@ -1372,13 +1502,16 @@ class LektorUI(tk.Tk):
self._buttons_idle()
self.status.configure(text="Idle")
self.log("Queue finished.\n")
self._log_issue("===== queue finished =====")
return
label, mode, cmd = self.jobs.pop(0)
label, mode, cmd, folder = self.jobs.pop(0)
self.running = True
self.gen_btn.set_enabled(False)
self.stop_btn.set_enabled(True)
self.status.configure(text=f"{mode}: {label} ({len(self.jobs)} left)")
self.log(f"\n=== {mode.upper()}: {label} ===")
self._log_issue(f"--- {mode}: {label}")
self._job_tail.clear()
self.log("$ " + " ".join(f'"{c}"' if " " in c else c for c in cmd[1:]))
threading.Thread(target=self._run, args=(cmd,), daemon=True).start()
@@ -1441,11 +1574,17 @@ class LektorUI(tk.Tk):
self.proc = None
if payload == 0:
self.log(" OK - finished")
self._log_issue(" finished OK")
elif payload is not None and payload < 0:
self.log(f" STOPPED (signal {-payload})")
self._log_issue(f" STOPPED by signal {-payload} — "
f"{len(self.jobs)} job(s) cancelled")
self.jobs.clear()
else:
self.log(f" FAILED (exit {payload})")
self._log_issue(f" FAILED with exit code {payload} — "
f"{len(self.jobs)} job(s) cancelled")
self._log_tail()
self.jobs.clear()
self.after(0, lambda c=payload: messagebox.showerror(
"Job failed",
@@ -1457,19 +1596,45 @@ class LektorUI(tk.Tk):
pass
self.after(80, self._drain_output)
def _at_bottom(self):
"""True when the view is already showing the end of the log.
Scrolling to the end on every insert makes the pane unreadable during a
run: output arrives several times a second, so any attempt to look back
is yanked to the bottom before a line can be read. Following the tail
only while the user is already at it means scrolling up pauses the
follow, and scrolling back down resumes it, with no button to press.
"""
try:
return self.log_txt.yview()[1] >= 0.999
except tk.TclError: # widget gone during shutdown
return True
def _append(self, text):
follow = self._at_bottom()
self.log_txt.configure(state="normal")
self.log_txt.insert("end", text)
self.log_txt.see("end")
self.log_txt.configure(state="disabled")
if follow:
self.log_txt.see("end")
def _replace_last(self, text):
"""Overwrite the last line, the way a terminal handles a carriage return."""
follow = self._at_bottom()
self.log_txt.configure(state="normal")
self.log_txt.delete("end-1c linestart", "end-1c")
self.log_txt.insert("end-1c", text)
self.log_txt.see("end")
self.log_txt.configure(state="disabled")
if follow:
self.log_txt.see("end")
def _record_if_issue(self, text):
line = text.strip()
if not line:
return
self._job_tail.append(line)
if self.ISSUE_RE.search(line):
self._log_issue(line)
def _emit_line(self, text):
"""A \n-terminated segment.
@@ -1479,6 +1644,7 @@ class LektorUI(tk.Tk):
to overwrite the line rather than start a new one, or every update is
appended and the bar marches down the pane instead of animating.
"""
self._record_if_issue(text)
if self._in_redraw:
self._replace_last(text)
self._append("\n")
@@ -1493,6 +1659,12 @@ class LektorUI(tk.Tk):
def log(self, msg):
self._append(msg + "\n")
def log_both(self, msg):
"""Output pane and queue.log. For things the GUI does itself — starting
or stopping the backend — which never pass through a job's pipe."""
self.log(msg)
self._log_issue(msg)
def _buttons_idle(self):
self.gen_btn.set_enabled(True)
self.stop_btn.set_enabled(False)
@@ -1538,12 +1710,12 @@ class LektorUI(tk.Tk):
try:
if core.comfy_pid() is None:
return
self.log("Stopping ComfyUI…")
self.log_both("Stopping ComfyUI on exit…")
self.update_idletasks() # let the message paint before we block
if not core.stop_comfy_backend():
self.log("ComfyUI did not stop — check for a leftover process.")
self.log_both("ComfyUI did not stop — check for a leftover process.")
except Exception as exc: # noqa: BLE001
self.log(f"Could not stop ComfyUI: {exc}")
self.log_both(f"Could not stop ComfyUI: {exc}")
if __name__ == "__main__":