Compare commits

..

2 Commits

Author SHA1 Message Date
jadssss
999934f174 improve GUI layout and LRC stage controls
- make the main page scrollable and keep the console reachable on small screens
- add fullscreen toggle (button + F11, Escape to exit)
- add enable switches for LRC generation/embedding and skip ready text.lrc
- sanitize multi-line ASR text before writing LRC timestamps
- emit LRC/embed progress once per file instead of twice
- increase progress-bar contrast
2026-10-27 22:29:13 +03:00
jadssss
15c0ba5d66 add resume support, metadata paths, LRCLIB order and overall progress
- store source track path in metadata.json and validate it when skipping ready files
- add skip_existing/resume_partial options to config and GUI
- identify lyrics via embedded text, Shazam, then one LRCLIB author search
- keep post-ASR LRCLIB text matching and allow cancelling safely
- recover from CUDA OOM and free VRAM between batch stages
- add overall completed/total file progress bar to GUI and pipelines
2026-10-25 20:24:01 +03:00
10 changed files with 892 additions and 241 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ output/
preset.json
*.log
combined_files.txt

View File

@@ -66,6 +66,9 @@ class LyricsConfig:
@dataclass
class LrcConfig:
enabled: bool = True # LRC generation stage
embed_enabled: bool = True # LRC embedding stage
skip_existing: bool = True # skip tracks with a ready text.lrc
language: Optional[str] = "ru"
align_model: Optional[str] = None
device: str = "auto"
@@ -81,6 +84,9 @@ class PipelineConfig:
keep_intermediate: bool = True
overwrite: bool = True
mode: str = "sequential"
chunk_size: int = 64
skip_existing: bool = False # skip files that already have text.txt
resume_partial: bool = True # reuse existing vocals.wav / convert.wav
convert: ConvertConfig = field(default_factory=ConvertConfig)
separate: SeparateConfig = field(default_factory=SeparateConfig)

View File

@@ -37,6 +37,10 @@ def segments_to_lrc(segments: list[dict]) -> str:
text = (seg.get("text") or "").strip()
if not text:
continue
# WhisperX can return multi-line text on long segments. An LRC line
# must be a single line: any newline would produce a broken line
# without a timestamp. Collapse all whitespace into single spaces.
text = re.sub(r"\s+", " ", text)
start = float(seg.get("start", 0.0))
lines.append(f"{format_timestamp(start)}{text}")
return "\n".join(lines) + "\n"

View File

@@ -11,12 +11,15 @@ from .config import PipelineConfig
from .convert import convert_to_wav
from .lrc import embed_lrc, is_lrc, read_embedded_lyrics, segments_to_lrc
from .metadata import read_metadata
from .pipeline import FileResult, _save_unrecognized, collect_audio_files
from .pipeline import (
FileResult, _artifact_belongs_to, _save_unrecognized, collect_audio_files,
)
from .transcribe import transcribe_aligned
LogFn = Callable[[str], None]
ProgressFn = Callable[[int, int, str], None]
ProgressFn = Callable[[str, int, int], None] # stage, current, total
OverallFn = Callable[[int, int], None] # completed_files, total_files
def _pick_audio_source(
@@ -44,11 +47,26 @@ def _pick_audio_source(
return target
def _ready_lrc(track_out: Path, source: Path) -> Optional[str]:
"""Return existing valid LRC text for this exact source track, if any."""
if not _artifact_belongs_to(track_out, source):
return None
lrc_path = track_out / "text.lrc"
if not lrc_path.is_file():
return None
try:
content = lrc_path.read_text(encoding="utf-8")
except OSError:
return None
return content if content.strip() and is_lrc(content) else None
def run_lrc_pipeline(
config: PipelineConfig,
log: LogFn = print,
on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None,
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]:
input_dir = Path(config.input_dir)
if not input_dir.is_dir():
@@ -56,6 +74,10 @@ def run_lrc_pipeline(
output_dir = Path(config.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if not config.lrc.enabled:
log("LRC: генерация отключена — пропуск этапа")
return []
files = collect_audio_files(input_dir, config.recursive)
if not files:
log("Аудиофайлы не найдены.")
@@ -67,13 +89,15 @@ def run_lrc_pipeline(
results: list[FileResult] = []
total = len(files)
if on_progress:
on_progress("lrc", 0, total)
if on_overall_progress:
on_overall_progress(0, total)
for idx, f in enumerate(files, 1):
if stop_event is not None and stop_event.is_set():
log("⏹ Отменено")
break
if on_progress:
on_progress(idx - 1, total, "lrc")
log(f"\n▶ [{idx}/{total}] {f.name}")
track_out = output_dir / f.stem
@@ -81,6 +105,17 @@ def run_lrc_pipeline(
lrc_path = track_out / "text.lrc"
error: Optional[str] = None
if config.lrc.skip_existing:
ready = _ready_lrc(track_out, f)
if ready is not None:
log(" ⊘ text.lrc уже готов — пропуск")
results.append(FileResult(f, None, track_out, None, "existing+lrc"))
if on_progress:
on_progress("lrc", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
continue
tmp = Path(tempfile.mkdtemp(prefix=f"at_lrc_{f.stem}_"))
try:
embedded = read_embedded_lyrics(f) if config.lyrics.use_embedded_lyrics else None
@@ -127,7 +162,9 @@ def run_lrc_pipeline(
finally:
shutil.rmtree(tmp, ignore_errors=True)
if on_progress:
on_progress(idx, total, "lrc")
on_progress("lrc", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
return results
@@ -137,6 +174,7 @@ def run_embed_pipeline(
log: LogFn = print,
on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None,
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]:
input_dir = Path(config.input_dir)
if not input_dir.is_dir():
@@ -144,6 +182,10 @@ def run_embed_pipeline(
output_dir = Path(config.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if not config.lrc.embed_enabled:
log("LRC embed: встраивание отключено — пропуск этапа")
return []
files = collect_audio_files(input_dir, config.recursive)
if not files:
log("Аудиофайлы не найдены.")
@@ -154,13 +196,15 @@ def run_embed_pipeline(
results: list[FileResult] = []
total = len(files)
if on_progress:
on_progress("embed", 0, total)
if on_overall_progress:
on_overall_progress(0, total)
for idx, f in enumerate(files, 1):
if stop_event is not None and stop_event.is_set():
log("⏹ Отменено")
break
if on_progress:
on_progress(idx - 1, total, "embed")
lrc_path = output_dir / f.stem / "text.lrc"
error: Optional[str] = None
@@ -190,6 +234,8 @@ def run_embed_pipeline(
results.append(FileResult(f, None, None, error, "embed"))
finally:
if on_progress:
on_progress(idx, total, "embed")
on_progress("embed", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
return results

File diff suppressed because it is too large Load Diff

View File

@@ -91,13 +91,10 @@ def separate_vocals_batch(
total = len(input_wavs)
processed = 0
while proc.poll() is None:
time.sleep(2.0)
if progress_callback and processed < total - 1:
if proc.wait(2.0) is not None:
break
processed += 1
progress_callback(processed)
else:
proc.wait(2.0)
if proc.returncode != 0:
raise RuntimeError(f"unblend batch failed with exit code {proc.returncode}")

View File

@@ -11,6 +11,31 @@ from pathlib import Path
from typing import Optional
# Set permanently once the CUDA context has died (e.g. after an unrecoverable
# OOM). Every later CUDA transcription fails instantly instead of re-loading
# the model forever and hanging the pipeline.
_CUDA_BROKEN = False
def _is_cuda_error(exc: BaseException) -> bool:
err = str(exc).lower()
return (
"out of memory" in err
or "cuda" in err
or "device ordinal" in err
or "cublas" in err
or "cudnn" in err
)
def _check_cuda_usable(device: str) -> None:
if device == "cuda" and _CUDA_BROKEN:
raise RuntimeError(
"CUDA context is broken (previous OOM was unrecoverable). "
"Restart the app or switch device to CPU."
)
def _resolve_device(device: str) -> str:
if device in ("cuda", "cpu"):
return device
@@ -39,7 +64,7 @@ def _free_vram() -> None:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except ImportError:
except Exception: # noqa: BLE001 — cleanup must never mask the original error
pass
@@ -124,7 +149,9 @@ def transcribe(
local_files_only: bool = False,
vad_filter: bool = True,
) -> str:
global _CUDA_BROKEN
device = _resolve_device(device)
_check_cuda_usable(device)
if engine == "whisperx":
try:
@@ -133,16 +160,26 @@ def transcribe(
batch_size, local_files_only, vad_filter,
)
except Exception as e:
err = str(e).lower()
if "out of memory" in err or "cuda" in err or "device ordinal" in err:
print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...")
_WhisperXModels._cache.clear()
_free_vram()
if not _is_cuda_error(e):
raise
print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...")
_WhisperXModels._cache.clear()
_free_vram()
try:
return _transcribe_whisperx(
audio_path, model, language, device, compute_type,
batch_size, local_files_only, vad_filter,
)
raise
except Exception as retry_error:
if not _is_cuda_error(retry_error):
raise
_CUDA_BROKEN = True
print(" [ASR] CUDA context is unrecoverable — "
"GPU disabled for the rest of this run.")
raise RuntimeError(
"CUDA context is broken after OOM recovery failed. "
"Restart the app or switch device to CPU."
) from retry_error
if engine == "gigaam":
return _transcribe_gigaam(audio_path, gigaam_provider)
@@ -191,6 +228,7 @@ def transcribe_aligned(
"""
import whisperx
device = _resolve_device(device)
_check_cuda_usable(device)
model_obj = _WhisperXModels.get(model, device, compute_type, local_files_only)
audio = whisperx.load_audio(str(audio_path))
result = model_obj.transcribe(audio, batch_size=batch_size, language=language)

View File

@@ -6,12 +6,12 @@ from pathlib import Path
from typing import Optional
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QColor, QDesktopServices
from PySide6.QtGui import QColor, QDesktopServices, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QTabWidget,
QVBoxLayout, QWidget,
QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame,
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
QPlainTextEdit, QProgressBar, QPushButton, QScrollArea, QSpinBox,
QTabWidget, QVBoxLayout, QWidget,
)
from core.config import (
@@ -71,8 +71,17 @@ class MainWindow(QMainWindow):
# ---------------- UI ----------------
def _build_ui(self) -> None:
# The whole page lives in a scroll area: on small screens the user can
# scroll down to the log instead of the console being cut off by the
# bottom edge of the window.
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setCentralWidget(scroll)
central = QWidget()
self.setCentralWidget(central)
scroll.setWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(18, 16, 18, 16)
root.setSpacing(12)
@@ -82,6 +91,10 @@ class MainWindow(QMainWindow):
title.setObjectName("appTitle")
header.addWidget(title)
header.addStretch(1)
self.fullscreen_btn = QPushButton("⛶ На весь экран")
self.fullscreen_btn.setToolTip("Развернуть окно на весь экран (F11)")
self.fullscreen_btn.clicked.connect(self._toggle_fullscreen)
header.addWidget(self.fullscreen_btn)
self.device_label = QLabel("")
self.device_label.setObjectName("deviceInfo")
header.addWidget(self.device_label)
@@ -101,6 +114,16 @@ class MainWindow(QMainWindow):
self.mode_cb.addItem(label, key)
top_layout.addRow("Режим:", self.mode_cb)
self.chunk_size_spin = QSpinBox()
self.chunk_size_spin.setRange(1, 10000)
self.chunk_size_spin.setToolTip(
"Размер батча в staged-режиме.\n"
"После каждого батча: удаляются временные файлы, освобождается VRAM.\n"
"Модели WhisperX остаются в памяти — перезагрузки не будет.\n"
"64 — разумный компромисс для 32 ГБ RAM и 8 ГБ VRAM."
)
top_layout.addRow("Размер батча:", self.chunk_size_spin)
opts = QHBoxLayout()
self.recursive_cb = QCheckBox("Рекурсивный обход")
self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные")
@@ -110,6 +133,23 @@ class MainWindow(QMainWindow):
opts.addWidget(self.overwrite_cb)
opts.addStretch(1)
top_layout.addRow("Опции:", opts)
resume_opts = QHBoxLayout()
self.skip_existing_cb = QCheckBox("Пропускать готовые (text.txt)")
self.skip_existing_cb.setToolTip(
"Не трогать файлы, у которых в <output>/<stem>/ уже есть text.txt.\n"
"Позволяет продолжить большой прогон после сбоя без пересчёта.\n"
"Если в metadata.json записан path другого файла — папка не считается готовой."
)
self.resume_partial_cb = QCheckBox("Подхватывать vocals.wav / convert.wav")
self.resume_partial_cb.setToolTip(
"Если text.txt ещё нет, но есть vocals.wav — сразу переходить к\n"
"транскрибации. Если есть только convert.wav — пропустить конвертацию."
)
resume_opts.addWidget(self.skip_existing_cb)
resume_opts.addWidget(self.resume_partial_cb)
resume_opts.addStretch(1)
top_layout.addRow("Resume:", resume_opts)
root.addWidget(top_box)
self.tabs = QTabWidget()
@@ -126,6 +166,7 @@ class MainWindow(QMainWindow):
self.convert_enabled_cb.toggled.connect(self._sync_tab_indicators)
self.separate_enabled_cb.toggled.connect(self._sync_tab_indicators)
self.transcribe_enabled_cb.toggled.connect(self._sync_tab_indicators)
self.lrc_enabled_cb.toggled.connect(self._sync_tab_indicators)
self.engine_cb.currentTextChanged.connect(self._sync_engine_fields)
row = QHBoxLayout()
@@ -156,12 +197,33 @@ class MainWindow(QMainWindow):
self.progress.setFormat("Ожидание")
root.addWidget(self.progress)
self.overall_progress = QProgressBar()
self.overall_progress.setObjectName("overallProgress")
self.overall_progress.setFormat("Общий прогресс: ожидание")
root.addWidget(self.overall_progress)
self.log_view = QPlainTextEdit()
self.log_view.setObjectName("log")
self.log_view.setReadOnly(True)
self.log_view.setMinimumHeight(240)
self.log_view.setPlaceholderText("Здесь появится лог выполнения…")
root.addWidget(self.log_view, 1)
self._fs_shortcut = QShortcut(QKeySequence("F11"), self)
self._fs_shortcut.activated.connect(self._toggle_fullscreen)
self._esc_shortcut = QShortcut(QKeySequence("Escape"), self)
self._esc_shortcut.activated.connect(self._leave_fullscreen)
def _toggle_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
def _leave_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
def _with_browse(self, edit: QLineEdit, picker) -> QWidget:
w = QWidget()
h = QHBoxLayout(w)
@@ -209,8 +271,17 @@ class MainWindow(QMainWindow):
self.lyrics_user_agent = QLineEdit()
form.addRow("User-Agent:", self.lyrics_user_agent)
self.lyrics_use_tags_cb = QCheckBox("Искать по тегам")
self.lyrics_use_tags_cb.setToolTip(
"Использовать artist/title из тегов файла для поиска на LRCLIB.\n"
"Срабатывает как fallback, если Shazam ничего не нашёл\n"
"или его результат отключён."
)
form.addRow("", self.lyrics_use_tags_cb)
self.lyrics_use_shazam_cb = QCheckBox("Искать по Shazam")
self.lyrics_use_shazam_cb.setToolTip(
"Приоритетный источник для поиска на LRCLIB: artist/title из Shazam.\n"
"Теги файла используются только если Shazam не дал результата."
)
form.addRow("", self.lyrics_use_shazam_cb)
self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy)")
form.addRow("", self.lyrics_after_asr_cb)
@@ -333,6 +404,25 @@ class MainWindow(QMainWindow):
info.setStyleSheet("color: #a6adc8;")
form.addRow(info)
self.lrc_enabled_cb = QCheckBox("Включён (генерация LRC)")
self.lrc_enabled_cb.setToolTip(
"Отключите, чтобы пропустить этап генерации text.lrc.\n"
"Остальные этапы пайплайна продолжат работать."
)
form.addRow("", self.lrc_enabled_cb)
self.lrc_embed_enabled_cb = QCheckBox("Включён (встраивание LRC в аудио)")
self.lrc_embed_enabled_cb.setToolTip(
"Отключите, чтобы пропустить этап встраивания LRC в теги аудио."
)
form.addRow("", self.lrc_embed_enabled_cb)
self.lrc_skip_existing_cb = QCheckBox("Пропускать готовые text.lrc")
self.lrc_skip_existing_cb.setToolTip(
"Если <output>/<stem>/text.lrc уже существует и содержит корректный LRC,\n"
"файл не будет пересчитан. Папка с metadata.json от другого трека\n"
"готовой не считается."
)
form.addRow("", self.lrc_skip_existing_cb)
self.lrc_audio_source_cb = QComboBox()
self.lrc_audio_source_cb.addItems(["auto", "vocals", "convert", "input"])
self.lrc_audio_source_cb.setToolTip(
@@ -392,6 +482,7 @@ class MainWindow(QMainWindow):
bar.setTabTextColor(2, active if self.convert_enabled_cb.isChecked() else inactive)
bar.setTabTextColor(3, active if self.separate_enabled_cb.isChecked() else inactive)
bar.setTabTextColor(4, active if self.transcribe_enabled_cb.isChecked() else inactive)
bar.setTabTextColor(5, active if self.lrc_enabled_cb.isChecked() else inactive)
def _sync_engine_fields(self) -> None:
is_whisperx = self.engine_cb.currentText() == "whisperx"
@@ -410,6 +501,8 @@ class MainWindow(QMainWindow):
self.recursive_cb.setChecked(cfg.recursive)
self.keep_intermediate_cb.setChecked(cfg.keep_intermediate)
self.overwrite_cb.setChecked(cfg.overwrite)
self.skip_existing_cb.setChecked(cfg.skip_existing)
self.resume_partial_cb.setChecked(cfg.resume_partial)
i = self.mode_cb.findData(cfg.mode)
if i >= 0:
self.mode_cb.setCurrentIndex(i)
@@ -460,6 +553,10 @@ class MainWindow(QMainWindow):
self.lrc_align_model_edit.setText(cfg.lrc.align_model or "")
self.lrc_device_cb.setCurrentText(cfg.lrc.device)
self.lrc_embed_in_place_cb.setChecked(cfg.lrc.embed_in_place)
self.lrc_enabled_cb.setChecked(cfg.lrc.enabled)
self.lrc_embed_enabled_cb.setChecked(cfg.lrc.embed_enabled)
self.lrc_skip_existing_cb.setChecked(cfg.lrc.skip_existing)
self.chunk_size_spin.setValue(cfg.chunk_size)
def _collect_config(self) -> PipelineConfig:
cfg = PipelineConfig()
@@ -468,6 +565,8 @@ class MainWindow(QMainWindow):
cfg.recursive = self.recursive_cb.isChecked()
cfg.keep_intermediate = self.keep_intermediate_cb.isChecked()
cfg.overwrite = self.overwrite_cb.isChecked()
cfg.skip_existing = self.skip_existing_cb.isChecked()
cfg.resume_partial = self.resume_partial_cb.isChecked()
cfg.mode = self.mode_cb.currentData()
cfg.shazam = ShazamConfig(
@@ -516,12 +615,16 @@ class MainWindow(QMainWindow):
)
lrc_lang = self.lrc_language_cb.currentText().strip()
cfg.lrc = LrcConfig(
enabled=self.lrc_enabled_cb.isChecked(),
embed_enabled=self.lrc_embed_enabled_cb.isChecked(),
skip_existing=self.lrc_skip_existing_cb.isChecked(),
language=None if lrc_lang in ("", "auto") else lrc_lang,
align_model=self.lrc_align_model_edit.text().strip() or None,
device=self.lrc_device_cb.currentText(),
audio_source=self.lrc_audio_source_cb.currentText(),
embed_in_place=self.lrc_embed_in_place_cb.isChecked(),
)
cfg.chunk_size = self.chunk_size_spin.value()
return cfg
# ---------------- handlers ----------------
@@ -595,16 +698,32 @@ class MainWindow(QMainWindow):
"Align-модель доступна только для WhisperX. Переключите движок на whisperx."
)
return
if task == "lrc" and not cfg.lrc.enabled:
QMessageBox.warning(
self, "LRC отключён",
"Этап генерации LRC выключен. Включите его на вкладке LRC."
)
return
if task == "embed" and not cfg.lrc.embed_enabled:
QMessageBox.warning(
self, "Встраивание LRC отключено",
"Этап встраивания LRC выключен. Включите его на вкладке LRC."
)
return
self.log_view.clear()
self._log_device_info()
self.progress.setValue(0)
self.progress.setFormat("Запуск…")
self.overall_progress.setMaximum(1)
self.overall_progress.setValue(0)
self.overall_progress.setFormat("Общий прогресс: запуск…")
self._set_busy(True)
self._worker = PipelineWorker(cfg, task=task)
self._worker.log.connect(self._append_log)
self._worker.progress.connect(self._on_progress)
self._worker.overall_progress.connect(self._on_overall_progress)
self._worker.finished_ok.connect(self._on_finished)
self._worker.failed.connect(self._on_failed)
self._worker.start()
@@ -619,21 +738,61 @@ class MainWindow(QMainWindow):
sb = self.log_view.verticalScrollBar()
sb.setValue(sb.maximum())
def _on_progress(self, done: int, total: int, stage: str) -> None:
self.progress.setMaximum(total)
self.progress.setValue(done)
def _on_progress(self, stage: str, done: int, total: int) -> None:
label = STAGE_LABELS.get(stage, stage)
self.progress.setFormat(f"{label} — %v / %m")
if total > 0:
self.progress.setMaximum(total)
self.progress.setValue(min(done, total))
self.progress.setFormat(f"{label}: %v / %m")
else:
# Nothing to do for this stage — still show which stage we are on
# instead of leaving the previous stage's label frozen.
self.progress.setMaximum(1)
self.progress.setValue(0)
self.progress.setFormat(f"{label}: 0 / 0")
def _on_overall_progress(self, done: int, total: int) -> None:
"""Overall job progress: completed files / all files in the run."""
if total <= 0:
self.overall_progress.setMaximum(1)
self.overall_progress.setValue(0)
self.overall_progress.setFormat("Общий прогресс: 0 / 0")
return
done = max(0, min(done, total))
remaining = total - done
percent = int(done * 100 / total)
self.overall_progress.setMaximum(total)
self.overall_progress.setValue(done)
self.overall_progress.setFormat(
f"Общий прогресс: {done} / {total} ({percent}%) · осталось {remaining}"
)
def _on_finished(self, results: list) -> None:
self._set_busy(False)
ok = sum(1 for r in results if r.error is None)
fail = len(results) - ok
self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}")
total = self.overall_progress.maximum()
done = self.overall_progress.value()
if total > 0 and done >= total:
self.overall_progress.setFormat(
f"Общий прогресс: {done} / {total} — завершено"
)
elif total > 0:
remaining = total - done
self.overall_progress.setFormat(
f"Общий прогресс: {done} / {total} — остановлено, осталось {remaining}"
)
self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.")
def _on_failed(self, message: str) -> None:
self._set_busy(False)
self.progress.setFormat("Ошибка")
if self.overall_progress.maximum() > 0:
self.overall_progress.setFormat(
f"Общий прогресс: {self.overall_progress.value()} / "
f"{self.overall_progress.maximum()} — ошибка"
)
self._append_log(f"\n✗ Ошибка пайплайна: {message}")
QMessageBox.critical(self, "Ошибка", message)

View File

@@ -170,20 +170,32 @@ QCheckBox::indicator:checked {
}
QProgressBar {
background-color: #181825;
border: 1px solid #313244;
background-color: #0b0b12;
border: 1px solid #7f849c;
border-radius: 8px;
text-align: center;
color: #cdd6f4;
min-height: 24px;
max-height: 24px;
font-weight: 500;
color: #ffffff;
min-height: 26px;
max-height: 26px;
font-weight: 700;
}
QProgressBar::chunk {
background-color: #89b4fa;
background-color: #1e66f5;
border-radius: 7px;
margin: 1px;
}
QProgressBar#overallProgress {
min-height: 30px;
max-height: 30px;
font-weight: 700;
color: #ffffff;
border: 1px solid #a6adc8;
}
QProgressBar#overallProgress::chunk {
background-color: #2f9e44;
border-radius: 8px;
margin: 2px;
}
QScrollBar:vertical {
background: #181825;

View File

@@ -10,7 +10,8 @@ from core.config import PipelineConfig
class PipelineWorker(QThread):
log = Signal(str)
progress = Signal(int, int, str) # done, total, stage
progress = Signal(str, int, int) # stage, current, total_for_stage
overall_progress = Signal(int, int) # completed_files, total_files
finished_ok = Signal(list)
failed = Signal(str)
@@ -35,8 +36,9 @@ class PipelineWorker(QThread):
results = runner(
self._config,
log=self.log.emit,
on_progress=lambda d, t, s: self.progress.emit(d, t, s),
on_progress=lambda s, d, t: self.progress.emit(s, d, t),
stop_event=self._stop_event,
on_overall_progress=lambda d, t: self.overall_progress.emit(d, t),
)
self.finished_ok.emit(results)
except Exception as exc: # noqa: BLE001