2026-08-10 12:36:59 +02:00
|
|
|
#!/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.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-13 09:48:20 +02:00
|
|
|
import collections
|
2026-08-10 12:36:59 +02:00
|
|
|
import configparser
|
|
|
|
|
import os
|
|
|
|
|
import queue
|
|
|
|
|
import re
|
|
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import threading
|
2026-08-13 09:48:20 +02:00
|
|
|
import time
|
2026-08-10 12:36:59 +02:00
|
|
|
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"
|
|
|
|
|
|
2026-08-13 09:48:20 +02:00
|
|
|
# Status dot on the ComfyUI button.
|
|
|
|
|
COMFY_UP = "#4caf50" # answering
|
|
|
|
|
COMFY_BUSY = "#d9a441" # holds the port but does not answer
|
|
|
|
|
|
2026-08-10 12:36:59 +02:00
|
|
|
# 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)
|
2026-08-13 09:48:20 +02:00
|
|
|
self._dot = None
|
2026-08-10 12:36:59 +02:00
|
|
|
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)
|
2026-08-13 09:48:20 +02:00
|
|
|
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)
|
2026-08-10 12:36:59 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
2026-08-10 14:12:33 +02:00
|
|
|
"truncation_retries": "retries if cut mid-word",
|
2026-08-10 12:36:59 +02:00
|
|
|
"strip_leading_ms": "strip leading (ms)",
|
|
|
|
|
"trailing_fade_ms": "trailing fade (ms)",
|
|
|
|
|
"orig_volume": "sound volume",
|
|
|
|
|
"tts_volume": "voiceover volume",
|
|
|
|
|
"output_dir": "output folder",
|
2026-08-12 19:40:44 +02:00
|
|
|
"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",
|
2026-08-10 12:36:59 +02:00
|
|
|
"comfy_url": "ComfyUI URL",
|
2026-08-10 14:12:33 +02:00
|
|
|
"comfy_autostart": "start ComfyUI if down",
|
|
|
|
|
"comfy_start_command": "start script",
|
2026-08-10 12:36:59 +02:00
|
|
|
}
|
2026-08-12 19:40:44 +02:00
|
|
|
SECTION_TITLES = {"tts": "Speech", "mix": "Mix", "paths": "Paths",
|
|
|
|
|
"normalize": "Numbers", "comfy": "Backend"}
|
2026-08-10 12:36:59 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 19:40:44 +02:00
|
|
|
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).")
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 12:36:59 +02:00
|
|
|
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
|
2026-08-12 19:40:44 +02:00
|
|
|
for section in ("tts", "mix", "paths", "normalize", "comfy"):
|
2026-08-10 12:36:59 +02:00
|
|
|
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
|
2026-08-10 14:12:33 +02:00
|
|
|
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:
|
2026-08-10 12:36:59 +02:00
|
|
|
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()
|
|
|
|
|
|
2026-08-12 19:40:44 +02:00
|
|
|
# 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)]
|
|
|
|
|
|
2026-08-10 12:36:59 +02:00
|
|
|
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
|
2026-08-13 09:48:20 +02:00
|
|
|
self.app.enqueue([(self.label(), mode, cmd, self.job_folder())])
|
2026-08-10 12:36:59 +02:00
|
|
|
|
|
|
|
|
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
|
2026-08-13 09:48:20 +02:00
|
|
|
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)
|
2026-08-10 12:36:59 +02:00
|
|
|
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")
|
2026-08-12 19:40:44 +02:00
|
|
|
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))
|
2026-08-13 09:48:20 +02:00
|
|
|
self.comfy_btn = RoundButton(top, "Start ComfyUI", self.toggle_comfy,
|
2026-08-12 19:40:44 +02:00
|
|
|
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)
|
2026-08-13 09:48:20 +02:00
|
|
|
self.after(60_000, self._trim_backend_log)
|
2026-08-10 12:36:59 +02:00
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
2026-08-12 19:40:44 +02:00
|
|
|
# ── 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.
|
|
|
|
|
|
2026-08-13 09:48:20 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-08-12 19:40:44 +02:00
|
|
|
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:
|
2026-08-13 09:48:20 +02:00
|
|
|
self.comfy_btn.set_text("ComfyUI")
|
|
|
|
|
self.comfy_btn.set_indicator(COMFY_UP)
|
2026-08-12 19:40:44 +02:00
|
|
|
elif running:
|
2026-08-13 09:48:20 +02:00
|
|
|
# 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)
|
2026-08-12 19:40:44 +02:00
|
|
|
else:
|
|
|
|
|
self.comfy_btn.set_text("Start ComfyUI")
|
2026-08-13 09:48:20 +02:00
|
|
|
self.comfy_btn.set_indicator(None)
|
2026-08-12 19:40:44 +02:00
|
|
|
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:
|
2026-08-13 09:48:20 +02:00
|
|
|
self.log_both("Stopping ComfyUI…")
|
2026-08-12 19:40:44 +02:00
|
|
|
core.stop_comfy_backend()
|
2026-08-13 09:48:20 +02:00
|
|
|
self.log_both("Starting ComfyUI…")
|
2026-08-12 19:40:44 +02:00
|
|
|
if not core.start_comfy_backend(core.COMFY_START_SCRIPT):
|
2026-08-13 09:48:20 +02:00
|
|
|
self.log_both("Could not start ComfyUI — see the terminal window.")
|
2026-08-12 19:40:44 +02:00
|
|
|
return
|
|
|
|
|
if core.wait_for_backend(180.0):
|
2026-08-13 09:48:20 +02:00
|
|
|
self.log_both("ComfyUI is up.")
|
2026-08-12 19:40:44 +02:00
|
|
|
else:
|
2026-08-13 09:48:20 +02:00
|
|
|
self.log_both(f"ComfyUI did not answer within 180s. "
|
|
|
|
|
f"Its output is in {core.COMFY_LOG}.")
|
2026-08-12 19:40:44 +02:00
|
|
|
finally:
|
|
|
|
|
self.after(0, lambda: self.comfy_btn.set_enabled(True))
|
|
|
|
|
self.after(0, self._refresh_comfy_button)
|
|
|
|
|
|
2026-08-10 12:36:59 +02:00
|
|
|
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:
|
2026-08-13 09:48:20 +02:00
|
|
|
jobs.append((b.label(), "generate", b.build_command("generate"),
|
|
|
|
|
b.job_folder()))
|
2026-08-10 12:36:59 +02:00
|
|
|
except ValueError as exc:
|
|
|
|
|
messagebox.showerror("Cannot start", f"{b.label()}:\n\n{exc}")
|
|
|
|
|
return
|
|
|
|
|
self.enqueue(jobs)
|
|
|
|
|
|
2026-08-13 09:48:20 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-10 12:36:59 +02:00
|
|
|
def enqueue(self, jobs):
|
2026-08-13 09:48:20 +02:00
|
|
|
if not self.running:
|
|
|
|
|
self._open_log(jobs)
|
2026-08-10 12:36:59 +02:00
|
|
|
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")
|
2026-08-13 09:48:20 +02:00
|
|
|
self._log_issue("===== queue finished =====")
|
2026-08-10 12:36:59 +02:00
|
|
|
return
|
2026-08-13 09:48:20 +02:00
|
|
|
label, mode, cmd, folder = self.jobs.pop(0)
|
2026-08-10 12:36:59 +02:00
|
|
|
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} ===")
|
2026-08-13 09:48:20 +02:00
|
|
|
self._log_issue(f"--- {mode}: {label}")
|
|
|
|
|
self._job_tail.clear()
|
2026-08-10 12:36:59 +02:00
|
|
|
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")
|
2026-08-13 09:48:20 +02:00
|
|
|
self._log_issue(" finished OK")
|
2026-08-10 12:36:59 +02:00
|
|
|
elif payload is not None and payload < 0:
|
|
|
|
|
self.log(f" STOPPED (signal {-payload})")
|
2026-08-13 09:48:20 +02:00
|
|
|
self._log_issue(f" STOPPED by signal {-payload} — "
|
|
|
|
|
f"{len(self.jobs)} job(s) cancelled")
|
2026-08-10 12:36:59 +02:00
|
|
|
self.jobs.clear()
|
|
|
|
|
else:
|
|
|
|
|
self.log(f" FAILED (exit {payload})")
|
2026-08-13 09:48:20 +02:00
|
|
|
self._log_issue(f" FAILED with exit code {payload} — "
|
|
|
|
|
f"{len(self.jobs)} job(s) cancelled")
|
|
|
|
|
self._log_tail()
|
2026-08-10 12:36:59 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-08-13 09:48:20 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-10 12:36:59 +02:00
|
|
|
def _append(self, text):
|
2026-08-13 09:48:20 +02:00
|
|
|
follow = self._at_bottom()
|
2026-08-10 12:36:59 +02:00
|
|
|
self.log_txt.configure(state="normal")
|
|
|
|
|
self.log_txt.insert("end", text)
|
|
|
|
|
self.log_txt.configure(state="disabled")
|
2026-08-13 09:48:20 +02:00
|
|
|
if follow:
|
|
|
|
|
self.log_txt.see("end")
|
2026-08-10 12:36:59 +02:00
|
|
|
|
|
|
|
|
def _replace_last(self, text):
|
|
|
|
|
"""Overwrite the last line, the way a terminal handles a carriage return."""
|
2026-08-13 09:48:20 +02:00
|
|
|
follow = self._at_bottom()
|
2026-08-10 12:36:59 +02:00
|
|
|
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")
|
2026-08-13 09:48:20 +02:00
|
|
|
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)
|
2026-08-10 12:36:59 +02:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
"""
|
2026-08-13 09:48:20 +02:00
|
|
|
self._record_if_issue(text)
|
2026-08-10 12:36:59 +02:00
|
|
|
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")
|
|
|
|
|
|
2026-08-13 09:48:20 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-08-10 12:36:59 +02:00
|
|
|
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()
|
2026-08-12 19:40:44 +02:00
|
|
|
|
|
|
|
|
# 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()
|
2026-08-10 12:36:59 +02:00
|
|
|
self.destroy()
|
|
|
|
|
|
2026-08-12 19:40:44 +02:00
|
|
|
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
|
2026-08-13 09:48:20 +02:00
|
|
|
self.log_both("Stopping ComfyUI on exit…")
|
2026-08-12 19:40:44 +02:00
|
|
|
self.update_idletasks() # let the message paint before we block
|
|
|
|
|
if not core.stop_comfy_backend():
|
2026-08-13 09:48:20 +02:00
|
|
|
self.log_both("ComfyUI did not stop — check for a leftover process.")
|
2026-08-12 19:40:44 +02:00
|
|
|
except Exception as exc: # noqa: BLE001
|
2026-08-13 09:48:20 +02:00
|
|
|
self.log_both(f"Could not stop ComfyUI: {exc}")
|
2026-08-12 19:40:44 +02:00
|
|
|
|
2026-08-10 12:36:59 +02:00
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
if not CLI.is_file():
|
|
|
|
|
sys.exit(f"lektor_ai_comfy.py not found next to this script ({CLI})")
|
|
|
|
|
LektorUI().mainloop()
|