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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user