Files
lektor-ai/ui.py
T
claudeandClaude Opus 5 200d221816 Add graphical queue (ui.py), fix runaway-clip padding
ui.py — graphical front-end
  Queue several videos and run them one after another. Each job shells out to
  lektor_ai_comfy.py with ordinary command-line arguments, so the terminal
  script stays the single source of truth and remains usable on its own.

  Videos chosen together in one multi-select form a linked set whose settings
  move together; blocks added with + stay independent. Subtitles are found
  beside the video automatically, picked manually, or chosen from the video's
  embedded tracks. REGENERATE / RESUME / RE-BUILD map to --regen,
  --resume-from and --rebuild; RESUME pre-fills the last clip on disk, since an
  interrupted run can leave that one half-written. A Settings dialog edits
  settings.ini in place, rewriting only the value lines so the file's comments
  survive — configparser would have dropped all 29 of them. Closing asks first,
  and names the queued jobs that would be discarded.

  tkinter, so no new dependencies. It needs a Tk with real fonts: the system
  one after `apt install python3-tk`. Under miniforge's Python it still runs but
  falls back to X11 bitmap fonts, which the UI detects and reports.

  Reading the child's pipe deliberately avoids text=True. Text mode enables
  universal newlines, which rewrites a lone carriage return as a newline — so
  ffmpeg's progress bars redrew as a new line per update instead of animating.
  Wheel events are bound per widget rather than with bind_all, so scrolling
  stops at a widget's limit instead of spilling into whatever is behind it, and
  can no longer change a dropdown's value in passing.

start_lektor_ui.sh
  Picks a Python that has tkinter, preferring the system one for its fonts.

Runaway clip padding
  When the model fails to emit a stop token it generates until --max-tokens is
  exhausted: a two-second line becomes a 136s clip. Placement uses a
  push-forward cursor, so that padding delayed every clip after it — measured
  on one film, 1807 of 2075 clips were pushed late, the worst by 981 seconds,
  which is why the voiceover fell silent over later dialogue.

  Clips are now trimmed before they are saved. Two padding modes occur and both
  are handled: digital silence, found with silencedetect; and a steady tone that
  sits above any sane silence threshold, found by its flatness instead — tone
  tails spread 3-5 dB across their windows where speech spreads 43-61 dB.

  --trim-silence repairs jobs generated before this, in place and without
  regenerating anything. Applied to the six existing jobs it removed 77 clips'
  worth of padding, about 1h55m, leaving every job within a few seconds of its
  subtitle timing.

--embedded-sub-index
  Selects an embedded subtitle track by ffmpeg stream index instead of
  prompting, for the UI and for scripted runs. Interactive behaviour is
  unchanged when it is omitted.

settings.ini — voice gudowski_short_3, temperature 0.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:36:59 +02:00

1325 lines
55 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",
"strip_leading_ms": "strip leading (ms)",
"trailing_fade_ms": "trailing fade (ms)",
"orig_volume": "sound volume",
"tts_volume": "voiceover volume",
"output_dir": "output folder",
"comfy_url": "ComfyUI URL",
}
SECTION_TITLES = {"tts": "Speech", "mix": "Mix", "paths": "Paths", "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 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", "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 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()
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")
# 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)
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()
self.destroy()
if __name__ == "__main__":
if not CLI.is_file():
sys.exit(f"lektor_ai_comfy.py not found next to this script ({CLI})")
LektorUI().mainloop()