Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dbe862531 | ||
|
|
e541ef7ee9 | ||
|
|
39452a4281 | ||
|
|
d350b314ad | ||
|
|
4a9b05de66 |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 676 B |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -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 |
@@ -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
|
||||
@@ -819,6 +819,63 @@ def split_dialogue_blocks(blocks: list) -> list:
|
||||
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:
|
||||
"""Convert (start, end, text) tuples back to SRT string for inspection."""
|
||||
lines = []
|
||||
@@ -1196,12 +1253,48 @@ def get_media_duration(path: str) -> float:
|
||||
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,
|
||||
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:
|
||||
track 0 — Original (first audio stream from source, stream-copied)
|
||||
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:
|
||||
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)
|
||||
mix_path = out.parent / f"{out.stem}_mix.m4a"
|
||||
sub_tmp: list = [] # extracted .srt files, removed after the mux
|
||||
try:
|
||||
# ── Pass 1: render the lektor mix on its own ─────────────────────────
|
||||
_run_progress([
|
||||
@@ -1269,15 +1363,66 @@ def merge_voiceover(video_path: str, tts_path: str, output_path: str,
|
||||
if sub_streams:
|
||||
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 = [
|
||||
"ffmpeg", "-y",
|
||||
"-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:
|
||||
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 += [
|
||||
"-c", "copy",
|
||||
# Must be an OUTPUT option (before the output path) — placed before
|
||||
# -i it silently configures the demuxer instead and does nothing.
|
||||
# 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)")
|
||||
finally:
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# caught in Python — and without a cap an overnight queue stops there and does
|
||||
# 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
|
||||
# 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:
|
||||
"""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
|
||||
crash and burn one of the clip's three attempts."""
|
||||
crash and burn one of the clip's attempts."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
@@ -1666,7 +1813,7 @@ def run_workflow(workflow: dict, poll_interval: float = 0.25,
|
||||
prompt_id, offline = None, False
|
||||
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
|
||||
# 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
|
||||
# different seed, which is the only thing that changes the outcome.
|
||||
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
|
||||
# that killed ComfyUI three times in a 2073-clip run generated
|
||||
# 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.
|
||||
seed = base_seed + idx + attempt * 100_003
|
||||
try:
|
||||
@@ -1868,7 +2015,7 @@ def generate_clips(blocks: list, voice_filename: str, voice_ref_text: str,
|
||||
break
|
||||
except BackendCrashed as exc:
|
||||
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
|
||||
# skipping is right: either this clip kills ComfyUI — some
|
||||
# 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 "
|
||||
f"(TTS {int(args.tts_volume*100)}% / orig {int(args.orig_volume*100)}%)…")
|
||||
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}")
|
||||
|
||||
@@ -2260,6 +2408,11 @@ def main():
|
||||
"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("--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",
|
||||
help="With --embedded-subs, pick this ffmpeg stream index instead "
|
||||
"of prompting. Used by ui.py and for scripted runs.")
|
||||
@@ -2400,6 +2553,11 @@ def main():
|
||||
if end_sec is None:
|
||||
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 = []
|
||||
if args.dictionary.strip():
|
||||
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 "
|
||||
f"TTS {int(args.tts_volume*100)}% / orig {int(args.orig_volume*100)}%)…")
|
||||
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}")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -45,6 +45,11 @@ import lektor_ai_comfy as core # noqa: E402 (path set above)
|
||||
|
||||
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 ───────────────────────────────────────
|
||||
BG = "#3c3c3c" # body
|
||||
BAR = "#2b2b2b" # top strip
|
||||
@@ -575,6 +580,7 @@ class RoundCheck(tk.Frame):
|
||||
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._enabled = True
|
||||
self.canvas = tk.Canvas(self, width=box, height=box,
|
||||
bg=master.cget("background"),
|
||||
highlightthickness=0, bd=0, cursor="hand2")
|
||||
@@ -594,6 +600,8 @@ class RoundCheck(tk.Frame):
|
||||
self.refresh()
|
||||
|
||||
def _toggle(self, _=None):
|
||||
if not self._enabled:
|
||||
return
|
||||
self.var.set(not self.var.get())
|
||||
self.refresh()
|
||||
if self.command:
|
||||
@@ -602,7 +610,19 @@ class RoundCheck(tk.Frame):
|
||||
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)
|
||||
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
|
||||
# rather than limits.
|
||||
@@ -658,10 +678,18 @@ class JobBlock(tk.Frame):
|
||||
self.f_btn = (F, 9, "bold")
|
||||
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
|
||||
# input fields line up exactly as they do in the mock-up.
|
||||
grid = tk.Frame(self, bg=BG)
|
||||
grid.pack(fill="x")
|
||||
grid.pack(side="left", fill="both", expand=True)
|
||||
grid.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# ── Row 1: video + the four numeric dropdowns ───────────────────────
|
||||
@@ -682,9 +710,6 @@ class JobBlock(tk.Frame):
|
||||
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.
|
||||
@@ -708,6 +733,13 @@ class JobBlock(tk.Frame):
|
||||
font=(F, 9), command=self.toggle_embedded, box=22)
|
||||
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
|
||||
for label, cmd in (("RE-BUILD", self.do_rebuild),
|
||||
("RESUME ...", self.ask_resume),
|
||||
@@ -781,6 +813,33 @@ class JobBlock(tk.Frame):
|
||||
finally:
|
||||
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 ──────────────────────────────────────────────────────
|
||||
def browse_video(self):
|
||||
p = choose_files("Select video", VIDEO_TYPES,
|
||||
@@ -815,6 +874,7 @@ class JobBlock(tk.Frame):
|
||||
if not video or not Path(video).is_file():
|
||||
self.embedded.set(False)
|
||||
self.emb_check.refresh()
|
||||
self._sync_include_state()
|
||||
messagebox.showwarning("No video", "Choose a video file first.")
|
||||
return
|
||||
self.sub_field.grid_remove()
|
||||
@@ -824,10 +884,12 @@ class JobBlock(tk.Frame):
|
||||
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._sync_include_state()
|
||||
self._mirror_embedded()
|
||||
else:
|
||||
self.sub_combo.grid_remove()
|
||||
self.sub_field.grid()
|
||||
self._sync_include_state()
|
||||
self._mirror_embedded()
|
||||
|
||||
def _probe_subs(self, video):
|
||||
@@ -911,6 +973,10 @@ class JobBlock(tk.Frame):
|
||||
|
||||
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
|
||||
# video. --regen and --rebuild work purely from tts_input.srt, so the
|
||||
# flag would do nothing there. A resume normally reuses that file too,
|
||||
@@ -1061,7 +1127,11 @@ class SimplePrompt(tk.Toplevel):
|
||||
|
||||
class LektorUI(tk.Tk):
|
||||
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.geometry("1180x900")
|
||||
self.minsize(1000, 600)
|
||||
@@ -1080,6 +1150,7 @@ class LektorUI(tk.Tk):
|
||||
self.out_q = queue.Queue()
|
||||
self._in_redraw = False
|
||||
|
||||
self._set_icon()
|
||||
self._style()
|
||||
self._build()
|
||||
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("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 ─────────────────────────────────────────────────────────────
|
||||
def _style(self):
|
||||
st = ttk.Style(self)
|
||||
|
||||
@@ -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?!
|
||||
|
||||