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:
221
src/livesub/ui/pages/models_page.py
Normal file
221
src/livesub/ui/pages/models_page.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""모델 — 5단계 품질 티어 선택과 GPU 상태."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...config import AppConfig
|
||||
from ...models import Tier, detect_gpu, ordered_tiers
|
||||
from ..theme import SPACING, palette
|
||||
from ..widgets import Card, PageHeader
|
||||
|
||||
|
||||
class TierCard(QFrame):
|
||||
"""티어 하나를 라디오 버튼 카드로."""
|
||||
|
||||
def __init__(self, tier: Tier, gpu_vram_gb: float, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.tier = tier
|
||||
self.setObjectName("Card")
|
||||
p = palette("dark")
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(16, 14, 16, 14)
|
||||
layout.setSpacing(14)
|
||||
|
||||
self.radio = QRadioButton()
|
||||
layout.addWidget(self.radio, 0, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
text = QVBoxLayout()
|
||||
text.setSpacing(3)
|
||||
|
||||
head = QHBoxLayout()
|
||||
head.setSpacing(8)
|
||||
name = QLabel(f"{tier.order}. {tier.name}")
|
||||
name.setStyleSheet("font-size: 16px; font-weight: 700;")
|
||||
head.addWidget(name)
|
||||
if tier.recommended:
|
||||
head.addWidget(_badge("추천", p.accent))
|
||||
if tier.best_after_finetune:
|
||||
head.addWidget(_badge("추가학습 최적", p.success))
|
||||
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
|
||||
head.addWidget(_badge(f"VRAM {tier.min_vram_gb:g}GB 필요", p.warning))
|
||||
head.addStretch(1)
|
||||
text.addLayout(head)
|
||||
|
||||
tagline = QLabel(tier.tagline)
|
||||
tagline.setObjectName("Subtitle")
|
||||
text.addWidget(tagline)
|
||||
|
||||
spec = QLabel(
|
||||
f"예상 지연 {tier.approx_latency_s:g}초 · 권장 VRAM {tier.min_vram_gb:g}GB "
|
||||
f"· 내려받기 약 {tier.download_gb:g}GB"
|
||||
)
|
||||
spec.setObjectName("Hint")
|
||||
text.addWidget(spec)
|
||||
|
||||
if tier.notes:
|
||||
note = QLabel(tier.notes)
|
||||
note.setObjectName("Hint")
|
||||
note.setWordWrap(True)
|
||||
text.addWidget(note)
|
||||
|
||||
layout.addLayout(text, 1)
|
||||
|
||||
def set_selected(self, selected: bool) -> None:
|
||||
self.setObjectName("CardSelected" if selected else "Card")
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
|
||||
|
||||
class ModelsPage(QWidget):
|
||||
tier_changed = Signal(str)
|
||||
config_changed = Signal()
|
||||
|
||||
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
self._cards: list[TierCard] = []
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(SPACING + 4)
|
||||
root.addWidget(
|
||||
PageHeader(
|
||||
"모델",
|
||||
"속도 우선(1)부터 품질 우선(5)까지 다섯 단계입니다. "
|
||||
"모델은 처음 사용할 때 자동으로 내려받습니다.",
|
||||
)
|
||||
)
|
||||
|
||||
root.addWidget(self._build_gpu_card())
|
||||
|
||||
self._group = QButtonGroup(self)
|
||||
self._group.setExclusive(True)
|
||||
for tier in ordered_tiers():
|
||||
card = TierCard(tier, self._gpu_vram_gb)
|
||||
self._group.addButton(card.radio, tier.order)
|
||||
card.radio.toggled.connect(
|
||||
lambda checked, t=tier: self._on_tier_selected(t) if checked else None
|
||||
)
|
||||
self._cards.append(card)
|
||||
root.addWidget(card)
|
||||
|
||||
root.addWidget(self._build_lora_card())
|
||||
root.addStretch(1)
|
||||
self._apply_selection(config.models.tier)
|
||||
|
||||
# --- 구성 -----------------------------------------------------------
|
||||
def _build_gpu_card(self) -> Card:
|
||||
gpu = detect_gpu()
|
||||
self._gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
|
||||
p = palette("dark")
|
||||
|
||||
card = Card("실행 환경")
|
||||
row = QWidget()
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(12)
|
||||
|
||||
if gpu.available:
|
||||
text = (
|
||||
f"GPU: {gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB "
|
||||
f"(여유 {gpu.free_vram_mb / 1024:.1f}GB)"
|
||||
)
|
||||
color = p.success
|
||||
else:
|
||||
text = f"GPU를 쓸 수 없습니다 — {gpu.reason} (CPU로도 동작하지만 많이 느립니다)"
|
||||
color = p.warning
|
||||
info = QLabel(text)
|
||||
info.setStyleSheet(f"color: {color}; font-weight: 600;")
|
||||
info.setWordWrap(True)
|
||||
layout.addWidget(info, 1)
|
||||
|
||||
self.device_combo = QComboBox()
|
||||
self.device_combo.addItem("GPU (CUDA)", "cuda")
|
||||
self.device_combo.addItem("CPU", "cpu")
|
||||
self.device_combo.setCurrentIndex(
|
||||
max(0, self.device_combo.findData(self.config.models.device))
|
||||
)
|
||||
self.device_combo.setEnabled(gpu.available)
|
||||
self.device_combo.currentIndexChanged.connect(self._on_device_changed)
|
||||
layout.addWidget(self.device_combo)
|
||||
|
||||
card.add(row)
|
||||
return card
|
||||
|
||||
def _build_lora_card(self) -> Card:
|
||||
card = Card(
|
||||
"추가학습 어댑터 (선택)",
|
||||
"게임·방송 용어로 따로 학습시킨 LoRA 어댑터 폴더를 지정하면 번역 모델에 얹습니다. "
|
||||
"scripts/finetune_mt.py 로 만들 수 있습니다.",
|
||||
)
|
||||
row = QWidget()
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(10)
|
||||
|
||||
self.lora_edit = QLineEdit(self.config.models.lora_adapter_path)
|
||||
self.lora_edit.setPlaceholderText("비워두면 사용하지 않습니다")
|
||||
self.lora_edit.editingFinished.connect(self._on_lora_changed)
|
||||
layout.addWidget(self.lora_edit, 1)
|
||||
|
||||
browse = QPushButton("폴더 선택")
|
||||
browse.clicked.connect(self._browse_lora)
|
||||
layout.addWidget(browse)
|
||||
|
||||
card.add(row)
|
||||
return card
|
||||
|
||||
# --- 동작 -----------------------------------------------------------
|
||||
def _apply_selection(self, tier_key: str) -> None:
|
||||
for card in self._cards:
|
||||
selected = card.tier.key == tier_key
|
||||
card.radio.setChecked(selected)
|
||||
card.set_selected(selected)
|
||||
|
||||
def _on_tier_selected(self, tier: Tier) -> None:
|
||||
if self.config.models.tier == tier.key:
|
||||
return
|
||||
self.config.models.tier = tier.key
|
||||
for card in self._cards:
|
||||
card.set_selected(card.tier.key == tier.key)
|
||||
self.tier_changed.emit(tier.key)
|
||||
self.config_changed.emit()
|
||||
|
||||
def _on_device_changed(self) -> None:
|
||||
self.config.models.device = self.device_combo.currentData()
|
||||
self.config_changed.emit()
|
||||
|
||||
def _on_lora_changed(self) -> None:
|
||||
self.config.models.lora_adapter_path = self.lora_edit.text().strip()
|
||||
self.config_changed.emit()
|
||||
|
||||
def _browse_lora(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
path = QFileDialog.getExistingDirectory(self, "LoRA 어댑터 폴더 선택")
|
||||
if path:
|
||||
self.lora_edit.setText(path)
|
||||
self._on_lora_changed()
|
||||
|
||||
|
||||
def _badge(text: str, color: str) -> QLabel:
|
||||
label = QLabel(text)
|
||||
label.setStyleSheet(
|
||||
f"color: {color}; border: 1px solid {color}; border-radius: 9px;"
|
||||
f"padding: 1px 8px; font-size: 11px; font-weight: 700;"
|
||||
)
|
||||
return label
|
||||
Reference in New Issue
Block a user