Files
lektor-ai/ui.py
T
claudeandClaude Opus 5 e201aadc3e Add subtitle dictionaries, Polish number normalizer, ComfyUI lifecycle control
Text processing before TTS:

- dictionary.txt rewrites words and phrases (km/h, np., units). Rules compile
  into one alternation applied in a single pass, so replacements never chain
  and longest-literal wins. Case-sensitive, whole-word.
- A per-queue second dictionary, written by the GUI into the first video's job
  folder and merged with the shared one (--dictionary-extra).
- ALL-CAPS blocks become Title Case rather than sentence case: lowercasing
  destroyed proper nouns inside captions ("NASA - MARGO MADISON") and invited
  the normalizer to "correct" the name. Paired clips in output/clips/all-caps
  showed the TTS reads both casings the same.

normalizer/ — digits to spoken Polish, in its own venv because it needs
transformers>=5, which must not touch ComfyUI's pinned versions:

- Two backends. Plain integers go to num2words for the value and a general
  Polish LLM for the case; times, dates, decimals and identifiers go to a 0.6B
  fine-tune. Measured on 40 real subtitle lines, each is clearly better than
  the other on its half. 74% of numbered blocks take the first leg.
- Guards on every answer: runaway length, repetition loops, invalid bytes,
  leftover digits, a lost minus sign, a changed number, a lost word. A failure
  falls back to num2words, which is nominative but never wrong about the value.
- ComfyUI is asked to unload before normalizing; a normalizer that does not fit
  in VRAM is spilled to RAM silently at 5x the cost.

Backend lifecycle:

- ComfyUI is found by port through /proc, so a backend started by hand is
  managed too. SIGTERM escalates to SIGKILL, and a suspended process is killed
  outright since it never runs its handlers.
- A clip that kills the backend is retried three times, each with a different
  seed (the aborts are seed-dependent, not text-dependent), then skipped and
  reported so an overnight queue keeps moving.
- start_comfyui.sh execs python, so the script's PID is ComfyUI's.
- GUI gains ComfyUI and Queue dictionary buttons; quitting stops the backend.

--resume-from now reuses the job folder's tts_input.srt instead of reparsing
the subtitles, so block N still pairs with clip_NNNN.flac.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:55:31 +02:00

1553 lines
66 KiB
Python

#!/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 configparser
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
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"
# ── 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"
# 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.bind("<Enter>", lambda _: self._paint(self._hover))
self.bind("<Leave>", lambda _: self._paint(self._bg))
self.bind("<Button-1>", 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)
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, "<Button-1>", lambda _: on_browse())
self.canvas.tag_bind(self._dots, "<Enter>",
lambda _: self.canvas.itemconfigure(self._dots, fill="#111111"))
self.canvas.tag_bind(self._dots, "<Leave>",
lambda _: self.canvas.itemconfigure(self._dots, fill="#606060"))
self.canvas.bind("<Configure>", 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("<Escape>", 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("<Escape>", 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.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("<Button-1>", self._toggle)
self.refresh()
def _toggle(self, _=None):
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")
self.canvas.itemconfigure(self._box, fill="#ffffff" if on else FIELD)
# 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 <stem>.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)
# 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(fill="x")
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))
RoundButton(r1, "\u00d7", self.remove, bg="#4a4a4a", fg=MUTED,
hover="#7a3a3a", font=(F, 11, "bold"), padx=7, pady=5
).pack(side="left", padx=(10, 0))
# 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))
# 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
# ── 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()
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._mirror_embedded()
else:
self.sub_combo.grid_remove()
self.sub_field.grid()
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()
# 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)])
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("<Return>", ok)
self.bind("<Escape>", 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):
super().__init__()
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), …]
self.proc = None
self.running = False
self.out_q = queue.Queue()
self._in_redraw = False
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.")
# ── 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, "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)
# 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("<Configure>",
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
self.canvas.bind("<Configure>",
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("<Button-1>", lambda _: self.add_block(None))
plus.bind("<Enter>", lambda _: plus.itemconfigure("p", fill="#d8d8d8"))
plus.bind("<Leave>", 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 _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 ●")
elif running:
self.comfy_btn.set_text("ComfyUI ◍") # up but not answering
else:
self.comfy_btn.set_text("Start ComfyUI")
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("Stopping ComfyUI…")
core.stop_comfy_backend()
self.log("Starting ComfyUI…")
if not core.start_comfy_backend(core.COMFY_START_SCRIPT):
self.log("Could not start ComfyUI — see the terminal window.")
return
if core.wait_for_backend(180.0):
self.log("ComfyUI is up.")
else:
self.log(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 ("<MouseWheel>", "<Button-4>", "<Button-5>"):
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")))
except ValueError as exc:
messagebox.showerror("Cannot start", f"{b.label()}:\n\n{exc}")
return
self.enqueue(jobs)
def enqueue(self, 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")
return
label, mode, cmd = 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("$ " + " ".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")
elif payload is not None and payload < 0:
self.log(f" STOPPED (signal {-payload})")
self.jobs.clear()
else:
self.log(f" FAILED (exit {payload})")
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 _append(self, text):
self.log_txt.configure(state="normal")
self.log_txt.insert("end", text)
self.log_txt.see("end")
self.log_txt.configure(state="disabled")
def _replace_last(self, text):
"""Overwrite the last line, the way a terminal handles a carriage return."""
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")
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.
"""
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 _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("Stopping ComfyUI…")
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.")
except Exception as exc: # noqa: BLE001
self.log(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()