#!/usr/bin/env python3 """ ui — graphical front-end for lektor_ai_comfy.py Queue several videos, set the per-video knobs, and run them one after another. Every job is executed by shelling out to lektor_ai_comfy.py with ordinary command-line arguments, so the terminal script remains the single source of truth and stays fully usable on its own. Run it with: ./start_lektor_ui.sh Needs tkinter (sudo apt install python3-tk). It also runs under ~/miniforge3/bin/python, but that Tk build has no scalable fonts and falls back to X11 bitmaps, so the window looks dated there; the UI detects this and says so. """ import collections import configparser import os import queue import re import shutil import subprocess import sys import threading import time from pathlib import Path try: import tkinter as tk from tkinter import ttk, filedialog, messagebox except ImportError: sys.exit( "tkinter is not available in this Python.\n\n" " sudo apt install python3-tk\n\n" "or run it with a Python that already has it:\n" " ~/miniforge3/bin/python ui.py\n" ) HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) import lektor_ai_comfy as core # noqa: E402 (path set above) CLI = HERE / "lektor_ai_comfy.py" # Window icon. Tk 8.6 reads PNG but not SVG, so assets/ holds sizes rendered # from icon.svg; the WM picks whichever fits its title bar, alt-tab and dock. ICON_DIR = HERE / "assets" ICON_SIZES = (256, 128, 64, 48, 32, 16) # ── Palette, sampled from the mock-up ─────────────────────────────────────── BG = "#3c3c3c" # body BAR = "#2b2b2b" # top strip BOTBAR = "#4a4a4a" # bottom strip, slightly lighter than the body FIELD = "#e9e9e9" # text inputs FIELD_ED = "#cfcfcf" # the "..." affordance inside a field FIELD_FG = "#1a1a1a" BTN = "#9d9d9d" # action buttons BTN_ON = "#b5b5b5" BTN_FG = "#141414" GEN = "#a8a8a8" # GENERATE FG = "#ededed" MUTED = "#b0b0b0" SEP = "#5c5c5c" 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 # Vertical gap above each block and above each separator. BLOCK_GAP = 20 def pick_font(root): """Best available UI font. Conda's Tk ships only legacy X11 bitmap fonts and substitutes 'fixed' for everything else, which is why the UI looks dated when run from miniforge. A system Tk (apt install python3-tk) exposes the real fonts. Detect what is actually usable rather than assuming. """ from tkinter import font as tkfont for fam in ("Inter", "Ubuntu", "Noto Sans", "DejaVu Sans", "Cantarell", "Liberation Sans", "Segoe UI", "Helvetica"): try: if tkfont.Font(root=root, family=fam, size=12).actual("family").lower() \ .replace(" ", "") == fam.lower().replace(" ", ""): return fam, True except Exception: # noqa: BLE001 continue return tkfont.nametofont("TkDefaultFont").actual("family"), False def round_rect(canvas, x1, y1, x2, y2, r, **kw): """Rounded rectangle as a smoothed polygon — Tk has no native one.""" r = min(r, (x2 - x1) / 2, (y2 - y1) / 2) pts = [x1 + r, y1, x2 - r, y1, x2, y1, x2, y1 + r, x2, y2 - r, x2, y2, x2 - r, y2, x1 + r, y2, x1, y2, x1, y2 - r, x1, y1 + r, x1, y1] return canvas.create_polygon(pts, smooth=True, **kw) class RoundButton(tk.Canvas): """Flat rounded button. tk.Button cannot round its corners.""" def __init__(self, master, text, command, bg=BTN, fg=BTN_FG, hover=BTN_ON, font=None, padx=14, pady=7, radius=RADIUS, width=None): self._bg, self._hover, self._enabled = bg, hover, True self.command = command parent_bg = master.cget("background") if "background" in master.keys() else BG f = font or ("TkDefaultFont", 9, "bold") tmp = tk.Label(master, text=text, font=f) w = width or tmp.winfo_reqwidth() + padx * 2 h = tmp.winfo_reqheight() + pady * 2 tmp.destroy() super().__init__(master, width=w, height=h, bg=parent_bg, 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("", lambda _: self._paint(self._hover)) self.bind("", lambda _: self._paint(self._bg)) self.bind("", self._click) def _paint(self, colour): if self._enabled: self.itemconfigure(self._shape, fill=colour) def _click(self, _): if self._enabled and self.command: self.command() def set_enabled(self, on, disabled_bg="#6d6d6d"): self._enabled = on self.itemconfigure(self._shape, fill=self._bg if on else disabled_bg) 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): """Entry on a rounded light background, with the browse affordance inside it on the right — as in the mock-up, rather than a separate square button. """ def __init__(self, master, textvariable, on_browse=None, font=None, height=34): super().__init__(master, bg=master.cget("background"), height=height) self.pack_propagate(False) self.canvas = tk.Canvas(self, height=height, bg=master.cget("background"), highlightthickness=0, bd=0) self.canvas.pack(fill="both", expand=True) self._shape = round_rect(self.canvas, 0, 0, 400, height, RADIUS, fill=FIELD, outline="") self.entry = tk.Entry(self.canvas, textvariable=textvariable, bg=FIELD, fg=FIELD_FG, insertbackground=FIELD_FG, relief="flat", highlightthickness=0, bd=0, font=font or ("TkDefaultFont", 11)) self._entry_id = self.canvas.create_window(12, height / 2, window=self.entry, anchor="w", width=360) self._dots = None if on_browse: self._dots = self.canvas.create_text(0, height / 2, text="...", fill="#606060", anchor="e", font=(font[0] if font else "TkDefaultFont", 12, "bold")) self.canvas.tag_bind(self._dots, "", lambda _: on_browse()) self.canvas.tag_bind(self._dots, "", lambda _: self.canvas.itemconfigure(self._dots, fill="#111111")) self.canvas.tag_bind(self._dots, "", lambda _: self.canvas.itemconfigure(self._dots, fill="#606060")) self.canvas.bind("", self._resize) def _resize(self, ev): h = ev.height self.canvas.coords(self._shape, *self._rr(0, 0, ev.width, h, RADIUS)) pad_r = 34 if self._dots else 12 self.canvas.coords(self._entry_id, 12, h / 2) self.canvas.itemconfigure(self._entry_id, width=max(40, ev.width - 12 - pad_r)) if self._dots: self.canvas.coords(self._dots, ev.width - 12, h / 2) @staticmethod def _rr(x1, y1, x2, y2, r): r = min(r, (x2 - x1) / 2, (y2 - y1) / 2) return [x1 + r, y1, x2 - r, y1, x2, y1, x2, y1 + r, x2, y2 - r, x2, y2, x2 - r, y2, x1 + r, y2, x1, y2, x1, y2 - r, x1, y1 + r, x1, y1] def show_tail(self): self.entry.after_idle(lambda: self.entry.xview_moveto(1.0)) VIDEO_TYPES = [("Video files", "*.mkv *.mp4 *.avi *.mov *.m4v *.webm *.ts"), ("All files", "*.*")] SRT_TYPES = [("Subtitle files", "*.srt *.ass *.ssa *.vtt"), ("All files", "*.*")] # Tk draws its own file chooser on Linux, which looks nothing like the rest of # the desktop. zenity is a thin wrapper over the GTK chooser and, under GNOME, # routes through xdg-desktop-portal — so this gets the real system dialog with # its bookmarks, recent files and search. Falls back to Tk's if absent. ZENITY = shutil.which("zenity") def choose_files(title, filetypes, multiple=False, initial=None): """System file chooser. Returns a list when multiple, else a path or ''.""" if ZENITY: cmd = [ZENITY, "--file-selection", f"--title={title}"] if multiple: cmd += ["--multiple", "--separator=\n"] if initial: cmd.append(f"--filename={initial}") for label, pats in filetypes: cmd.append(f"--file-filter={label} | {pats.replace('*.*', '*')}") try: r = subprocess.run(cmd, capture_output=True, text=True) except OSError: pass # fall through to the Tk dialog else: picked = [x for x in r.stdout.strip().split("\n") if x] \ if r.returncode == 0 else [] return picked if multiple else (picked[0] if picked else "") if multiple: return list(filedialog.askopenfilenames(title=title, filetypes=filetypes)) return filedialog.askopenfilename(title=title, filetypes=filetypes) SETTING_LABELS = { "voice": "voice reference", "speed": "speed", "temperature": "speech temperature", "max_tokens": "max tokens per clip", "truncation_retries": "retries if cut mid-word", "strip_leading_ms": "strip leading (ms)", "trailing_fade_ms": "trailing fade (ms)", "orig_volume": "sound volume", "tts_volume": "voiceover volume", "output_dir": "output folder", "dictionary": "replacement dictionary", "normalize_numbers": "spell out numbers", "normalizer_python": "normalizer interpreter", "normalizer_llm": "inflection model", "normalizer_timeout": "normalizer timeout (s)", "free_comfy_vram": "unload Higgs to normalize", "comfy_url": "ComfyUI URL", "comfy_autostart": "start ComfyUI if down", "comfy_start_command": "start script", } SECTION_TITLES = {"tts": "Speech", "mix": "Mix", "paths": "Paths", "normalize": "Numbers", "comfy": "Backend"} def raw_settings(path): """Values exactly as written in the file, so the editor shows them verbatim. load_settings() casts to float/int, which would turn "0.70" into "0.7" and rewrite the user's formatting the first time they pressed Save without changing anything. """ values = {k: str(spec[3]) for k, spec in core._DEFAULTS.items()} if not path.is_file(): return values parser = configparser.ConfigParser(inline_comment_prefixes=("#", ";")) try: parser.read(path, encoding="utf-8") except configparser.Error: return values for key, (section, option, _cast, _default) in core._DEFAULTS.items(): if parser.has_option(section, option): raw = parser.get(section, option).strip() if raw: values[key] = raw return values def save_settings(path, values): """Write values back to settings.ini, preserving its comments and layout. configparser can round-trip the file but throws away every comment, and this one is mostly comments explaining what each value does. So the matching `key = value` lines are rewritten in place instead, and anything missing is appended under its section. """ schema = {k: (spec[0], spec[1]) for k, spec in core._DEFAULTS.items()} original = path.read_text(encoding="utf-8").splitlines(keepends=True) \ if path.is_file() else [] out, seen, section = [], set(), None for line in original: header = re.match(r"\s*\[([^\]]+)\]", line) if header: section = header.group(1).strip().lower() out.append(line) continue entry = re.match(r"(\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$", line.rstrip("\n")) if entry and section: name = entry.group(2) match = next((k for k, (sec, opt) in schema.items() if sec == section and opt == name and k not in seen), None) if match: trailing = "" rest = entry.group(4) comment = re.search(r"\s+[#;].*$", rest) # keep an inline comment if comment: trailing = comment.group(0) out.append(f"{entry.group(1)}{name}{entry.group(3)}" f"{values[match]}{trailing}\n") seen.add(match) continue out.append(line) missing = [k for k in schema if k not in seen] if missing: by_section = {} for k in missing: by_section.setdefault(schema[k][0], []).append(k) text = "".join(out) for sec, keys in by_section.items(): block = "".join(f"{schema[k][1]} = {values[k]}\n" for k in keys) if re.search(rf"^\s*\[{re.escape(sec)}\]\s*$", text, re.M): text = re.sub(rf"(^\s*\[{re.escape(sec)}\]\s*\n)", rf"\1{block}", text, count=1, flags=re.M) else: text += f"\n[{sec}]\n{block}" out = [text] tmp = path.with_suffix(".ini.tmp") tmp.write_text("".join(out), encoding="utf-8") tmp.replace(path) # atomic, so a crash cannot truncate it class QueueDictionaryDialog(tk.Toplevel): """Plain text editor for the queue's own replacement rules. Deliberately a text box rather than a table: the file format is one `pattern = replacement` per line, and typing that is faster than filling in two columns. The same parser reads it as the shared dictionary, so regex rules and `#` comments work here too. """ def __init__(self, app): super().__init__(app) self.app = app F = app.font_family self.title("Queue dictionary") self.configure(bg=BG) self.transient(app) path = app.queue_dictionary_path() where = str(path) if path else "(add a video to the queue first)" tk.Label(self, text="Replacements applied to every video in this queue,", bg=BG, fg=FG, font=(F, 10)).pack(anchor="w", padx=22, pady=(16, 0)) tk.Label(self, text="in addition to dictionary.txt. One rule per line, " "case-sensitive, whole words:", bg=BG, fg=FG, font=(F, 10)).pack(anchor="w", padx=22) tk.Label(self, text=" Joshua = Dżoszua", bg=BG, fg="#8fb8d6", font=(F, 9)).pack(anchor="w", padx=22, pady=(6, 0)) tk.Label(self, text=where, bg=BG, fg=MUTED, font=(F, 8) ).pack(anchor="w", padx=22, pady=(8, 4)) frame = tk.Frame(self, bg=BG) frame.pack(fill="both", expand=True, padx=22) bar = tk.Scrollbar(frame) bar.pack(side="right", fill="y") self.text = tk.Text(frame, width=64, height=16, bg=FIELD, fg=FIELD_FG, insertbackground=FIELD_FG, relief="flat", bd=0, font=(F, 10), yscrollcommand=bar.set, undo=True) self.text.pack(side="left", fill="both", expand=True) bar.config(command=self.text.yview) if path is not None and path.is_file(): try: self.text.insert("1.0", path.read_text(encoding="utf-8")) except OSError as exc: messagebox.showerror("Queue dictionary", str(exc), parent=self) self.status = tk.Label(self, text="", bg=BG, fg=MUTED, font=(F, 8)) self.status.pack(anchor="w", padx=22, pady=(6, 0)) buttons = tk.Frame(self, bg=BG) buttons.pack(fill="x", padx=22, pady=16) RoundButton(buttons, "Close", self.destroy, bg="#6f6f6f", fg="#f0f0f0", hover="#828282", font=(F, 9, "bold"), padx=16, pady=6 ).pack(side="right") RoundButton(buttons, "Save", self._save, font=(F, 9, "bold"), padx=22, pady=6).pack(side="right", padx=(0, 8)) RoundButton(buttons, "Clear", self._clear, bg="#4a4a4a", fg=MUTED, hover="#5c5c5c", font=(F, 9), padx=14, pady=6).pack(side="left") self.bind("", lambda _: self.destroy()) self.update_idletasks() x = app.winfo_rootx() + (app.winfo_width() - self.winfo_width()) // 2 self.geometry(f"+{max(0, x)}+{app.winfo_rooty() + 60}") self.grab_set() def _clear(self): """Empties the box only. Nothing is written until Save, so a misclick costs a Ctrl+Z rather than the file.""" self.text.delete("1.0", "end") self.status.config(text="Cleared — press Save to write the empty file.") def _save(self): path = self.app.queue_dictionary_path() if path is None: messagebox.showinfo( "Queue dictionary", "Add a video to the queue first — the file is written to the " "first video's job folder.", parent=self) return body = self.text.get("1.0", "end").rstrip() + "\n" try: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(body, encoding="utf-8") except OSError as exc: messagebox.showerror("Queue dictionary", str(exc), parent=self) return # Report what the pipeline will actually make of it, so a typo shows up # now rather than as a rule that silently does nothing during the run. loaded = core.Dictionary.load(path) self.status.config(text=f"Saved — {len(loaded)} rule(s) parsed.") self.app.log(f"Queue dictionary saved to {path} ({len(loaded)} rules).") class SettingsDialog(tk.Toplevel): """Editor for settings.ini — the defaults new job blocks start from.""" def __init__(self, app): super().__init__(app) self.app, self.saved = app, False F = app.font_family self.title("Settings") self.configure(bg=BG) self.resizable(False, False) self.transient(app) path = core.SETTINGS_FILE current = raw_settings(path) self.vars = {} tk.Label(self, text=str(path), bg=BG, fg=MUTED, font=(F, 8) ).pack(anchor="w", padx=22, pady=(16, 2)) body = tk.Frame(self, bg=BG) body.pack(fill="both", expand=True, padx=22, pady=(4, 0)) body.grid_columnconfigure(1, weight=1) voices = sorted(p.stem for p in core.VOICE_PROMPTS_DIR.glob("*.wav")) \ if core.VOICE_PROMPTS_DIR.is_dir() else [] row = 0 for section in ("tts", "mix", "paths", "normalize", "comfy"): keys = [k for k, spec in core._DEFAULTS.items() if spec[0] == section] if not keys: continue tk.Label(body, text=SECTION_TITLES.get(section, section).upper(), bg=BG, fg="#8fb8d6", font=(F, 8, "bold") ).grid(row=row, column=0, columnspan=2, sticky="w", pady=(14 if row else 2, 4)) row += 1 for key in keys: tk.Label(body, text=SETTING_LABELS.get(key, key), bg=BG, fg=FG, font=(F, 10)).grid(row=row, column=0, sticky="w", padx=(6, 14)) var = tk.StringVar(value=current[key]) self.vars[key] = var if core._DEFAULTS[key][2] is core._as_bool: w = ttk.Combobox(body, textvariable=var, values=["yes", "no"], width=26, state="readonly", style="Dark.TCombobox", font=(F, 10)) elif key == "voice" and voices: w = ttk.Combobox(body, textvariable=var, values=voices, width=26, style="Dark.TCombobox", font=(F, 10)) else: w = tk.Entry(body, textvariable=var, bg=FIELD, fg=FIELD_FG, relief="flat", insertbackground=FIELD_FG, font=(F, 10), width=28) w.grid(row=row, column=1, sticky="ew", pady=3, ipady=3) row += 1 note = tk.Label(self, bg=BG, fg=MUTED, font=(F, 8), justify="left", text="Saved values become the defaults for job blocks added from now on.\n" "Blocks already in the queue keep the values they are showing.") note.pack(anchor="w", padx=22, pady=(14, 0)) buttons = tk.Frame(self, bg=BG) buttons.pack(fill="x", padx=22, pady=16) RoundButton(buttons, "Cancel", self.destroy, bg="#6f6f6f", fg="#f0f0f0", hover="#828282", font=(F, 9, "bold"), padx=16, pady=6 ).pack(side="right") RoundButton(buttons, "Save", self._save, font=(F, 9, "bold"), padx=22, pady=6 ).pack(side="right", padx=(0, 8)) RoundButton(buttons, "Restore defaults", self._restore, bg="#4a4a4a", fg=MUTED, hover="#5c5c5c", font=(F, 9), padx=14, pady=6).pack(side="left") self.bind("", lambda _: self.destroy()) self.update_idletasks() x = app.winfo_rootx() + (app.winfo_width() - self.winfo_width()) // 2 y = app.winfo_rooty() + 80 self.geometry(f"+{max(0, x)}+{max(0, y)}") self.grab_set() app.wait_window(self) def _restore(self): for key, spec in core._DEFAULTS.items(): self.vars[key].set(str(spec[3])) def _save(self): """Validate against the schema before writing anything.""" cleaned, bad = {}, [] for key, (_sec, _opt, cast, _default) in core._DEFAULTS.items(): raw = self.vars[key].get().strip() if not raw: bad.append(f"{SETTING_LABELS.get(key, key)}: must not be empty") continue try: cast(raw) except ValueError: bad.append(f"{SETTING_LABELS.get(key, key)}: " f"{raw!r} is not a valid {cast.__name__}") continue cleaned[key] = raw if bad: messagebox.showerror("Invalid settings", "\n".join(bad), parent=self) return try: save_settings(core.SETTINGS_FILE, cleaned) except OSError as exc: messagebox.showerror("Could not save", str(exc), parent=self) return self.app.defaults = core.load_settings(core.SETTINGS_FILE) self.app.log(f"Settings saved to {core.SETTINGS_FILE.name}. " f"New blocks will use them.") self.saved = True self.destroy() class RoundCheck(tk.Frame): """Checkbox drawn on a canvas. tk.Checkbutton's indicator is sized by the widget theme and cannot simply be made bigger, so the box is drawn here instead — which also lets it match the rounded styling of the other controls. """ def __init__(self, master, variable, text, font, command=None, box=22): super().__init__(master, bg=master.cget("background")) self.var, self.command = variable, command self._enabled = True self.canvas = tk.Canvas(self, width=box, height=box, bg=master.cget("background"), highlightthickness=0, bd=0, cursor="hand2") self.canvas.pack(side="left") self._box = round_rect(self.canvas, 1, 1, box - 1, box - 1, 5, fill=FIELD, outline="") pad = box * 0.27 self._tick = self.canvas.create_line( pad, box * 0.52, box * 0.44, box - pad, box - pad, pad, fill="#1a1a1a", width=max(2, int(box * 0.13)), capstyle="round", joinstyle="round", state="hidden") self.label = tk.Label(self, text=text, bg=master.cget("background"), fg=FG, font=font, justify="left", cursor="hand2") self.label.pack(side="left", padx=(6, 0)) for w in (self.canvas, self.label): w.bind("", self._toggle) self.refresh() def _toggle(self, _=None): if not self._enabled: return self.var.set(not self.var.get()) self.refresh() if self.command: self.command() def refresh(self): on = bool(self.var.get()) self.canvas.itemconfigure(self._tick, state="normal" if on else "hidden") if self._enabled: fill = "#ffffff" if on else FIELD else: fill = "#8f8f8f" if on else SEP self.canvas.itemconfigure(self._box, fill=fill) def set_enabled(self, on): """Grey the box out and stop it responding to clicks.""" self._enabled = bool(on) cursor = "hand2" if self._enabled else "arrow" self.canvas.configure(cursor=cursor) self.label.configure(cursor=cursor, fg=FG if self._enabled else "#828282") self.refresh() # Dropdown choices. The comboboxes are editable, so these are starting points # rather than limits. def _steps(lo, hi, step, fmt="{:.2f}"): out, v = [], lo while v <= hi + 1e-9: out.append(fmt.format(v)) v += step return out VOL_CHOICES = _steps(0.0, 2.0, 0.05) TTSVOL_CHOICE = _steps(0.5, 3.0, 0.05) TEMP_CHOICES = _steps(0.1, 1.2, 0.05) SPEED_CHOICES = _steps(1.0, 2.0, 0.05) def find_sidecar_srt(video: Path): """Best-guess subtitle file sitting next to the video. Exact .srt wins. Otherwise anything starting with the stem is considered, preferring Polish-looking names (film.pl.srt, film_PL.srt), which is how these files are usually shipped. """ exact = video.with_suffix(".srt") if exact.is_file(): return exact try: candidates = sorted(p for p in video.parent.glob(f"{video.stem}*") if p.suffix.lower() in (".srt", ".ass", ".ssa", ".vtt")) except OSError: return None if not candidates: return None for p in candidates: low = p.name.lower() if ".pl." in low or "_pl." in low or ".pol." in low or "_pol." in low: return p return candidates[0] class JobBlock(tk.Frame): """One video and its settings — two rows, matching the mock-up.""" def __init__(self, master, app, video: Path = None, group=None): super().__init__(master, bg=BG) self.app = app self.group = group # blocks sharing a group keep settings in step self.sub_streams = [] # populated when embedded subs are used d, F = app.defaults, app.font_family self.f_field = (F, 13) self.f_small = (F, 8) self.f_btn = (F, 9, "bold") self.f_num = (F, 13) # The remove button gets a column of its own spanning both rows, so it # stays vertically centred against the block however tall that grows. close_col = tk.Frame(self, bg=BG) close_col.pack(side="left", fill="y", padx=(0, 12)) RoundButton(close_col, "\u00d7", self.remove, bg="#4a4a4a", fg=MUTED, hover="#7a3a3a", font=(F, 11, "bold"), padx=7, pady=5 ).pack(expand=True) # Both rows share one grid, so column 0 gets a single width and the two # input fields line up exactly as they do in the mock-up. grid = tk.Frame(self, bg=BG) grid.pack(side="left", fill="both", expand=True) grid.grid_columnconfigure(0, weight=1) # ── Row 1: video + the four numeric dropdowns ─────────────────────── r1 = tk.Frame(grid, bg=BG) r1.grid(row=0, column=1, sticky="e") self.video_var = tk.StringVar(value=str(video) if video else "") self.video_field = RoundField(grid, self.video_var, self.browse_video, font=self.f_field) self.video_field.grid(row=0, column=0, sticky="ew", padx=(0, 14)) self.video_entry = self.video_field.entry self.orig_vol = self._numeric(r1, "sound\nvolume", VOL_CHOICES, f"{d['orig_volume']:.2f}") self.tts_vol = self._numeric(r1, "voiceover\nvolume", TTSVOL_CHOICE, f"{d['tts_volume']:.2f}") self.temp = self._numeric(r1, "speech\ntemperature", TEMP_CHOICES, f"{d['temperature']:.2f}") self.speed = self._numeric(r1, "speed", SPEED_CHOICES, f"{d['speed']:.2f}") if self.group is not None: tk.Label(r1, text="linked", bg=BG, fg="#7fa8c9", font=(F, 8) ).pack(side="left", padx=(0, 6)) # Mirror every numeric field to the other blocks in this group. A write # trace catches typed values as well as menu picks. for name in ("orig_vol", "tts_vol", "temp", "speed"): getattr(self, name).trace_add( "write", lambda *_, n=name: self._mirror(n)) # ── Row 2: subtitles + per-job actions ────────────────────────────── r2 = tk.Frame(grid, bg=BG) r2.grid(row=1, column=1, sticky="ew", pady=(9, 0)) self.sub_var = tk.StringVar() self.sub_field = RoundField(grid, self.sub_var, self.browse_srt, font=self.f_field) self.sub_combo = ttk.Combobox(grid, state="readonly", style="Dark.TCombobox", font=(F, 11)) self.sub_field.grid(row=1, column=0, sticky="ew", padx=(0, 14), pady=(9, 0)) self.sub_entry = self.sub_field.entry self.embedded = tk.BooleanVar(value=False) self.emb_check = RoundCheck(r2, self.embedded, "embedded\nsubtitles", font=(F, 9), command=self.toggle_embedded, box=22) self.emb_check.pack(side="left", padx=(0, 16)) # Only meaningful for an external .srt — an embedded track is already # in the file, so the option is cleared and greyed out for those. self.include_subs = tk.BooleanVar(value=False) self.inc_check = RoundCheck(r2, self.include_subs, "include the\nsubtitles", font=(F, 9), command=self._mirror_include, box=22) self.inc_check.pack(side="left", padx=(0, 16)) # packed right-to-left so the group ends flush with the dropdowns above for label, cmd in (("RE-BUILD", self.do_rebuild), ("RESUME ...", self.ask_resume), ("REGENERATE ...", self.ask_regen)): RoundButton(r2, label, cmd, font=self.f_btn, padx=16, pady=7 ).pack(side="right", padx=(9, 0)) if video: self.autoload_srt() self.show_tails() # ── construction helpers ──────────────────────────────────────────────── def show_tails(self): """Scroll both path fields to the end. The full path stays in the field (and stays editable), but long paths would otherwise show only the leading directories and hide the one part that identifies the file. """ self.video_field.show_tail() self.sub_field.show_tail() def _numeric(self, parent, label, choices, default): tk.Label(parent, text=label, bg=BG, fg=FG, font=self.f_small, justify="right").pack(side="left", padx=(0, 5)) var = tk.StringVar(value=default) ttk.Combobox(parent, textvariable=var, values=choices, width=5, style="Dark.TCombobox", font=self.f_num ).pack(side="left", padx=(0, 14)) return var # ── keeping a linked set in step ──────────────────────────────────────── def _peers(self): if self.group is None: return [] return [b for b in self.app.blocks if b is not self and b.group == self.group] def _mirror(self, name): """Copy one numeric field to the rest of the group. The guard matters: setting a peer's variable fires its own write trace, which would otherwise bounce the value back and forth indefinitely. """ if self.app._syncing or self.group is None: return self.app._syncing = True try: value = getattr(self, name).get() for b in self._peers(): if getattr(b, name).get() != value: getattr(b, name).set(value) finally: self.app._syncing = False def _mirror_embedded(self): """Copy the embedded-subtitles state to the rest of the group. Each block still probes its own file, because track numbering differs from video to video. """ if self.app._syncing or self.group is None: return self.app._syncing = True try: want = self.embedded.get() for b in self._peers(): if b.embedded.get() != want: b.embedded.set(want) b.emb_check.refresh() b.toggle_embedded() finally: self.app._syncing = False def _mirror_include(self): """Copy the include-subtitles state to the rest of the group.""" if self.app._syncing or self.group is None: return self.app._syncing = True try: want = self.include_subs.get() for b in self._peers(): if b.embedded.get() or b.include_subs.get() == want: continue b.include_subs.set(want) b.inc_check.refresh() finally: self.app._syncing = False def _sync_include_state(self): """Follow the embedded-subtitles checkbox. With an embedded track the subtitles already travel with the video, so there is nothing to add — the option is cleared and greyed out. """ if self.embedded.get(): self.include_subs.set(False) self.inc_check.set_enabled(False) else: self.inc_check.set_enabled(True) # ── file selection ────────────────────────────────────────────────────── def browse_video(self): p = choose_files("Select video", VIDEO_TYPES, initial=self.video_var.get().strip() or None) if p: self.video_var.set(p) self.autoload_srt() self.show_tails() def browse_srt(self): start = self.sub_var.get().strip() or self.video_var.get().strip() or None p = choose_files("Select subtitles", SRT_TYPES, initial=start) if p: self.sub_var.set(p) self.show_tails() def autoload_srt(self): """Fill in a sidecar .srt if one exists beside the video.""" v = self.video_var.get().strip() if not v: return found = find_sidecar_srt(Path(v)) if found: self.sub_var.set(str(found)) self.show_tails() self.app.log(f" found subtitles: {found.name}") # ── embedded subtitle handling ────────────────────────────────────────── def toggle_embedded(self): if self.embedded.get(): video = self.video_var.get().strip() if not video or not Path(video).is_file(): self.embedded.set(False) self.emb_check.refresh() self._sync_include_state() messagebox.showwarning("No video", "Choose a video file first.") return self.sub_field.grid_remove() self.sub_combo.grid(row=1, column=0, sticky="ew", padx=(0, 14), pady=(9, 0), ipady=5) self.sub_combo.set("reading subtitle tracks...") self.sub_combo["values"] = [] # ffprobe on a large file takes a moment; keep the UI responsive. threading.Thread(target=self._probe_subs, args=(video,), daemon=True).start() self._sync_include_state() self._mirror_embedded() else: self.sub_combo.grid_remove() self.sub_field.grid() self._sync_include_state() self._mirror_embedded() def _probe_subs(self, video): try: streams = core.list_subtitle_streams(video) except Exception as exc: # noqa: BLE001 self.app.after(0, lambda: self._subs_failed(str(exc))) return usable = [s for s in streams if s["codec_name"].lower() in core._TEXT_SUB_CODECS] self.app.after(0, lambda: self._subs_ready(usable, len(streams))) def _subs_ready(self, usable, total): self.sub_streams = usable if not usable: self.sub_combo["values"] = [] self.sub_combo.set(f"no text subtitles ({total} image-based)" if total else "no subtitle tracks") return labels = [] for s in usable: bits = [f"#{s['index']}"] if s["language"]: bits.append(f"[{s['language']}]") if s["title"]: bits.append(s["title"]) bits.append(f"({s['codec_name']})") labels.append(" ".join(bits)) self.sub_combo["values"] = labels # Polish track first if there is one — that is the usual target. pick = next((i for i, s in enumerate(usable) if s["language"].lower().startswith("pol")), 0) self.sub_combo.current(pick) def _subs_failed(self, msg): self.sub_combo.set("could not read subtitle tracks") self.app.log(f" ! ffprobe failed: {msg}") def selected_sub_index(self): if not self.sub_streams: return None i = self.sub_combo.current() if i < 0: return None return self.sub_streams[i]["index"] # ── command construction ──────────────────────────────────────────────── def common_args(self): return ["--orig-volume", self.orig_vol.get().strip(), "--tts-volume", self.tts_vol.get().strip(), "--temperature", self.temp.get().strip(), "--speed", self.speed.get().strip()] def build_command(self, mode="generate", regen=None, resume=None): """Assemble the exact argv a terminal user would type.""" video = self.video_var.get().strip() if not video: raise ValueError("No video file selected.") if not Path(video).is_file(): raise ValueError(f"Video not found:\n{video}") cmd = [sys.executable, str(CLI)] # --rebuild and --regen need only the video; they read the job folder. if mode in ("rebuild", "regen"): cmd.append(video) elif self.embedded.get(): idx = self.selected_sub_index() if idx is None: raise ValueError("Tick a subtitle track, or untick " "'embedded subtitles' and pick an .srt file.") cmd += [video, "--embedded-subs", "--embedded-sub-index", str(idx)] else: srt = self.sub_var.get().strip() if not srt: raise ValueError("No subtitles. Choose an .srt file, or tick " "'embedded subtitles'.") if not Path(srt).is_file(): raise ValueError(f"Subtitle file not found:\n{srt}") cmd += [srt, video] cmd += self.common_args() # --regen only writes clips, so there is no mux for the track to join. if self.include_subs.get() and not self.embedded.get() and mode != "regen": cmd.append("--include-subs") # The whole queue shares one extra dictionary, kept with the first # video. --regen and --rebuild work purely from tts_input.srt, so the # flag would do nothing there. A resume normally reuses that file too, # but falls back to parsing when it is missing — and then the queue's # rules must still apply, so it is passed. if mode in ("generate", "resume"): extra = self.app.queue_dictionary_path() if extra is not None and extra.is_file(): cmd += ["--dictionary-extra", str(extra)] if mode == "rebuild": cmd.append("--rebuild") elif mode == "regen": cmd += ["--regen"] + [str(n) for n in regen] elif mode == "resume": cmd += ["--resume-from", str(resume)] return cmd def label(self): v = self.video_var.get().strip() return Path(v).name if v else "(no file)" # ── inspecting what has already been generated ────────────────────────── def job_folder(self): """Where this video's clips live, matching what the CLI would use.""" video = self.video_var.get().strip() if not video: return None return core.job_dir(Path(self.app.defaults["output_dir"]), video) def highest_clip(self): """Number of the last clip generated for this video, or None.""" job = self.job_folder() if job is None or not job.is_dir(): return None numbers = [] for path in job.glob("clip_*.flac"): try: numbers.append(int(path.stem.split("_")[1])) except (IndexError, ValueError): continue return max(numbers) if numbers else None # ── per-block actions ─────────────────────────────────────────────────── def _enqueue(self, mode, **kw): try: cmd = self.build_command(mode, **kw) except ValueError as exc: messagebox.showerror("Cannot run", str(exc)) return self.app.enqueue([(self.label(), mode, cmd, self.job_folder())]) def ask_regen(self): raw = SimplePrompt(self.app, "Regenerate clips", "Block numbers to regenerate, separated by commas:", "e.g. 12, 22, 41").result if not raw: return try: nums = [int(x) for x in raw.replace(";", ",").split(",") if x.strip()] except ValueError: messagebox.showerror("Invalid input", "Use whole numbers separated by commas.") return if not nums or any(n < 1 for n in nums): messagebox.showerror("Invalid input", "Block numbers start at 1.") return self._enqueue("regen", regen=nums) def ask_resume(self): # Offer the last clip on disk rather than the one after it: an # interrupted run can leave that final clip half-written, so it is the # safer place to pick up. Editable, and pre-selected so typing replaces it. last = self.highest_clip() if last is None: hint = "no clips generated yet for this video" initial = "" else: hint = f"{last} clips already generated — {last} regenerates the last one" initial = last raw = SimplePrompt(self.app, "Resume run", "Resume generation from block number:", hint, initial=initial).result if not raw: return try: n = int(raw.strip()) except ValueError: messagebox.showerror("Invalid input", "Enter a single whole number.") return if n < 1: messagebox.showerror("Invalid input", "Block numbers start at 1.") return self._enqueue("resume", resume=n) def do_rebuild(self): self._enqueue("rebuild") def remove(self): self.app.remove_block(self) class SimplePrompt(tk.Toplevel): """Small modal text prompt — used by REGENERATE and RESUME.""" def __init__(self, app, title, prompt, hint="", initial=""): super().__init__(app) self.result = None self.title(title) self.configure(bg=BG) self.resizable(False, False) self.transient(app) tk.Label(self, text=prompt, bg=BG, fg=FG, font=("TkDefaultFont", 10) ).pack(padx=18, pady=(16, 4), anchor="w") var = tk.StringVar(value=str(initial)) entry = tk.Entry(self, textvariable=var, bg=FIELD, fg=FIELD_FG, relief="flat", insertbackground=FIELD_FG, font=("TkDefaultFont", 12), width=30) entry.pack(padx=18, ipady=4, fill="x") if initial != "": entry.select_range(0, "end") # typing replaces the suggestion entry.icursor("end") if hint: tk.Label(self, text=hint, bg=BG, fg=MUTED, font=("TkDefaultFont", 8) ).pack(padx=18, pady=(3, 0), anchor="w") row = tk.Frame(self, bg=BG) row.pack(padx=18, pady=14, fill="x") def ok(_=None): self.result = var.get() self.destroy() tk.Button(row, text="Cancel", command=self.destroy, bg=BTN, fg=BTN_FG, relief="flat", padx=12, pady=3, highlightthickness=0).pack(side="right") tk.Button(row, text="OK", command=ok, bg=BTN, fg=BTN_FG, relief="flat", padx=18, pady=3, highlightthickness=0).pack(side="right", padx=(0, 8)) entry.bind("", ok) self.bind("", lambda _: self.destroy()) entry.focus_set() self.update_idletasks() x = app.winfo_rootx() + (app.winfo_width() - self.winfo_width()) // 2 y = app.winfo_rooty() + 120 self.geometry(f"+{max(0, x)}+{max(0, y)}") self.grab_set() app.wait_window(self) class LektorUI(tk.Tk): def __init__(self): # className lands in WM_CLASS; without it Tk reports itself as "Tk" and # that is what the dock shows on hover. Tk lowercases everything past # the first letter, so this is kept as a plain identifier — the pretty # name comes from lektor-ai.desktop, which matches on StartupWMClass. super().__init__(className="lektor-ai") self.title("lektor_ai — voiceover queue") self.geometry("1180x900") self.minsize(1000, 600) self.configure(bg=BG) self.font_family, self.font_ok = pick_font(self) self.defaults = core.load_settings(core.SETTINGS_FILE) self.blocks = [] self._group_seq = 0 self._syncing = False 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() self._in_redraw = False self._set_icon() self._style() self._build() self.after(80, self._drain_output) self.protocol("WM_DELETE_WINDOW", self.on_close) settings_note = (core.SETTINGS_FILE.name if core.SETTINGS_FILE.is_file() else "built-in defaults (no settings.ini)") self.log(f"Defaults from {settings_note}.") if not self.font_ok: self.log("Note: this Python's Tk has no scalable fonts, so text looks dated.") self.log(" 'sudo apt install python3-tk', then run with plain python3.") self.log("Add videos with 'Add videos...' or the + button, then press GENERATE.") # ── window icon ───────────────────────────────────────────────────────── def _set_icon(self): """Icon for the title bar, alt-tab and the dock. Kept on self because Tk holds only a weak reference to PhotoImages — drop it and the icon blanks out. Missing or unreadable files are not worth failing a launch over, so any trouble here is simply ignored. """ self._icons = [] for size in ICON_SIZES: path = ICON_DIR / f"icon-{size}.png" if not path.is_file(): continue try: self._icons.append(tk.PhotoImage(file=str(path))) except tk.TclError: pass if self._icons: # True: also the default for dialogs opened later. self.iconphoto(True, *self._icons) # ── theming ───────────────────────────────────────────────────────────── def _style(self): st = ttk.Style(self) try: st.theme_use("clam") except tk.TclError: pass st.configure("TFrame", background=BG) st.configure("Block.TFrame", background=BG) st.configure("Bar.TFrame", background=BAR) st.configure("Dark.TCombobox", fieldbackground=FIELD, background=FIELD, foreground=FIELD_FG, arrowcolor="#404040", bordercolor=FIELD, lightcolor=FIELD, darkcolor=FIELD, relief="flat", padding=(7, 6)) self.option_add("*TCombobox*Listbox.background", FIELD) self.option_add("*TCombobox*Listbox.foreground", FIELD_FG) self.option_add("*TCombobox*Listbox.selectBackground", "#7f7f7f") self.option_add("*TCombobox*Listbox.selectForeground", "#ffffff") st.map("Dark.TCombobox", fieldbackground=[("readonly", FIELD)], foreground=[("readonly", FIELD_FG)]) st.configure("Vertical.TScrollbar", background="#5a5a5a", troughcolor=BG, bordercolor=BG, arrowcolor=FG) # ── layout ────────────────────────────────────────────────────────────── def _build(self): F = self.font_family top = tk.Frame(self, bg=BAR, height=52) top.pack(fill="x"); top.pack_propagate(False) RoundButton(top, "Add videos...", self.add_videos, font=(F, 9, "bold"), padx=16, pady=7).pack(side="left", padx=16) self.count_lbl = tk.Label(top, text="0 videos queued", bg=BAR, fg=MUTED, font=(F, 9)) self.count_lbl.pack(side="left", padx=4) RoundButton(top, "Clear all", self.clear_all, bg="#3a3a3a", fg=MUTED, hover="#4a4a4a", font=(F, 9), padx=13, pady=6 ).pack(side="right", padx=16) RoundButton(top, "Settings", self.open_settings, bg="#3a3a3a", fg=MUTED, hover="#4a4a4a", font=(F, 9), padx=13, pady=6 ).pack(side="right") 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, "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) mid.pack(fill="both", expand=True) self.canvas = tk.Canvas(mid, bg=BG, highlightthickness=0, bd=0) vsb = ttk.Scrollbar(mid, orient="vertical", command=self.canvas.yview, style="Vertical.TScrollbar") self.canvas.configure(yscrollcommand=vsb.set) vsb.pack(side="right", fill="y") self.canvas.pack(side="left", fill="both", expand=True) self.holder = tk.Frame(self.canvas, bg=BG) self.holder_id = self.canvas.create_window((0, 0), window=self.holder, anchor="nw") self.holder.bind("", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) self.canvas.bind("", lambda e: self.canvas.itemconfigure(self.holder_id, width=e.width)) self._bind_wheel(self.canvas, self.canvas) # the + button, centred as in the mock-up plus_row = tk.Frame(self.holder, bg=BG) plus_row.pack(fill="x", pady=26) plus = tk.Canvas(plus_row, width=46, height=46, bg=BG, highlightthickness=0, bd=0) arm, t = 17, 7 # drawn, so it never depends on a glyph plus.create_rectangle(23 - t / 2, 23 - arm, 23 + t / 2, 23 + arm, fill=PLUS, outline="", tags="p") plus.create_rectangle(23 - arm, 23 - t / 2, 23 + arm, 23 + t / 2, fill=PLUS, outline="", tags="p") plus.bind("", lambda _: self.add_block(None)) plus.bind("", lambda _: plus.itemconfigure("p", fill="#d8d8d8")) plus.bind("", lambda _: plus.itemconfigure("p", fill=PLUS)) plus.configure(cursor="hand2") plus.pack() self.plus_row = plus_row # log logf = tk.Frame(self, bg=BG) logf.pack(fill="both", expand=False) tk.Label(logf, text="output", bg=BG, fg=MUTED, font=(F, 8)).pack(anchor="w", padx=18, pady=(2, 1)) wrap = tk.Frame(logf, bg=LOG_BG) wrap.pack(fill="both", expand=True, padx=18, pady=(0, 10)) self.log_txt = tk.Text(wrap, height=10, bg=LOG_BG, fg=LOG_FG, relief="flat", wrap="none", font=("TkFixedFont", 9), insertbackground=LOG_FG, bd=0, highlightthickness=0, padx=10, pady=8) lsb = ttk.Scrollbar(wrap, orient="vertical", command=self.log_txt.yview, style="Vertical.TScrollbar") self.log_txt.configure(yscrollcommand=lsb.set, state="disabled") lsb.pack(side="right", fill="y") self.log_txt.pack(fill="both", expand=True) self._bind_wheel(self.log_txt, self.log_txt) # bottom bar bot = tk.Frame(self, bg=BOTBAR, height=64) bot.pack(fill="x"); bot.pack_propagate(False) self.status = tk.Label(bot, text="Idle", bg=BOTBAR, fg="#dcdcdc", font=(F, 9)) self.status.pack(side="left", padx=20) self.gen_btn = RoundButton(bot, "GENERATE", self.generate_all, bg=GEN, hover="#c0c0c0", font=(F, 10, "bold"), padx=34, pady=9) self.gen_btn.pack(side="right", padx=20) self.stop_btn = RoundButton(bot, "STOP", self.stop, bg="#8a4040", fg="#f2f2f2", hover="#a04a4a", font=(F, 10, "bold"), padx=20, pady=9) self.stop_btn.pack(side="right") self.stop_btn.set_enabled(False) # ── queue dictionary ──────────────────────────────────────────────────── # # Replacements that apply to this batch only — a character's name the model # mangles, a recurring mistranslation — without editing the shared # dictionary.txt, which holds the rules that are true of Polish generally. # It lives in the first queued video's job folder so it sits with the # material it belongs to, and every job in the queue is passed it. QUEUE_DICT_NAME = "queue_dictionary.txt" def queue_dictionary_path(self): """Path in the first queued video's job folder, or None if the queue is empty — there is nowhere to put it until a video is chosen.""" for blk in self.blocks: folder = blk.job_folder() if folder is not None: return folder / self.QUEUE_DICT_NAME return None def open_queue_dictionary(self): QueueDictionaryDialog(self) # ── ComfyUI lifecycle ─────────────────────────────────────────────────── # # The backend is normally started by hand in a terminal and left running, # which is fine until it crashes or gets suspended: it then holds the GPU # while answering nothing, and a queue started overnight goes nowhere. All # 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.""" pid = core.comfy_pid() if pid is None: return False, False try: core._comfy_get("/system_stats") return True, True except Exception: return True, False def _refresh_comfy_button(self): running, healthy = self._comfy_state() if healthy: self.comfy_btn.set_text("ComfyUI") self.comfy_btn.set_indicator(COMFY_UP) elif running: # 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): """Start it, or restart it when it is up but wedged.""" core.COMFY_URL = self.defaults["comfy_url"] running, healthy = self._comfy_state() if healthy: if not messagebox.askyesno( "ComfyUI", "ComfyUI is running and answering.\n\n" "Restart it anyway?", parent=self): return self.comfy_btn.set_enabled(False) threading.Thread(target=self._comfy_worker, args=(running,), daemon=True).start() def _comfy_worker(self, running): try: if running: self.log_both("Stopping ComfyUI…") core.stop_comfy_backend() self.log_both("Starting ComfyUI…") if not core.start_comfy_backend(core.COMFY_START_SCRIPT): self.log_both("Could not start ComfyUI — see the terminal window.") return if core.wait_for_backend(180.0): self.log_both("ComfyUI is up.") else: 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) def open_settings(self): SettingsDialog(self) @staticmethod def _wheel_delta(ev): if getattr(ev, "num", None) == 4: return -1 if getattr(ev, "num", None) == 5: return 1 return -1 if getattr(ev, "delta", 0) > 0 else 1 def _bind_wheel(self, widget, target): """Route wheel events over `widget` to `target`, and nowhere else. This used to be bind_all, which fires for the whole application: the wheel scrolled the block list no matter what the pointer was over, on top of whatever that widget did itself. So the log pane and the open combobox lists scrolled their own contents *and* the window behind them, and hitting one widget's limit spilled into another. Binding on the widget puts the handler ahead of the class, toplevel and "all" bindings; returning "break" stops the event there, so a widget that cannot scroll any further simply stops rather than handing the leftover motion to its neighbours. """ def handler(ev, target=target): target.yview_scroll(self._wheel_delta(ev), "units") return "break" for seq in ("", "", ""): widget.bind(seq, handler) def _bind_wheel_tree(self, widget, target): """Apply _bind_wheel to a widget and everything inside it.""" self._bind_wheel(widget, target) for child in widget.winfo_children(): self._bind_wheel_tree(child, target) # ── block management ──────────────────────────────────────────────────── def add_videos(self): paths = choose_files("Select one or more videos", VIDEO_TYPES, multiple=True) if not paths: return # Files chosen together in one go are treated as a batch: their settings # stay in step, since that is almost always what you want for a series. # Blocks added later with + are independent. group = None if len(paths) > 1: self._group_seq += 1 group = self._group_seq for p in paths: self.add_block(Path(p), group=group) if group is not None: self.log(f" {len(paths)} videos added as a linked set — changing one " f"setting changes them all.") def add_block(self, video, group=None): blk = JobBlock(self.holder, self, video, group=group) blk.pack(fill="x", padx=14, pady=(BLOCK_GAP, 0), before=self.plus_row) sep = tk.Frame(self.holder, bg=SEP, height=1) sep.pack(fill="x", padx=14, pady=(BLOCK_GAP, 0), before=self.plus_row) blk._sep = sep # Children exist only now, and the wheel must reach the list from them. self._bind_wheel_tree(blk, self.canvas) self._bind_wheel(sep, self.canvas) self.blocks.append(blk) self._refresh_count() def remove_block(self, blk): if self.running: messagebox.showinfo("Busy", "Stop the queue before changing it.") return blk._sep.destroy() blk.destroy() self.blocks.remove(blk) self._refresh_count() def clear_all(self): if self.running: messagebox.showinfo("Busy", "Stop the queue before changing it.") return for b in list(self.blocks): b._sep.destroy() b.destroy() self.blocks.clear() self._refresh_count() def _refresh_count(self): n = len(self.blocks) self.count_lbl.configure(text=f"{n} video{'' if n == 1 else 's'} queued") # ── queue ─────────────────────────────────────────────────────────────── def generate_all(self): if not self.blocks: messagebox.showinfo("Nothing to do", "Add at least one video first.") return jobs = [] for b in self.blocks: try: 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).") if not self.running: self._next_job() def _next_job(self): if not self.jobs: self.running = False self._buttons_idle() self.status.configure(text="Idle") self.log("Queue finished.\n") self._log_issue("===== queue finished =====") return 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() def _run(self, cmd): """Run one job, streaming its output. Worker thread — no widget access.""" try: # Read the pipe as bytes, NOT with text=True. Text mode turns on # universal newlines, which rewrites every lone carriage return as a # newline — so the \r that redraws a progress bar would arrive as a # line break and the bar would march down the pane, one line per # update, instead of animating in place. self.proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0, cwd=str(HERE), ) except OSError as exc: self.out_q.put(("line", f" ! could not start: {exc}")) self.out_q.put(("done", 1)) return NL, CR = 10, 13 buf = bytearray() def flush(kind): self.out_q.put((kind, buf.decode("utf-8", "replace"))) buf.clear() fd = self.proc.stdout.fileno() while True: try: chunk = os.read(fd, 4096) # returns what is available except OSError: break if not chunk: break for byte in chunk: if byte == NL: flush("line") elif byte == CR: flush("rewrite") # redraw the current line else: buf.append(byte) if buf: flush("line") self.out_q.put(("done", self.proc.wait())) def _drain_output(self): """Main thread: move queued output into the log widget.""" try: while True: kind, payload = self.out_q.get_nowait() if kind == "line": self._emit_line(payload) elif kind == "rewrite": self._emit_rewrite(payload) elif kind == "done": if self._in_redraw: # close off an unfinished bar self._append("\n") self._in_redraw = False 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", f"lektor_ai_comfy.py exited with code {c}.\n\n" "See the output pane for details.\n" "Remaining queued jobs were cancelled.")) self._next_job() except queue.Empty: 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.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.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. ffmpeg writes progress bars as \r-PREFIXED frames, so the final frame arrives terminated by \n. While a bar is being redrawn that frame has 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") self._in_redraw = False else: self._append(text + "\n") def _emit_rewrite(self, text): self._replace_last(text) self._in_redraw = True 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) def stop(self): self.jobs.clear() if self.proc and self.proc.poll() is None: self.log(" ... stopping") self.proc.terminate() try: self.proc.wait(timeout=5) except subprocess.TimeoutExpired: self.proc.kill() self.running = False self._buttons_idle() self.status.configure(text="Stopped") def on_close(self): if self.running: pending = len(self.jobs) extra = f" and discard {pending} queued job(s)" if pending else "" if not messagebox.askyesno( "Quit lektor_ai", f"A job is still running.\n\nStop it{extra} and quit?", default="no", icon="warning", parent=self): return elif not messagebox.askyesno("Quit lektor_ai", "Close lektor_ai?", default="no", parent=self): return if self.proc and self.proc.poll() is None: self.proc.kill() # Leaving ComfyUI behind means ~8 GB of VRAM held by a backend nobody is # driving. It is stopped by port, so one started by hand in a terminal # goes too — which is the case that actually strands the GPU. self._stop_comfy_on_exit() self.destroy() def _stop_comfy_on_exit(self): """Best effort: quitting must not be blocked by a backend that will not die, so failures are reported and swallowed.""" core.COMFY_URL = self.defaults["comfy_url"] try: if core.comfy_pid() is None: return 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_both("ComfyUI did not stop — check for a leftover process.") except Exception as exc: # noqa: BLE001 self.log_both(f"Could not stop ComfyUI: {exc}") if __name__ == "__main__": if not CLI.is_file(): sys.exit(f"lektor_ai_comfy.py not found next to this script ({CLI})") LektorUI().mainloop()