Compare commits

..
5 Commits
Author SHA1 Message Date
claudeandClaude Opus 5 1dbe862531 Add the Knapik voice and a fourth Gudowski sample
Tomasz_Knapik.wav is a second lektor voice to try against Gudowski. It has no
reference transcript yet, so it needs one written before it can be used the way
the others are.

gudowski_short_4.wav is a fourth reference sample with its transcript, and
gudowski_generated.wav is TTS output kept beside the samples for comparison,
not an input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkWDx9dVB7xBpqntvjvm6A
2026-08-31 21:18:34 +02:00
claudeandClaude Opus 5 e541ef7ee9 Give a wedged ComfyUI ten restarts per clip instead of three
Three was not enough in practice: a backend that dies during model load can
burn all three before it ever comes up healthy, and the clip is then skipped —
or, when the backend is simply gone, the run stalls waiting for it.

Both caps move together. COMFY_AUTOSTART_MAX is what actually gates a relaunch,
so raising CLIP_CRASH_LIMIT alone would still have stopped at three restarts.
Counting is unchanged: both are per clip, not per run.

The cost of a truly dead backend goes up accordingly — up to ten rounds of a
90s cooldown plus the 180s health timeout before a clip is given up on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkWDx9dVB7xBpqntvjvm6A
2026-08-31 21:15:36 +02:00
claudeandClaude Opus 5 39452a4281 Mux the spoken subtitles into the output, and fix MP4 subtitle copying
--include-subs adds the subtitles the voiceover was made from to the finished
video as one more track, alongside whatever the source already carried. Useful
where the source has no Polish subtitles of its own, and for checking the
voiceover against the text it came from.

Every run now writes clips/<job>/voiceover_subs.srt: the cue text verbatim,
timestamps shifted by --start and cues outside --start..--end dropped. Verbatim
because this is what a viewer reads, not what the model was handed — dictionary
rewrites and spelled-out numbers must not leak into it. It is written whether
or not --include-subs was passed, because --rebuild --include-subs is given
only the video and would otherwise have nothing to mux; runs made before this
existed fall back to tts_input.srt with a note that the wording is the model's.

Copying subtitle tracks from an MP4 source was broken, and silently: MP4 stores
them as mov_text (tx3g), which the Matroska muxer rejects — "Subtitle codec
94213 is not supported" — and then no output file is written at all. Those
tracks are now decoded to SubRip in a temporary file and muxed from there,
while image formats (PGS, VOBSUB) are still stream-copied since they cannot
become text. Two details that follow from the detour: ffmpeg's tx3g decoder
emits the format's style records as <font face="Serif"><font size="18"> markup,
which players either honour (subtitles rendered at whatever pixel size the MP4
specified, usually unreadably small) or print literally — so the tags are
stripped; and a standalone .srt carries no language, so the source track's
language and title tags are re-applied by hand or the output reads "undefined".
An empty placeholder track, which rips often carry, is dropped rather than
muxed as an empty one.

UI: an "include the subtitles" checkbox next to the embedded-subtitles one. It
is cleared and greyed out when the embedded track is chosen, since those
subtitles already travel with the video, and it mirrors across linked jobs like
the other per-job settings. RoundCheck grew a set_enabled() for that. The
remove button moves into a column of its own spanning both rows, so it stays
centred now that the second row is taller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkWDx9dVB7xBpqntvjvm6A
2026-08-31 21:15:27 +02:00
claudeandClaude Opus 5 d350b314ad Give the app an icon and a place in the application menu
icon.svg is the source; make_icons.py rasterises it into assets/icon-<size>.png
at the six sizes a window manager asks for. Tk 8.6 reads PNG but not SVG, so
the icon has to ship pre-rendered, and the glyph is laid on a rounded plate in
the app's own colours — light on dark stays legible against both a light and a
dark dock, which slate grey on nothing does not.

ui.py loads whichever of those files exist and passes them all to iconphoto(),
letting the WM pick per context (title bar, alt-tab, dock). The images are kept
on the instance because Tk holds only a weak reference to a PhotoImage and the
icon blanks out when it is collected. A missing or unreadable file is ignored
rather than allowed to fail a launch.

It also passes className="lektor-ai", which lands in WM_CLASS. Without it Tk
calls itself "Tk" and that is what the dock shows on hover; the readable name
comes from the desktop entry, which matches the window through StartupWMClass.

install_desktop.sh writes ~/.local/share/applications/lektor-ai.desktop and
copies the icons into the hicolor theme, so the app appears in the menu and can
be pinned. Everything it installs lives under ~/.local and comes back out with
--uninstall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkWDx9dVB7xBpqntvjvm6A
2026-08-31 21:14:36 +02:00
claudeandClaude Opus 5 4a9b05de66 Add two standalone tools: prose to speech, TMPlayer to SRT
tts_txt.py reads a plain text file aloud with the lektor voice. The pipeline is
built around subtitles — every clip is placed at a timestamp — so prose needs
the one piece that is genuinely missing, a chunker that splits text into
sentence-sized pieces (240 chars, on sentence boundaries, long sentences split
at clause breaks). Everything else is lektor_ai_comfy's: the dictionary, the
Polish number normalizer, the ComfyUI call with its truncation retries and
crash recovery, the per-clip trim and speed-up. Clips land in
output/clips/<stem>/ like a film's, so --resume-from and re-assembly work the
same way, and --dry-run prints the chunks without generating anything.

tmp2srt.py converts TMPlayer subtitles (HH:MM:SS:text, '|' for a line break)
to SRT. TMPlayer stores only a start time, so an end is inferred: the next
cue's start less a small gap, or sooner when 16 chars/second says the line can
be read in less. The separator after the timestamp is optional in the wild —
some files use ':', some a space, some nothing — so all three parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkWDx9dVB7xBpqntvjvm6A
2026-08-31 21:14:00 +02:00
17 changed files with 841 additions and 19 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="64" height="64" viewBox="0 0 48 48" style="color: rgb(74, 85, 101); opacity: 1; transform: rotate(0deg);"><path fill="currentColor" fill-rule="evenodd" d="M2 1.5a.5.5 0 0 0-.5.5v44a.5.5 0 0 0 .5.5c5.623 0 8.656-.668 9.894-1.033c.617-.182.974-.75.974-1.343V39.69l2.673.323a3.5 3.5 0 0 0 3.908-3.174l.59-6.844c1.703-.44 2.974-1.043 3.845-1.563c1.151-.686 1.227-2.126.512-3.101l-3.953-5.39C20.238 9.742 12.625 1.5 2 1.5m11 15a1.5 1.5 0 0 1 1.5 1.5v1a1.5 1.5 0 0 1-3 0v-1a1.5 1.5 0 0 1 1.5-1.5m25.766 25.259a15.15 15.15 0 0 0 0-21.518a1.75 1.75 0 0 1 2.468-2.482c7.355 7.312 7.355 19.17 0 26.482a1.75 1.75 0 0 1-2.468-2.482m-4.813-3.934c3.396-3.751 3.396-9.899 0-13.65a1.75 1.75 0 1 1 2.594-2.35c4.604 5.086 4.604 13.265 0 18.35a1.75 1.75 0 1 1-2.594-2.35M28.307 27.78c1.924 1.793 1.924 4.647 0 6.44a1.75 1.75 0 1 0 2.386 2.56c3.41-3.177 3.41-8.383 0-11.56a1.75 1.75 0 1 0-2.386 2.56" clip-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
#
# install_desktop.sh — put "Lektor AI" in the application menu and the dock.
#
# Two things live outside this folder afterwards, both under ~/.local and both
# removable with --uninstall:
#
# ~/.local/share/applications/lektor-ai.desktop
# ~/.local/share/icons/hicolor/<size>/apps/lektor-ai.png
#
# The desktop entry matches the running window through StartupWMClass, which is
# the WM_CLASS that ui.py sets. Without the match the dock falls back to the raw
# WM_CLASS for its hover label.
#
set -euo pipefail
HERE="$(dirname "$(readlink -f "$0")")"
APPS="$HOME/.local/share/applications"
ICONS="$HOME/.local/share/icons/hicolor"
ENTRY="$APPS/lektor-ai.desktop"
SIZES=(16 32 48 64 128 256)
if [[ "${1:-}" == "--uninstall" ]]; then
rm -f "$ENTRY"
for s in "${SIZES[@]}"; do
rm -f "$ICONS/${s}x${s}/apps/lektor-ai.png"
done
echo "Removed the desktop entry and its icons."
else
for s in "${SIZES[@]}"; do
src="$HERE/assets/icon-$s.png"
[[ -f "$src" ]] || continue
mkdir -p "$ICONS/${s}x${s}/apps"
cp "$src" "$ICONS/${s}x${s}/apps/lektor-ai.png"
done
mkdir -p "$APPS"
cat > "$ENTRY" <<DESKTOP
[Desktop Entry]
Type=Application
Name=Lektor AI
Comment=Polish AI voiceover queue
Exec=$HERE/start_lektor_ui.sh
Icon=lektor-ai
Terminal=false
Categories=AudioVideo;Audio;
StartupWMClass=Lektor-ai
DESKTOP
chmod +x "$ENTRY"
echo "Installed $ENTRY"
fi
# Harmless if the tools are missing; the dock picks the change up either way.
update-desktop-database "$APPS" 2>/dev/null || true
gtk-update-icon-cache -f -t "$ICONS" 2>/dev/null || true
+173 -13
View File
@@ -819,6 +819,63 @@ def split_dialogue_blocks(blocks: list) -> list:
return result return result
def save_voiceover_subs(srt_path: str, clips_dir: Path, start_sec: float,
end_sec: float) -> Path:
"""Keep a copy of the subtitles this run speaks, timed to the output video.
Written for every run, so a later --rebuild --include-subs still has them
when the original .srt is no longer on the command line. Cue text is copied
verbatim: this is the track a viewer reads, not the text handed to the
model, so dictionary replacements and spelled-out numbers stay out of it.
Timestamps are shifted by --start, because the video they are muxed into
begins there, and cues outside --start..--end are dropped.
"""
out = clips_dir / "voiceover_subs.srt"
text = Path(srt_path).read_text(encoding="utf-8-sig")
kept = []
for block in re.split(r"\n{2,}", text.strip()):
lines = block.strip().splitlines()
ti = next((i for i, l in enumerate(lines) if "-->" in l), None)
if ti is None:
continue
parts = lines[ti].split("-->")
if len(parts) != 2:
continue
t_start = _parse_srt_time(parts[0])
t_end = _parse_srt_time(parts[1])
if t_start < start_sec or t_start > end_sec:
continue
body = [l for l in lines[ti + 1:] if l.strip()]
if not body:
continue
kept.append(f"{len(kept) + 1}\n"
f"{_fmt_srt_time(t_start - start_sec)} --> "
f"{_fmt_srt_time(max(0.0, t_end - start_sec))}\n"
+ "\n".join(body) + "\n")
out.write_text("\n".join(kept), encoding="utf-8")
return out
def voiceover_subs_file(job: Path):
"""The subtitle file --include-subs should mux, or None if there is none.
Prefers the verbatim copy kept by the run itself. Runs made before that
copy existed still have tts_input.srt, which is the same timing but the
model's wording — better than no track at all, so it is used with a note.
"""
saved = job / "voiceover_subs.srt"
if saved.is_file() and saved.stat().st_size:
return saved
fallback = job / "tts_input.srt"
if fallback.is_file() and fallback.stat().st_size:
print(f" --include-subs: no {saved.name} from the original run — using "
f"{fallback.name} instead (the model's text, not the original wording).")
return fallback
print(" --include-subs: no subtitles found in the job folder — skipping "
"the extra track.", file=sys.stderr)
return None
def blocks_to_srt(blocks: list) -> str: def blocks_to_srt(blocks: list) -> str:
"""Convert (start, end, text) tuples back to SRT string for inspection.""" """Convert (start, end, text) tuples back to SRT string for inspection."""
lines = [] lines = []
@@ -1196,12 +1253,48 @@ def get_media_duration(path: str) -> float:
return 0.0 return 0.0
_FONT_TAG_RE = re.compile(r'</?font[^>]*>', re.IGNORECASE)
def extract_mov_text_srt(video_path: str, stream_index: int, out_path: Path) -> bool:
"""Decode one mov_text track to a SubRip file, without its styling.
ffmpeg's tx3g decoder turns the format's style records into markup, so
every cue comes out wrapped in <font face="Serif"><font size="18">. Players
that honour those tags then render the subtitles at whatever pixel size the
MP4 happened to specify — usually far too small on a 1080p screen — and the
ones that don't honour them show the tags as literal text. Neither is what
the source looked like, and the tags carry nothing worth keeping, so they
are dropped and the plain cue text is muxed instead.
Returns False if the track holds no cues, which is common for the empty
placeholder track MP4 rips often carry.
"""
result = subprocess.run(
["ffmpeg", "-y", "-v", "error", "-i", video_path,
"-map", f"0:{stream_index}", "-c:s", "srt", "-f", "srt", str(out_path)],
capture_output=True, text=True,
)
if result.returncode != 0 or not out_path.exists():
print(f" WARNING: could not extract subtitle stream #{stream_index}: "
f"{result.stderr.strip()[-300:]}", file=sys.stderr)
return False
text = out_path.read_text(encoding="utf-8", errors="replace")
cleaned = _FONT_TAG_RE.sub("", text)
if not cleaned.strip():
return False
out_path.write_text(cleaned, encoding="utf-8")
return True
def merge_voiceover(video_path: str, tts_path: str, output_path: str, def merge_voiceover(video_path: str, tts_path: str, output_path: str,
orig_volume: float = 0.60, tts_volume: float = 1.8): orig_volume: float = 0.60, tts_volume: float = 1.8,
extra_subs: Path = None):
"""Produce a video with two audio tracks: """Produce a video with two audio tracks:
track 0 — Original (first audio stream from source, stream-copied) track 0 — Original (first audio stream from source, stream-copied)
track 1 — Polish (lektor mix: TTS at tts_volume + original at orig_volume) track 1 — Polish (lektor mix: TTS at tts_volume + original at orig_volume)
plus every subtitle track from the source, stream-copied. plus every subtitle track from the source, stream-copied, and — when
extra_subs is given — the subtitles that were spoken, as one more track.
Done in two passes: Done in two passes:
1. Render the lektor mix on its own to a temporary audio file. 1. Render the lektor mix on its own to a temporary audio file.
@@ -1253,6 +1346,7 @@ def merge_voiceover(video_path: str, tts_path: str, output_path: str,
out = Path(output_path) out = Path(output_path)
mix_path = out.parent / f"{out.stem}_mix.m4a" mix_path = out.parent / f"{out.stem}_mix.m4a"
sub_tmp: list = [] # extracted .srt files, removed after the mux
try: try:
# ── Pass 1: render the lektor mix on its own ───────────────────────── # ── Pass 1: render the lektor mix on its own ─────────────────────────
_run_progress([ _run_progress([
@@ -1269,15 +1363,66 @@ def merge_voiceover(video_path: str, tts_path: str, output_path: str,
if sub_streams: if sub_streams:
print(f" Including {len(sub_streams)} subtitle track(s) in output") print(f" Including {len(sub_streams)} subtitle track(s) in output")
# Subtitles cannot all be stream-copied: an MP4 source carries them as
# mov_text (tx3g), which the matroska muxer rejects outright —
# "Subtitle codec 94213 is not supported", and no output file is
# written at all. Those tracks are decoded to SubRip in a temporary
# file and muxed from there; everything else is still copied, which is
# what the image-based formats (PGS, VOBSUB) require since they cannot
# become text at all.
cmd = [ cmd = [
"ffmpeg", "-y", "ffmpeg", "-y",
"-i", video_path, "-i", str(mix_path), "-i", video_path, "-i", str(mix_path),
"-map", "0:v", "-map", "0:a:0", "-map", "1:a:0",
] ]
maps = ["-map", "0:v", "-map", "0:a:0", "-map", "1:a:0"]
sub_codecs, sub_meta = [], []
next_input = 2
converted = 0
out_idx = 0 # position among the OUTPUT subtitle streams,
# which a skipped track must not advance
for s in sub_streams: for s in sub_streams:
cmd += ["-map", f"0:{s['index']}"] if s.get("codec_name") == "mov_text":
srt_path = out.parent / f"{out.stem}_sub{s['index']}.srt"
if not extract_mov_text_srt(video_path, s["index"], srt_path):
srt_path.unlink(missing_ok=True)
print(f" Subtitle stream #{s['index']} has no cues — skipping it")
continue
sub_tmp.append(srt_path)
cmd += ["-i", str(srt_path)]
maps += ["-map", f"{next_input}:0"]
# A standalone .srt carries no language, so the tag the source
# track had has to be re-applied by hand or the output track
# shows up as "undefined".
lang = (s.get("tags") or {}).get("language")
if lang:
sub_meta += [f"-metadata:s:s:{out_idx}", f"language={lang}"]
title = (s.get("tags") or {}).get("title")
if title:
sub_meta += [f"-metadata:s:s:{out_idx}", f"title={title}"]
next_input += 1
converted += 1
else:
maps += ["-map", f"0:{s['index']}"]
sub_codecs += [f"-c:s:{out_idx}", "copy"]
out_idx += 1
# The subtitles the voiceover was made from, added as one more track
# so the file carries them even when the source had none of its own.
if extra_subs is not None:
print(f" Adding the voiceover subtitles as an extra track: "
f"{Path(extra_subs).name}")
cmd += ["-i", str(extra_subs)]
maps += ["-map", f"{next_input}:0"]
sub_codecs += [f"-c:s:{out_idx}", "copy"]
sub_meta += [f"-metadata:s:s:{out_idx}", "language=pol",
f"-metadata:s:s:{out_idx}", "title=Lektor"]
next_input += 1
out_idx += 1
if converted:
print(f" Converted {converted} mov_text subtitle track(s) to SubRip — "
f"Matroska cannot store the MP4 subtitle format")
cmd += maps + ["-c:v", "copy", "-c:a", "copy"] + sub_codecs + sub_meta
cmd += [ cmd += [
"-c", "copy",
# Must be an OUTPUT option (before the output path) — placed before # Must be an OUTPUT option (before the output path) — placed before
# -i it silently configures the demuxer instead and does nothing. # -i it silently configures the demuxer instead and does nothing.
# Sparse subtitle tracks make the muxer give up on interleaving # Sparse subtitle tracks make the muxer give up on interleaving
@@ -1295,6 +1440,8 @@ def merge_voiceover(video_path: str, tts_path: str, output_path: str,
_run(cmd, "Muxing final video (2 audio tracks + subtitles)") _run(cmd, "Muxing final video (2 audio tracks + subtitles)")
finally: finally:
mix_path.unlink(missing_ok=True) mix_path.unlink(missing_ok=True)
for p in sub_tmp:
p.unlink(missing_ok=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1395,7 +1542,7 @@ BACKEND_RETRY_SECONDS = 3.0 # how often to look for ComfyUI while it is down
# Launching the backend is guarded: a ComfyUI that dies on startup would # Launching the backend is guarded: a ComfyUI that dies on startup would
# otherwise spawn a new terminal window every few seconds. # otherwise spawn a new terminal window every few seconds.
COMFY_AUTOSTART_MAX = 3 # restarts allowed while one clip is in flight COMFY_AUTOSTART_MAX = 10 # restarts allowed while one clip is in flight
COMFY_AUTOSTART_COOLDOWN = 90.0 # seconds between attempts COMFY_AUTOSTART_COOLDOWN = 90.0 # seconds between attempts
# A clip that kills the backend this many times is skipped rather than retried # A clip that kills the backend this many times is skipped rather than retried
@@ -1403,7 +1550,7 @@ COMFY_AUTOSTART_COOLDOWN = 90.0 # seconds between attempts
# tripped a CUDA device-side assert inside the audio tokenizer, which cannot be # tripped a CUDA device-side assert inside the audio tokenizer, which cannot be
# caught in Python — and without a cap an overnight queue stops there and does # caught in Python — and without a cap an overnight queue stops there and does
# nothing until morning. # nothing until morning.
CLIP_CRASH_LIMIT = 3 CLIP_CRASH_LIMIT = 10
# How long a restarted backend gets to answer before we conclude it is not the # How long a restarted backend gets to answer before we conclude it is not the
# clip's fault but the backend's absence. # clip's fault but the backend's absence.
@@ -1605,7 +1752,7 @@ def trim_comfy_log(path: Path = None, max_lines: int = COMFY_LOG_MAX_LINES) -> i
def wait_for_backend(timeout: float = 300.0) -> bool: def wait_for_backend(timeout: float = 300.0) -> bool:
"""Block until the backend answers again. After a restart the port is not """Block until the backend answers again. After a restart the port is not
listening for a while, and submitting into it would look like another listening for a while, and submitting into it would look like another
crash and burn one of the clip's three attempts.""" crash and burn one of the clip's attempts."""
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
try: try:
@@ -1666,7 +1813,7 @@ def run_workflow(workflow: dict, poll_interval: float = 0.25,
prompt_id, offline = None, False prompt_id, offline = None, False
start, interval, next_dot = time.monotonic(), poll_interval, 1.0 start, interval, next_dot = time.monotonic(), poll_interval, 1.0
# Restarts are counted per clip, not per run. A crash in block 4000 of an # Restarts are counted per clip, not per run. A crash in block 4000 of an
# overnight queue deserves the same three attempts the first one got. # overnight queue deserves the same number of attempts the first one got.
# One death is enough to hand control back: the caller retries with a # One death is enough to hand control back: the caller retries with a
# different seed, which is the only thing that changes the outcome. # different seed, which is the only thing that changes the outcome.
crashes = 0 crashes = 0
@@ -1855,7 +2002,7 @@ def generate_clips(blocks: list, voice_filename: str, voice_ref_text: str,
# A crash is seed-dependent, not purely text-dependent: the block # A crash is seed-dependent, not purely text-dependent: the block
# that killed ComfyUI three times in a 2073-clip run generated # that killed ComfyUI three times in a 2073-clip run generated
# perfectly on its own with a different seed. So each attempt gets a # perfectly on its own with a different seed. So each attempt gets a
# fresh seed — three tries at the identical computation would just # fresh seed — repeated tries at the identical computation would just
# reproduce the same abort. # reproduce the same abort.
seed = base_seed + idx + attempt * 100_003 seed = base_seed + idx + attempt * 100_003
try: try:
@@ -1868,7 +2015,7 @@ def generate_clips(blocks: list, voice_filename: str, voice_ref_text: str,
break break
except BackendCrashed as exc: except BackendCrashed as exc:
attempt += 1 attempt += 1
# The backend died three times on this clip. That means one of # The backend died on this clip. That means one of
# two very different things, and the difference decides whether # two very different things, and the difference decides whether
# skipping is right: either this clip kills ComfyUI — some # skipping is right: either this clip kills ComfyUI — some
# inputs abort it from inside CUDA — or ComfyUI is simply not # inputs abort it from inside CUDA — or ComfyUI is simply not
@@ -2008,7 +2155,8 @@ def _rebuild_video(args, out_dir: Path):
print(f"\n Building 2-track video " print(f"\n Building 2-track video "
f"(TTS {int(args.tts_volume*100)}% / orig {int(args.orig_volume*100)}%)…") f"(TTS {int(args.tts_volume*100)}% / orig {int(args.orig_volume*100)}%)…")
merge_voiceover(video_src, str(assembled_tts), str(final_output), merge_voiceover(video_src, str(assembled_tts), str(final_output),
orig_volume=args.orig_volume, tts_volume=args.tts_volume) orig_volume=args.orig_volume, tts_volume=args.tts_volume,
extra_subs=voiceover_subs_file(job) if args.include_subs else None)
print(f"\nDone! Output: {final_output}") print(f"\nDone! Output: {final_output}")
@@ -2260,6 +2408,11 @@ def main():
"this video, then stop. Repairs runs where the model failed " "this video, then stop. Repairs runs where the model failed "
"to stop and padded a clip out to --max-tokens, which pushes " "to stop and padded a clip out to --max-tokens, which pushes "
"every later clip out of sync. Follow with --rebuild.") "every later clip out of sync. Follow with --rebuild.")
parser.add_argument("--include-subs", action="store_true",
help="Also mux the subtitles the voiceover was made from into "
"the finished video, as an extra track alongside whatever "
"subtitle tracks the source already had. Works with "
"--rebuild too: the run keeps its own copy of them.")
parser.add_argument("--embedded-sub-index", type=int, default=None, metavar="N", parser.add_argument("--embedded-sub-index", type=int, default=None, metavar="N",
help="With --embedded-subs, pick this ffmpeg stream index instead " help="With --embedded-subs, pick this ffmpeg stream index instead "
"of prompting. Used by ui.py and for scripted runs.") "of prompting. Used by ui.py and for scripted runs.")
@@ -2400,6 +2553,11 @@ def main():
if end_sec is None: if end_sec is None:
end_sec = 99 * 3600 end_sec = 99 * 3600
# Kept before any processing, and for every run: --rebuild --include-subs
# is given only the video, so this is the only place the original wording
# can be preserved from.
save_voiceover_subs(srt_path, clips_dir, start_sec, end_sec)
wanted = [] wanted = []
if args.dictionary.strip(): if args.dictionary.strip():
wanted.append(resolve_dictionary_path(args.dictionary)) wanted.append(resolve_dictionary_path(args.dictionary))
@@ -2504,7 +2662,9 @@ def _run_generation(args, cfg, blocks, clips_dir, start_sec, end_sec):
print(f"\n[4/4] Building 2-track video (Original + Polish lektor " print(f"\n[4/4] Building 2-track video (Original + Polish lektor "
f"TTS {int(args.tts_volume*100)}% / orig {int(args.orig_volume*100)}%)…") f"TTS {int(args.tts_volume*100)}% / orig {int(args.orig_volume*100)}%)…")
merge_voiceover(str(video_fragment), str(assembled_tts), str(final_output), merge_voiceover(str(video_fragment), str(assembled_tts), str(final_output),
orig_volume=args.orig_volume, tts_volume=args.tts_volume) orig_volume=args.orig_volume, tts_volume=args.tts_volume,
extra_subs=(voiceover_subs_file(clips_dir)
if args.include_subs else None))
print(f"\nDone! Output: {final_output}") print(f"\nDone! Output: {final_output}")
Executable
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Rasterise icon.svg into the PNG sizes ui.py loads for the window icon.
Tk 8.6 reads PNG but not SVG, so the icon ships pre-rendered in assets/. The
glyph is laid on a rounded plate in the app's own colours: light on dark keeps
it legible against both a light and a dark dock, which the bare SVG (slate grey
on nothing) is not.
Needs inkscape for the render and Pillow for the downscales. Run it after
editing icon.svg, then ./install_desktop.sh to refresh the dock's copies.
"""
import re
import shutil
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
SRC = HERE / "icon.svg"
OUT = HERE / "assets"
SIZES = (256, 128, 64, 48, 32, 16)
PLATE = 64 # plate side, in the units the composite is drawn in
GLYPH = 44 # how much of that side the glyph's longest edge covers
RADIUS = 14 # corner rounding of the plate
BG = "#2b2b2b" # ui.BAR
FG = "#ededed" # ui.FG
def glyph_box(svg):
"""Bounding box of the drawing, in viewBox units.
inkscape answers in the file's own pixel units, which are viewBox units
only when width and viewBox agree — so convert.
"""
out = subprocess.run(["inkscape", "--query-all", str(svg)],
capture_output=True, text=True, check=True).stdout
line = next(l for l in out.splitlines() if l.startswith("svg"))
x, y, w, h = (float(v) for v in line.split(",")[1:5])
text = svg.read_text()
width = float(re.search(r'\swidth="([\d.]+)"', text).group(1))
vb_w = float(re.search(r'viewBox="[\d.\-]+ [\d.\-]+ ([\d.]+)', text).group(1))
u = vb_w / width
return x * u, y * u, w * u, h * u
def main():
if not SRC.is_file():
sys.exit(f"{SRC.name} not found")
if not shutil.which("inkscape"):
sys.exit("inkscape not found — needed to rasterise the SVG")
try:
from PIL import Image
except ImportError:
sys.exit("Pillow not found — needed to downscale the render")
d = re.search(r'\sd="([^"]+)"', SRC.read_text()).group(1)
x, y, w, h = glyph_box(SRC)
# Scale the glyph to GLYPH along its longest edge, then centre what that
# leaves. The offsets cancel the bbox origin, which is not at 0,0.
s = GLYPH / max(w, h)
tx = (PLATE - w * s) / 2 - x * s
ty = (PLATE - h * s) / 2 - y * s
OUT.mkdir(exist_ok=True)
composite = OUT / "_composite.svg"
composite.write_text(
f'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" '
f'viewBox="0 0 {PLATE} {PLATE}">\n'
f' <rect width="{PLATE}" height="{PLATE}" rx="{RADIUS}" ry="{RADIUS}" '
f'fill="{BG}"/>\n'
f' <g transform="translate({tx:.4f} {ty:.4f}) scale({s:.6f})">\n'
f' <path fill="{FG}" fill-rule="evenodd" clip-rule="evenodd" '
f'd="{d}"/>\n'
f' </g>\n</svg>\n')
master = OUT / "_master.png"
subprocess.run(["inkscape", "--export-type=png",
f"--export-filename={master}", "-w", "512", "-h", "512",
str(composite)], check=True, capture_output=True)
img = Image.open(master).convert("RGBA")
for size in SIZES:
img.resize((size, size), Image.LANCZOS).save(OUT / f"icon-{size}.png")
composite.unlink()
master.unlink()
print(f"glyph bbox {w:.2f}x{h:.2f} at ({x:.2f}, {y:.2f}) "
f"-> scale {s:.3f}, offset ({tx:.2f}, {ty:.2f})")
print("wrote " + ", ".join(f"icon-{s}.png" for s in SIZES) + f" in {OUT}")
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Convert TMPlayer subtitles (HH:MM:SS:text, '|' = line break) to SRT.
TMPlayer stores only a start time per line, so end times are inferred:
each cue ends when the next one starts (minus a small gap), or earlier if
its text is short enough to be read sooner.
python3 tmp2srt.py input/film.txt [output.srt]
"""
import re
import sys
from pathlib import Path
# A separator after the timestamp is optional in the wild: some lines use
# ':', some a bare space, some nothing at all.
LINE_RE = re.compile(r"^\s*(\d{1,2}):(\d{2}):(\d{2}):?\s*(.*)$")
CPS = 16.0 # reading speed used to size a cue when there is room
MIN_DUR = 1.2 # s
MAX_DUR = 7.0 # s
GAP = 0.08 # s left between consecutive cues
def _fmt(t: float) -> str:
ms = int(round(t * 1000))
h, ms = divmod(ms, 3600000)
m, ms = divmod(ms, 60000)
s, ms = divmod(ms, 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def parse(path: Path):
cues, skipped = [], []
for n, raw in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
if not raw.strip():
continue
m = LINE_RE.match(raw)
if not m:
skipped.append((n, raw))
continue
h, mi, s, text = m.groups()
text = text.replace("|", "\n").strip()
if not text:
continue
cues.append((int(h) * 3600 + int(mi) * 60 + int(s), text))
cues.sort(key=lambda c: c[0])
return cues, skipped
def to_srt(cues) -> str:
out = []
for i, (start, text) in enumerate(cues):
wanted = len(text.replace("\n", " ")) / CPS
end = start + max(MIN_DUR, min(MAX_DUR, wanted))
if i + 1 < len(cues):
end = min(end, cues[i + 1][0] - GAP)
if end <= start: # cues closer together than MIN_DUR
end = (cues[i + 1][0] if i + 1 < len(cues) else start + MIN_DUR) - 0.01
if end <= start:
end = start + 0.1
out.append(f"{i + 1}\n{_fmt(start)} --> {_fmt(end)}\n{text}\n")
return "\n".join(out)
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
src = Path(sys.argv[1])
dst = Path(sys.argv[2]) if len(sys.argv) > 2 else src.with_suffix(".srt")
cues, skipped = parse(src)
dst.write_text(to_srt(cues), encoding="utf-8")
print(f"{src} -> {dst}: {len(cues)} cues")
for n, raw in skipped:
print(f" skipped line {n}: {raw[:70]!r}")
if __name__ == "__main__":
main()
Executable
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""Read a plain text file aloud with the lektor_ai voice — no video, no SRT.
The dubbing pipeline is built around subtitles: every clip is placed at a
timestamp against a film. Prose has no timings, so this script does the one
thing that is genuinely missing — it splits the text into sentence-sized
chunks itself — and then reuses lektor_ai_comfy for everything else: the same
dictionary, the same Polish number normalizer, the same ComfyUI TTS call with
its truncation retries and crash recovery, the same per-clip trim and speed-up.
The clips are laid end to end with a short pause instead of at an SRT time.
python3 tts_txt.py input/oko_proroka.txt
python3 tts_txt.py input/oko_proroka.txt -o output/oko.mp3 --speed 1.1
python3 tts_txt.py input/oko_proroka.txt --dry-run # just show the chunks
Clips live in output/clips/<stem>/ exactly as for a film, so an interrupted
run resumes with --resume-from N and a re-assembly costs nothing.
"""
import argparse
import random
import re
import sys
from pathlib import Path
import lektor_ai_comfy as lek
# Chunk size is a compromise, not a limit of the model: longer chunks read with
# better prosody but are likelier to come back cut off mid-word, and a retry
# then costs the whole chunk. Subtitle blocks — what the voice was tuned on —
# sit well under this.
MAX_CHARS = 240
SENTENCE_GAP = 0.35 # s of silence between chunks
PARAGRAPH_GAP = 0.90 # s at a blank line
# A dot after one of these is not the end of a sentence. Single letters are
# handled separately ("p. kasztelana", initials).
ABBREV = {
"np.", "itd.", "itp.", "tzn.", "tj.", "ok.", "dr.", "prof.", "św.",
"ul.", "nr.", "str.", "m.in.", "tzw.", "ds.", "godz.", "mln.", "mld.",
}
_SENT_SPLIT = re.compile(r'(?<=[.!?…])["»”\'’)\]]*\s+')
_CLOSERS = '"»”\'’)]'
_CLAUSE_SPLIT = re.compile(r'(?<=[,;:])\s+|\s+(?=—\s)')
def _terminal_punct(text: str) -> str:
"""Give the chunk an ending the model can land on.
lek._ensure_terminal_punct is written for subtitles and does not know the
single-character ellipsis or a closing quote, so it would turn `uciekło…`
into `uciekło……` and `jeść!”` into `jeść!”...`.
"""
if not text:
return text
bare = text.rstrip(_CLOSERS)
if bare and bare[-1] in '.,!?;:…':
return text
return text + '…'
def _prepare(text: str, dictionary) -> str:
text = lek._strip_sub_tags(text)
# A chunk cut out of the middle of a long sentence starts lowercase, which
# the TTS reads with the wrong intonation — the pipeline capitalises these
# for the same reason.
if text and text[0].islower():
text = text[0].upper() + text[1:]
if dictionary is not None:
text = dictionary.apply(text)
return _terminal_punct(text)
def _is_real_boundary(before: str) -> bool:
"""False when the dot that ended `before` belongs to an abbreviation."""
tail = before.rstrip('"»”\'’)]').split()[-1] if before.split() else ""
if not tail.endswith("."):
return True # ended on ! ? … — always a boundary
if tail.lower() in ABBREV:
return False
stem = tail[:-1]
return not (len(stem) == 1 and stem.isalpha()) # "p.", initials
def split_sentences(paragraph: str) -> list:
parts, pos = [], 0
for m in _SENT_SPLIT.finditer(paragraph):
candidate = paragraph[pos:m.start()]
if not _is_real_boundary(candidate):
continue
parts.append(paragraph[pos:m.end()].strip())
pos = m.end()
tail = paragraph[pos:].strip()
if tail:
parts.append(tail)
return parts
def _split_long(sentence: str, max_chars: int) -> list:
"""A sentence longer than one chunk, broken at commas and dashes.
Nineteenth-century prose runs to sentences of several hundred characters;
handing one to the model whole is exactly the case that comes back
truncated.
"""
pieces, current = [], ""
for part in _CLAUSE_SPLIT.split(sentence):
if current and len(current) + 1 + len(part) > max_chars:
pieces.append(current)
current = part
else:
current = f"{current} {part}".strip()
if current:
pieces.append(current)
return pieces
def chunk_text(raw: str, max_chars: int = MAX_CHARS) -> list:
"""Text -> [(chunk_text, ends_paragraph)].
Hard line wraps inside a paragraph are joined: they are an artefact of how
the file was typeset, not pauses the reader should hear. A blank line is a
real break and becomes the longer pause.
"""
chunks = []
paragraphs = [p for p in re.split(r'\n\s*\n', raw) if p.strip()]
for para in paragraphs:
joined = re.sub(r'\s*\n\s*', ' ', para.strip())
joined = re.sub(r'([?!.…])(—)', r'\1 \2', joined) # "jak?— Ja" -> "jak? — Ja"
joined = re.sub(r'\s{2,}', ' ', joined)
buf = []
for sentence in split_sentences(joined):
for piece in ([sentence] if len(sentence) <= max_chars
else _split_long(sentence, max_chars)):
if buf and len(" ".join(buf)) + 1 + len(piece) > max_chars:
chunks.append([" ".join(buf), False])
buf = []
buf.append(piece)
if buf:
chunks.append([" ".join(buf), False])
if chunks:
chunks[-1][1] = True # last chunk of the paragraph
return [(t, end) for t, end in chunks]
def main():
pre = argparse.ArgumentParser(add_help=False)
pre.add_argument("--settings", default=str(lek.SETTINGS_FILE), metavar="FILE")
settings_path = Path(pre.parse_known_args()[0].settings).expanduser()
cfg = lek.load_settings(settings_path)
p = argparse.ArgumentParser(
description="Turn a plain text file into a lektor-voiced audio file.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
p.add_argument("text", help="UTF-8 .txt file to read aloud")
p.add_argument("-o", "--output", default=None, metavar="FILE",
help="output audio (extension picks the format); "
"default <output_dir>/<stem>.mp3")
p.add_argument("--settings", default=str(lek.SETTINGS_FILE), metavar="FILE")
p.add_argument("--voice", default=cfg["voice"],
help="voice name in voices/ or a path to a WAV")
# 1.0 rather than the settings default: the pipeline's speed-up exists to
# fit a clip into its subtitle slot, and prose has no slot to fit.
p.add_argument("--speed", type=float, default=1.0, metavar="FACTOR")
p.add_argument("--temperature", type=float, default=cfg["temperature"], metavar="T")
p.add_argument("--max-tokens", type=int, default=cfg["max_tokens"], metavar="N")
p.add_argument("--seed", type=int, default=0, help="0 = random")
p.add_argument("--truncation-retries", type=int,
default=cfg["truncation_retries"], metavar="N")
p.add_argument("--strip-leading-ms", type=float, default=cfg["strip_leading_ms"])
p.add_argument("--trailing-fade-ms", type=float, default=cfg["trailing_fade_ms"])
p.add_argument("--dictionary", default=cfg["dictionary"], metavar="FILE",
help='replacement list; "" disables')
p.add_argument("--dictionary-extra", default=None, metavar="FILE",
help="second list merged on top, for rules specific to this text")
numbers = p.add_mutually_exclusive_group()
numbers.add_argument("--normalize-numbers", dest="normalize_numbers",
action="store_true", default=cfg["normalize_numbers"])
numbers.add_argument("--no-normalize-numbers", dest="normalize_numbers",
action="store_false")
p.add_argument("--max-chars", type=int, default=MAX_CHARS, metavar="N",
help="longest chunk handed to the model in one go")
p.add_argument("--sentence-gap", type=float, default=SENTENCE_GAP, metavar="S")
p.add_argument("--paragraph-gap", type=float, default=PARAGRAPH_GAP, metavar="S")
p.add_argument("--limit", type=int, default=0, metavar="N",
help="generate only the first N chunks (for a quick listen)")
p.add_argument("--resume-from", type=int, default=0, metavar="N",
help="reuse clips 1..N-1 already in the job folder")
p.add_argument("--rebuild", action="store_true",
help="re-assemble from the clips on disk, no TTS")
p.add_argument("--dry-run", action="store_true",
help="print the chunks and exit without touching ComfyUI")
p.add_argument("--output-dir", default=cfg["output_dir"])
p.add_argument("--comfy-url", default=cfg["comfy_url"])
p.add_argument("--no-comfy-autostart", action="store_true")
args = p.parse_args()
lek.COMFY_URL = args.comfy_url
lek.COMFY_AUTOSTART = cfg["comfy_autostart"] and not args.no_comfy_autostart
start_cmd = Path(cfg["comfy_start_command"]).expanduser()
lek.COMFY_START_SCRIPT = (start_cmd if start_cmd.is_absolute()
else Path(lek.__file__).resolve().parent / start_cmd)
src = Path(args.text)
if not src.is_file():
sys.exit(f"No such text file: {src}")
out_dir = Path(args.output_dir)
clips_dir = lek.job_dir(out_dir, str(src))
clips_dir.mkdir(parents=True, exist_ok=True)
final = Path(args.output) if args.output else out_dir / f"{src.stem}.mp3"
print(f" Settings: {settings_path if settings_path.is_file() else 'built-in defaults'}")
print(f" Job folder: {clips_dir}")
# ── Step 1: Text -> blocks ────────────────────────────────────────────────
print(f"\n[1/3] Splitting {src.name}…")
chunks = chunk_text(src.read_text(encoding="utf-8-sig"), args.max_chars)
if not chunks:
sys.exit(f"{src} has no text.")
if args.limit:
chunks = chunks[:args.limit]
print(f" --limit {args.limit}: only the first {len(chunks)} chunk(s).")
wanted = []
if args.dictionary.strip():
wanted.append(lek.resolve_dictionary_path(args.dictionary))
if args.dictionary_extra and args.dictionary_extra.strip():
wanted.append(Path(args.dictionary_extra).expanduser())
dictionary = lek.Dictionary.load_all(wanted) if wanted else None
if dictionary is not None:
for path, count in dictionary.sources:
print(f" Dictionary: {count} rule(s) from {path}")
# The synthetic timings are placeholders — every clip asks to start at 0 and
# generate_clips pushes it after the previous one. The real spacing is
# applied after generation, when the durations are known.
blocks, gaps = [], []
for text, ends_para in chunks:
clean = _prepare(text, dictionary)
blocks.append((0.0, 0.0, clean))
gaps.append(args.paragraph_gap if ends_para else args.sentence_gap)
if dictionary is not None and dictionary.hits:
print(f" {dictionary.hits} dictionary replacement(s) applied.")
if args.normalize_numbers:
py = Path(cfg["normalizer_python"]).expanduser()
if not py.is_absolute():
py = Path(lek.__file__).resolve().parent / py
llm = Path(cfg["normalizer_llm"]).expanduser() if cfg["normalizer_llm"] else None
if llm and not llm.is_absolute():
llm = Path(lek.__file__).resolve().parent / llm
blocks = lek.normalize_block_numbers(blocks, py, cfg["normalizer_timeout"],
free_vram=cfg["free_comfy_vram"],
llm_dir=llm)
chars = sum(len(t) for _s, _e, t in blocks)
print(f" {len(blocks)} chunk(s), {chars} characters "
f"(longest {max(len(t) for _s, _e, t in blocks)}).")
preview = clips_dir / "tts_input.txt"
preview.write_text("\n\n".join(f"[{i + 1}] {t}" for i, (_s, _e, t) in enumerate(blocks)),
encoding="utf-8")
print(f" Saved to {preview} for inspection.")
if args.dry_run:
for i, (_s, _e, text) in enumerate(blocks, 1):
print(f" [{i:4d}] ({len(text):3d}) {text}")
return
# ── Step 2: TTS ───────────────────────────────────────────────────────────
if args.rebuild:
clips = [(0.0, path) for path in
sorted(clips_dir.glob("clip_[0-9][0-9][0-9][0-9].flac"))]
if not clips:
sys.exit(f"--rebuild: no clips in {clips_dir}.")
print(f"\n[2/3] Rebuilding from {len(clips)} existing clip(s) — no TTS.")
else:
print(f"\n[2/3] Generating TTS ({len(blocks)} clips, speed {args.speed}×)…")
wav_path = Path(args.voice)
if not wav_path.is_file():
wav_path = lek.VOICE_PROMPTS_DIR / f"{args.voice}.wav"
if not wav_path.is_file():
sys.exit(f"Voice WAV not found: {wav_path}")
print(f" Voice: {wav_path.name}")
# A cold backend answers the first submission with a connection error,
# which run_workflow would count against the clip's crash budget.
if not lek.wait_for_backend(2.0):
print(" ComfyUI is not up — starting it…")
if not (lek.COMFY_AUTOSTART and lek.start_comfy_backend(lek.COMFY_START_SCRIPT)
and lek.wait_for_backend(lek.BACKEND_HEALTH_TIMEOUT)):
sys.exit(f"ComfyUI did not come up at {lek.COMFY_URL}.")
base_seed = args.seed if args.seed != 0 else random.randint(1, 2**31 - 1)
print(f" Base seed: {base_seed}")
voice_filename, voice_ref_text = lek.upload_voice_wav(wav_path)
clips = lek.generate_clips(blocks, voice_filename, voice_ref_text,
base_seed, args.speed, clips_dir,
strip_leading_ms=args.strip_leading_ms,
resume_from=args.resume_from,
temperature=args.temperature,
trailing_fade_ms=args.trailing_fade_ms,
max_new_tokens=args.max_tokens,
retries=args.truncation_retries)
if cfg["free_comfy_vram"]:
lek.free_comfy_vram()
# ── Step 3: Lay the clips end to end ─────────────────────────────────────
print(f"\n[3/3] Assembling…")
timed, cursor = [], 0.0
for _start, path in clips:
# generate_clips skips a clip that repeatedly killed the backend, so the
# gap belongs to the block the clip came from, not to its position here.
idx = int(re.search(r'(\d+)', path.stem).group(1)) - 1
timed.append((cursor, path))
cursor += lek.get_audio_duration(path)
cursor += gaps[idx] if idx < len(gaps) else args.sentence_gap
assembled = clips_dir / "tts_assembled.flac"
lek.assemble_timed_audio(timed, str(assembled))
final.parent.mkdir(parents=True, exist_ok=True)
if final.suffix.lower() == ".flac":
assembled.replace(final)
else:
lek._run(["ffmpeg", "-y", "-i", str(assembled), "-b:a", "192k", str(final)],
f"Encoding {final.name}")
total = lek.get_audio_duration(final)
print(f"\nDone! Output: {final} ({int(total // 60)}:{int(total % 60):02d}, "
f"{final.stat().st_size // 1024} KB)")
if __name__ == "__main__":
main()
+98 -6
View File
@@ -45,6 +45,11 @@ import lektor_ai_comfy as core # noqa: E402 (path set above)
CLI = HERE / "lektor_ai_comfy.py" CLI = HERE / "lektor_ai_comfy.py"
# Window icon. Tk 8.6 reads PNG but not SVG, so assets/ holds sizes rendered
# from icon.svg; the WM picks whichever fits its title bar, alt-tab and dock.
ICON_DIR = HERE / "assets"
ICON_SIZES = (256, 128, 64, 48, 32, 16)
# ── Palette, sampled from the mock-up ─────────────────────────────────────── # ── Palette, sampled from the mock-up ───────────────────────────────────────
BG = "#3c3c3c" # body BG = "#3c3c3c" # body
BAR = "#2b2b2b" # top strip BAR = "#2b2b2b" # top strip
@@ -575,6 +580,7 @@ class RoundCheck(tk.Frame):
def __init__(self, master, variable, text, font, command=None, box=22): def __init__(self, master, variable, text, font, command=None, box=22):
super().__init__(master, bg=master.cget("background")) super().__init__(master, bg=master.cget("background"))
self.var, self.command = variable, command self.var, self.command = variable, command
self._enabled = True
self.canvas = tk.Canvas(self, width=box, height=box, self.canvas = tk.Canvas(self, width=box, height=box,
bg=master.cget("background"), bg=master.cget("background"),
highlightthickness=0, bd=0, cursor="hand2") highlightthickness=0, bd=0, cursor="hand2")
@@ -594,6 +600,8 @@ class RoundCheck(tk.Frame):
self.refresh() self.refresh()
def _toggle(self, _=None): def _toggle(self, _=None):
if not self._enabled:
return
self.var.set(not self.var.get()) self.var.set(not self.var.get())
self.refresh() self.refresh()
if self.command: if self.command:
@@ -602,7 +610,19 @@ class RoundCheck(tk.Frame):
def refresh(self): def refresh(self):
on = bool(self.var.get()) on = bool(self.var.get())
self.canvas.itemconfigure(self._tick, state="normal" if on else "hidden") self.canvas.itemconfigure(self._tick, state="normal" if on else "hidden")
self.canvas.itemconfigure(self._box, fill="#ffffff" if on else FIELD) if self._enabled:
fill = "#ffffff" if on else FIELD
else:
fill = "#8f8f8f" if on else SEP
self.canvas.itemconfigure(self._box, fill=fill)
def set_enabled(self, on):
"""Grey the box out and stop it responding to clicks."""
self._enabled = bool(on)
cursor = "hand2" if self._enabled else "arrow"
self.canvas.configure(cursor=cursor)
self.label.configure(cursor=cursor, fg=FG if self._enabled else "#828282")
self.refresh()
# Dropdown choices. The comboboxes are editable, so these are starting points # Dropdown choices. The comboboxes are editable, so these are starting points
# rather than limits. # rather than limits.
@@ -658,10 +678,18 @@ class JobBlock(tk.Frame):
self.f_btn = (F, 9, "bold") self.f_btn = (F, 9, "bold")
self.f_num = (F, 13) self.f_num = (F, 13)
# The remove button gets a column of its own spanning both rows, so it
# stays vertically centred against the block however tall that grows.
close_col = tk.Frame(self, bg=BG)
close_col.pack(side="left", fill="y", padx=(0, 12))
RoundButton(close_col, "\u00d7", self.remove, bg="#4a4a4a", fg=MUTED,
hover="#7a3a3a", font=(F, 11, "bold"), padx=7, pady=5
).pack(expand=True)
# Both rows share one grid, so column 0 gets a single width and the two # 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. # input fields line up exactly as they do in the mock-up.
grid = tk.Frame(self, bg=BG) grid = tk.Frame(self, bg=BG)
grid.pack(fill="x") grid.pack(side="left", fill="both", expand=True)
grid.grid_columnconfigure(0, weight=1) grid.grid_columnconfigure(0, weight=1)
# ── Row 1: video + the four numeric dropdowns ─────────────────────── # ── Row 1: video + the four numeric dropdowns ───────────────────────
@@ -682,9 +710,6 @@ class JobBlock(tk.Frame):
if self.group is not None: if self.group is not None:
tk.Label(r1, text="linked", bg=BG, fg="#7fa8c9", font=(F, 8) tk.Label(r1, text="linked", bg=BG, fg="#7fa8c9", font=(F, 8)
).pack(side="left", padx=(0, 6)) ).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 # Mirror every numeric field to the other blocks in this group. A write
# trace catches typed values as well as menu picks. # trace catches typed values as well as menu picks.
@@ -708,6 +733,13 @@ class JobBlock(tk.Frame):
font=(F, 9), command=self.toggle_embedded, box=22) font=(F, 9), command=self.toggle_embedded, box=22)
self.emb_check.pack(side="left", padx=(0, 16)) self.emb_check.pack(side="left", padx=(0, 16))
# Only meaningful for an external .srt — an embedded track is already
# in the file, so the option is cleared and greyed out for those.
self.include_subs = tk.BooleanVar(value=False)
self.inc_check = RoundCheck(r2, self.include_subs, "include the\nsubtitles",
font=(F, 9), command=self._mirror_include, box=22)
self.inc_check.pack(side="left", padx=(0, 16))
# packed right-to-left so the group ends flush with the dropdowns above # packed right-to-left so the group ends flush with the dropdowns above
for label, cmd in (("RE-BUILD", self.do_rebuild), for label, cmd in (("RE-BUILD", self.do_rebuild),
("RESUME ...", self.ask_resume), ("RESUME ...", self.ask_resume),
@@ -781,6 +813,33 @@ class JobBlock(tk.Frame):
finally: finally:
self.app._syncing = False self.app._syncing = False
def _mirror_include(self):
"""Copy the include-subtitles state to the rest of the group."""
if self.app._syncing or self.group is None:
return
self.app._syncing = True
try:
want = self.include_subs.get()
for b in self._peers():
if b.embedded.get() or b.include_subs.get() == want:
continue
b.include_subs.set(want)
b.inc_check.refresh()
finally:
self.app._syncing = False
def _sync_include_state(self):
"""Follow the embedded-subtitles checkbox.
With an embedded track the subtitles already travel with the video, so
there is nothing to add — the option is cleared and greyed out.
"""
if self.embedded.get():
self.include_subs.set(False)
self.inc_check.set_enabled(False)
else:
self.inc_check.set_enabled(True)
# ── file selection ────────────────────────────────────────────────────── # ── file selection ──────────────────────────────────────────────────────
def browse_video(self): def browse_video(self):
p = choose_files("Select video", VIDEO_TYPES, p = choose_files("Select video", VIDEO_TYPES,
@@ -815,6 +874,7 @@ class JobBlock(tk.Frame):
if not video or not Path(video).is_file(): if not video or not Path(video).is_file():
self.embedded.set(False) self.embedded.set(False)
self.emb_check.refresh() self.emb_check.refresh()
self._sync_include_state()
messagebox.showwarning("No video", "Choose a video file first.") messagebox.showwarning("No video", "Choose a video file first.")
return return
self.sub_field.grid_remove() self.sub_field.grid_remove()
@@ -824,10 +884,12 @@ class JobBlock(tk.Frame):
self.sub_combo["values"] = [] self.sub_combo["values"] = []
# ffprobe on a large file takes a moment; keep the UI responsive. # ffprobe on a large file takes a moment; keep the UI responsive.
threading.Thread(target=self._probe_subs, args=(video,), daemon=True).start() threading.Thread(target=self._probe_subs, args=(video,), daemon=True).start()
self._sync_include_state()
self._mirror_embedded() self._mirror_embedded()
else: else:
self.sub_combo.grid_remove() self.sub_combo.grid_remove()
self.sub_field.grid() self.sub_field.grid()
self._sync_include_state()
self._mirror_embedded() self._mirror_embedded()
def _probe_subs(self, video): def _probe_subs(self, video):
@@ -911,6 +973,10 @@ class JobBlock(tk.Frame):
cmd += self.common_args() cmd += self.common_args()
# --regen only writes clips, so there is no mux for the track to join.
if self.include_subs.get() and not self.embedded.get() and mode != "regen":
cmd.append("--include-subs")
# The whole queue shares one extra dictionary, kept with the first # The whole queue shares one extra dictionary, kept with the first
# video. --regen and --rebuild work purely from tts_input.srt, so the # video. --regen and --rebuild work purely from tts_input.srt, so the
# flag would do nothing there. A resume normally reuses that file too, # flag would do nothing there. A resume normally reuses that file too,
@@ -1061,7 +1127,11 @@ class SimplePrompt(tk.Toplevel):
class LektorUI(tk.Tk): class LektorUI(tk.Tk):
def __init__(self): def __init__(self):
super().__init__() # className lands in WM_CLASS; without it Tk reports itself as "Tk" and
# that is what the dock shows on hover. Tk lowercases everything past
# the first letter, so this is kept as a plain identifier — the pretty
# name comes from lektor-ai.desktop, which matches on StartupWMClass.
super().__init__(className="lektor-ai")
self.title("lektor_ai — voiceover queue") self.title("lektor_ai — voiceover queue")
self.geometry("1180x900") self.geometry("1180x900")
self.minsize(1000, 600) self.minsize(1000, 600)
@@ -1080,6 +1150,7 @@ class LektorUI(tk.Tk):
self.out_q = queue.Queue() self.out_q = queue.Queue()
self._in_redraw = False self._in_redraw = False
self._set_icon()
self._style() self._style()
self._build() self._build()
self.after(80, self._drain_output) self.after(80, self._drain_output)
@@ -1093,6 +1164,27 @@ class LektorUI(tk.Tk):
self.log(" 'sudo apt install python3-tk', then run with plain python3.") 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.") self.log("Add videos with 'Add videos...' or the + button, then press GENERATE.")
# ── window icon ─────────────────────────────────────────────────────────
def _set_icon(self):
"""Icon for the title bar, alt-tab and the dock.
Kept on self because Tk holds only a weak reference to PhotoImages —
drop it and the icon blanks out. Missing or unreadable files are not
worth failing a launch over, so any trouble here is simply ignored.
"""
self._icons = []
for size in ICON_SIZES:
path = ICON_DIR / f"icon-{size}.png"
if not path.is_file():
continue
try:
self._icons.append(tk.PhotoImage(file=str(path)))
except tk.TclError:
pass
if self._icons:
# True: also the default for dialogs opened later.
self.iconphoto(True, *self._icons)
# ── theming ───────────────────────────────────────────────────────────── # ── theming ─────────────────────────────────────────────────────────────
def _style(self): def _style(self):
st = ttk.Style(self) st = ttk.Style(self)
Binary file not shown.
Binary file not shown.
+2
View File
@@ -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. 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. 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. Kto to jest? Dostałeś wiadomość? Nie przeszkadzamy? Aż dziesięć funtów?!
Binary file not shown.