feat: LiveSub 2차 — 자막 on/off, 모니터·위치 선택, GPU 저부하, 게임 용어집

이름을 Hearo → LiveSub 로 변경. 말장난보다 하는 일이 바로 보이는 쪽이 낫다.

자막 on/off
- 전역 단축키 Ctrl+Alt+S (자막) / Ctrl+Alt+D (번역) — Windows RegisterHotKey +
  네이티브 이벤트 필터라 게임 창이 떠 있어도 동작. 다른 OS 에서는 no-op
- 단축키·체크박스·우클릭 메뉴가 모두 같은 경로를 타도록 통합

모니터 선택 + 디스코드식 배치
- placement.py: Qt 비의존 배치 계산 (모니터 목록 → 9분할 좌표)
- AnchorGrid 위젯으로 3x3 위치 선택, 드래그하면 자유 배치로 전환
- 모니터 구성이 바뀌어도 자막이 화면 밖으로 사라지지 않도록 클램프

GPU 저부하 모드 (기본 켜짐)
- 연산 정밀도 int8 강등, VRAM 상한 35%, 추론 후 60ms 양보,
  300초 무음 시 모델 언로드, 중간 결과 비활성
- 기본 티어를 균형(6GB) → 신속(4GB) 으로 하향
- 8GB GPU 기준 자막 2.8GB / 게임 5.2GB

게임 용어집 번들 (304개)
- 롤 74 · 발로란트 59 · 오버워치2 49 · FPS공통 46 · 마크 38 · 방송 38
- 체크박스로 켜고 끄며, 사용자가 직접 등록한 항목이 항상 우선

검증: pytest 91개 통과 (배치 14 + 팩 12 + UI 6 신규), ruff clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-09-21 17:49:26 +09:00
parent 5596f905d1
commit 8b36b244ff
60 changed files with 1939 additions and 300 deletions

View File

@@ -0,0 +1,327 @@
"""메인 컨트롤 창 — 사이드바 + 페이지 스택.
디자인 의도: 자막은 게임 위 오버레이가 전담하고, 이 창은 '설정과 상태'만
담당한다. 그래서 두 창이 서로 방해하지 않는다.
"""
from __future__ import annotations
import logging
from datetime import datetime
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import (
QButtonGroup,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from ..config import AppConfig
from ..constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ..core import EngineState, EngineStatus, TranslationEngine, TranslationLine
from .hotkeys import GlobalHotkeys
from .overlay import SubtitleOverlay
from .pages import GlossaryPage, HomePage, ModelsPage, SettingsPage, SubtitlePage
from .theme import SPACING, palette, stylesheet
log = logging.getLogger(__name__)
NAV = [
("홈", "실시간 번역"),
("모델", "품질 티어"),
("자막", "표시 설정"),
("용어집", "고유명사 고정"),
("설정", "세부 조정"),
]
class MainWindow(QMainWindow):
#: 엔진 콜백은 워커 스레드에서 오므로 시그널로 GUI 스레드에 넘긴다.
line_received = Signal(object)
status_received = Signal(object)
def __init__(self, config: AppConfig) -> None:
super().__init__()
self.config = config
self.setWindowTitle(f"{APP_NAME} · {APP_SLOGAN}")
self.resize(1060, 820)
self.engine = TranslationEngine(
config,
on_line=self.line_received.emit,
on_status=self.status_received.emit,
)
self.overlay = SubtitleOverlay(config.subtitle, config.overlay)
self.hotkeys = GlobalHotkeys(self)
self._transcript_file = None
self._build_ui()
self._connect()
self._install_hotkeys()
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.setInterval(800) # 입력 중 계속 쓰지 않도록 묶어서 저장
self._save_timer.timeout.connect(lambda: self.config.save())
# --- UI -------------------------------------------------------------
def _build_ui(self) -> None:
self.setStyleSheet(stylesheet(self.config.theme))
central = QWidget()
layout = QHBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self._build_sidebar())
self.stack = QStackedWidget()
self.home_page = HomePage(self.config)
self.models_page = ModelsPage(self.config)
self.subtitle_page = SubtitlePage(self.config)
self.glossary_page = GlossaryPage(self.config)
self.settings_page = SettingsPage(self.config)
for page in (
self.home_page,
self.models_page,
self.subtitle_page,
self.glossary_page,
self.settings_page,
):
self.stack.addWidget(_scrollable(page))
layout.addWidget(self.stack, 1)
self.setCentralWidget(central)
def _build_sidebar(self) -> QFrame:
p = palette(self.config.theme)
bar = QFrame()
bar.setObjectName("Sidebar")
bar.setFixedWidth(212)
layout = QVBoxLayout(bar)
layout.setContentsMargins(14, 22, 14, 18)
layout.setSpacing(6)
brand = QLabel(APP_NAME)
brand.setStyleSheet(
f"font-size: 24px; font-weight: 800; color: {p.accent}; padding-left: 6px;"
)
layout.addWidget(brand)
tag = QLabel(APP_SLOGAN)
tag.setObjectName("Hint")
tag.setStyleSheet(f"color: {p.text_dim}; padding-left: 7px;")
layout.addWidget(tag)
layout.addSpacing(SPACING + 8)
self._nav_group = QButtonGroup(self)
self._nav_group.setExclusive(True)
for index, (title, hint) in enumerate(NAV):
button = QPushButton(title)
button.setObjectName("NavButton")
button.setCheckable(True)
button.setToolTip(hint)
button.setCursor(Qt.CursorShape.PointingHandCursor)
self._nav_group.addButton(button, index)
layout.addWidget(button)
self._nav_group.button(0).setChecked(True)
self._nav_group.idClicked.connect(self._goto_page)
layout.addStretch(1)
version = QLabel(f"v{APP_VERSION}")
version.setObjectName("Hint")
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(version)
return bar
def _connect(self) -> None:
self.line_received.connect(self._on_line)
self.status_received.connect(self._on_status)
self.home_page.start_requested.connect(self.start_engine)
self.home_page.stop_requested.connect(self.stop_engine)
self.home_page.config_changed.connect(self._schedule_save)
self.models_page.tier_changed.connect(self._on_tier_changed)
self.models_page.config_changed.connect(self._schedule_save)
self.subtitle_page.style_changed.connect(self._on_style_changed)
self.subtitle_page.overlay_toggled.connect(self._on_overlay_toggled)
self.subtitle_page.placement_changed.connect(self._on_placement_changed)
self.glossary_page.glossary_changed.connect(self._on_glossary_changed)
self.settings_page.config_changed.connect(self._schedule_save)
self.settings_page.restart_needed.connect(self._on_restart_needed)
self.settings_page.hotkeys_changed.connect(self._install_hotkeys)
self.overlay.geometry_changed.connect(self._on_overlay_moved)
self.overlay.visibility_changed.connect(self.subtitle_page.overlay_check.setChecked)
self.overlay.closed.connect(
lambda: self.subtitle_page.overlay_check.setChecked(False)
)
# --- 전역 단축키 ------------------------------------------------------
def _install_hotkeys(self) -> None:
"""설정에 적힌 단축키를 OS에 등록한다. 변경될 때마다 다시 등록한다."""
self.hotkeys.unregister_all()
keys = self.config.hotkeys
if not keys.enabled:
self.settings_page.set_hotkey_status("전역 단축키를 끈 상태입니다.")
return
if not self.hotkeys.supported:
self.settings_page.set_hotkey_status(
"전역 단축키는 Windows에서만 동작합니다. "
"이 창이 떠 있을 때는 자막 화면에서 직접 끄고 켤 수 있습니다.",
ok=False,
)
return
registered, failed = [], []
for label, sequence, callback in (
("자막", keys.toggle_subtitle, self.toggle_subtitle),
("번역", keys.toggle_engine, self.toggle_engine),
):
if not sequence:
continue
if self.hotkeys.register(sequence, callback):
registered.append(f"{label} {sequence}")
else:
failed.append(f"{label} {sequence}")
if failed:
self.settings_page.set_hotkey_status(
f"등록 실패: {', '.join(failed)} — {self.hotkeys.last_error}", ok=False
)
else:
self.settings_page.set_hotkey_status(
f"등록됨: {' · '.join(registered)}" if registered else "등록된 단축키가 없습니다."
)
def toggle_subtitle(self) -> None:
"""자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다."""
visible = self.overlay.toggle_subtitle()
self._schedule_save()
log.info("자막 %s", "켬" if visible else "끔")
def toggle_engine(self) -> None:
if self.engine.running:
self.stop_engine()
else:
self.start_engine()
# --- 엔진 -----------------------------------------------------------
def start_engine(self) -> None:
if self.config.overlay.visible:
self.overlay.show()
self.config.save()
self.engine.start()
def stop_engine(self) -> None:
self.engine.stop()
self.overlay.clear()
self._close_transcript()
def _on_tier_changed(self, tier_key: str) -> None:
was_running = self.engine.running
if was_running:
self.engine.stop()
self.engine.models.switch_tier(tier_key)
if was_running:
self.engine.start()
def _on_restart_needed(self) -> None:
if self.engine.running:
self.engine.stop()
self.engine.start()
# --- 이벤트 ---------------------------------------------------------
def _on_line(self, line: TranslationLine) -> None:
self.overlay.set_line(line.translated_text, line.source_text, line.is_final)
self.home_page.on_line(line)
if line.is_final and self.config.log_transcripts:
self._write_transcript(line)
def _on_status(self, status: EngineStatus) -> None:
self.home_page.on_status(status)
if status.state is EngineState.ERROR:
QMessageBox.warning(self, "오류", status.message)
def _on_style_changed(self) -> None:
self.overlay.update()
self._schedule_save()
def _on_overlay_toggled(self, visible: bool) -> None:
if visible == self.config.overlay.visible:
return # 단축키로 바뀐 것을 체크박스가 되돌려 보내는 루프 차단
self.overlay.set_subtitle_visible(visible)
self._schedule_save()
def _on_placement_changed(self) -> None:
"""모니터·9분할 위치·여백·클릭통과가 바뀌면 자막 창에 바로 반영."""
self.overlay.apply_config()
self.overlay.snap_to_anchor()
self.subtitle_page.sync_from_config()
self._schedule_save()
def _on_overlay_moved(self, *_) -> None:
"""드래그로 옮기면 '자유 배치'가 되므로 설정 화면 표시를 맞춘다."""
self.subtitle_page.sync_from_config()
self._schedule_save()
def _on_glossary_changed(self) -> None:
self.engine.reload_glossary()
self._schedule_save()
def _goto_page(self, index: int) -> None:
self.stack.setCurrentIndex(index)
def _schedule_save(self) -> None:
self._save_timer.start()
# --- 기록 -----------------------------------------------------------
def _write_transcript(self, line: TranslationLine) -> None:
if self._transcript_file is None:
folder = user_data_dir() / "transcripts"
folder.mkdir(parents=True, exist_ok=True)
name = datetime.now().strftime("%Y%m%d-%H%M%S.txt")
# 세션 내내 열어두는 핸들이다. _close_transcript() 에서 닫는다.
self._transcript_file = open(folder / name, "a", encoding="utf-8") # noqa: SIM115
stamp = datetime.now().strftime("%H:%M:%S")
self._transcript_file.write(
f"[{stamp}] {line.source_text}\n {line.translated_text}\n"
)
self._transcript_file.flush()
def _close_transcript(self) -> None:
if self._transcript_file is not None:
self._transcript_file.close()
self._transcript_file = None
# --- 종료 -----------------------------------------------------------
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self.config.save()
self.hotkeys.unregister_all() # OS에 잡아둔 키를 반드시 풀어준다
self.engine.shutdown()
self._close_transcript()
self.overlay.close()
super().closeEvent(event)
def _scrollable(widget: QWidget) -> QScrollArea:
area = QScrollArea()
area.setWidgetResizable(True)
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
holder = QWidget()
layout = QVBoxLayout(holder)
layout.setContentsMargins(26, 24, 26, 24)
layout.addWidget(widget)
area.setWidget(holder)
return area