# Architecture How `lektor_ai_comfy.py` turns subtitles into a finished video, and why each stage works the way it does. Most of the non-obvious choices here exist because the straightforward version was tried first and broke. ``` SRT / embedded track │ parse, clean, split dialogue ▼ subtitle blocks ──────────────► /tts_input.srt │ one HTTP job per block ▼ ComfyUI + Higgs Audio V3 │ per-clip atempo + fade ▼ /clip_NNNN.flac │ adelay + amix, batched ▼ tts_assembled.flac │ pass 1: mix with original audio │ pass 2: stream-copy mux ▼ {stem}_lektor.mkv ``` ## 1. Subtitle parsing `parse_subtitle_blocks()` reads the SRT with `utf-8-sig` (tolerating a BOM), splits on blank lines, and keeps blocks whose start falls inside the requested range. Times become **relative to `--start`**, which is why a later `--rebuild` must repeat the same `--start`/`--end`. `_clean_text()` then normalises each block, in this order: 1. **Strip formatting tags.** `{...}` ASS overrides (`{\an8}`, `{\pos(x,y)}`) and `<...>` HTML tags (``, ``). 2. **Detect dialogue.** Lines beginning `- ` mean two speakers and are kept separate; ordinary wrapped lines are joined with a space. 3. **Title-case ALL-CAPS blocks**, which the model would otherwise shout. Every word is capitalised rather than lowercasing all but the first letter, because the obvious version also lowercases the proper nouns inside a caption: `NASA – MARGO MADISON` became `Nasa – margo madison`, which is wrong to read aloud and invites the number normalizer to "correct" the name (it produced `marga`). A/B clips showed the TTS reads both casings the same, so keeping the names costs nothing. 4. **Capitalise a lowercase first letter**, since a fragment continuing from the previous subtitle confuses the model. 5. **Apply the replacement dictionary** — `km/h` → `kilometrów na godzinę` and anything else the model reads out wrong (see below). 6. **Add terminal punctuation** so the model produces a natural falling tail. Step 1 must come first. `ALL CAPS` looks mixed-case while the tag is still present — the `i` in `` is a lowercase letter — so the caps detection in step 3 would silently miss it. Step 5 must come after step 3 for the same reason in reverse: a lowercase replacement dropped into `JEDZIE 200 KM/H` would make the block look mixed-case and the shouting would survive. It comes before step 6 so a rule can supply punctuation of its own. ### The replacement dictionary `Dictionary.load()` reads `dictionary.txt` (`[paths] dictionary`, or `--dictionary`) into `pattern = replacement` rules — literal and case-insensitive, or a regular expression when the pattern is wrapped in slashes. Every rule is compiled into **one alternation** applied in a single `re.sub` pass, which buys three properties worth keeping: - Replacement text is never rescanned, so rules cannot chain or loop. - Literals are ordered longest-first inside the alternation, so `km/h` beats a plain `km` rule regardless of the order in the file. - Rules cost one pass over the text no matter how many there are. Literal patterns get `(?=5`, which cannot be installed beside ComfyUI's pinned dependencies, so it lives in `normalizer/` with its own venv and runs as a **separate process**. `NumberNormalizer` is the client half: - One worker per run, not per block — the 1.1 GB load dominates everything else. It is started only if some block actually contains a number worth expanding, and closed before step 2, so it is not holding VRAM while Higgs generates. - **ComfyUI is asked to unload first** (`free_comfy_vram`). Higgs stays resident between runs and leaves too little VRAM; a normalizer that does not fit is spilled to RAM by accelerate *silently*, at about 5× the time per block. A `POST /free` is cheaper than a restart — the server stays warm — and the wait is on `torch_vram_total` in `/system_stats` rather than a fixed sleep, because the flag is consumed by ComfyUI's worker loop rather than by the request. - JSON lines over stdin/stdout, strictly one request per response. stdout is the protocol; logs go to stderr and are kept in a 40-line ring for diagnostics. - Reads are on a queue fed by a reader thread, which is what makes a per-block timeout possible at all — a wedged worker cannot block the run. - **Nothing here is fatal.** Missing venv, dead worker, timeout, out-of-order reply: each prints a warning, disables the stage, and lets the digits through untouched. A number read badly is a bad clip; a crash here is a lost film. - **Two models behind one interface.** Plain integers go to num2words for the value and a general Polish LLM for the case; anything with a time, date, decimal or identifier goes to the 0.6B fine-tune. Measured on 40 real subtitle lines, each is clearly better than the other on its half and clearly worse on the other's. The worker reports which leg answered, and the run prints the split. - **Guards, not trust.** Both models can emit something worse than nothing — a repetition loop to the token ceiling, a dropped minus that inverts a temperature, a vanished noun, a number changed from 8250 to 8205. Each answer is checked against the input and falls back to num2words when it fails. `needs_number_normalization()` duplicates the worker's own selection rule so blocks that need nothing never cross the process boundary. The two must stay in step; the worker's copy is the authoritative one, and a test in the normalizer suite imports the caller and compares both on a shared sample. The result is written to `tts_input.srt` in that video's job folder (`output/clips/