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

@@ -1,176 +0,0 @@
"""설정 — 인식 민감도, 응답 속도, 기타 동작."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QSpinBox,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ..theme import SPACING
from ..widgets import Card, PageHeader
class SettingsPage(QWidget):
config_changed = Signal()
#: 오디오 파이프라인 설정이 바뀌어 엔진 재시작이 필요할 때
restart_needed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(PageHeader("설정", "인식 민감도와 응답 속도를 조절합니다."))
root.addWidget(self._build_audio_card())
root.addWidget(self._build_behavior_card())
root.addWidget(self._build_about_card())
root.addStretch(1)
# --- 구성 -----------------------------------------------------------
def _build_audio_card(self) -> Card:
cfg = self.config.audio
card = Card(
"음성 인식",
"소리가 작아 인식이 안 되면 입력 증폭을 올리고, 주변 소음까지 잡히면 "
"민감도를 낮추세요. 값을 바꾸면 다음 시작부터 적용됩니다.",
)
form = card.add_form()
self.gain_slider, gain_row = _slider_row(
int(cfg.input_gain * 100), 25, 400, "%", self._on_audio_changed
)
form.addRow("입력 증폭", gain_row)
self.vad_combo = QComboBox()
for label, value in (
("낮음 — 작은 소리도 잡음", 0),
("보통", 1),
("높음 (기본)", 2),
("매우 높음 — 또렷한 말만", 3),
):
self.vad_combo.addItem(label, value)
self.vad_combo.setCurrentIndex(max(0, self.vad_combo.findData(cfg.vad_aggressiveness)))
self.vad_combo.currentIndexChanged.connect(self._on_audio_changed)
form.addRow("잡음 억제", self.vad_combo)
self.silence_spin = _spin(cfg.silence_ms, 200, 2000, 50, " ms", self._on_audio_changed)
self.silence_spin.setToolTip(
"이 시간만큼 조용하면 한 문장이 끝난 것으로 봅니다. "
"짧게 하면 자막이 빨리 뜨지만 문장이 자주 끊깁니다."
)
form.addRow("문장 끊는 침묵", self.silence_spin)
self.max_spin = _spin(cfg.max_segment_ms, 3000, 30_000, 1000, " ms", self._on_audio_changed)
self.max_spin.setToolTip("말이 계속 이어져도 이 길이에서 강제로 끊습니다.")
form.addRow("문장 최대 길이", self.max_spin)
self.partial_spin = _spin(
cfg.partial_interval_ms, 0, 3000, 100, " ms", self._on_audio_changed
)
self.partial_spin.setToolTip(
"말하는 도중에도 인식 중인 내용을 흐리게 미리 보여줍니다. "
"0이면 끕니다. GPU 부하가 조금 늘어납니다."
)
form.addRow("중간 결과 갱신", self.partial_spin)
return card
def _build_behavior_card(self) -> Card:
card = Card("동작")
form = card.add_form()
self.theme_combo = QComboBox()
self.theme_combo.addItem("어두운 테마", "dark")
self.theme_combo.addItem("밝은 테마", "light")
self.theme_combo.setCurrentIndex(max(0, self.theme_combo.findData(self.config.theme)))
self.theme_combo.currentIndexChanged.connect(self._on_behavior_changed)
form.addRow("테마", self.theme_combo)
self.preload_check = QCheckBox("시작할 때 모델을 미리 올려두기")
self.preload_check.setChecked(self.config.models.preload_on_start)
self.preload_check.setToolTip(
"끄면 프로그램이 빨리 뜨지만 첫 문장 번역이 오래 걸립니다."
)
self.preload_check.toggled.connect(self._on_behavior_changed)
form.addRow("", self.preload_check)
self.log_check = QCheckBox("번역 기록을 파일로 남기기")
self.log_check.setChecked(self.config.log_transcripts)
self.log_check.toggled.connect(self._on_behavior_changed)
form.addRow("", self.log_check)
open_folder = QPushButton("설정 폴더 열기")
open_folder.clicked.connect(self._open_folder)
form.addRow("", open_folder)
return card
def _build_about_card(self) -> Card:
card = Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN)
path = QLabel(f"설정 위치: {user_data_dir()}")
path.setObjectName("Hint")
path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
path.setWordWrap(True)
card.add(path)
return card
# --- 동작 -----------------------------------------------------------
def _on_audio_changed(self, *_) -> None:
cfg = self.config.audio
cfg.input_gain = self.gain_slider.value() / 100
cfg.vad_aggressiveness = self.vad_combo.currentData()
cfg.silence_ms = self.silence_spin.value()
cfg.max_segment_ms = self.max_spin.value()
cfg.partial_interval_ms = self.partial_spin.value()
self.config_changed.emit()
self.restart_needed.emit()
def _on_behavior_changed(self, *_) -> None:
self.config.theme = self.theme_combo.currentData()
self.config.models.preload_on_start = self.preload_check.isChecked()
self.config.log_transcripts = self.log_check.isChecked()
self.config_changed.emit()
def _open_folder(self) -> None:
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
QDesktopServices.openUrl(QUrl.fromLocalFile(str(user_data_dir())))
def _spin(value, low, high, step, suffix, slot) -> QSpinBox:
spin = QSpinBox()
spin.setRange(low, high)
spin.setSingleStep(step)
spin.setSuffix(suffix)
spin.setValue(value)
spin.valueChanged.connect(slot)
return spin
def _slider_row(value, low, high, suffix, slot) -> tuple[QSlider, QWidget]:
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(low, high)
slider.setValue(value)
label = QLabel(f"{value}{suffix}")
label.setMinimumWidth(48)
slider.valueChanged.connect(lambda v: label.setText(f"{v}{suffix}"))
slider.valueChanged.connect(slot)
layout.addWidget(slider, 1)
layout.addWidget(label)
return slider, row

View File

@@ -1,3 +0,0 @@
from .common import Card, LevelMeter, PageHeader, StatusPill, labelled_row
__all__ = ["Card", "LevelMeter", "PageHeader", "StatusPill", "labelled_row"]

View File

@@ -1,4 +1,4 @@
"""Hearo — 프로그램 소리를 실시간으로 듣고 번역해 자막으로 보여주는 도구."""
"""LiveSub — 프로그램 소리를 실시간으로 듣고 번역해 자막으로 보여주는 도구."""
from .constants import APP_NAME, APP_SLOGAN, APP_VERSION

View File

@@ -11,7 +11,7 @@ from .constants import APP_NAME, ORG_NAME, user_data_dir
def setup_logging(verbose: bool = False) -> None:
log_file = user_data_dir() / "hearo.log"
log_file = user_data_dir() / "livesub.log"
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
with contextlib.suppress(OSError):
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))

View File

@@ -59,7 +59,7 @@ def create_capture(config: AudioConfig) -> CaptureBackend:
return ProcessLoopbackCapture(config.target_pid)
if backend == "process":
raise CaptureError(
"프로그램별 캡처를 쓰려면 hearo_capture.exe 가 필요합니다. "
"프로그램별 캡처를 쓰려면 livesub_capture.exe 가 필요합니다. "
"native/process_loopback/build.ps1 로 빌드하거나 '출력 장치 전체'를 선택하세요."
)
log.warning("프로그램별 캡처를 쓸 수 없어 장치 루프백으로 전환합니다.")

View File

@@ -1,6 +1,6 @@
"""프로그램별 오디오 캡처 (Windows WASAPI process loopback).
`native/process_loopback` 에서 빌드한 `hearo_capture.exe` 를 자식 프로세스로 띄우고
`native/process_loopback` 에서 빌드한 `livesub_capture.exe` 를 자식 프로세스로 띄우고
stdout 으로 흘러나오는 16kHz mono float32 PCM 을 그대로 읽는다.
보조 프로그램이 없으면 `available()` 이 False 를 돌려주고, 상위 레이어는
@@ -21,7 +21,7 @@ from .base import AudioSource, CaptureBackend, CaptureError
log = logging.getLogger(__name__)
HELPER_NAME = "hearo_capture.exe"
HELPER_NAME = "livesub_capture.exe"
READ_BYTES = FRAME_SAMPLES * 4 * 5 # 100ms 분량

View File

@@ -39,14 +39,22 @@ class SubtitleStyle:
class OverlayConfig:
"""자막 창(오버레이) 위치/동작."""
x: int = -1 # -1 = 화면 하단 중앙 자동 배치
x: int = -1 # -1 = 아직 배치 안 됨 (앵커로 계산)
y: int = -1
width: int = 1100
height: int = 200
always_on_top: bool = True
click_through: bool = False
#: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다.
click_through: bool = True
locked: bool = False
#: 자막 표시 on/off (단축키로 토글)
visible: bool = True
#: 자막을 띄울 모니터 인덱스 (0부터)
screen_index: int = 0
#: 디스코드 오버레이식 9분할 위치. "custom"이면 드래그로 옮긴 자유 위치.
anchor: str = "bottom-center"
#: 화면 가장자리에서 띄울 간격
margin: int = 60
@dataclass
@@ -79,6 +87,40 @@ class GlossaryConfig:
enabled: bool = True
path: str = "" # 비어있으면 user_data_dir()/glossary.json
case_sensitive: bool = False
#: 함께 불러온 기본 제공 용어집 팩 키 목록 (presets/games 참고)
enabled_packs: list[str] = field(default_factory=list)
@dataclass
class PerformanceConfig:
"""게임과 동시에 돌릴 때 GPU를 얼마나 양보할지.
이 프로그램은 게임 위에 얹혀 돌아가는 것이 기본 용도라, 자막 품질보다
게임 프레임을 지키는 쪽이 대체로 더 중요하다.
"""
#: 저부하 모드. 켜면 아래 항목들이 자동으로 보수적인 값으로 적용된다.
game_mode: bool = True
#: 프로세스가 쓸 수 있는 VRAM 상한 비율 (0이면 제한 없음).
#: 게임이 VRAM 부족으로 버벅이는 것을 막는다.
vram_limit_ratio: float = 0.35
#: 한 번 추론하고 나서 GPU를 놓아주는 시간. 게임 프레임 확보용.
yield_ms: int = 60
#: 이 시간 동안 말소리가 없으면 모델을 내려 VRAM을 통째로 반납한다. 0이면 끔.
idle_unload_s: int = 300
#: 저부하 모드에서 연산 정밀도를 int8로 낮출지
force_int8: bool = True
@dataclass
class HotkeyConfig:
"""전역 단축키. 게임 창이 떠 있어도 동작한다."""
enabled: bool = True
#: 자막 표시 on/off
toggle_subtitle: str = "Ctrl+Alt+S"
#: 번역 엔진 시작/정지
toggle_engine: str = "Ctrl+Alt+D"
@dataclass
@@ -88,6 +130,8 @@ class AppConfig:
subtitle: SubtitleStyle = field(default_factory=SubtitleStyle)
overlay: OverlayConfig = field(default_factory=OverlayConfig)
glossary: GlossaryConfig = field(default_factory=GlossaryConfig)
performance: PerformanceConfig = field(default_factory=PerformanceConfig)
hotkeys: HotkeyConfig = field(default_factory=HotkeyConfig)
theme: str = "dark" # dark | light
start_minimized: bool = False
log_transcripts: bool = True

View File

@@ -4,10 +4,10 @@ from __future__ import annotations
from pathlib import Path
APP_NAME = "Hearo"
APP_NAME_KO = "히어로"
APP_SLOGAN = "듣고, 바로 이해하다"
APP_VERSION = "0.1.0"
APP_NAME = "LiveSub"
APP_NAME_KO = "라이브섭"
APP_SLOGAN = "게임 소리를 실시간 자막으로"
APP_VERSION = "0.2.0"
ORG_NAME = "tkrmagid"
# 지원 언어 (1차: 4개)
@@ -29,7 +29,10 @@ LANGUAGE_CODES = tuple(LANGUAGES.keys())
# 기본 모델 품질 티어. 실제 티어 정의는 models/tiers.py 에 있지만,
# config 가 models 패키지를 import 하면 순환 의존이 생기므로 키만 여기에 둔다.
DEFAULT_TIER = "balance"
#
# 게임과 동시에 돌리는 것이 주 용도라 기본값을 '균형'(6GB)에서 '신속'(4GB)으로
# 낮췄다. 인식 품질은 그대로 최상급이고 번역만 경량이라 체감 손해가 가장 적다.
DEFAULT_TIER = "swift"
# 오디오 파이프라인 규격
SAMPLE_RATE = 16_000

View File

@@ -26,6 +26,8 @@ from ..audio import (
from ..config import AppConfig
from ..constants import LANGUAGE_CODES, user_data_dir
from ..models import Glossary, ModelManager
from ..models.manager import apply_vram_limit
from ..models.packs import build_glossary
from .events import EngineState, EngineStatus, TranslationLine
log = logging.getLogger(__name__)
@@ -45,7 +47,7 @@ class TranslationEngine:
self._on_line = on_line
self._on_status = on_status
self.models = ModelManager(config.models)
self.models = ModelManager(config.models, low_power=config.performance.game_mode)
self.glossary = self._load_glossary()
self._capture: CaptureBackend | None = None
@@ -56,6 +58,9 @@ class TranslationEngine:
self._level = 0.0
#: 인식 정확도를 높이기 위해 직전 확정 문장을 프롬프트로 넘긴다.
self._last_final_text = ""
#: 마지막으로 실제 발화를 처리한 시각. 유휴 언로드 판단에 쓴다.
self._last_activity = time.monotonic()
self._models_unloaded = False
# --- 수명주기 -------------------------------------------------------
@property
@@ -101,6 +106,10 @@ class TranslationEngine:
# --- 내부 스레드 ----------------------------------------------------
def _bootstrap(self) -> None:
try:
perf = self.config.performance
if perf.game_mode and self.config.models.device == "cuda":
# 모델을 올리기 *전에* 걸어야 상한이 실제로 먹는다.
apply_vram_limit(perf.vram_limit_ratio, self.config.models.device_index)
if self.config.models.preload_on_start:
self.models.preload(
lambda msg, pct: self._emit_status(EngineState.LOADING, msg, pct)
@@ -124,12 +133,15 @@ class TranslationEngine:
def _segment_loop(self) -> None:
cfg = self.config.audio
# 중간 결과는 같은 오디오를 여러 번 인식하므로 GPU 작업이 거의 두 배가 된다.
# 게임과 같이 돌릴 때는 그만한 값어치가 없으므로 저부하 모드에서는 끈다.
partial_ms = 0 if self.config.performance.game_mode else cfg.partial_interval_ms
segmenter = Segmenter(
SegmenterConfig(
silence_ms=cfg.silence_ms,
min_segment_ms=cfg.min_segment_ms,
max_segment_ms=cfg.max_segment_ms,
partial_interval_ms=cfg.partial_interval_ms,
partial_interval_ms=partial_ms,
vad_aggressiveness=cfg.vad_aggressiveness,
)
)
@@ -169,25 +181,56 @@ class TranslationEngine:
pass
def _infer_loop(self) -> None:
recognizer = self.models.recognizer()
translator = self.models.translator()
mcfg = self.config.models
perf = self.config.performance
while not self._stop.is_set():
try:
segment = self._segments.get(timeout=0.3)
except queue.Empty:
self._maybe_unload_idle()
continue
# 처리 중 더 새로운 확정 구간이 들어왔다면 오래된 중간 결과는 버린다.
if not segment.is_final and not self._segments.empty():
continue
# 모델 참조를 캐시하지 않는다. 유휴 언로드 뒤에는 새로 올려야 하고,
# 캐시된 참조를 쓰면 이미 내려간 모델을 붙잡고 있게 된다.
try:
self._process(segment, recognizer, translator, mcfg)
self._process(
segment, self.models.recognizer(), self.models.translator(), mcfg
)
self._last_activity = time.monotonic()
self._models_unloaded = False
except Exception as exc: # noqa: BLE001 - 한 문장 실패로 엔진이 죽으면 안 된다
log.warning("구간 처리 실패: %s", exc, exc_info=log.isEnabledFor(logging.DEBUG))
# 게임에 GPU를 잠깐 돌려준다. 연속 발화 때 우리가 GPU를 독점하면
# 프레임이 눈에 띄게 끊긴다.
if perf.game_mode and perf.yield_ms > 0:
self._stop.wait(perf.yield_ms / 1000)
def _maybe_unload_idle(self) -> None:
"""한동안 말소리가 없으면 모델을 내려 VRAM을 통째로 돌려준다.
게임 중에 조용한 구간(로딩·메뉴·솔로 플레이)이 길게 이어지는데
그동안 수 GB를 붙잡고 있을 이유가 없다. 다음 발화가 오면 자동으로
다시 올라간다 — 그때 한 번 로딩 지연이 생기는 것이 트레이드오프다.
"""
perf = self.config.performance
if not perf.game_mode or perf.idle_unload_s <= 0 or self._models_unloaded:
return
if time.monotonic() - self._last_activity < perf.idle_unload_s:
return
if not self.models.ready:
self._models_unloaded = True
return
log.info("%d초간 조용해 모델을 내립니다 (VRAM 반납)", perf.idle_unload_s)
self.models.unload()
self._models_unloaded = True
self._emit_status(EngineState.RUNNING, "듣는 중 · 대기 (VRAM 반납됨)")
def _process(self, segment: Segment, recognizer, translator, mcfg) -> None:
source_lang = None if mcfg.source_lang == "auto" else mcfg.source_lang
@@ -236,9 +279,16 @@ class TranslationEngine:
# --- 유틸 -----------------------------------------------------------
def _load_glossary(self) -> Glossary:
"""사용자 용어집 + 켜둔 기본 팩을 합쳐서 하나로 만든다."""
cfg = self.config.glossary
path = cfg.path or str(user_data_dir() / "glossary.json")
return Glossary.load(path, cfg.case_sensitive)
user = Glossary.load(path, cfg.case_sensitive)
merged = build_glossary(user, cfg.enabled_packs, cfg.case_sensitive)
log.info(
"용어집 %d개 (사용자 %d + 팩 %s)",
len(merged), len(user), ", ".join(cfg.enabled_packs) or "없음",
)
return merged
def _drain_segments(self) -> None:
while not self._segments.empty():

View File

@@ -0,0 +1,53 @@
{
"key": "fps-common",
"name": "FPS 공통 (배그·CS·에이펙스)",
"description": "총기·교전·파티 콜처럼 슈터 장르 전반에서 공통으로 쓰는 말.",
"entries": [
{"source": "enemy", "targets": {"ko": "적", "ja": "敵", "zh": "敌人"}},
{"source": "enemy spotted", "targets": {"ko": "적 발견", "ja": "敵発見", "zh": "发现敌人"}},
{"source": "contact", "targets": {"ko": "교전", "ja": "接敵", "zh": "接敌"}},
{"source": "reloading", "targets": {"ko": "재장전 중", "ja": "リロード中", "zh": "换弹中"}},
{"source": "cover me", "targets": {"ko": "엄호해줘", "ja": "援護して", "zh": "掩护我"}},
{"source": "covering", "targets": {"ko": "엄호 중", "ja": "援護中", "zh": "掩护中"}},
{"source": "push", "targets": {"ko": "밀자", "ja": "押そう", "zh": "推进"}},
{"source": "fall back", "targets": {"ko": "후퇴", "ja": "後退", "zh": "撤退"}},
{"source": "hold", "targets": {"ko": "대기", "ja": "ホールド", "zh": "守住"}},
{"source": "knocked", "targets": {"ko": "기절", "ja": "ダウン", "zh": "被击倒"}},
{"source": "down", "targets": {"ko": "다운", "ja": "ダウン", "zh": "倒地"}},
{"source": "revive", "targets": {"ko": "부활", "ja": "リヴァイヴ", "zh": "复活"}},
{"source": "res", "targets": {"ko": "살려줘", "ja": "蘇生して", "zh": "救我"}},
{"source": "heal up", "targets": {"ko": "회복하자", "ja": "回復しよう", "zh": "回血"}},
{"source": "loot", "targets": {"ko": "파밍", "ja": "ルート", "zh": "搜物资"}},
{"source": "looting", "targets": {"ko": "파밍 중", "ja": "ルート中", "zh": "搜物资中"}},
{"source": "third party", "targets": {"ko": "어부", "ja": "漁夫", "zh": "第三方"}},
{"source": "zone", "targets": {"ko": "자기장", "ja": "安全地帯", "zh": "毒圈"}},
{"source": "circle", "targets": {"ko": "자기장", "ja": "サークル", "zh": "圈"}},
{"source": "storm", "targets": {"ko": "폭풍", "ja": "ストーム", "zh": "风暴"}},
{"source": "high ground", "targets": {"ko": "고지", "ja": "高所", "zh": "高地"}},
{"source": "flank", "targets": {"ko": "측면", "ja": "側面", "zh": "侧翼"}},
{"source": "behind us", "targets": {"ko": "뒤에 있다", "ja": "後ろにいる", "zh": "背后有人"}},
{"source": "on me", "targets": {"ko": "나한테 붙어", "ja": "ついてきて", "zh": "跟着我"}},
{"source": "grenade", "targets": {"ko": "수류탄", "ja": "グレネード", "zh": "手雷"}},
{"source": "nade", "targets": {"ko": "수류탄", "ja": "グレネード", "zh": "手雷"}},
{"source": "frag", "targets": {"ko": "파편탄", "ja": "フラグ", "zh": "破片雷"}},
{"source": "smoke", "targets": {"ko": "연막", "ja": "スモーク", "zh": "烟雾弹"}},
{"source": "flashbang", "targets": {"ko": "섬광탄", "ja": "フラッシュ", "zh": "闪光弹"}},
{"source": "scope", "targets": {"ko": "조준경", "ja": "スコープ", "zh": "瞄准镜"}},
{"source": "ADS", "targets": {"ko": "정조준", "ja": "ADS", "zh": "开镜"}},
{"source": "recoil", "targets": {"ko": "반동", "ja": "リコイル", "zh": "后坐力"}},
{"source": "spray control", "targets": {"ko": "반동 제어", "ja": "リコイル制御", "zh": "压枪"}},
{"source": "aim", "targets": {"ko": "에임", "ja": "エイム", "zh": "枪法"}},
{"source": "headshot", "targets": {"ko": "헤드샷", "ja": "ヘッドショット", "zh": "爆头"}},
{"source": "wall bang", "targets": {"ko": "관통샷", "ja": "ウォールバン", "zh": "穿墙"}},
{"source": "prefire", "targets": {"ko": "선샷", "ja": "プリファイア", "zh": "预瞄"}},
{"source": "camping", "targets": {"ko": "캠핑", "ja": "キャンプ", "zh": "蹲点"}},
{"source": "respawn", "targets": {"ko": "리스폰", "ja": "リスポーン", "zh": "重生"}},
{"source": "kill streak", "targets": {"ko": "연속 킬", "ja": "キルストリーク", "zh": "连杀"}},
{"source": "ammo", "targets": {"ko": "탄약", "ja": "弾薬", "zh": "子弹"}},
{"source": "out of ammo", "targets": {"ko": "탄 없음", "ja": "弾切れ", "zh": "没子弹"}},
{"source": "backup", "targets": {"ko": "지원", "ja": "バックアップ", "zh": "支援"}},
{"source": "good game", "targets": {"ko": "잘 했다", "ja": "GG", "zh": "打得好"}},
{"source": "nice shot", "targets": {"ko": "잘 쐈다", "ja": "ナイスショット", "zh": "好枪"}},
{"source": "my bad", "targets": {"ko": "내 실수", "ja": "ごめん", "zh": "我的错"}}
]
}

View File

@@ -0,0 +1,81 @@
{
"key": "lol",
"name": "리그 오브 레전드",
"description": "라인·오브젝트·교전 콜, 한국 서버에서 실제로 쓰는 말로 맞췄습니다.",
"entries": [
{"source": "Nexus", "targets": {"ko": "넥서스", "ja": "ネクサス", "zh": "基地"}},
{"source": "inhibitor", "targets": {"ko": "억제기", "ja": "抑制器", "zh": "水晶"}},
{"source": "inhib", "targets": {"ko": "억제기", "ja": "抑制器", "zh": "水晶"}},
{"source": "turret", "targets": {"ko": "포탑", "ja": "タワー", "zh": "防御塔"}},
{"source": "tower", "targets": {"ko": "포탑", "ja": "タワー", "zh": "防御塔"}},
{"source": "baron", "targets": {"ko": "바론", "ja": "バロン", "zh": "大龙"}},
{"source": "Baron Nashor", "targets": {"ko": "내셔 남작", "ja": "バロン・ナッシャー", "zh": "纳什男爵"}},
{"source": "dragon", "targets": {"ko": "드래곤", "ja": "ドラゴン", "zh": "小龙"}},
{"source": "drake", "targets": {"ko": "드래곤", "ja": "ドラゴン", "zh": "小龙"}},
{"source": "elder", "targets": {"ko": "장로 드래곤", "ja": "エルダードラゴン", "zh": "远古龙"}},
{"source": "herald", "targets": {"ko": "전령", "ja": "ヘラルド", "zh": "峡谷先锋"}},
{"source": "Rift Herald", "targets": {"ko": "협곡의 전령", "ja": "リフトヘラルド", "zh": "峡谷先锋"}},
{"source": "grubs", "targets": {"ko": "유충", "ja": "虫", "zh": "虚空幼虫"}},
{"source": "gank", "targets": {"ko": "갱", "ja": "ガンク", "zh": "抓人"}},
{"source": "ganking", "targets": {"ko": "갱킹", "ja": "ガンク", "zh": "抓人"}},
{"source": "counter gank", "targets": {"ko": "역갱", "ja": "カウンターガンク", "zh": "反蹲"}},
{"source": "roam", "targets": {"ko": "로밍", "ja": "ローム", "zh": "游走"}},
{"source": "jungle", "targets": {"ko": "정글", "ja": "ジャングル", "zh": "打野"}},
{"source": "jungler", "targets": {"ko": "정글러", "ja": "ジャングラー", "zh": "打野"}},
{"source": "top lane", "targets": {"ko": "탑", "ja": "トップ", "zh": "上路"}},
{"source": "mid lane", "targets": {"ko": "미드", "ja": "ミッド", "zh": "中路"}},
{"source": "bot lane", "targets": {"ko": "바텀", "ja": "ボット", "zh": "下路"}},
{"source": "support", "targets": {"ko": "서폿", "ja": "サポート", "zh": "辅助"}},
{"source": "ADC", "targets": {"ko": "원딜", "ja": "ADC", "zh": "ADC"}},
{"source": "minion", "targets": {"ko": "미니언", "ja": "ミニオン", "zh": "小兵"}},
{"source": "wave", "targets": {"ko": "웨이브", "ja": "ウェーブ", "zh": "兵线"}},
{"source": "creep score", "targets": {"ko": "CS", "ja": "CS", "zh": "补刀"}},
{"source": "last hit", "targets": {"ko": "막타", "ja": "ラストヒット", "zh": "补刀"}},
{"source": "freeze", "targets": {"ko": "프리징", "ja": "フリーズ", "zh": "控线"}},
{"source": "push", "targets": {"ko": "푸시", "ja": "プッシュ", "zh": "推线"}},
{"source": "split push", "targets": {"ko": "스플릿", "ja": "スプリットプッシュ", "zh": "分推"}},
{"source": "back", "targets": {"ko": "귀환", "ja": "リコール", "zh": "回城"}},
{"source": "recall", "targets": {"ko": "귀환", "ja": "リコール", "zh": "回城"}},
{"source": "ward", "targets": {"ko": "와드", "ja": "ワード", "zh": "眼"}},
{"source": "vision", "targets": {"ko": "시야", "ja": "視界", "zh": "视野"}},
{"source": "flash", "targets": {"ko": "점멸", "ja": "フラッシュ", "zh": "闪现"}},
{"source": "ignite", "targets": {"ko": "점화", "ja": "イグナイト", "zh": "点燃"}},
{"source": "teleport", "targets": {"ko": "순간이동", "ja": "テレポート", "zh": "传送"}},
{"source": "smite", "targets": {"ko": "강타", "ja": "スマイト", "zh": "惩戒"}},
{"source": "ult", "targets": {"ko": "궁", "ja": "アルト", "zh": "大招"}},
{"source": "ultimate", "targets": {"ko": "궁극기", "ja": "アルティメット", "zh": "大招"}},
{"source": "cooldown", "targets": {"ko": "쿨", "ja": "クールダウン", "zh": "冷却"}},
{"source": "on cooldown", "targets": {"ko": "쿨 도는 중", "ja": "クール中", "zh": "冷却中"}},
{"source": "engage", "targets": {"ko": "이니시", "ja": "エンゲージ", "zh": "开团"}},
{"source": "disengage", "targets": {"ko": "빠지기", "ja": "ディスエンゲージ", "zh": "撤退"}},
{"source": "team fight", "targets": {"ko": "한타", "ja": "集団戦", "zh": "团战"}},
{"source": "teamfight", "targets": {"ko": "한타", "ja": "集団戦", "zh": "团战"}},
{"source": "peel", "targets": {"ko": "피어싱 보호", "ja": "ピール", "zh": "保护"}},
{"source": "kite", "targets": {"ko": "카이팅", "ja": "カイト", "zh": "风筝"}},
{"source": "poke", "targets": {"ko": "견제", "ja": "ポーク", "zh": "消耗"}},
{"source": "dive", "targets": {"ko": "다이브", "ja": "ダイブ", "zh": "越塔"}},
{"source": "tower dive", "targets": {"ko": "타워 다이브", "ja": "タワーダイブ", "zh": "越塔强杀"}},
{"source": "ace", "targets": {"ko": "에이스", "ja": "エース", "zh": "团灭"}},
{"source": "first blood", "targets": {"ko": "퍼블", "ja": "ファーストブラッド", "zh": "一血"}},
{"source": "shutdown", "targets": {"ko": "현상금", "ja": "シャットダウン", "zh": "赏金"}},
{"source": "respawn", "targets": {"ko": "부활", "ja": "リスポーン", "zh": "复活"}},
{"source": "missing", "targets": {"ko": "실종", "ja": "ミア", "zh": "消失"}},
{"source": "MIA", "targets": {"ko": "실종", "ja": "ミア", "zh": "消失"}},
{"source": "SS", "targets": {"ko": "실종", "ja": "ミア", "zh": "消失"}},
{"source": "fed", "targets": {"ko": "잘 큰", "ja": "育った", "zh": "养肥"}},
{"source": "scaling", "targets": {"ko": "성장력", "ja": "スケーリング", "zh": "后期"}},
{"source": "objective", "targets": {"ko": "오브젝트", "ja": "オブジェクト", "zh": "资源"}},
{"source": "backdoor", "targets": {"ko": "백도어", "ja": "バックドア", "zh": "偷家"}},
{"source": "surrender", "targets": {"ko": "항복", "ja": "サレンダー", "zh": "投降"}},
{"source": "buff", "targets": {"ko": "버프", "ja": "バフ", "zh": "增益"}},
{"source": "nerf", "targets": {"ko": "너프", "ja": "ナーフ", "zh": "削弱"}},
{"source": "CC", "targets": {"ko": "군중제어", "ja": "CC", "zh": "控制"}},
{"source": "stun", "targets": {"ko": "기절", "ja": "スタン", "zh": "眩晕"}},
{"source": "slow", "targets": {"ko": "둔화", "ja": "スロウ", "zh": "减速"}},
{"source": "shield", "targets": {"ko": "실드", "ja": "シールド", "zh": "护盾"}},
{"source": "heal", "targets": {"ko": "회복", "ja": "ヒール", "zh": "治疗"}},
{"source": "mana", "targets": {"ko": "마나", "ja": "マナ", "zh": "蓝"}},
{"source": "low HP", "targets": {"ko": "체력 낮음", "ja": "体力低い", "zh": "残血"}},
{"source": "full build", "targets": {"ko": "풀템", "ja": "フルビルド", "zh": "满装备"}}
]
}

View File

@@ -0,0 +1,45 @@
{
"key": "minecraft",
"name": "마인크래프트",
"description": "블록·몹·아이템 이름. 한국어판 공식 번역어를 따랐습니다.",
"entries": [
{"source": "creeper", "targets": {"ko": "크리퍼", "ja": "クリーパー", "zh": "苦力怕"}},
{"source": "enderman", "targets": {"ko": "엔더맨", "ja": "エンダーマン", "zh": "末影人"}},
{"source": "ender dragon", "targets": {"ko": "엔더 드래곤", "ja": "エンダードラゴン", "zh": "末影龙"}},
{"source": "wither", "targets": {"ko": "위더", "ja": "ウィザー", "zh": "凋灵"}},
{"source": "skeleton", "targets": {"ko": "스켈레톤", "ja": "スケルトン", "zh": "骷髅"}},
{"source": "zombie", "targets": {"ko": "좀비", "ja": "ゾンビ", "zh": "僵尸"}},
{"source": "piglin", "targets": {"ko": "피글린", "ja": "ピグリン", "zh": "猪灵"}},
{"source": "villager", "targets": {"ko": "주민", "ja": "村人", "zh": "村民"}},
{"source": "the Nether", "targets": {"ko": "네더", "ja": "ネザー", "zh": "下界"}},
{"source": "the End", "targets": {"ko": "엔드", "ja": "ジ・エンド", "zh": "末地"}},
{"source": "Overworld", "targets": {"ko": "오버월드", "ja": "オーバーワールド", "zh": "主世界"}},
{"source": "nether portal", "targets": {"ko": "네더 차원문", "ja": "ネザーゲート", "zh": "下界传送门"}},
{"source": "stronghold", "targets": {"ko": "요새", "ja": "要塞", "zh": "要塞"}},
{"source": "bastion", "targets": {"ko": "잔해 성채", "ja": "砦の残骸", "zh": "堡垒遗迹"}},
{"source": "diamond", "targets": {"ko": "다이아몬드", "ja": "ダイヤ", "zh": "钻石"}},
{"source": "netherite", "targets": {"ko": "네더라이트", "ja": "ネザライト", "zh": "下界合金"}},
{"source": "obsidian", "targets": {"ko": "흑요석", "ja": "黒曜石", "zh": "黑曜石"}},
{"source": "redstone", "targets": {"ko": "레드스톤", "ja": "レッドストーン", "zh": "红石"}},
{"source": "ender pearl", "targets": {"ko": "엔더 진주", "ja": "エンダーパール", "zh": "末影珍珠"}},
{"source": "blaze rod", "targets": {"ko": "블레이즈 막대", "ja": "ブレイズロッド", "zh": "烈焰棒"}},
{"source": "elytra", "targets": {"ko": "겉날개", "ja": "エリトラ", "zh": "鞘翅"}},
{"source": "totem", "targets": {"ko": "불사의 토템", "ja": "不死のトーテム", "zh": "不死图腾"}},
{"source": "enchant", "targets": {"ko": "인챈트", "ja": "エンチャント", "zh": "附魔"}},
{"source": "anvil", "targets": {"ko": "모루", "ja": "金床", "zh": "铁砧"}},
{"source": "crafting table", "targets": {"ko": "작업대", "ja": "作業台", "zh": "工作台"}},
{"source": "furnace", "targets": {"ko": "화로", "ja": "かまど", "zh": "熔炉"}},
{"source": "chest", "targets": {"ko": "상자", "ja": "チェスト", "zh": "箱子"}},
{"source": "spawn point", "targets": {"ko": "스폰 지점", "ja": "スポーン地点", "zh": "出生点"}},
{"source": "respawn", "targets": {"ko": "리스폰", "ja": "リスポーン", "zh": "重生"}},
{"source": "biome", "targets": {"ko": "생물 군계", "ja": "バイオーム", "zh": "生物群系"}},
{"source": "seed", "targets": {"ko": "시드", "ja": "シード", "zh": "种子"}},
{"source": "mob", "targets": {"ko": "몹", "ja": "モブ", "zh": "生物"}},
{"source": "hardcore", "targets": {"ko": "하드코어", "ja": "ハードコア", "zh": "极限模式"}},
{"source": "speedrun", "targets": {"ko": "스피드런", "ja": "RTA", "zh": "速通"}},
{"source": "mine", "targets": {"ko": "채굴", "ja": "採掘", "zh": "挖矿"}},
{"source": "craft", "targets": {"ko": "제작", "ja": "クラフト", "zh": "合成"}},
{"source": "build", "targets": {"ko": "건축", "ja": "建築", "zh": "建造"}},
{"source": "farm", "targets": {"ko": "농장", "ja": "トラップ", "zh": "农场"}}
]
}

View File

@@ -0,0 +1,56 @@
{
"key": "overwatch",
"name": "오버워치 2",
"description": "역할·궁·거점 콜. 한국 공식 번역어를 우선했습니다.",
"entries": [
{"source": "tank", "targets": {"ko": "탱커", "ja": "タンク", "zh": "重装"}},
{"source": "DPS", "targets": {"ko": "딜러", "ja": "ダメージ", "zh": "输出"}},
{"source": "damage", "targets": {"ko": "딜", "ja": "ダメージ", "zh": "伤害"}},
{"source": "support", "targets": {"ko": "지원가", "ja": "サポート", "zh": "辅助"}},
{"source": "healer", "targets": {"ko": "힐러", "ja": "ヒーラー", "zh": "奶"}},
{"source": "ult", "targets": {"ko": "궁", "ja": "アルト", "zh": "大招"}},
{"source": "ultimate", "targets": {"ko": "궁극기", "ja": "アルティメット", "zh": "终极技能"}},
{"source": "ult charge", "targets": {"ko": "궁 게이지", "ja": "アルトゲージ", "zh": "大招能量"}},
{"source": "ult economy", "targets": {"ko": "궁 경제", "ja": "アルト回し", "zh": "大招经济"}},
{"source": "combo", "targets": {"ko": "연계", "ja": "コンボ", "zh": "连招"}},
{"source": "dive", "targets": {"ko": "다이브", "ja": "ダイブ", "zh": "突进"}},
{"source": "poke", "targets": {"ko": "견제", "ja": "ポーク", "zh": "消耗"}},
{"source": "brawl", "targets": {"ko": "난전", "ja": "ブロール", "zh": "近战团"}},
{"source": "payload", "targets": {"ko": "화물", "ja": "ペイロード", "zh": "运载目标"}},
{"source": "point", "targets": {"ko": "거점", "ja": "拠点", "zh": "点"}},
{"source": "capture", "targets": {"ko": "점령", "ja": "占領", "zh": "占点"}},
{"source": "contest", "targets": {"ko": "저지", "ja": "コンテスト", "zh": "拖点"}},
{"source": "overtime", "targets": {"ko": "연장", "ja": "オーバータイム", "zh": "加时"}},
{"source": "checkpoint", "targets": {"ko": "관문", "ja": "チェックポイント", "zh": "检查点"}},
{"source": "spawn", "targets": {"ko": "리스폰", "ja": "リスポーン", "zh": "重生点"}},
{"source": "regroup", "targets": {"ko": "모이자", "ja": "集合", "zh": "集合"}},
{"source": "group up", "targets": {"ko": "모이자", "ja": "集合", "zh": "集合"}},
{"source": "stagger", "targets": {"ko": "스태거", "ja": "スタッガー", "zh": "送人头"}},
{"source": "flank", "targets": {"ko": "측면", "ja": "フランク", "zh": "绕后"}},
{"source": "high ground", "targets": {"ko": "고지", "ja": "高台", "zh": "高地"}},
{"source": "cooldown", "targets": {"ko": "쿨", "ja": "クールダウン", "zh": "冷却"}},
{"source": "barrier", "targets": {"ko": "방벽", "ja": "バリア", "zh": "护盾"}},
{"source": "shield", "targets": {"ko": "보호막", "ja": "シールド", "zh": "护盾"}},
{"source": "armor", "targets": {"ko": "방어력", "ja": "アーマー", "zh": "护甲"}},
{"source": "burst", "targets": {"ko": "순간 딜", "ja": "バースト", "zh": "爆发"}},
{"source": "focus", "targets": {"ko": "집중 공격", "ja": "フォーカス", "zh": "集火"}},
{"source": "peel", "targets": {"ko": "보호", "ja": "ピール", "zh": "保护"}},
{"source": "nano", "targets": {"ko": "나노", "ja": "ナノ", "zh": "纳米"}},
{"source": "beat", "targets": {"ko": "비트", "ja": "ビート", "zh": "音障"}},
{"source": "sound barrier", "targets": {"ko": "음파 방벽", "ja": "サウンドバリア", "zh": "音障"}},
{"source": "rez", "targets": {"ko": "부활", "ja": "リザ", "zh": "复活"}},
{"source": "resurrect", "targets": {"ko": "부활", "ja": "リザレクト", "zh": "复活"}},
{"source": "discord", "targets": {"ko": "불화의 구슬", "ja": "ディスコード", "zh": "不和之球"}},
{"source": "hack", "targets": {"ko": "해킹", "ja": "ハック", "zh": "黑入"}},
{"source": "EMP", "targets": {"ko": "EMP", "ja": "EMP", "zh": "EMP"}},
{"source": "team wipe", "targets": {"ko": "전멸", "ja": "全滅", "zh": "团灭"}},
{"source": "team fight", "targets": {"ko": "한타", "ja": "チームファイト", "zh": "团战"}},
{"source": "counter pick", "targets": {"ko": "카운터 픽", "ja": "カウンターピック", "zh": "针对英雄"}},
{"source": "swap", "targets": {"ko": "영웅 교체", "ja": "ヒーロー変更", "zh": "换英雄"}},
{"source": "health pack", "targets": {"ko": "회복팩", "ja": "回復パック", "zh": "血包"}},
{"source": "critical", "targets": {"ko": "치명타", "ja": "クリティカル", "zh": "暴击"}},
{"source": "behind you", "targets": {"ko": "뒤에 있다", "ja": "後ろだ", "zh": "背后有人"}},
{"source": "falling back", "targets": {"ko": "후퇴한다", "ja": "後退する", "zh": "撤退"}},
{"source": "push", "targets": {"ko": "밀자", "ja": "押そう", "zh": "推进"}}
]
}

View File

@@ -0,0 +1,45 @@
{
"key": "streaming",
"name": "방송·스트리밍",
"description": "해외 방송을 볼 때 자주 나오는 말. 게임 용어집과 같이 켜면 좋습니다.",
"entries": [
{"source": "stream", "targets": {"ko": "방송", "ja": "配信", "zh": "直播"}},
{"source": "streamer", "targets": {"ko": "스트리머", "ja": "配信者", "zh": "主播"}},
{"source": "chat", "targets": {"ko": "채팅", "ja": "チャット", "zh": "弹幕"}},
{"source": "donation", "targets": {"ko": "후원", "ja": "投げ銭", "zh": "打赏"}},
{"source": "donate", "targets": {"ko": "후원하다", "ja": "投げ銭する", "zh": "打赏"}},
{"source": "sub", "targets": {"ko": "구독", "ja": "サブスク", "zh": "订阅"}},
{"source": "subscriber", "targets": {"ko": "구독자", "ja": "登録者", "zh": "订阅者"}},
{"source": "follow", "targets": {"ko": "팔로우", "ja": "フォロー", "zh": "关注"}},
{"source": "raid", "targets": {"ko": "합방 몰아주기", "ja": "レイド", "zh": "转播"}},
{"source": "mod", "targets": {"ko": "매니저", "ja": "モデレーター", "zh": "房管"}},
{"source": "clip", "targets": {"ko": "클립", "ja": "クリップ", "zh": "切片"}},
{"source": "highlight", "targets": {"ko": "하이라이트", "ja": "ハイライト", "zh": "高光"}},
{"source": "VOD", "targets": {"ko": "다시보기", "ja": "アーカイブ", "zh": "录播"}},
{"source": "lag", "targets": {"ko": "렉", "ja": "ラグ", "zh": "卡"}},
{"source": "ping", "targets": {"ko": "핑", "ja": "ピン", "zh": "延迟"}},
{"source": "frame drop", "targets": {"ko": "프레임 드랍", "ja": "フレーム落ち", "zh": "掉帧"}},
{"source": "patch", "targets": {"ko": "패치", "ja": "パッチ", "zh": "补丁"}},
{"source": "meta", "targets": {"ko": "메타", "ja": "メタ", "zh": "版本环境"}},
{"source": "nerf", "targets": {"ko": "너프", "ja": "ナーフ", "zh": "削弱"}},
{"source": "buff", "targets": {"ko": "버프", "ja": "バフ", "zh": "加强"}},
{"source": "ranked", "targets": {"ko": "랭크", "ja": "ランク", "zh": "排位"}},
{"source": "casual", "targets": {"ko": "일반전", "ja": "カジュアル", "zh": "休闲"}},
{"source": "smurf", "targets": {"ko": "부계정", "ja": "サブ垢", "zh": "小号"}},
{"source": "grind", "targets": {"ko": "노가다", "ja": "周回", "zh": "刷"}},
{"source": "carry", "targets": {"ko": "캐리", "ja": "キャリー", "zh": "带飞"}},
{"source": "throw", "targets": {"ko": "던지다", "ja": "スローする", "zh": "送"}},
{"source": "griefing", "targets": {"ko": "트롤링", "ja": "荒らし", "zh": "演员"}},
{"source": "toxic", "targets": {"ko": "매너 나쁜", "ja": "民度が低い", "zh": "喷子"}},
{"source": "cracked", "targets": {"ko": "미쳤다", "ja": "ヤバい", "zh": "太强了"}},
{"source": "insane", "targets": {"ko": "대박", "ja": "やばい", "zh": "离谱"}},
{"source": "clutch", "targets": {"ko": "클러치", "ja": "クラッチ", "zh": "关键时刻"}},
{"source": "let's go", "targets": {"ko": "가자", "ja": "行くぞ", "zh": "冲"}},
{"source": "no way", "targets": {"ko": "말도 안 돼", "ja": "ありえない", "zh": "不可能吧"}},
{"source": "give me a second", "targets": {"ko": "잠깐만", "ja": "ちょっと待って", "zh": "等一下"}},
{"source": "hold on", "targets": {"ko": "잠깐", "ja": "待って", "zh": "等等"}},
{"source": "be right back", "targets": {"ko": "금방 올게", "ja": "すぐ戻る", "zh": "马上回来"}},
{"source": "thanks for the sub", "targets": {"ko": "구독 고마워", "ja": "サブありがとう", "zh": "谢谢订阅"}},
{"source": "welcome back", "targets": {"ko": "다시 왔네", "ja": "おかえり", "zh": "欢迎回来"}}
]
}

View File

@@ -0,0 +1,66 @@
{
"key": "valorant",
"name": "발로란트",
"description": "스파이크·유틸·이코노미 콜. 아시아 서버 영어 콜아웃에 맞췄습니다.",
"entries": [
{"source": "spike", "targets": {"ko": "스파이크", "ja": "スパイク", "zh": "斩杀者"}},
{"source": "plant", "targets": {"ko": "설치", "ja": "設置", "zh": "安装"}},
{"source": "planted", "targets": {"ko": "설치됐다", "ja": "設置された", "zh": "已安装"}},
{"source": "defuse", "targets": {"ko": "해체", "ja": "解除", "zh": "拆包"}},
{"source": "defusing", "targets": {"ko": "해체 중", "ja": "解除中", "zh": "拆包中"}},
{"source": "fake defuse", "targets": {"ko": "페이크 해체", "ja": "フェイク解除", "zh": "假拆"}},
{"source": "post plant", "targets": {"ko": "설치 후", "ja": "ポストプラント", "zh": "安装后"}},
{"source": "retake", "targets": {"ko": "리테이크", "ja": "リテイク", "zh": "残局夺回"}},
{"source": "site", "targets": {"ko": "사이트", "ja": "サイト", "zh": "包点"}},
{"source": "A site", "targets": {"ko": "A사이트", "ja": "Aサイト", "zh": "A点"}},
{"source": "B site", "targets": {"ko": "B사이트", "ja": "Bサイト", "zh": "B点"}},
{"source": "mid", "targets": {"ko": "미드", "ja": "ミッド", "zh": "中路"}},
{"source": "rotate", "targets": {"ko": "로테", "ja": "ローテ", "zh": "换点"}},
{"source": "rotating", "targets": {"ko": "로테 중", "ja": "ローテ中", "zh": "换点中"}},
{"source": "eco", "targets": {"ko": "이코", "ja": "エコ", "zh": "经济局"}},
{"source": "full eco", "targets": {"ko": "풀이코", "ja": "フルエコ", "zh": "全经济局"}},
{"source": "eco round", "targets": {"ko": "이코 라운드", "ja": "エコラウンド", "zh": "经济局"}},
{"source": "save", "targets": {"ko": "세이브", "ja": "セーブ", "zh": "存钱"}},
{"source": "buy round", "targets": {"ko": "풀 구매", "ja": "フルバイ", "zh": "全起"}},
{"source": "force buy", "targets": {"ko": "강제 구매", "ja": "フォースバイ", "zh": "强起"}},
{"source": "pistol round", "targets": {"ko": "권총 라운드", "ja": "ピストルラウンド", "zh": "手枪局"}},
{"source": "ult", "targets": {"ko": "궁", "ja": "アルト", "zh": "大招"}},
{"source": "ultimate", "targets": {"ko": "궁극기", "ja": "アルティメット", "zh": "终极技能"}},
{"source": "ult orb", "targets": {"ko": "궁 오브", "ja": "アルトオーブ", "zh": "能量球"}},
{"source": "util", "targets": {"ko": "유틸", "ja": "ユーティリティ", "zh": "技能"}},
{"source": "utility", "targets": {"ko": "유틸", "ja": "ユーティリティ", "zh": "技能"}},
{"source": "smoke", "targets": {"ko": "연막", "ja": "スモーク", "zh": "烟"}},
{"source": "flash", "targets": {"ko": "섬광", "ja": "フラッシュ", "zh": "闪"}},
{"source": "flashed", "targets": {"ko": "섬광 맞음", "ja": "フラッシュ食らった", "zh": "被闪了"}},
{"source": "molly", "targets": {"ko": "몰리", "ja": "モリー", "zh": "火"}},
{"source": "wall", "targets": {"ko": "벽", "ja": "ウォール", "zh": "墙"}},
{"source": "recon", "targets": {"ko": "정찰", "ja": "リコン", "zh": "侦察"}},
{"source": "one tap", "targets": {"ko": "원탭", "ja": "ワンタップ", "zh": "一枪"}},
{"source": "headshot", "targets": {"ko": "헤드샷", "ja": "ヘッドショット", "zh": "爆头"}},
{"source": "entry", "targets": {"ko": "진입", "ja": "エントリー", "zh": "突破"}},
{"source": "entry frag", "targets": {"ko": "선취점", "ja": "エントリーキル", "zh": "首杀"}},
{"source": "trade", "targets": {"ko": "트레이드", "ja": "トレード", "zh": "换人"}},
{"source": "peek", "targets": {"ko": "픽", "ja": "ピーク", "zh": "架点"}},
{"source": "wide peek", "targets": {"ko": "와이드 픽", "ja": "ワイドピーク", "zh": "大幅架点"}},
{"source": "clear", "targets": {"ko": "클리어", "ja": "クリア", "zh": "清点"}},
{"source": "rush", "targets": {"ko": "러쉬", "ja": "ラッシュ", "zh": "强攻"}},
{"source": "stack", "targets": {"ko": "몰빵", "ja": "スタック", "zh": "集火"}},
{"source": "lurk", "targets": {"ko": "럴커", "ja": "ラーク", "zh": "潜伏"}},
{"source": "op", "targets": {"ko": "오페", "ja": "オペ", "zh": "狙"}},
{"source": "operator", "targets": {"ko": "오퍼레이터", "ja": "オペレーター", "zh": "狙击枪"}},
{"source": "clutch", "targets": {"ko": "클러치", "ja": "クラッチ", "zh": "残局"}},
{"source": "ace", "targets": {"ko": "에이스", "ja": "エース", "zh": "一穿五"}},
{"source": "spray", "targets": {"ko": "연사", "ja": "スプレー", "zh": "扫射"}},
{"source": "crosshair", "targets": {"ko": "크로스헤어", "ja": "クロスヘア", "zh": "准星"}},
{"source": "callout", "targets": {"ko": "콜", "ja": "コール", "zh": "报点"}},
{"source": "spotted", "targets": {"ko": "봤다", "ja": "発見", "zh": "看到了"}},
{"source": "one down", "targets": {"ko": "한 명 잡음", "ja": "一人ダウン", "zh": "倒一个"}},
{"source": "man advantage", "targets": {"ko": "인원 우위", "ja": "人数有利", "zh": "人数优势"}},
{"source": "low", "targets": {"ko": "피 없음", "ja": "体力低い", "zh": "残血"}},
{"source": "armor", "targets": {"ko": "방어구", "ja": "アーマー", "zh": "护甲"}},
{"source": "creds", "targets": {"ko": "크레딧", "ja": "クレジット", "zh": "钱"}},
{"source": "drop", "targets": {"ko": "드랍", "ja": "ドロップ", "zh": "丢枪"}},
{"source": "overtime", "targets": {"ko": "연장", "ja": "オーバータイム", "zh": "加时"}},
{"source": "match point", "targets": {"ko": "매치포인트", "ja": "マッチポイント", "zh": "赛点"}}
]
}

173
src/livesub/ui/hotkeys.py Normal file
View File

@@ -0,0 +1,173 @@
"""전역 단축키.
Qt 의 QShortcut 은 우리 창이 포커스를 가졌을 때만 동작한다. 게임을 하는 중에
자막을 껐다 켜야 하므로 OS 레벨 단축키가 필요하다. Windows 에서는
`RegisterHotKey` + 네이티브 이벤트 필터로 구현하고, 다른 OS 에서는 조용히
아무 것도 하지 않는다(창이 떠 있을 때만 쓰는 QShortcut 은 상위에서 따로 건다).
"""
from __future__ import annotations
import logging
import sys
from collections.abc import Callable
from PySide6.QtCore import QAbstractNativeEventFilter, QObject
log = logging.getLogger(__name__)
WM_HOTKEY = 0x0312
MOD_ALT = 0x0001
MOD_CONTROL = 0x0002
MOD_SHIFT = 0x0004
MOD_WIN = 0x0008
MOD_NOREPEAT = 0x4000
_MODIFIERS = {
"ctrl": MOD_CONTROL,
"control": MOD_CONTROL,
"alt": MOD_ALT,
"shift": MOD_SHIFT,
"win": MOD_WIN,
"meta": MOD_WIN,
}
#: 문자/숫자가 아닌 키의 가상 키 코드
_NAMED_KEYS = {
"f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73, "f5": 0x74, "f6": 0x75,
"f7": 0x76, "f8": 0x77, "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
"space": 0x20, "enter": 0x0D, "return": 0x0D, "tab": 0x09, "esc": 0x1B,
"escape": 0x1B, "insert": 0x2D, "delete": 0x2E, "home": 0x24, "end": 0x23,
"pageup": 0x21, "pagedown": 0x22, "up": 0x26, "down": 0x28,
"left": 0x25, "right": 0x27, "`": 0xC0, "-": 0xBD, "=": 0xBB,
}
class HotkeyError(RuntimeError):
"""단축키를 등록할 수 없을 때 (대개 다른 프로그램이 선점)."""
def parse(sequence: str) -> tuple[int, int]:
""""Ctrl+Alt+S" → (수정자 비트마스크, 가상 키 코드)."""
parts = [p.strip().lower() for p in sequence.split("+") if p.strip()]
if not parts:
raise HotkeyError(f"빈 단축키입니다: {sequence!r}")
mods = 0
key_part = ""
for part in parts:
if part in _MODIFIERS:
mods |= _MODIFIERS[part]
else:
key_part = part
if not key_part:
raise HotkeyError(f"조합키만 있고 실제 키가 없습니다: {sequence!r}")
if not mods:
# 수정자 없는 전역 단축키는 게임 입력을 통째로 가로채므로 막는다.
raise HotkeyError(f"Ctrl/Alt/Shift 중 하나는 있어야 합니다: {sequence!r}")
if key_part in _NAMED_KEYS:
vk = _NAMED_KEYS[key_part]
elif len(key_part) == 1 and key_part.isalnum():
vk = ord(key_part.upper())
else:
raise HotkeyError(f"알 수 없는 키입니다: {key_part!r}")
return mods | MOD_NOREPEAT, vk
class GlobalHotkeys(QObject, QAbstractNativeEventFilter):
"""전역 단축키 묶음. Windows 전용이며 그 외에서는 no-op."""
def __init__(self, parent: QObject | None = None) -> None:
QObject.__init__(self, parent)
QAbstractNativeEventFilter.__init__(self)
self._callbacks: dict[int, Callable[[], None]] = {}
self._next_id = 1
self._installed = False
self._last_error = ""
@property
def supported(self) -> bool:
return sys.platform == "win32"
@property
def last_error(self) -> str:
return self._last_error
def register(self, sequence: str, callback: Callable[[], None]) -> bool:
"""단축키 하나를 등록한다. 성공하면 True."""
if not self.supported:
self._last_error = "전역 단축키는 Windows에서만 지원합니다."
return False
try:
mods, vk = parse(sequence)
except HotkeyError as exc:
self._last_error = str(exc)
log.warning("단축키 해석 실패: %s", exc)
return False
import ctypes
hotkey_id = self._next_id
if not ctypes.windll.user32.RegisterHotKey(None, hotkey_id, mods, vk):
self._last_error = (
f"'{sequence}' 를 등록하지 못했습니다. 다른 프로그램이 쓰고 있을 수 있습니다."
)
log.warning(self._last_error)
return False
self._callbacks[hotkey_id] = callback
self._next_id += 1
self._ensure_filter()
log.info("전역 단축키 등록: %s", sequence)
return True
def unregister_all(self) -> None:
if not self.supported or not self._callbacks:
return
import ctypes
from PySide6.QtWidgets import QApplication
for hotkey_id in self._callbacks:
ctypes.windll.user32.UnregisterHotKey(None, hotkey_id)
self._callbacks.clear()
if self._installed:
app = QApplication.instance()
if app is not None:
app.removeNativeEventFilter(self)
self._installed = False
def _ensure_filter(self) -> None:
if self._installed:
return
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is not None:
app.installNativeEventFilter(self)
self._installed = True
def nativeEventFilter(self, event_type, message): # noqa: N802 - Qt 규약
if not self._callbacks or event_type not in (b"windows_generic_MSG",
b"windows_dispatcher_MSG"):
return False, 0
import ctypes
import ctypes.wintypes
msg = ctypes.cast(int(message), ctypes.POINTER(ctypes.wintypes.MSG)).contents
if msg.message != WM_HOTKEY:
return False, 0
callback = self._callbacks.get(int(msg.wParam))
if callback is None:
return False, 0
try:
callback()
except Exception: # noqa: BLE001 - 콜백 오류로 이벤트 루프가 죽으면 안 된다
log.exception("단축키 처리 실패")
return True, 0

View File

@@ -28,6 +28,7 @@ from PySide6.QtWidgets import (
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
@@ -60,10 +61,12 @@ class MainWindow(QMainWindow):
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)
@@ -151,17 +154,69 @@ class MainWindow(QMainWindow):
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(lambda *_: self._schedule_save())
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:
@@ -204,8 +259,21 @@ class MainWindow(QMainWindow):
self._schedule_save()
def _on_overlay_toggled(self, visible: bool) -> None:
self.config.overlay.visible = visible
self.overlay.setVisible(visible)
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:
@@ -240,6 +308,7 @@ class MainWindow(QMainWindow):
# --- 종료 -----------------------------------------------------------
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()

View File

@@ -22,9 +22,10 @@ from PySide6.QtGui import (
QPainterPath,
QPen,
)
from PySide6.QtWidgets import QApplication, QMenu, QWidget
from PySide6.QtWidgets import QMenu, QWidget
from ..config import OverlayConfig, SubtitleStyle
from .placement import ANCHOR_CUSTOM, list_screens, pick_screen, resolve_rect, screen_for_point
GRIP = 18 # 우하단 리사이즈 핸들 크기
@@ -142,6 +143,8 @@ class SubtitleOverlay(QWidget):
"""항상 위에 떠 있는 자막 창."""
geometry_changed = Signal(int, int, int, int)
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
visibility_changed = Signal(bool)
closed = Signal()
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
@@ -182,17 +185,45 @@ class SubtitleOverlay(QWidget):
Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through
)
if cfg.x < 0 or cfg.y < 0:
screen = QApplication.primaryScreen()
area = screen.availableGeometry() if screen else QRect(0, 0, 1920, 1080)
cfg.width = min(cfg.width, area.width() - 80)
cfg.x = area.x() + (area.width() - cfg.width) // 2
cfg.y = area.y() + area.height() - cfg.height - 90
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
if cfg.anchor != ANCHOR_CUSTOM or cfg.x < 0 or cfg.y < 0:
self.snap_to_anchor(save_only=True)
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
if cfg.visible:
self.show()
self.setVisible(cfg.visible)
self.update()
def snap_to_anchor(self, anchor: str | None = None, save_only: bool = False) -> None:
"""선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식)."""
cfg = self.overlay_cfg
if anchor is not None:
cfg.anchor = anchor
if cfg.anchor == ANCHOR_CUSTOM:
return
screens = list_screens()
screen = pick_screen(screens, cfg.screen_index)
if screen is None:
return
cfg.screen_index = screen.index
cfg.x, cfg.y, cfg.width, cfg.height = resolve_rect(
screen, cfg.anchor, cfg.width, cfg.height, cfg.margin
)
if not save_only:
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
self.geometry_changed.emit(cfg.x, cfg.y, cfg.width, cfg.height)
def set_subtitle_visible(self, visible: bool) -> None:
"""자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다."""
self.overlay_cfg.visible = visible
self.setVisible(visible)
if not visible:
self._clear_text()
self.visibility_changed.emit(visible)
def toggle_subtitle(self) -> bool:
self.set_subtitle_visible(not self.overlay_cfg.visible)
return self.overlay_cfg.visible
def set_line(self, translated: str, source: str = "", is_final: bool = True) -> None:
if is_final:
self._final_text = translated or source
@@ -260,13 +291,22 @@ class SubtitleOverlay(QWidget):
self.move(event.globalPosition().toPoint() - self._drag_from)
def mouseReleaseEvent(self, event) -> None: # noqa: N802
if self._drag_from is not None or self._resize_from is not None:
self._drag_from = None
self._resize_from = None
g = self.geometry()
self.overlay_cfg.x, self.overlay_cfg.y = g.x(), g.y()
self.overlay_cfg.width, self.overlay_cfg.height = g.width(), g.height()
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
if self._drag_from is None and self._resize_from is None:
return
self._drag_from = None
self._resize_from = None
cfg = self.overlay_cfg
g = self.geometry()
cfg.x, cfg.y = g.x(), g.y()
cfg.width, cfg.height = g.width(), g.height()
# 손으로 옮긴 순간부터는 앵커가 아니라 그 자리를 그대로 지킨다.
cfg.anchor = ANCHOR_CUSTOM
# 다른 모니터로 끌고 갔을 수 있으므로 소속을 다시 찾는다.
owner = screen_for_point(list_screens(), g.center().x(), g.center().y())
if owner is not None:
cfg.screen_index = owner.index
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
def wheelEvent(self, event) -> None: # noqa: N802
if self.overlay_cfg.locked:
@@ -311,8 +351,7 @@ class SubtitleOverlay(QWidget):
self.apply_config()
def hide_overlay(self) -> None:
self.overlay_cfg.visible = False
self.hide()
self.set_subtitle_visible(False)
self.closed.emit()
def _restart_fade(self) -> None:

View File

@@ -25,6 +25,7 @@ from PySide6.QtWidgets import (
from ...config import AppConfig
from ...constants import LANGUAGE_CODES, LANGUAGES, user_data_dir
from ...models import Glossary, GlossaryEntry
from ...models.packs import available_packs
from ..theme import SPACING
from ..widgets import Card, PageHeader
@@ -50,10 +51,50 @@ class GlossaryPage(QWidget):
"모델을 추가로 학습시키지 않아도 바로 적용됩니다.",
)
)
root.addWidget(self._build_packs_card())
root.addWidget(self._build_options_card())
root.addWidget(self._build_table_card(), 1)
self._reload_table()
# --- 기본 제공 팩 ----------------------------------------------------
def _build_packs_card(self) -> Card:
card = Card(
"게임별 기본 용어집",
"체크만 하면 바로 적용됩니다. 역어가 마음에 안 들면 아래 표에 같은 "
"단어를 등록하세요 — 직접 등록한 쪽이 항상 이깁니다.",
)
self._pack_checks: dict[str, QCheckBox] = {}
enabled = set(self.config.glossary.enabled_packs)
packs = available_packs()
if not packs:
card.add(_hint("기본 제공 용어집을 찾지 못했습니다."))
return card
for pack in packs:
check = QCheckBox(f"{pack.name} ({len(pack)}개)")
check.setChecked(pack.key in enabled)
check.setToolTip(pack.description)
check.toggled.connect(self._on_packs_changed)
self._pack_checks[pack.key] = check
card.add(check)
self.pack_total = card.add(_hint(""))
self._update_pack_total()
return card
def _on_packs_changed(self, *_) -> None:
self.config.glossary.enabled_packs = [
key for key, check in self._pack_checks.items() if check.isChecked()
]
self._update_pack_total()
self.glossary_changed.emit()
def _update_pack_total(self) -> None:
enabled = set(self.config.glossary.enabled_packs)
total = sum(len(p) for p in available_packs() if p.key in enabled)
self.pack_total.setText(f"켜둔 기본 용어 {total}개")
# --- 구성 -----------------------------------------------------------
def _build_options_card(self) -> Card:
card = Card()

View File

@@ -146,7 +146,7 @@ class HomePage(QWidget):
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
elif caps["device"]:
msg = (
"프로그램별 캡처 보조 프로그램(hearo_capture.exe)이 없어 "
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
"프로그램 단위로 분리됩니다."
)

View File

@@ -0,0 +1,324 @@
"""설정 — 인식 민감도, 응답 속도, 기타 동작."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSlider,
QSpinBox,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ...models import detect_gpu
from ..theme import SPACING, palette
from ..widgets import Card, PageHeader
class SettingsPage(QWidget):
config_changed = Signal()
#: 오디오 파이프라인 설정이 바뀌어 엔진 재시작이 필요할 때
restart_needed = Signal()
#: 전역 단축키를 다시 등록해야 할 때
hotkeys_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader("설정", "게임과 같이 돌릴 때의 GPU 부담, 단축키, 인식 민감도를 조절합니다.")
)
root.addWidget(self._build_performance_card())
root.addWidget(self._build_hotkey_card())
root.addWidget(self._build_audio_card())
root.addWidget(self._build_behavior_card())
root.addWidget(self._build_about_card())
root.addStretch(1)
# --- GPU 부담 --------------------------------------------------------
def _build_performance_card(self) -> Card:
perf = self.config.performance
card = Card(
"게임과 같이 쓰기 (GPU 절약)",
"게임이 먼저입니다. 이 모드를 켜면 자막 품질을 조금 내주는 대신 "
"게임 프레임과 VRAM을 지킵니다.",
)
form = card.add_form()
self.game_mode_check = QCheckBox("저부하 모드 (권장)")
self.game_mode_check.setChecked(perf.game_mode)
self.game_mode_check.setToolTip(
"연산 정밀도를 int8로 낮추고, 추론 사이에 GPU를 잠깐 쉬게 하고, "
"조용할 때 모델을 내립니다."
)
self.game_mode_check.toggled.connect(self._on_performance_changed)
form.addRow("", self.game_mode_check)
self.vram_slider, vram_row = _slider_row(
int(perf.vram_limit_ratio * 100), 0, 90, "%", self._on_performance_changed
)
self.vram_slider.setToolTip(
"이 프로그램이 쓸 수 있는 VRAM 상한입니다. 0%면 제한하지 않습니다.\n"
"게임이 VRAM 부족으로 버벅이면 낮추세요."
)
form.addRow("VRAM 상한", vram_row)
self.yield_spin = _spin(perf.yield_ms, 0, 500, 10, " ms", self._on_performance_changed)
self.yield_spin.setToolTip(
"한 문장을 처리하고 나서 GPU를 놓아주는 시간입니다.\n"
"길수록 게임이 부드럽고 자막은 조금 늦게 뜹니다."
)
form.addRow("GPU 양보 시간", self.yield_spin)
self.idle_spin = _spin(
perf.idle_unload_s, 0, 3600, 30, " 초", self._on_performance_changed
)
self.idle_spin.setToolTip(
"이 시간 동안 말소리가 없으면 모델을 내려 VRAM을 통째로 반납합니다.\n"
"다음 대사가 나오면 자동으로 다시 올라갑니다. 0이면 끕니다."
)
form.addRow("유휴 시 모델 해제", self.idle_spin)
self.perf_summary = QLabel()
self.perf_summary.setObjectName("Hint")
self.perf_summary.setWordWrap(True)
card.add(self.perf_summary)
self._update_perf_summary()
return card
def _update_perf_summary(self) -> None:
perf = self.config.performance
gpu = detect_gpu()
if not gpu.available or not gpu.total_vram_mb:
self.perf_summary.setText("GPU 정보를 읽지 못해 상한을 계산할 수 없습니다.")
return
if perf.vram_limit_ratio <= 0:
self.perf_summary.setText(
f"{gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB — 상한 없음 "
"(게임이 VRAM 부족으로 버벅일 수 있습니다)"
)
return
budget = gpu.total_vram_mb * perf.vram_limit_ratio / 1024
left = gpu.total_vram_mb / 1024 - budget
self.perf_summary.setText(
f"{gpu.name} · 자막이 최대 {budget:.1f}GB 사용, 게임 몫 약 {left:.1f}GB"
)
# --- 단축키 ----------------------------------------------------------
def _build_hotkey_card(self) -> Card:
keys = self.config.hotkeys
card = Card(
"전역 단축키",
"게임 창이 떠 있어도 동작합니다. Ctrl / Alt / Shift 중 하나는 반드시 넣으세요.",
)
form = card.add_form()
self.hotkey_enabled = QCheckBox("전역 단축키 사용")
self.hotkey_enabled.setChecked(keys.enabled)
self.hotkey_enabled.toggled.connect(self._on_hotkey_changed)
form.addRow("", self.hotkey_enabled)
self.subtitle_hotkey = QLineEdit(keys.toggle_subtitle)
self.subtitle_hotkey.setPlaceholderText("예: Ctrl+Alt+S")
self.subtitle_hotkey.editingFinished.connect(self._on_hotkey_changed)
form.addRow("자막 켜기/끄기", self.subtitle_hotkey)
self.engine_hotkey = QLineEdit(keys.toggle_engine)
self.engine_hotkey.setPlaceholderText("예: Ctrl+Alt+D")
self.engine_hotkey.editingFinished.connect(self._on_hotkey_changed)
form.addRow("번역 시작/정지", self.engine_hotkey)
self.hotkey_status = QLabel()
self.hotkey_status.setObjectName("Hint")
self.hotkey_status.setWordWrap(True)
card.add(self.hotkey_status)
return card
def set_hotkey_status(self, message: str, ok: bool = True) -> None:
p = palette("dark")
self.hotkey_status.setText(message)
self.hotkey_status.setStyleSheet(f"color: {p.text_dim if ok else p.warning};")
def _on_performance_changed(self, *_) -> None:
perf = self.config.performance
perf.game_mode = self.game_mode_check.isChecked()
perf.vram_limit_ratio = self.vram_slider.value() / 100
perf.yield_ms = self.yield_spin.value()
perf.idle_unload_s = self.idle_spin.value()
for widget in (self.vram_slider, self.yield_spin, self.idle_spin):
widget.setEnabled(perf.game_mode)
self._update_perf_summary()
if hasattr(self, "partial_spin"): # 오디오 카드가 아직 안 만들어졌을 수 있다
self._sync_partial_enabled()
self.config_changed.emit()
self.restart_needed.emit()
def _on_hotkey_changed(self, *_) -> None:
keys = self.config.hotkeys
keys.enabled = self.hotkey_enabled.isChecked()
keys.toggle_subtitle = self.subtitle_hotkey.text().strip()
keys.toggle_engine = self.engine_hotkey.text().strip()
for widget in (self.subtitle_hotkey, self.engine_hotkey):
widget.setEnabled(keys.enabled)
self.config_changed.emit()
self.hotkeys_changed.emit()
# --- 구성 -----------------------------------------------------------
def _build_audio_card(self) -> Card:
cfg = self.config.audio
card = Card(
"음성 인식",
"소리가 작아 인식이 안 되면 입력 증폭을 올리고, 주변 소음까지 잡히면 "
"민감도를 낮추세요. 값을 바꾸면 다음 시작부터 적용됩니다.",
)
form = card.add_form()
self.gain_slider, gain_row = _slider_row(
int(cfg.input_gain * 100), 25, 400, "%", self._on_audio_changed
)
form.addRow("입력 증폭", gain_row)
self.vad_combo = QComboBox()
for label, value in (
("낮음 — 작은 소리도 잡음", 0),
("보통", 1),
("높음 (기본)", 2),
("매우 높음 — 또렷한 말만", 3),
):
self.vad_combo.addItem(label, value)
self.vad_combo.setCurrentIndex(max(0, self.vad_combo.findData(cfg.vad_aggressiveness)))
self.vad_combo.currentIndexChanged.connect(self._on_audio_changed)
form.addRow("잡음 억제", self.vad_combo)
self.silence_spin = _spin(cfg.silence_ms, 200, 2000, 50, " ms", self._on_audio_changed)
self.silence_spin.setToolTip(
"이 시간만큼 조용하면 한 문장이 끝난 것으로 봅니다. "
"짧게 하면 자막이 빨리 뜨지만 문장이 자주 끊깁니다."
)
form.addRow("문장 끊는 침묵", self.silence_spin)
self.max_spin = _spin(cfg.max_segment_ms, 3000, 30_000, 1000, " ms", self._on_audio_changed)
self.max_spin.setToolTip("말이 계속 이어져도 이 길이에서 강제로 끊습니다.")
form.addRow("문장 최대 길이", self.max_spin)
self.partial_spin = _spin(
cfg.partial_interval_ms, 0, 3000, 100, " ms", self._on_audio_changed
)
self.partial_spin.setToolTip(
"말하는 도중에도 인식 중인 내용을 흐리게 미리 보여줍니다.\n"
"같은 오디오를 여러 번 인식하므로 GPU 작업이 거의 두 배가 됩니다.\n"
"저부하 모드가 켜져 있으면 무시됩니다."
)
self.partial_row_label = QLabel("중간 결과 갱신")
form.addRow(self.partial_row_label, self.partial_spin)
self._sync_partial_enabled()
return card
def _sync_partial_enabled(self) -> None:
"""저부하 모드에서는 중간 결과를 쓰지 않으므로 입력을 잠근다."""
on = not self.config.performance.game_mode
self.partial_spin.setEnabled(on)
self.partial_row_label.setText(
"중간 결과 갱신" if on else "중간 결과 갱신 (저부하 모드에서 꺼짐)"
)
def _build_behavior_card(self) -> Card:
card = Card("동작")
form = card.add_form()
self.theme_combo = QComboBox()
self.theme_combo.addItem("어두운 테마", "dark")
self.theme_combo.addItem("밝은 테마", "light")
self.theme_combo.setCurrentIndex(max(0, self.theme_combo.findData(self.config.theme)))
self.theme_combo.currentIndexChanged.connect(self._on_behavior_changed)
form.addRow("테마", self.theme_combo)
self.preload_check = QCheckBox("시작할 때 모델을 미리 올려두기")
self.preload_check.setChecked(self.config.models.preload_on_start)
self.preload_check.setToolTip(
"끄면 프로그램이 빨리 뜨지만 첫 문장 번역이 오래 걸립니다."
)
self.preload_check.toggled.connect(self._on_behavior_changed)
form.addRow("", self.preload_check)
self.log_check = QCheckBox("번역 기록을 파일로 남기기")
self.log_check.setChecked(self.config.log_transcripts)
self.log_check.toggled.connect(self._on_behavior_changed)
form.addRow("", self.log_check)
open_folder = QPushButton("설정 폴더 열기")
open_folder.clicked.connect(self._open_folder)
form.addRow("", open_folder)
return card
def _build_about_card(self) -> Card:
card = Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN)
path = QLabel(f"설정 위치: {user_data_dir()}")
path.setObjectName("Hint")
path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
path.setWordWrap(True)
card.add(path)
return card
# --- 동작 -----------------------------------------------------------
def _on_audio_changed(self, *_) -> None:
cfg = self.config.audio
cfg.input_gain = self.gain_slider.value() / 100
cfg.vad_aggressiveness = self.vad_combo.currentData()
cfg.silence_ms = self.silence_spin.value()
cfg.max_segment_ms = self.max_spin.value()
cfg.partial_interval_ms = self.partial_spin.value()
self.config_changed.emit()
self.restart_needed.emit()
def _on_behavior_changed(self, *_) -> None:
self.config.theme = self.theme_combo.currentData()
self.config.models.preload_on_start = self.preload_check.isChecked()
self.config.log_transcripts = self.log_check.isChecked()
self.config_changed.emit()
def _open_folder(self) -> None:
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
QDesktopServices.openUrl(QUrl.fromLocalFile(str(user_data_dir())))
def _spin(value, low, high, step, suffix, slot) -> QSpinBox:
spin = QSpinBox()
spin.setRange(low, high)
spin.setSingleStep(step)
spin.setSuffix(suffix)
spin.setValue(value)
spin.valueChanged.connect(slot)
return spin
def _slider_row(value, low, high, suffix, slot) -> tuple[QSlider, QWidget]:
row = QWidget()
row.setObjectName("Row")
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(low, high)
slider.setValue(value)
label = QLabel(f"{value}{suffix}")
label.setMinimumWidth(48)
slider.valueChanged.connect(lambda v: label.setText(f"{v}{suffix}"))
slider.valueChanged.connect(slot)
layout.addWidget(slider, 1)
layout.addWidget(label)
return slider, row

View File

@@ -20,8 +20,9 @@ from PySide6.QtWidgets import (
from ...config import AppConfig
from ..overlay import SubtitlePreview
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, list_screens
from ..theme import SPACING
from ..widgets import Card, PageHeader
from ..widgets import AnchorGrid, Card, PageHeader
PREVIEW_TEXT = "적이 왼쪽에서 들어온다, 지금 바로 빠져!"
PREVIEW_SOURCE = "Enemy coming from the left, fall back now!"
@@ -56,7 +57,10 @@ class ColorButton(QPushButton):
class SubtitlePage(QWidget):
style_changed = Signal()
#: 자막 표시 on/off
overlay_toggled = Signal(bool)
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜
placement_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
@@ -66,9 +70,10 @@ class SubtitlePage(QWidget):
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader("자막", "자막 창에 표시될 글씨의 모양을 정합니다. 아래에서 바로 확인하세요.")
PageHeader("자막", "자막을 켜고 끄고, 어느 모니터 어디에 띄울지 정합니다.")
)
root.addWidget(self._build_display_card())
root.addWidget(self._build_preview_card())
root.addWidget(self._build_text_card())
root.addWidget(self._build_layout_card())
@@ -77,6 +82,86 @@ class SubtitlePage(QWidget):
self._sync_preview()
# --- 구성 -----------------------------------------------------------
def _build_display_card(self) -> Card:
"""자막 on/off + 모니터 선택 + 디스코드식 위치 선택."""
cfg = self.config.overlay
card = Card(
"표시 위치",
"모니터를 고르고 네모칸을 누르면 그 자리에 자막이 붙습니다. "
"직접 드래그해서 옮기면 그 위치를 그대로 기억합니다.",
)
# --- on/off + 단축키 안내 ---
toggle_row = QWidget()
toggle_layout = QHBoxLayout(toggle_row)
toggle_layout.setContentsMargins(0, 0, 0, 0)
toggle_layout.setSpacing(10)
self.overlay_check = QCheckBox("자막 켜기")
self.overlay_check.setChecked(cfg.visible)
self.overlay_check.setStyleSheet("font-weight: 700;")
self.overlay_check.toggled.connect(self.overlay_toggled.emit)
toggle_layout.addWidget(self.overlay_check)
hotkey_hint = QLabel(f"게임 중에도 {self.config.hotkeys.toggle_subtitle} 로 켜고 끌 수 있습니다")
hotkey_hint.setObjectName("Hint")
toggle_layout.addWidget(hotkey_hint)
toggle_layout.addStretch(1)
self.click_through_check = QCheckBox("클릭 통과")
self.click_through_check.setChecked(cfg.click_through)
self.click_through_check.setToolTip(
"켜면 자막 위를 클릭해도 게임으로 전달됩니다. 게임 중에는 켜두세요."
)
self.click_through_check.toggled.connect(self._on_placement_changed)
toggle_layout.addWidget(self.click_through_check)
card.add(toggle_row)
# --- 모니터 선택 ---
monitor_row = QWidget()
monitor_layout = QHBoxLayout(monitor_row)
monitor_layout.setContentsMargins(0, 0, 0, 0)
monitor_layout.setSpacing(10)
monitor_layout.addWidget(QLabel("모니터"))
self.screen_combo = QComboBox()
self.screen_combo.currentIndexChanged.connect(self._on_screen_changed)
monitor_layout.addWidget(self.screen_combo, 1)
refresh = QPushButton("새로고침")
refresh.setToolTip("모니터를 연결하거나 뺐다면 눌러주세요.")
refresh.clicked.connect(self.refresh_screens)
monitor_layout.addWidget(refresh)
card.add(monitor_row)
# --- 9분할 위치 ---
self.anchor_grid = AnchorGrid()
self.anchor_grid.anchor_selected.connect(self._on_anchor_selected)
card.add(self.anchor_grid)
self.anchor_label = QLabel()
self.anchor_label.setObjectName("Hint")
self.anchor_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
card.add(self.anchor_label)
margin_row = QWidget()
margin_layout = QHBoxLayout(margin_row)
margin_layout.setContentsMargins(0, 0, 0, 0)
margin_layout.setSpacing(10)
margin_layout.addWidget(QLabel("가장자리 여백"))
self.margin_spin = QSpinBox()
self.margin_spin.setRange(0, 400)
self.margin_spin.setSingleStep(10)
self.margin_spin.setSuffix(" px")
self.margin_spin.setValue(cfg.margin)
self.margin_spin.valueChanged.connect(self._on_placement_changed)
margin_layout.addWidget(self.margin_spin)
margin_layout.addStretch(1)
card.add(margin_row)
self.refresh_screens()
return card
def _build_preview_card(self) -> Card:
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
self.preview = SubtitlePreview(self.config.subtitle)
@@ -140,6 +225,7 @@ class SubtitlePage(QWidget):
form.addRow("배경 색", self.bg_color)
opacity_row = QWidget()
opacity_row.setObjectName("Row")
layout = QHBoxLayout(opacity_row)
layout.setContentsMargins(0, 0, 0, 0)
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
@@ -180,12 +266,70 @@ class SubtitlePage(QWidget):
self.show_source_check.toggled.connect(self._on_change)
form.addRow("", self.show_source_check)
self.overlay_check = QCheckBox("자막 창 보이기")
self.overlay_check.setChecked(self.config.overlay.visible)
self.overlay_check.toggled.connect(self.overlay_toggled.emit)
form.addRow("", self.overlay_check)
return card
# --- 표시 위치 -------------------------------------------------------
def refresh_screens(self) -> None:
"""연결된 모니터를 다시 읽어 콤보를 채운다."""
cfg = self.config.overlay
screens = list_screens()
self.screen_combo.blockSignals(True)
self.screen_combo.clear()
for screen in screens:
self.screen_combo.addItem(screen.label, screen.index)
if not screens:
self.screen_combo.addItem("모니터를 찾지 못했습니다", -1)
found = self.screen_combo.findData(cfg.screen_index)
self.screen_combo.setCurrentIndex(found if found >= 0 else 0)
self.screen_combo.blockSignals(False)
self.anchor_grid.set_anchor(cfg.anchor)
self._update_anchor_label()
def sync_from_config(self) -> None:
"""드래그 등으로 설정이 바뀐 뒤 화면 표시를 맞춘다."""
cfg = self.config.overlay
self.overlay_check.blockSignals(True)
self.overlay_check.setChecked(cfg.visible)
self.overlay_check.blockSignals(False)
self.anchor_grid.set_anchor(cfg.anchor)
self._update_anchor_label()
def _update_anchor_label(self) -> None:
anchor = self.config.overlay.anchor
if anchor == ANCHOR_CUSTOM:
self.anchor_label.setText(
"자유 배치 — 직접 옮긴 자리를 쓰고 있습니다. 칸을 누르면 다시 맞춰집니다."
)
else:
self.anchor_label.setText(f"현재 위치: {ANCHOR_LABELS.get(anchor, anchor)}")
def _on_screen_changed(self) -> None:
index = self.screen_combo.currentData()
if index is None or index < 0:
return
self.config.overlay.screen_index = index
# 모니터를 바꾸면 그 모니터 기준으로 다시 배치해야 한다. 자유 배치
# 상태였다면 하단 중앙으로 되돌린다 — 이전 좌표는 다른 모니터 것이라
# 그대로 쓰면 화면 밖으로 나간다.
if self.config.overlay.anchor == ANCHOR_CUSTOM:
self.config.overlay.anchor = "bottom-center"
self.anchor_grid.set_anchor("bottom-center")
self._update_anchor_label()
self.placement_changed.emit()
def _on_anchor_selected(self, anchor: str) -> None:
self.config.overlay.anchor = anchor
self._update_anchor_label()
self.placement_changed.emit()
def _on_placement_changed(self, *_) -> None:
cfg = self.config.overlay
cfg.margin = self.margin_spin.value()
cfg.click_through = self.click_through_check.isChecked()
self.placement_changed.emit()
# --- 동작 -----------------------------------------------------------
def _on_font_changed(self, font: QFont) -> None:
self.config.subtitle.font_family = font.family()

141
src/livesub/ui/placement.py Normal file
View File

@@ -0,0 +1,141 @@
"""자막 창 배치 계산.
디스코드 오버레이와 같은 방식이다. 모니터를 고르고 9개 구역 중 하나를 누르면
그 자리에 딱 붙는다. 세밀하게 옮기고 싶으면 그 다음에 드래그하면 된다.
Qt 에 의존하지 않는 순수 계산부(`resolve_rect`)와 Qt 화면 조회부를 나눠놨다.
덕분에 모니터가 없는 환경에서도 배치 로직을 그대로 테스트할 수 있다.
"""
from __future__ import annotations
from dataclasses import dataclass
#: 디스코드 오버레이와 같은 3x3 그리드. 값은 (가로 정렬, 세로 정렬).
ANCHORS: dict[str, tuple[str, str]] = {
"top-left": ("left", "top"),
"top-center": ("center", "top"),
"top-right": ("right", "top"),
"middle-left": ("left", "middle"),
"middle-center": ("center", "middle"),
"middle-right": ("right", "middle"),
"bottom-left": ("left", "bottom"),
"bottom-center": ("center", "bottom"),
"bottom-right": ("right", "bottom"),
}
#: UI 표시용 이름 (그리드 순서와 동일)
ANCHOR_LABELS: dict[str, str] = {
"top-left": "좌상단",
"top-center": "상단 중앙",
"top-right": "우상단",
"middle-left": "좌측 중앙",
"middle-center": "정중앙",
"middle-right": "우측 중앙",
"bottom-left": "좌하단",
"bottom-center": "하단 중앙",
"bottom-right": "우하단",
}
#: 자유 배치 — 사용자가 드래그로 직접 옮긴 상태
ANCHOR_CUSTOM = "custom"
DEFAULT_ANCHOR = "bottom-center"
@dataclass(frozen=True)
class ScreenInfo:
"""모니터 하나. Qt 의 QScreen 에서 뽑아낸 값만 담는다."""
index: int
name: str
x: int
y: int
width: int
height: int
is_primary: bool = False
@property
def label(self) -> str:
tag = " (주 모니터)" if self.is_primary else ""
return f"{self.index + 1}. {self.name} · {self.width}×{self.height}{tag}"
def resolve_rect(
screen: ScreenInfo,
anchor: str,
width: int,
height: int,
margin: int = 60,
) -> tuple[int, int, int, int]:
"""앵커와 크기로 최종 창 위치를 계산한다.
창이 모니터보다 크면 모니터 안에 들어가도록 줄인다. 그러지 않으면
자막이 화면 밖으로 나가 영영 안 보이는 상태가 될 수 있다.
"""
width = max(200, min(width, screen.width - margin * 2))
height = max(80, min(height, screen.height - margin * 2))
horizontal, vertical = ANCHORS.get(anchor, ANCHORS[DEFAULT_ANCHOR])
if horizontal == "left":
x = screen.x + margin
elif horizontal == "right":
x = screen.x + screen.width - width - margin
else:
x = screen.x + (screen.width - width) // 2
if vertical == "top":
y = screen.y + margin
elif vertical == "bottom":
y = screen.y + screen.height - height - margin
else:
y = screen.y + (screen.height - height) // 2
return x, y, width, height
def screen_for_point(screens: list[ScreenInfo], x: int, y: int) -> ScreenInfo | None:
"""해당 좌표를 품고 있는 모니터. 드래그로 옮긴 뒤 소속을 다시 찾을 때 쓴다."""
for screen in screens:
if screen.x <= x < screen.x + screen.width and screen.y <= y < screen.y + screen.height:
return screen
return None
def pick_screen(screens: list[ScreenInfo], index: int) -> ScreenInfo | None:
"""저장된 인덱스로 모니터를 찾는다. 모니터 구성이 바뀌었으면 주 모니터로."""
if not screens:
return None
for screen in screens:
if screen.index == index:
return screen
for screen in screens:
if screen.is_primary:
return screen
return screens[0]
def list_screens() -> list[ScreenInfo]:
"""현재 연결된 모니터 목록 (Qt 필요)."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is None:
return []
primary = app.primaryScreen()
out: list[ScreenInfo] = []
for index, screen in enumerate(app.screens()):
geo = screen.geometry()
out.append(
ScreenInfo(
index=index,
name=screen.name() or f"모니터 {index + 1}",
x=geo.x(),
y=geo.y(),
width=geo.width(),
height=geo.height(),
is_primary=screen is primary,
)
)
return out

View File

@@ -75,7 +75,8 @@ def stylesheet(theme: str = "dark") -> str:
}}
/* 라벨류는 부모(카드/사이드바) 배경이 비쳐야 한다. 지정하지 않으면
QWidget 규칙의 창 배경색을 상속해 카드 위에 검은 박스가 생긴다. */
QLabel, QCheckBox, QRadioButton, QScrollArea > QWidget > QWidget {{
QLabel, QCheckBox, QRadioButton, QScrollArea > QWidget > QWidget,
QWidget#Row {{
background: transparent;
}}
QLabel#Title {{ font-size: 22px; font-weight: 700; }}

View File

@@ -0,0 +1,4 @@
from .anchor_grid import AnchorGrid
from .common import Card, LevelMeter, PageHeader, StatusPill, labelled_row
__all__ = ["AnchorGrid", "Card", "LevelMeter", "PageHeader", "StatusPill", "labelled_row"]

View File

@@ -0,0 +1,69 @@
"""디스코드 오버레이식 9분할 위치 선택기.
모니터 모양의 사각형 안에 3x3 버튼을 놓고, 누르면 자막이 그 자리로 붙는다.
좌표를 숫자로 입력하게 하는 것보다 훨씬 직관적이다.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QButtonGroup, QFrame, QGridLayout, QPushButton, QWidget
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, ANCHORS
from ..theme import palette
GRID_ORDER = [
["top-left", "top-center", "top-right"],
["middle-left", "middle-center", "middle-right"],
["bottom-left", "bottom-center", "bottom-right"],
]
class AnchorGrid(QFrame):
"""3x3 위치 버튼. 선택된 칸이 강조된다."""
anchor_selected = Signal(str)
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setObjectName("Card")
self.setFixedHeight(190)
p = palette("dark")
layout = QGridLayout(self)
layout.setContentsMargins(14, 14, 14, 14)
layout.setSpacing(6)
self._buttons: dict[str, QPushButton] = {}
self._group = QButtonGroup(self)
self._group.setExclusive(True)
for row, keys in enumerate(GRID_ORDER):
for col, key in enumerate(keys):
button = QPushButton()
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setToolTip(ANCHOR_LABELS[key])
button.setMinimumHeight(42)
button.setStyleSheet(
f"QPushButton {{ background: {p.surface_alt};"
f"border: 1px solid {p.border}; border-radius: 6px; }}"
f"QPushButton:hover {{ border-color: {p.accent}; }}"
f"QPushButton:checked {{ background: {p.accent};"
f"border-color: {p.accent}; }}"
)
button.clicked.connect(lambda _, k=key: self.anchor_selected.emit(k))
self._group.addButton(button)
self._buttons[key] = button
layout.addWidget(button, row, col)
def set_anchor(self, anchor: str) -> None:
"""현재 선택 표시. 자유 배치(custom)면 아무 칸도 켜지 않는다."""
self._group.setExclusive(False)
for key, button in self._buttons.items():
button.setChecked(anchor != ANCHOR_CUSTOM and key == anchor)
self._group.setExclusive(True)
@staticmethod
def is_valid(anchor: str) -> bool:
return anchor in ANCHORS

View File

@@ -42,6 +42,10 @@ class Card(QFrame):
outer.addLayout(self.body)
def add(self, widget: QWidget) -> QWidget:
# 레이아웃용 맨 QWidget 은 창 배경색을 상속해 카드 위에 검은 막대가 된다.
# 이름을 달아주면 QSS 가 투명하게 처리한다 (theme.py 의 QWidget#Row).
if type(widget) is QWidget:
widget.setObjectName("Row")
self.body.addWidget(widget)
return widget
@@ -126,6 +130,7 @@ class LevelMeter(QWidget):
def labelled_row(label: str, widget: QWidget) -> QWidget:
row = QWidget()
row.setObjectName("Row")
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)