Add subtitle dictionaries, Polish number normalizer, ComfyUI lifecycle control

Text processing before TTS:

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

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

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

Backend lifecycle:

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 21:55:31 +02:00
co-authored by Claude Opus 5
parent 7e2cee02e5
commit e201aadc3e
21 changed files with 3759 additions and 52 deletions
+223 -2
View File
@@ -228,11 +228,18 @@ SETTING_LABELS = {
"orig_volume": "sound volume",
"tts_volume": "voiceover volume",
"output_dir": "output folder",
"dictionary": "replacement dictionary",
"normalize_numbers": "spell out numbers",
"normalizer_python": "normalizer interpreter",
"normalizer_llm": "inflection model",
"normalizer_timeout": "normalizer timeout (s)",
"free_comfy_vram": "unload Higgs to normalize",
"comfy_url": "ComfyUI URL",
"comfy_autostart": "start ComfyUI if down",
"comfy_start_command": "start script",
}
SECTION_TITLES = {"tts": "Speech", "mix": "Mix", "paths": "Paths", "comfy": "Backend"}
SECTION_TITLES = {"tts": "Speech", "mix": "Mix", "paths": "Paths",
"normalize": "Numbers", "comfy": "Backend"}
def raw_settings(path):
@@ -315,6 +322,100 @@ def save_settings(path, values):
tmp.replace(path) # atomic, so a crash cannot truncate it
class QueueDictionaryDialog(tk.Toplevel):
"""Plain text editor for the queue's own replacement rules.
Deliberately a text box rather than a table: the file format is one
`pattern = replacement` per line, and typing that is faster than filling in
two columns. The same parser reads it as the shared dictionary, so regex
rules and `#` comments work here too.
"""
def __init__(self, app):
super().__init__(app)
self.app = app
F = app.font_family
self.title("Queue dictionary")
self.configure(bg=BG)
self.transient(app)
path = app.queue_dictionary_path()
where = str(path) if path else "(add a video to the queue first)"
tk.Label(self, text="Replacements applied to every video in this queue,",
bg=BG, fg=FG, font=(F, 10)).pack(anchor="w", padx=22, pady=(16, 0))
tk.Label(self, text="in addition to dictionary.txt. One rule per line, "
"case-sensitive, whole words:",
bg=BG, fg=FG, font=(F, 10)).pack(anchor="w", padx=22)
tk.Label(self, text=" Joshua = Dżoszua", bg=BG, fg="#8fb8d6",
font=(F, 9)).pack(anchor="w", padx=22, pady=(6, 0))
tk.Label(self, text=where, bg=BG, fg=MUTED, font=(F, 8)
).pack(anchor="w", padx=22, pady=(8, 4))
frame = tk.Frame(self, bg=BG)
frame.pack(fill="both", expand=True, padx=22)
bar = tk.Scrollbar(frame)
bar.pack(side="right", fill="y")
self.text = tk.Text(frame, width=64, height=16, bg=FIELD, fg=FIELD_FG,
insertbackground=FIELD_FG, relief="flat", bd=0,
font=(F, 10), yscrollcommand=bar.set, undo=True)
self.text.pack(side="left", fill="both", expand=True)
bar.config(command=self.text.yview)
if path is not None and path.is_file():
try:
self.text.insert("1.0", path.read_text(encoding="utf-8"))
except OSError as exc:
messagebox.showerror("Queue dictionary", str(exc), parent=self)
self.status = tk.Label(self, text="", bg=BG, fg=MUTED, font=(F, 8))
self.status.pack(anchor="w", padx=22, pady=(6, 0))
buttons = tk.Frame(self, bg=BG)
buttons.pack(fill="x", padx=22, pady=16)
RoundButton(buttons, "Close", self.destroy, bg="#6f6f6f", fg="#f0f0f0",
hover="#828282", font=(F, 9, "bold"), padx=16, pady=6
).pack(side="right")
RoundButton(buttons, "Save", self._save, font=(F, 9, "bold"),
padx=22, pady=6).pack(side="right", padx=(0, 8))
RoundButton(buttons, "Clear", self._clear, bg="#4a4a4a", fg=MUTED,
hover="#5c5c5c", font=(F, 9), padx=14, pady=6).pack(side="left")
self.bind("<Escape>", lambda _: self.destroy())
self.update_idletasks()
x = app.winfo_rootx() + (app.winfo_width() - self.winfo_width()) // 2
self.geometry(f"+{max(0, x)}+{app.winfo_rooty() + 60}")
self.grab_set()
def _clear(self):
"""Empties the box only. Nothing is written until Save, so a misclick
costs a Ctrl+Z rather than the file."""
self.text.delete("1.0", "end")
self.status.config(text="Cleared — press Save to write the empty file.")
def _save(self):
path = self.app.queue_dictionary_path()
if path is None:
messagebox.showinfo(
"Queue dictionary",
"Add a video to the queue first — the file is written to the "
"first video's job folder.", parent=self)
return
body = self.text.get("1.0", "end").rstrip() + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
except OSError as exc:
messagebox.showerror("Queue dictionary", str(exc), parent=self)
return
# Report what the pipeline will actually make of it, so a typo shows up
# now rather than as a rule that silently does nothing during the run.
loaded = core.Dictionary.load(path)
self.status.config(text=f"Saved — {len(loaded)} rule(s) parsed.")
self.app.log(f"Queue dictionary saved to {path} ({len(loaded)} rules).")
class SettingsDialog(tk.Toplevel):
"""Editor for settings.ini — the defaults new job blocks start from."""
@@ -342,7 +443,7 @@ class SettingsDialog(tk.Toplevel):
if core.VOICE_PROMPTS_DIR.is_dir() else []
row = 0
for section in ("tts", "mix", "paths", "comfy"):
for section in ("tts", "mix", "paths", "normalize", "comfy"):
keys = [k for k, spec in core._DEFAULTS.items() if spec[0] == section]
if not keys:
continue
@@ -774,6 +875,16 @@ class JobBlock(tk.Frame):
cmd += self.common_args()
# The whole queue shares one extra dictionary, kept with the first
# video. --regen and --rebuild work purely from tts_input.srt, so the
# flag would do nothing there. A resume normally reuses that file too,
# but falls back to parsing when it is missing — and then the queue's
# rules must still apply, so it is passed.
if mode in ("generate", "resume"):
extra = self.app.queue_dictionary_path()
if extra is not None and extra.is_file():
cmd += ["--dictionary-extra", str(extra)]
if mode == "rebuild":
cmd.append("--rebuild")
elif mode == "regen":
@@ -985,6 +1096,14 @@ class LektorUI(tk.Tk):
RoundButton(top, "Settings", self.open_settings, bg="#3a3a3a", fg=MUTED,
hover="#4a4a4a", font=(F, 9), padx=13, pady=6
).pack(side="right")
RoundButton(top, "Queue dictionary", self.open_queue_dictionary,
bg="#3a3a3a", fg=MUTED, hover="#4a4a4a", font=(F, 9),
padx=13, pady=6).pack(side="right", padx=(0, 8))
self.comfy_btn = RoundButton(top, "ComfyUI", self.toggle_comfy,
bg="#3a3a3a", fg=MUTED, hover="#4a4a4a",
font=(F, 9), padx=13, pady=6)
self.comfy_btn.pack(side="right", padx=(0, 8))
self.after(500, self._refresh_comfy_button)
# scrollable list of job blocks
mid = tk.Frame(self, bg=BG)
@@ -1053,6 +1172,89 @@ class LektorUI(tk.Tk):
self.stop_btn.pack(side="right")
self.stop_btn.set_enabled(False)
# ── queue dictionary ────────────────────────────────────────────────────
#
# Replacements that apply to this batch only — a character's name the model
# mangles, a recurring mistranslation — without editing the shared
# dictionary.txt, which holds the rules that are true of Polish generally.
# It lives in the first queued video's job folder so it sits with the
# material it belongs to, and every job in the queue is passed it.
QUEUE_DICT_NAME = "queue_dictionary.txt"
def queue_dictionary_path(self):
"""Path in the first queued video's job folder, or None if the queue is
empty — there is nowhere to put it until a video is chosen."""
for blk in self.blocks:
folder = blk.job_folder()
if folder is not None:
return folder / self.QUEUE_DICT_NAME
return None
def open_queue_dictionary(self):
QueueDictionaryDialog(self)
# ── ComfyUI lifecycle ───────────────────────────────────────────────────
#
# The backend is normally started by hand in a terminal and left running,
# which is fine until it crashes or gets suspended: it then holds the GPU
# while answering nothing, and a queue started overnight goes nowhere. All
# of the work is in lektor_ai_comfy so the terminal script and this button
# behave identically.
def _comfy_state(self):
"""(running, healthy) — separate, because a backend can hold the port
and the GPU while no longer answering."""
pid = core.comfy_pid()
if pid is None:
return False, False
try:
core._comfy_get("/system_stats")
return True, True
except Exception:
return True, False
def _refresh_comfy_button(self):
running, healthy = self._comfy_state()
if healthy:
self.comfy_btn.set_text("ComfyUI ●")
elif running:
self.comfy_btn.set_text("ComfyUI ◍") # up but not answering
else:
self.comfy_btn.set_text("Start ComfyUI")
self.after(5000, self._refresh_comfy_button)
def toggle_comfy(self):
"""Start it, or restart it when it is up but wedged."""
core.COMFY_URL = self.defaults["comfy_url"]
running, healthy = self._comfy_state()
if healthy:
if not messagebox.askyesno(
"ComfyUI", "ComfyUI is running and answering.\n\n"
"Restart it anyway?", parent=self):
return
self.comfy_btn.set_enabled(False)
threading.Thread(target=self._comfy_worker, args=(running,),
daemon=True).start()
def _comfy_worker(self, running):
try:
if running:
self.log("Stopping ComfyUI…")
core.stop_comfy_backend()
self.log("Starting ComfyUI…")
if not core.start_comfy_backend(core.COMFY_START_SCRIPT):
self.log("Could not start ComfyUI — see the terminal window.")
return
if core.wait_for_backend(180.0):
self.log("ComfyUI is up.")
else:
self.log(f"ComfyUI did not answer within 180s. "
f"Its output is in {core.COMFY_LOG}.")
finally:
self.after(0, lambda: self.comfy_btn.set_enabled(True))
self.after(0, self._refresh_comfy_button)
def open_settings(self):
SettingsDialog(self)
@@ -1322,8 +1524,27 @@ class LektorUI(tk.Tk):
return
if self.proc and self.proc.poll() is None:
self.proc.kill()
# Leaving ComfyUI behind means ~8 GB of VRAM held by a backend nobody is
# driving. It is stopped by port, so one started by hand in a terminal
# goes too — which is the case that actually strands the GPU.
self._stop_comfy_on_exit()
self.destroy()
def _stop_comfy_on_exit(self):
"""Best effort: quitting must not be blocked by a backend that will not
die, so failures are reported and swallowed."""
core.COMFY_URL = self.defaults["comfy_url"]
try:
if core.comfy_pid() is None:
return
self.log("Stopping ComfyUI…")
self.update_idletasks() # let the message paint before we block
if not core.stop_comfy_backend():
self.log("ComfyUI did not stop — check for a leftover process.")
except Exception as exc: # noqa: BLE001
self.log(f"Could not stop ComfyUI: {exc}")
if __name__ == "__main__":
if not CLI.is_file():