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>
1325 lines
55 KiB
Python
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()
|