diff --git a/lektor_ai_comfy.py b/lektor_ai_comfy.py index a26797c..1651aae 100644 --- a/lektor_ai_comfy.py +++ b/lektor_ai_comfy.py @@ -373,6 +373,91 @@ def strip_leading_audio(input_path: Path, output_path: Path, strip_ms: float): ], f"Strip {strip_ms:.0f}ms leading audio") +# A clip's trailing silence must not count towards its duration: placement uses +# a push-forward cursor, so padding at the end of one clip delays every clip +# after it. Usually that padding is a fraction of a second, but when the model +# fails to emit a stop token it generates until --max-tokens runs out — a short +# line then yields a clip of speech followed by two minutes of digital silence, +# which shunts the rest of the film out of sync. +SILENCE_DB = -45.0 # anything quieter counts as silence +SILENCE_MIN = 0.35 # ignore shorter gaps; commas and beats are not padding +SILENCE_KEEP_MS = 120 # leave this much tail so speech does not end abruptly + + +def trailing_silence_start(path: Path) -> float: + """Time at which the clip's final run of silence begins, or None.""" + result = subprocess.run( + ["ffmpeg", "-hide_banner", "-i", str(path), + "-af", f"silencedetect=noise={SILENCE_DB}dB:d={SILENCE_MIN}", + "-f", "null", "-"], + capture_output=True, text=True, + ) + try: + duration = get_audio_duration(path) + except RuntimeError: + return None + + open_at = None + for kind, value in re.findall(r"silence_(start|end):\s*([0-9.]+)", result.stderr): + t = float(value) + if kind == "start": + open_at = t + elif t < duration - 0.05: + open_at = None # that silence ended mid-clip, so it is a pause + return open_at + + +# Some runaway generations pad with a steady tone instead of digital silence. +# It sits above any sane silence threshold, so silencedetect cannot see it — +# but real speech swings tens of dB between windows while a tone does not. +# Measured here: tone tails spread 3-5 dB, speech tails 43-61 dB. +FLAT_TAIL_DB = 15.0 # spread below this is not speech +FLAT_TAIL_MIN = 5.0 # the flat run must last at least this long +RUNAWAY_SECONDS = 20.0 # only look for tone padding on clips this long + + +def flat_tail_start(path: Path, window: float = 0.5) -> float: + """Start of a long, unnaturally constant tail, or None.""" + result = subprocess.run( + ["ffmpeg", "-hide_banner", "-i", str(path), + "-af", f"astats=metadata=1:reset={window}," + "ametadata=print:key=lavfi.astats.Overall.RMS_level", + "-f", "null", "-"], + capture_output=True, text=True, + ) + blob = result.stderr + result.stdout + values = [(-120.0 if "inf" in m else float(m)) + for m in re.findall(r"RMS_level=(-?[0-9.]+|-?inf)", blob)] + need = int(FLAT_TAIL_MIN / window) + if len(values) < need + 1: + return None + for i in range(len(values) - need): + tail = values[i:] + if max(tail) - min(tail) <= FLAT_TAIL_DB: + return i * window + return None + + +def trim_trailing_silence(input_path: Path, output_path: Path) -> float: + """Cut the final silence off a clip. Returns the seconds removed.""" + duration = get_audio_duration(input_path) + start = trailing_silence_start(input_path) + if start is None and duration > RUNAWAY_SECONDS: + start = flat_tail_start(input_path) + keep = None if start is None else start + SILENCE_KEEP_MS / 1000 + if keep is None or keep >= duration - 0.02 or keep <= 0.05: + if input_path != output_path: + shutil.copy2(input_path, output_path) + return 0.0 + _run([ + "ffmpeg", "-y", + "-i", str(input_path), + "-t", f"{keep:.3f}", + str(output_path), + ], f"Trim {duration - keep:.1f}s trailing silence") + return duration - keep + + def _assemble_batch(clips: list, output_path: str, label: str, total_seconds: float = 0.0, start_offset: float = 0.0): """Mix a batch of (start_sec, path) clips with adelay into output_path. @@ -771,8 +856,22 @@ def _generate_one(text: str, voice_filename: str, voice_ref_text: str, strip_leading_audio(raw_clip, stripped, strip_leading_ms) raw_clip = stripped + raw_duration = get_audio_duration(raw_clip) + trimmed = out_dir / f"{label}_t.flac" + removed = trim_trailing_silence(raw_clip, trimmed) + if removed > 0: + raw_clip = trimmed + # Losing most of the clip to silence means the model never stopped and + # ran to the token ceiling. The speech is usually fine, but say so — + # it can also mean the line was cut short, which needs a --regen. + if removed > 20 and removed > raw_duration * 0.5: + print(f" ! {removed:.0f}s of trailing silence removed " + f"({raw_duration:.0f}s generated for {raw_duration - removed:.1f}s of " + f"speech) — the model did not stop on its own; check this clip") + fast_clip = out_dir / f"{label}.flac" speed_up_audio(raw_clip, fast_clip, speed, fade_out_ms=trailing_fade_ms) + trimmed.unlink(missing_ok=True) return fast_clip, get_audio_duration(fast_clip) @@ -924,6 +1023,45 @@ def _rebuild_video(args, out_dir: Path): print(f"\nDone! Output: {final_output}") +def _trim_job_clips(args, out_dir: Path): + """Trim trailing silence from every clip already on disk for this video. + + Repairs a job generated before trimming existed, or one where the model ran + to the token ceiling. Only silence is removed, so the speech is untouched + and nothing needs regenerating — follow with --rebuild. + """ + job = job_dir(out_dir, args.video) + clips = sorted(job.glob("clip_*.flac")) + if not clips: + sys.exit(f"No clips found in {job}." + _legacy_hint(out_dir, job)) + + print(f"\n Scanning {len(clips)} clips in {job}…") + total_removed, changed, runaways = 0.0, 0, [] + for i, clip in enumerate(clips, 1): + before = get_audio_duration(clip) + tmp = clip.with_suffix(".trim.flac") + removed = trim_trailing_silence(clip, tmp) + if removed > 0.02: + tmp.replace(clip) + total_removed += removed + changed += 1 + if removed > 20: + runaways.append((clip.name, before, removed)) + print(f" {clip.name} {before:7.2f}s -> {before - removed:6.2f}s " + f"(-{removed:.2f}s)") + else: + tmp.unlink(missing_ok=True) + + print(f"\n Trimmed {changed}/{len(clips)} clips, {total_removed:.0f}s of silence removed.") + if runaways: + print(f" {len(runaways)} clip(s) had run to the token ceiling:") + for name, before, removed in runaways: + print(f" {name}: {before:.0f}s generated for {before - removed:.1f}s of speech") + print(" Their speech is intact, but listen to them — if any line is cut " + "short, regenerate it with --regen.") + print("\n Now run --rebuild to reassemble with the corrected timing.") + + def _regen_clips(args, out_dir: Path): """Regenerate specific clips by tts_input block number. Only replaces FLAC files — run --rebuild afterwards to reassemble audio and video. @@ -1050,6 +1188,14 @@ def main(): "(same number as in clips/clip_NNNN.flac and tts_input.srt). " "Only replaces the selected FLAC files; run --rebuild " "afterwards to reassemble audio and rebuild the video.") + parser.add_argument("--trim-silence", action="store_true", + help="Trim trailing silence from the clips already generated for " + "this video, then stop. Repairs runs where the model failed " + "to stop and padded a clip out to --max-tokens, which pushes " + "every later clip out of sync. Follow with --rebuild.") + parser.add_argument("--embedded-sub-index", type=int, default=None, metavar="N", + help="With --embedded-subs, pick this ffmpeg stream index instead " + "of prompting. Used by ui.py and for scripted runs.") parser.add_argument("--embedded-subs", action="store_true", help="Pick a subtitle track embedded in the video file instead of " "providing an external SRT. Lists available tracks and prompts " @@ -1068,7 +1214,8 @@ def main(): # When the user writes `script.py video.mkv --embedded-subs` (or --rebuild / # --regen without an SRT), argparse puts the lone positional into args.srt. # Detect this and move it to args.video. - needs_srt = not (args.embedded_subs or args.rebuild or bool(args.regen)) + needs_srt = not (args.embedded_subs or args.rebuild or args.trim_silence + or bool(args.regen)) if args.video is None and args.srt is not None and not needs_srt: args.video = args.srt args.srt = None @@ -1081,6 +1228,11 @@ def main(): out_dir = Path(args.output_dir) out_dir.mkdir(parents=True, exist_ok=True) + # ── Maintenance: trim silence from existing clips, then stop ───────────── + if args.trim_silence: + _trim_job_clips(args, out_dir) + return + # ── Rebuild mode: reassemble + remux from existing clips, no TTS ───────── if args.rebuild: _rebuild_video(args, out_dir) @@ -1107,22 +1259,35 @@ def main(): sub_list = list_subtitle_streams(args.video) if not sub_list: sys.exit(f"No subtitle tracks found in {Path(args.video).name}.") - print(f" Available subtitle tracks in {Path(args.video).name}:") - for i, s in enumerate(sub_list): - lang = f" [{s['language']}]" if s['language'] else "" - title = f" — {s['title']}" if s['title'] else "" - compat = ("" if s['codec_name'].lower() in _TEXT_SUB_CODECS - else " (image-based, cannot be used for TTS)") - print(f" [{i + 1}] Stream #{s['index']}: {s['codec_name']}{lang}{title}{compat}") - while True: - try: - choice = int(input(" Select track number: ").strip()) - 1 - if 0 <= choice < len(sub_list): - break - print(f" Enter a number from 1 to {len(sub_list)}.") - except (ValueError, EOFError, KeyboardInterrupt): - sys.exit("\nAborted.") - chosen = sub_list[choice] + if args.embedded_sub_index is not None: + # Non-interactive selection by ffmpeg stream index, for the GUI and + # for scripted batch runs. Interactive behaviour is unchanged when + # this is omitted. + chosen = next((s for s in sub_list + if s["index"] == args.embedded_sub_index), None) + if chosen is None: + avail = ", ".join(str(s["index"]) for s in sub_list) + sys.exit(f"No subtitle stream #{args.embedded_sub_index} in " + f"{Path(args.video).name}. Available: {avail}") + print(f" Using subtitle stream #{chosen['index']} " + f"({chosen['codec_name']}{' ' + chosen['language'] if chosen['language'] else ''})") + else: + print(f" Available subtitle tracks in {Path(args.video).name}:") + for i, s in enumerate(sub_list): + lang = f" [{s['language']}]" if s['language'] else "" + title = f" — {s['title']}" if s['title'] else "" + compat = ("" if s['codec_name'].lower() in _TEXT_SUB_CODECS + else " (image-based, cannot be used for TTS)") + print(f" [{i + 1}] Stream #{s['index']}: {s['codec_name']}{lang}{title}{compat}") + while True: + try: + choice = int(input(" Select track number: ").strip()) - 1 + if 0 <= choice < len(sub_list): + break + print(f" Enter a number from 1 to {len(sub_list)}.") + except (ValueError, EOFError, KeyboardInterrupt): + sys.exit("\nAborted.") + chosen = sub_list[choice] if chosen['codec_name'].lower() not in _TEXT_SUB_CODECS: sys.exit(f"Stream #{chosen['index']} ({chosen['codec_name']}) is image-based " "and cannot be converted to text for TTS.") diff --git a/settings.ini b/settings.ini index 33bee77..ee7fc61 100644 --- a/settings.ini +++ b/settings.ini @@ -14,7 +14,7 @@ [tts] # Voice reference. Either a name in voices/ (expects NAME.wav + NAME.txt), # or a full path to a .wav file. -voice = gudowski_short +voice = gudowski_short_3 # Per-clip speed-up applied with atempo, before assembly. # Raise it if speech regularly overruns its subtitle window; 1.0 = no change. @@ -23,7 +23,7 @@ speed = 1.2 # Sampling temperature. 1.0 makes the model occasionally recite the reference # transcript instead of the subtitle; 0.9 largely prevents that. Lower is more # deterministic but flatter. -temperature = 0.7 +temperature = 0.6 # Ceiling on audio tokens per clip. Raise only if long clips are cut off # mid-word. diff --git a/start_lektor_ui.sh b/start_lektor_ui.sh new file mode 100755 index 0000000..bdc802f --- /dev/null +++ b/start_lektor_ui.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# start_lektor_ui.sh — launch the lektor_ai voiceover queue UI. +# +# Picks a Python that actually has tkinter. The system one is preferred: its Tk +# is built against fontconfig and renders real fonts, while the miniforge build +# only carries legacy X11 bitmap fonts and looks dated. Either will run. +# +# ComfyUI needs to be running for GENERATE to do anything — start_comfyui.sh. +# +set -euo pipefail + +cd "$(dirname "$(readlink -f "$0")")" + +if [[ ! -f ui.py ]]; then + echo "ui.py not found next to this script ($PWD)" >&2 + exit 1 +fi + +# Preference order, best-looking first. +CANDIDATES=( + /usr/bin/python3 + python3 + "$HOME/miniforge3/bin/python" + "$HOME/miniforge3/envs/comfy-tts/bin/python" +) + +PY="" +for c in "${CANDIDATES[@]}"; do + if command -v "$c" >/dev/null 2>&1 && "$c" -c "import tkinter" >/dev/null 2>&1; then + PY="$c" + break + fi +done + +if [[ -z "$PY" ]]; then + cat >&2 <<'EOF' +No Python with tkinter was found. + +Install it for the system Python (recommended — gives proper fonts): + sudo apt install python3-tk + +Miniforge's Python also ships tkinter if you have it: + ~/miniforge3/bin/python ui.py +EOF + exit 1 +fi + +exec "$PY" ui.py "$@" diff --git a/ui.py b/ui.py new file mode 100644 index 0000000..eabb6dd --- /dev/null +++ b/ui.py @@ -0,0 +1,1324 @@ +#!/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("", lambda _: self._paint(self._hover)) + self.bind("", lambda _: self._paint(self._bg)) + self.bind("", self._click) + + def _paint(self, colour): + if self._enabled: + self.itemconfigure(self._shape, fill=colour) + + def _click(self, _): + if self._enabled and self.command: + self.command() + + def set_enabled(self, on, disabled_bg="#6d6d6d"): + self._enabled = on + self.itemconfigure(self._shape, fill=self._bg if on else disabled_bg) + + def set_text(self, text): + self.itemconfigure(self._label, text=text) + + +class RoundField(tk.Frame): + """Entry on a rounded light background, with the browse affordance inside + it on the right — as in the mock-up, rather than a separate square button. + """ + + def __init__(self, master, textvariable, on_browse=None, font=None, height=34): + super().__init__(master, bg=master.cget("background"), height=height) + self.pack_propagate(False) + self.canvas = tk.Canvas(self, height=height, bg=master.cget("background"), + highlightthickness=0, bd=0) + self.canvas.pack(fill="both", expand=True) + self._shape = round_rect(self.canvas, 0, 0, 400, height, RADIUS, + fill=FIELD, outline="") + self.entry = tk.Entry(self.canvas, textvariable=textvariable, bg=FIELD, + fg=FIELD_FG, insertbackground=FIELD_FG, relief="flat", + highlightthickness=0, bd=0, + font=font or ("TkDefaultFont", 11)) + self._entry_id = self.canvas.create_window(12, height / 2, window=self.entry, + anchor="w", width=360) + self._dots = None + if on_browse: + self._dots = self.canvas.create_text(0, height / 2, text="...", + fill="#606060", anchor="e", + font=(font[0] if font else "TkDefaultFont", 12, "bold")) + self.canvas.tag_bind(self._dots, "", lambda _: on_browse()) + self.canvas.tag_bind(self._dots, "", + lambda _: self.canvas.itemconfigure(self._dots, fill="#111111")) + self.canvas.tag_bind(self._dots, "", + lambda _: self.canvas.itemconfigure(self._dots, fill="#606060")) + self.canvas.bind("", self._resize) + + def _resize(self, ev): + h = ev.height + self.canvas.coords(self._shape, *self._rr(0, 0, ev.width, h, RADIUS)) + pad_r = 34 if self._dots else 12 + self.canvas.coords(self._entry_id, 12, h / 2) + self.canvas.itemconfigure(self._entry_id, width=max(40, ev.width - 12 - pad_r)) + if self._dots: + self.canvas.coords(self._dots, ev.width - 12, h / 2) + + @staticmethod + def _rr(x1, y1, x2, y2, r): + r = min(r, (x2 - x1) / 2, (y2 - y1) / 2) + return [x1 + r, y1, x2 - r, y1, x2, y1, x2, y1 + r, x2, y2 - r, x2, y2, + x2 - r, y2, x1 + r, y2, x1, y2, x1, y2 - r, x1, y1 + r, x1, y1] + + def show_tail(self): + self.entry.after_idle(lambda: self.entry.xview_moveto(1.0)) + +VIDEO_TYPES = [("Video files", "*.mkv *.mp4 *.avi *.mov *.m4v *.webm *.ts"), + ("All files", "*.*")] +SRT_TYPES = [("Subtitle files", "*.srt *.ass *.ssa *.vtt"), ("All files", "*.*")] + +# Tk draws its own file chooser on Linux, which looks nothing like the rest of +# the desktop. zenity is a thin wrapper over the GTK chooser and, under GNOME, +# routes through xdg-desktop-portal — so this gets the real system dialog with +# its bookmarks, recent files and search. Falls back to Tk's if absent. +ZENITY = shutil.which("zenity") + + +def choose_files(title, filetypes, multiple=False, initial=None): + """System file chooser. Returns a list when multiple, else a path or ''.""" + if ZENITY: + cmd = [ZENITY, "--file-selection", f"--title={title}"] + if multiple: + cmd += ["--multiple", "--separator=\n"] + if initial: + cmd.append(f"--filename={initial}") + for label, pats in filetypes: + cmd.append(f"--file-filter={label} | {pats.replace('*.*', '*')}") + try: + r = subprocess.run(cmd, capture_output=True, text=True) + except OSError: + pass # fall through to the Tk dialog + else: + picked = [x for x in r.stdout.strip().split("\n") if x] \ + if r.returncode == 0 else [] + return picked if multiple else (picked[0] if picked else "") + + if multiple: + return list(filedialog.askopenfilenames(title=title, filetypes=filetypes)) + return filedialog.askopenfilename(title=title, filetypes=filetypes) + + +SETTING_LABELS = { + "voice": "voice reference", + "speed": "speed", + "temperature": "speech temperature", + "max_tokens": "max tokens per clip", + "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("", 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("", 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 .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("", ok) + self.bind("", lambda _: self.destroy()) + entry.focus_set() + self.update_idletasks() + x = app.winfo_rootx() + (app.winfo_width() - self.winfo_width()) // 2 + y = app.winfo_rooty() + 120 + self.geometry(f"+{max(0, x)}+{max(0, y)}") + self.grab_set() + app.wait_window(self) + + +class LektorUI(tk.Tk): + def __init__(self): + 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("", + lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) + self.canvas.bind("", + lambda e: self.canvas.itemconfigure(self.holder_id, width=e.width)) + self._bind_wheel(self.canvas, self.canvas) + + # the + button, centred as in the mock-up + plus_row = tk.Frame(self.holder, bg=BG) + plus_row.pack(fill="x", pady=26) + plus = tk.Canvas(plus_row, width=46, height=46, bg=BG, highlightthickness=0, bd=0) + arm, t = 17, 7 # drawn, so it never depends on a glyph + plus.create_rectangle(23 - t / 2, 23 - arm, 23 + t / 2, 23 + arm, + fill=PLUS, outline="", tags="p") + plus.create_rectangle(23 - arm, 23 - t / 2, 23 + arm, 23 + t / 2, + fill=PLUS, outline="", tags="p") + plus.bind("", lambda _: self.add_block(None)) + plus.bind("", lambda _: plus.itemconfigure("p", fill="#d8d8d8")) + plus.bind("", lambda _: plus.itemconfigure("p", fill=PLUS)) + plus.configure(cursor="hand2") + plus.pack() + self.plus_row = plus_row + + # log + logf = tk.Frame(self, bg=BG) + logf.pack(fill="both", expand=False) + tk.Label(logf, text="output", bg=BG, fg=MUTED, + font=(F, 8)).pack(anchor="w", padx=18, pady=(2, 1)) + wrap = tk.Frame(logf, bg=LOG_BG) + wrap.pack(fill="both", expand=True, padx=18, pady=(0, 10)) + self.log_txt = tk.Text(wrap, height=10, bg=LOG_BG, fg=LOG_FG, relief="flat", + wrap="none", font=("TkFixedFont", 9), + insertbackground=LOG_FG, bd=0, highlightthickness=0, + padx=10, pady=8) + lsb = ttk.Scrollbar(wrap, orient="vertical", command=self.log_txt.yview, + style="Vertical.TScrollbar") + self.log_txt.configure(yscrollcommand=lsb.set, state="disabled") + lsb.pack(side="right", fill="y") + self.log_txt.pack(fill="both", expand=True) + self._bind_wheel(self.log_txt, self.log_txt) + + # bottom bar + bot = tk.Frame(self, bg=BOTBAR, height=64) + bot.pack(fill="x"); bot.pack_propagate(False) + self.status = tk.Label(bot, text="Idle", bg=BOTBAR, fg="#dcdcdc", font=(F, 9)) + self.status.pack(side="left", padx=20) + self.gen_btn = RoundButton(bot, "GENERATE", self.generate_all, bg=GEN, + hover="#c0c0c0", font=(F, 10, "bold"), + padx=34, pady=9) + self.gen_btn.pack(side="right", padx=20) + self.stop_btn = RoundButton(bot, "STOP", self.stop, bg="#8a4040", fg="#f2f2f2", + hover="#a04a4a", font=(F, 10, "bold"), + padx=20, pady=9) + self.stop_btn.pack(side="right") + self.stop_btn.set_enabled(False) + + def open_settings(self): + SettingsDialog(self) + + @staticmethod + def _wheel_delta(ev): + if getattr(ev, "num", None) == 4: + return -1 + if getattr(ev, "num", None) == 5: + return 1 + return -1 if getattr(ev, "delta", 0) > 0 else 1 + + def _bind_wheel(self, widget, target): + """Route wheel events over `widget` to `target`, and nowhere else. + + This used to be bind_all, which fires for the whole application: the + wheel scrolled the block list no matter what the pointer was over, on + top of whatever that widget did itself. So the log pane and the open + combobox lists scrolled their own contents *and* the window behind + them, and hitting one widget's limit spilled into another. + + Binding on the widget puts the handler ahead of the class, toplevel and + "all" bindings; returning "break" stops the event there, so a widget + that cannot scroll any further simply stops rather than handing the + leftover motion to its neighbours. + """ + def handler(ev, target=target): + target.yview_scroll(self._wheel_delta(ev), "units") + return "break" + for seq in ("", "", ""): + widget.bind(seq, handler) + + def _bind_wheel_tree(self, widget, target): + """Apply _bind_wheel to a widget and everything inside it.""" + self._bind_wheel(widget, target) + for child in widget.winfo_children(): + self._bind_wheel_tree(child, target) + + # ── block management ──────────────────────────────────────────────────── + def add_videos(self): + paths = choose_files("Select one or more videos", VIDEO_TYPES, multiple=True) + if not paths: + return + # Files chosen together in one go are treated as a batch: their settings + # stay in step, since that is almost always what you want for a series. + # Blocks added later with + are independent. + group = None + if len(paths) > 1: + self._group_seq += 1 + group = self._group_seq + for p in paths: + self.add_block(Path(p), group=group) + if group is not None: + self.log(f" {len(paths)} videos added as a linked set — changing one " + f"setting changes them all.") + + def add_block(self, video, group=None): + blk = JobBlock(self.holder, self, video, group=group) + blk.pack(fill="x", padx=14, pady=(BLOCK_GAP, 0), before=self.plus_row) + sep = tk.Frame(self.holder, bg=SEP, height=1) + sep.pack(fill="x", padx=14, pady=(BLOCK_GAP, 0), before=self.plus_row) + blk._sep = sep + # Children exist only now, and the wheel must reach the list from them. + self._bind_wheel_tree(blk, self.canvas) + self._bind_wheel(sep, self.canvas) + self.blocks.append(blk) + self._refresh_count() + + def remove_block(self, blk): + if self.running: + messagebox.showinfo("Busy", "Stop the queue before changing it.") + return + blk._sep.destroy() + blk.destroy() + self.blocks.remove(blk) + self._refresh_count() + + def clear_all(self): + if self.running: + messagebox.showinfo("Busy", "Stop the queue before changing it.") + return + for b in list(self.blocks): + b._sep.destroy() + b.destroy() + self.blocks.clear() + self._refresh_count() + + def _refresh_count(self): + n = len(self.blocks) + self.count_lbl.configure(text=f"{n} video{'' if n == 1 else 's'} queued") + + # ── queue ─────────────────────────────────────────────────────────────── + def generate_all(self): + if not self.blocks: + messagebox.showinfo("Nothing to do", "Add at least one video first.") + return + jobs = [] + for b in self.blocks: + try: + jobs.append((b.label(), "generate", b.build_command("generate"))) + 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() diff --git a/voices/gudowski_short_3.txt b/voices/gudowski_short_3.txt new file mode 100644 index 0000000..644e02d --- /dev/null +++ b/voices/gudowski_short_3.txt @@ -0,0 +1,2 @@ +Sprawował dyplomatyczną misję w trudnym okresie, w czasie wojen bałkańskich i na początku Wielkiej Wojny. W roku tysiąc dziewięćset szesnastym prosto z Sofii udał się do Stanów Zjednoczonych, by objąć funkcję ambasadora Austro-Węgier w tym kraju. Nie otrzymał jednak od aliantów dyplomatycznego listu żelaznego. Podróżował zatem do Ameryki okrężną drogą. Zjawił się w Waszyngtonie dopiero pod koniec stycznia tysiąc dziewięćset siedemnastego roku, kiedy Niemcy zaczynały swoją nieograniczoną wojnę podwodną na Atlantyku. Tarnowski tłumaczył amerykańskiemu sekretarzowi stanu Robertowi Lansingowi, że Austro-Węgry nie mają nic wspólnego z atakowaniem i zatapianiem przez okręty podwodne statków pasażerskich i handlowych. Kiedy okazało się, że prawda jest inna, Tarnowski wyjaśniał amerykańskim politykom, że to Niemcy. Zwykle w czwartki po południu jestem w Armfilmie. Jest tu bardzo fajnie. + diff --git a/voices/gudowski_short_3.wav b/voices/gudowski_short_3.wav new file mode 100644 index 0000000..e67cf3c Binary files /dev/null and b/voices/gudowski_short_3.wav differ