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:
385
src/livesub/ui/overlay.py
Normal file
385
src/livesub/ui/overlay.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""자막 오버레이 창.
|
||||
|
||||
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
|
||||
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
|
||||
|
||||
조작:
|
||||
* 드래그 — 위치 이동
|
||||
* 우하단 모서리 — 크기 조절
|
||||
* 휠 — 글자 크기 조절
|
||||
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QPoint, QRect, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
QColor,
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
)
|
||||
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 # 우하단 리사이즈 핸들 크기
|
||||
|
||||
|
||||
def paint_subtitle(
|
||||
painter: QPainter,
|
||||
rect: QRect,
|
||||
st: SubtitleStyle,
|
||||
translated: str,
|
||||
source: str = "",
|
||||
partial: str = "",
|
||||
) -> None:
|
||||
"""자막 한 화면을 그린다. 오버레이와 설정 미리보기가 이 함수를 공유한다."""
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
||||
|
||||
if st.background_opacity > 0:
|
||||
bg = QColor(st.background_color)
|
||||
bg.setAlpha(int(255 * st.background_opacity / 100))
|
||||
painter.setBrush(bg)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawRoundedRect(rect, 12, 12)
|
||||
|
||||
rows: list[tuple[str, QColor, int]] = []
|
||||
if st.show_source and source and translated:
|
||||
rows.append((source, QColor(st.source_text_color), int(st.font_size * 0.62)))
|
||||
main = translated or partial
|
||||
if main:
|
||||
color = QColor(st.text_color)
|
||||
if not translated: # 아직 확정 전 — 흐리게
|
||||
color.setAlpha(170)
|
||||
rows.append((main, color, st.font_size))
|
||||
if not rows:
|
||||
return
|
||||
|
||||
margin = 18
|
||||
available = rect.width() - margin * 2
|
||||
blocks: list[tuple[list[str], QColor, QFont]] = []
|
||||
total_h = 0
|
||||
for text, color, size in rows:
|
||||
font = QFont(st.font_family, size)
|
||||
font.setBold(st.bold)
|
||||
lines = _wrap(text, QFontMetrics(font), available, st.max_lines)
|
||||
blocks.append((lines, color, font))
|
||||
total_h += len(lines) * (QFontMetrics(font).height() + st.line_spacing)
|
||||
|
||||
y = rect.y() + max(margin, (rect.height() - total_h) // 2)
|
||||
for lines, color, font in blocks:
|
||||
painter.setFont(font)
|
||||
fm = QFontMetrics(font)
|
||||
for line in lines:
|
||||
width = fm.horizontalAdvance(line)
|
||||
if st.align == "left":
|
||||
x = rect.x() + margin
|
||||
elif st.align == "right":
|
||||
x = rect.right() - margin - width
|
||||
else:
|
||||
x = rect.x() + (rect.width() - width) // 2
|
||||
_draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st)
|
||||
y += fm.height() + st.line_spacing
|
||||
|
||||
|
||||
def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None:
|
||||
"""외곽선 있는 글자.
|
||||
|
||||
스트로크는 글자 윤곽선 '가운데'에 그려지므로 절반이 글자 안쪽을 파먹는다.
|
||||
한글처럼 획이 얇은 글꼴은 그대로 두면 글자가 통째로 외곽선 색이 된다.
|
||||
그래서 스트로크를 먼저 깔고 그 위에 글자를 채우는 2패스로 그린다.
|
||||
"""
|
||||
if st.outline_width <= 0:
|
||||
painter.setPen(color)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawText(x, y, text)
|
||||
return
|
||||
|
||||
path = QPainterPath()
|
||||
path.addText(x, y, font, text)
|
||||
|
||||
pen = QPen(QColor(st.outline_color))
|
||||
pen.setWidth(st.outline_width * 2) # 절반이 안쪽으로 먹히므로 2배
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawPath(path)
|
||||
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(color)
|
||||
painter.drawPath(path)
|
||||
|
||||
|
||||
class SubtitlePreview(QWidget):
|
||||
"""설정 화면에서 쓰는 미리보기. 실제 자막과 같은 코드로 그린다."""
|
||||
|
||||
def __init__(self, style: SubtitleStyle, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.style_cfg = style
|
||||
self.translated = ""
|
||||
self.source = ""
|
||||
self.setMinimumHeight(140)
|
||||
|
||||
def set_sample(self, translated: str, source: str) -> None:
|
||||
self.translated = translated
|
||||
self.source = source
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None: # noqa: N802
|
||||
painter = QPainter(self)
|
||||
paint_subtitle(
|
||||
painter, self.rect(), self.style_cfg, self.translated, self.source
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
super().__init__(None)
|
||||
self.style_cfg = style
|
||||
self.overlay_cfg = overlay
|
||||
|
||||
self._final_text = ""
|
||||
self._partial_text = ""
|
||||
self._source_text = ""
|
||||
self._drag_from: QPoint | None = None
|
||||
self._resize_from: QRect | None = None
|
||||
|
||||
self._fade = QTimer(self)
|
||||
self._fade.setSingleShot(True)
|
||||
self._fade.timeout.connect(self._clear_text)
|
||||
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.Tool
|
||||
| Qt.WindowType.WindowStaysOnTopHint
|
||||
)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
self.setMinimumSize(320, 90)
|
||||
self.apply_config()
|
||||
|
||||
# --- 외부 API -------------------------------------------------------
|
||||
def apply_config(self) -> None:
|
||||
cfg = self.overlay_cfg
|
||||
flags = self.windowFlags()
|
||||
flags = (
|
||||
flags | Qt.WindowType.WindowStaysOnTopHint
|
||||
if cfg.always_on_top
|
||||
else flags & ~Qt.WindowType.WindowStaysOnTopHint
|
||||
)
|
||||
self.setWindowFlags(flags)
|
||||
self.setAttribute(
|
||||
Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through
|
||||
)
|
||||
|
||||
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
|
||||
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)
|
||||
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
|
||||
self._partial_text = ""
|
||||
self._source_text = source
|
||||
else:
|
||||
self._partial_text = source
|
||||
self._restart_fade()
|
||||
self.update()
|
||||
|
||||
def clear(self) -> None:
|
||||
self._clear_text()
|
||||
|
||||
# --- 그리기 ---------------------------------------------------------
|
||||
def paintEvent(self, event) -> None: # noqa: N802 - Qt 규약
|
||||
painter = QPainter(self)
|
||||
paint_subtitle(
|
||||
painter,
|
||||
self.rect(),
|
||||
self.style_cfg,
|
||||
self._final_text,
|
||||
self._source_text,
|
||||
self._partial_text,
|
||||
)
|
||||
if not (self._final_text or self._partial_text) and not self.overlay_cfg.locked:
|
||||
self._draw_placeholder(painter)
|
||||
self._draw_grip(painter)
|
||||
|
||||
def _draw_placeholder(self, painter) -> None:
|
||||
painter.setPen(QColor(255, 255, 255, 90))
|
||||
font = QFont(self.style_cfg.font_family, 13)
|
||||
painter.setFont(font)
|
||||
painter.drawText(
|
||||
self.rect(),
|
||||
Qt.AlignmentFlag.AlignCenter,
|
||||
"자막이 여기에 표시됩니다\n드래그로 이동 · 휠로 크기 · 우클릭으로 메뉴",
|
||||
)
|
||||
|
||||
def _draw_grip(self, painter) -> None:
|
||||
if self.overlay_cfg.locked or self.overlay_cfg.click_through:
|
||||
return
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 110), 2))
|
||||
w, h = self.width(), self.height()
|
||||
for offset in (5, 10, 15):
|
||||
painter.drawLine(w - offset, h - 4, w - 4, h - offset)
|
||||
|
||||
# --- 마우스 ---------------------------------------------------------
|
||||
def mousePressEvent(self, event) -> None: # noqa: N802
|
||||
if self.overlay_cfg.locked or event.button() != Qt.MouseButton.LeftButton:
|
||||
return
|
||||
pos = event.position().toPoint()
|
||||
if pos.x() > self.width() - GRIP and pos.y() > self.height() - GRIP:
|
||||
self._resize_from = QRect(event.globalPosition().toPoint(), self.size())
|
||||
else:
|
||||
self._drag_from = event.globalPosition().toPoint() - self.pos()
|
||||
|
||||
def mouseMoveEvent(self, event) -> None: # noqa: N802
|
||||
if self._resize_from is not None:
|
||||
delta = event.globalPosition().toPoint() - self._resize_from.topLeft()
|
||||
self.resize(
|
||||
max(self.minimumWidth(), self._resize_from.width() + delta.x()),
|
||||
max(self.minimumHeight(), self._resize_from.height() + delta.y()),
|
||||
)
|
||||
elif self._drag_from is not None:
|
||||
self.move(event.globalPosition().toPoint() - self._drag_from)
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None: # noqa: N802
|
||||
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:
|
||||
return
|
||||
step = 2 if event.angleDelta().y() > 0 else -2
|
||||
self.style_cfg.font_size = max(12, min(120, self.style_cfg.font_size + step))
|
||||
self.update()
|
||||
|
||||
def contextMenuEvent(self, event) -> None: # noqa: N802
|
||||
menu = QMenu(self)
|
||||
cfg = self.overlay_cfg
|
||||
|
||||
lock = QAction("위치 잠금", self, checkable=True, checked=cfg.locked)
|
||||
lock.toggled.connect(self._set_locked)
|
||||
menu.addAction(lock)
|
||||
|
||||
through = QAction("클릭 통과", self, checkable=True, checked=cfg.click_through)
|
||||
through.toggled.connect(self._set_click_through)
|
||||
menu.addAction(through)
|
||||
|
||||
top = QAction("항상 위에", self, checkable=True, checked=cfg.always_on_top)
|
||||
top.toggled.connect(self._set_always_on_top)
|
||||
menu.addAction(top)
|
||||
|
||||
menu.addSeparator()
|
||||
menu.addAction("자막 지우기", self.clear)
|
||||
menu.addAction("자막 창 숨기기", self.hide_overlay)
|
||||
menu.exec(event.globalPos())
|
||||
|
||||
# --- 메뉴 동작 ------------------------------------------------------
|
||||
def _set_locked(self, value: bool) -> None:
|
||||
self.overlay_cfg.locked = value
|
||||
self.update()
|
||||
|
||||
def _set_click_through(self, value: bool) -> None:
|
||||
self.overlay_cfg.click_through = value
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, value)
|
||||
self.update()
|
||||
|
||||
def _set_always_on_top(self, value: bool) -> None:
|
||||
self.overlay_cfg.always_on_top = value
|
||||
self.apply_config()
|
||||
|
||||
def hide_overlay(self) -> None:
|
||||
self.set_subtitle_visible(False)
|
||||
self.closed.emit()
|
||||
|
||||
def _restart_fade(self) -> None:
|
||||
self._fade.stop()
|
||||
if self.style_cfg.fade_out_ms > 0:
|
||||
self._fade.start(self.style_cfg.fade_out_ms)
|
||||
|
||||
def _clear_text(self) -> None:
|
||||
self._final_text = ""
|
||||
self._partial_text = ""
|
||||
self._source_text = ""
|
||||
self.update()
|
||||
|
||||
|
||||
def _wrap(text: str, fm: QFontMetrics, width: int, max_lines: int) -> list[str]:
|
||||
"""단어 단위 줄바꿈. 마지막 줄이 넘치면 앞부분을 버리고 최신 내용을 남긴다."""
|
||||
if width <= 0 or not text:
|
||||
return [text] if text else []
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
for word in text.split():
|
||||
candidate = f"{current} {word}".strip()
|
||||
if fm.horizontalAdvance(candidate) <= width or not current:
|
||||
current = candidate
|
||||
else:
|
||||
lines.append(current)
|
||||
current = word
|
||||
if current:
|
||||
lines.append(current)
|
||||
# 실시간 자막에서는 앞이 아니라 뒤(최신)를 보여주는 게 맞다.
|
||||
return lines[-max_lines:] if max_lines > 0 else lines
|
||||
Reference in New Issue
Block a user