Compare commits
18 Commits
f9fd86464d
...
v0.3.0-tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0201c7b913 | ||
|
|
bec5e285bd | ||
|
|
7f678fba7b | ||
|
|
628280b227 | ||
|
|
88effe0e58 | ||
|
|
81e0271b69 | ||
|
|
f79293d893 | ||
|
|
2028b0a651 | ||
|
|
4fd8fabcb8 | ||
|
|
ca096a69c3 | ||
|
|
0a1389a1d9 | ||
|
|
a66eedee8b | ||
|
|
dbe1370425 | ||
|
|
03a2ca50c0 | ||
|
|
e234ad4401 | ||
|
|
162540b025 | ||
|
|
f37da0cbe2 | ||
|
|
89b0ee0cf7 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -10,9 +10,12 @@ venv/
|
||||
.ruff_cache/
|
||||
|
||||
# 네이티브 빌드 산출물
|
||||
#
|
||||
# ⚠️ 패키지 이름은 livesub 다. 예전 이름(hearo)이 그대로 남아 있어서 빌드한
|
||||
# livesub_capture.exe 가 무시 대상에서 빠져 있었다.
|
||||
native/process_loopback/build/
|
||||
src/hearo/resources/bin/*.exe
|
||||
src/hearo/resources/bin/*.pdb
|
||||
src/livesub/resources/bin/*.exe
|
||||
src/livesub/resources/bin/*.pdb
|
||||
|
||||
# 모델 캐시 / 학습 산출물 / 사용자 데이터
|
||||
#
|
||||
|
||||
31
README.md
31
README.md
@@ -25,12 +25,14 @@
|
||||
## 주요 기능
|
||||
|
||||
- **프로그램 단위 소리 캡처** — 게임 소리만 받고 디스코드 음성은 안 받습니다
|
||||
- **마이크 입력도 가능** — 홈 화면에서 목록을 입력 장치로 바꾸면 통화 상대나 내 목소리를 번역합니다
|
||||
- **게임과 같이 써도 안 무겁게** — GPU를 얼마나 양보할지 직접 정합니다 (아래 참고)
|
||||
- **디스코드식 자막 배치** — 모니터를 고르고 9칸 중 하나를 누르면 그 자리에 붙습니다
|
||||
- **평소엔 잠겨 있는 자막** — 게임 중에 실수로 끌려가지 않습니다. `자막 위치 조정`을 켜면 옮기고 모서리로 크기를 바꿀 수 있고, 크기에 맞춰 글자도 같이 커집니다
|
||||
- **전역 단축키** — 게임 중에 `Ctrl+Alt+S` 로 자막을 껐다 켭니다
|
||||
- **게임별 기본 용어집 내장** — 옵치2·워독스·배그·R6·롤·발로란트·에이펙스 등 **540개** (필수 게임은 기본 켜짐)
|
||||
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지
|
||||
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수
|
||||
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지. GPU가 없으면 CPU에서 실제로 돌아가는 티어를 추천합니다
|
||||
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수·배경 여백 (배경은 글자를 감싸는 크기로 그려집니다)
|
||||
- **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다
|
||||
- **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다
|
||||
- **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고)
|
||||
@@ -113,8 +115,8 @@ Enemy coming from the left 존댓말 → 적이 왼쪽에서 옵니다
|
||||
powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1
|
||||
```
|
||||
|
||||
`dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고 첫 실행 때
|
||||
`%APPDATA%\LiveSub\models`로 내려받습니다.
|
||||
`dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고, **첫 실행 때
|
||||
고른 폴더**로 내려받습니다.
|
||||
|
||||
---
|
||||
|
||||
@@ -169,6 +171,25 @@ livesub
|
||||
|
||||
## 처음 쓸 때
|
||||
|
||||
### 먼저 저장 위치를 고릅니다
|
||||
|
||||
모델이 티어에 따라 2~17GB를 차지합니다. C 드라이브가 좁으면 곤란하니
|
||||
**처음 켜면 어디에 저장할지 물어봅니다.** 고른 폴더에 모델·설정·번역 기록이
|
||||
전부 들어갑니다.
|
||||
|
||||

|
||||
|
||||
한 번 고르면 기억하므로 다음부터는 바로 뜹니다. Windows 표준 폴더
|
||||
(`%APPDATA%\LiveSub`)가 기본 제안이고 그대로 두셔도 됩니다 — 거기에는
|
||||
"어느 폴더를 쓰는지" 적은 쪽지 한 장만 남습니다.
|
||||
|
||||
나중에 바꾸려면 `설정 > 저장 위치 > 위치 변경`입니다. 받아둔 모델을 새 위치로
|
||||
**옮길지 물어보므로** 다시 받을 필요는 없습니다.
|
||||
|
||||

|
||||
|
||||
### 그다음
|
||||
|
||||
1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다
|
||||
2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다
|
||||
3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다)
|
||||
@@ -179,7 +200,7 @@ livesub
|
||||
VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다.
|
||||
|
||||
모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름).
|
||||
저장 위치는 `%APPDATA%\LiveSub\models` 입니다.
|
||||
저장 위치는 처음 켤 때 고른 그 폴더입니다.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ gg,잘 싸웠다,gg,gg,打得好,
|
||||
1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다.
|
||||
2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬.
|
||||
3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면
|
||||
`%APPDATA%/LiveSub/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
|
||||
저장 위치 폴더의 `transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
|
||||
**틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다.
|
||||
4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로.
|
||||
|
||||
|
||||
@@ -175,11 +175,91 @@ scripts/win-ci/build.sh # ISO 받기 + 무인설치 ISO 굽기 + 디스
|
||||
| 방향 | 통로 | 왜 |
|
||||
|---|---|---|
|
||||
| 호스트 → 게스트 | HTTP `10.0.2.2:8099` (QEMU user networking) | 스크립트·러너 토큰 전달 |
|
||||
| 게스트 → 호스트 | COM1 → `serial.log`, HTTP POST → `guest.log` | 네트워크가 **없는** 드라이버 설치 단계도 봐야 하므로 시리얼이 필수 |
|
||||
| 게스트 → 호스트 | COM1 → `serial.log`, HTTP POST `/log` → `guest.log` | 네트워크가 **없는** 드라이버 설치 단계도 봐야 하므로 시리얼이 필수 |
|
||||
| 게스트 → 호스트 | HTTP POST `/artifact/<이름>` → `results/<이름>.log` | 검증 출력 **전문**. 바로 아래 참고 |
|
||||
|
||||
##### 검증 결과는 `results/` 에 파일로 남습니다 — 그게 기준선입니다
|
||||
|
||||
`stage2.ps1` 의 `Step` 이 각 단계 출력을 게스트 `C:\win-ci\out\` 에 한 줄씩
|
||||
기록하면서 호스트 `/home/claude/win-ci/results/` 로 올립니다. 판정 요약은
|
||||
`results/summary.log` 이고 **검증한 리비전이 박혀** 있습니다.
|
||||
|
||||
처음에는 로그 채널로 마지막 몇 줄만 흘렸는데, `serial.log` 는 VM 이 뜰 때마다
|
||||
truncate 되고 `guest.log` 는 `stage2` 가 다시 돌 때만 채워집니다. 그래서 pytest
|
||||
실패 트레이스백이 호스트에 한 줄도 남지 않아, 화면을 읽고 옮겨 적는 수밖에
|
||||
없었습니다. 고친 뒤에 고쳐졌다고 말할 근거가 없는 상태였습니다.
|
||||
|
||||
재검증은 리비전으로 요청합니다.
|
||||
|
||||
```bash
|
||||
cd scripts/win-ci && ./sync.sh <sha> # stage2 의 $TargetRev 를 그 커밋으로 박아 배포
|
||||
tail -f /home/claude/win-ci/guest.log # 약 10분, 끝나면 results/summary.log 확인
|
||||
```
|
||||
|
||||
게스트 agent 는 `stage2.ps1` **내용 해시가 바뀔 때만** 다시 돕니다. 커밋만
|
||||
푸시하면 재검증이 걸리지 않으니 sha 를 넘기세요. `build.sh` 를 다시 돌리면
|
||||
안 됩니다 — 지워둔 Windows ISO 4.8GB 를 다시 내려받습니다.
|
||||
|
||||
배포할 포터블 exe 도 같은 통로로 돌아옵니다. 빌드와 기동이 **둘 다** 통과했을
|
||||
때만 `results/LiveSub.exe` 로 올라오므로, 거기 파일이 있다는 것 자체가
|
||||
"검증을 통과한 산출물" 이라는 뜻입니다. 그대로 릴리스에 올리면 됩니다.
|
||||
|
||||
##### exe 빌드 단계가 거짓 PASS 를 내던 함정
|
||||
|
||||
앞 회차의 `exe기동` 점검이 띄운 `LiveSub.exe` 가 파일을 잡고 있으면 PyInstaller
|
||||
가 `PermissionError` 로 죽습니다. 그런데 **옛 exe 는 그대로 남아 있어서**
|
||||
`Test-Path dist\LiveSub.exe` 검사는 통과했고, 결국 빌드가 실패한 회차가 PASS 로
|
||||
보고됐습니다. 지금은 빌드 전에 프로세스를 죽이고 `build`·`dist` 를 지운 뒤
|
||||
PyInstaller 종료코드까지 확인합니다. **산출물의 존재를 성공의 근거로 쓰면
|
||||
안 됩니다** — 이전 성공이 남긴 것일 수 있습니다.
|
||||
|
||||
##### MSVC 가 왜 VM 에 깔려 있나
|
||||
|
||||
`native\process_loopback` 의 `livesub_capture.exe` 는 WASAPI 프로세스 루프백을
|
||||
씁니다. 그 헤더(`audioclientactivationparams.h`)와 WRL 은 Windows SDK + MSVC
|
||||
에만 있어서 cmake 만으로는 못 만듭니다. 그래서 `stage2.ps1` 이 VS 2022 Build
|
||||
Tools(C++ 워크로드)를 설치합니다 — 첫 설치에 약 9분 걸리고 그 뒤로는 건너뜁니다.
|
||||
|
||||
이게 없으면 **포터블 빌드는 그대로 성공하되** 프로그램별 캡처가 출력 장치
|
||||
전체 캡처로 조용히 폴백됩니다. 기능이 빠진 exe 가 PASS 로 나오는 조합이라,
|
||||
검증 순서에서 `native_capture` 를 `windows_smoke` 와 `pyinstaller` **앞에**
|
||||
둡니다. 뒤에 두면 둘 다 "없음"을 정상으로 보고 지나갑니다.
|
||||
|
||||
실제로 이 경로는 MSVC 를 깔기 전까지 **한 번도 컴파일된 적이 없었고**, 깔자마자
|
||||
`main.cpp` 가 C2275 로 죽었습니다. CI 의 네이티브 빌드 단계가
|
||||
`continue-on-error` 였던 탓에 그때까지 드러나지 않았습니다.
|
||||
|
||||
접속 정보: 계정 `ci` / 비밀번호는 `~/.config/ejclaw/secrets.json` 의
|
||||
`win-ci.9` 항목. RDP 는 호스트 `127.0.0.1:13389`, VNC 는 `127.0.0.1:5919`.
|
||||
|
||||
재부팅해도 살아납니다. `~/.config/systemd/user/win-ci{,-http}.service` 로
|
||||
등록돼 있고 `linger=yes` 라 로그인 없이 `default.target` 이 끌어올립니다.
|
||||
`systemd-run` 트랜지언트로 띄우면 `/run` 에만 남아 재부팅에 사라지니,
|
||||
디버깅으로 한 번 띄웠더라도 마지막에는 반드시 아래로 되돌려 놓으세요.
|
||||
|
||||
```bash
|
||||
systemctl --user start default.target # 부팅과 같은 경로로 기동
|
||||
systemctl --user is-enabled win-ci.service # enabled 여야 한다. transient 면 안 된다
|
||||
systemctl --user show win-ci.service -p FragmentPath # ~/.config/... 를 가리켜야 한다
|
||||
```
|
||||
|
||||
##### 설치가 끝난 뒤 무엇을 지우나 — 기준
|
||||
|
||||
지우는 기준은 "VM 이 도는 데 계속 필요한가" 하나입니다.
|
||||
|
||||
| 파일 | 설치 후 | 왜 |
|
||||
|---|---|---|
|
||||
| `iso/win11-ltsc.iso` (4.8GB) | **지운다** | 설치 미디어. 다시 깔 때만 필요하고 `build.sh` 가 받아온다 |
|
||||
| `iso/virtio-win.iso` (837MB) | 남긴다 | VM 에 계속 물려 있다. 드라이버 재설치·재프로비저닝이 여기서 읽는다 |
|
||||
| `unattend.iso` (0.4MB) | 남긴다 | VM 에 계속 물려 있다 |
|
||||
| `win-ci.qcow2` (14GB) | 남긴다 | VM 그 자체 |
|
||||
|
||||
ISO 를 지우면 `-drive` 가 없는 파일을 가리켜 **qemu 가 기동 자체에 실패**합니다.
|
||||
재부팅 후 러너 호스트가 조용히 사라지므로, `run-vm.sh` 는 cdrom 을 있을 때만
|
||||
붙입니다. 그리고 같은 ISO 를 버전별로 여러 개 남겨두지 마세요. 어느 것이
|
||||
물려 있는지 헷갈리면 고친 줄 알았던 스크립트가 반영되지 않습니다. 현재
|
||||
물려 있는 것은 `query-block` 으로 확인합니다.
|
||||
|
||||
##### 무인 설치에서 실제로 걸린 것들
|
||||
|
||||
- **`Press any key to boot from CD`** — UEFI 는 이걸 5초 안에 안 누르면 CD 를
|
||||
|
||||
BIN
docs/images/change-location.png
Normal file
BIN
docs/images/change-location.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
docs/images/first-run.png
Normal file
BIN
docs/images/first-run.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -49,8 +49,13 @@ BOOL WINAPI ConsoleHandler(DWORD type) {
|
||||
}
|
||||
|
||||
// ActivateAudioInterfaceAsync 완료 콜백. 결과를 이벤트로 넘겨준다.
|
||||
//
|
||||
// FtmBase 는 플래그가 아니라 섞어 넣는 클래스다. RuntimeClassFlags 안에
|
||||
// `ClassicCom | FtmBase` 로 넣으면 MSVC 가 C2275 로 거부한다(타입을 값 자리에
|
||||
// 썼으므로). 인터페이스 목록 쪽에 나열해야 한다 — ApplicationLoopback 샘플과
|
||||
// 같은 형태다.
|
||||
class ActivationHandler
|
||||
: public RuntimeClass<RuntimeClassFlags<ClassicCom | FtmBase>,
|
||||
: public RuntimeClass<RuntimeClassFlags<ClassicCom>, FtmBase,
|
||||
IActivateAudioInterfaceCompletionHandler> {
|
||||
public:
|
||||
explicit ActivationHandler(HANDLE done) : done_(done) {}
|
||||
|
||||
@@ -72,7 +72,8 @@ a = Analysis( # noqa: F821
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
# contrib 훅이 빌드를 중단시키는 것을 덮어쓴다 (hook-webrtcvad.py 주석 참고).
|
||||
hookspath=[str(ROOT / "packaging" / "pyinstaller-hooks")],
|
||||
runtime_hooks=[],
|
||||
excludes=excludes,
|
||||
noarchive=False,
|
||||
|
||||
25
packaging/pyinstaller-hooks/hook-webrtcvad.py
Normal file
25
packaging/pyinstaller-hooks/hook-webrtcvad.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""webrtcvad 훅 재정의 — contrib 훅이 배포 이름을 찾지 못해 빌드를 중단시킨다.
|
||||
|
||||
우리가 설치하는 건 `webrtcvad-wheels` 다. 원본 `webrtcvad` 는 Windows 용 휠을
|
||||
올리지 않아서 MSVC 컴파일이 필요하기 때문이다. import 이름(`webrtcvad`)은 같지만
|
||||
**배포 이름이 다르다.**
|
||||
|
||||
pyinstaller-hooks-contrib 의 `hook-webrtcvad.py` 는 `copy_metadata('webrtcvad')` 를
|
||||
부른다. 배포 이름이 없으니 `PackageNotFoundError` 가 나고, PyInstaller 는 훅 모듈
|
||||
import 실패를 `ImportErrorWhenRunningHook` 으로 올려 **빌드를 통째로 중단한다.**
|
||||
Windows 포터블 빌드가 정확히 여기서 죽어 dist\\LiveSub.exe 가 안 나왔다.
|
||||
|
||||
hookspath 의 훅이 contrib 훅보다 먼저 검색되므로 같은 파일명으로 덮어쓴다.
|
||||
메타데이터를 실제 배포 이름으로 수집하고, 둘 다 없으면 그냥 넘어간다 —
|
||||
webrtcvad 런타임은 importlib.metadata 를 읽지 않으므로 없어도 동작한다.
|
||||
"""
|
||||
|
||||
from PyInstaller.utils.hooks import copy_metadata
|
||||
|
||||
datas = []
|
||||
for dist in ("webrtcvad-wheels", "webrtcvad"):
|
||||
try:
|
||||
datas = copy_metadata(dist)
|
||||
break
|
||||
except Exception: # noqa: BLE001 - 배포 이름이 없을 때만 다음 후보로 넘어간다
|
||||
continue
|
||||
@@ -14,9 +14,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -159,6 +163,39 @@ def run_source_checks() -> None:
|
||||
win.close()
|
||||
return "스크린샷 2장 저장 (artifacts/)"
|
||||
|
||||
@check("저장 위치 선택 화면")
|
||||
def _data_location():
|
||||
"""첫 실행 때 뜨는 위치 선택 화면이 실제 Windows 에서 제대로 그려지는지.
|
||||
|
||||
경로 표기(`C:\\...`)와 디스크 여유 공간 조회는 OS 마다 다르게 도는
|
||||
부분이라 리눅스에서 통과해도 여기서 깨질 수 있다.
|
||||
"""
|
||||
from livesub.storage import data_dir, default_data_dir, free_space, human_size
|
||||
from livesub.ui.data_location import DataLocationDialog
|
||||
from livesub.ui.theme import stylesheet
|
||||
|
||||
app = _qt_app()
|
||||
ARTIFACTS.mkdir(exist_ok=True)
|
||||
shots = 0
|
||||
for name, start, first in (
|
||||
("windows-first-run.png", default_data_dir(), True),
|
||||
("windows-change-location.png", data_dir(), False),
|
||||
):
|
||||
dialog = DataLocationDialog(start, first_run=first)
|
||||
try:
|
||||
dialog.setStyleSheet(stylesheet("dark"))
|
||||
dialog.resize(560, dialog.sizeHint().height())
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
if not dialog.ok_button.isEnabled():
|
||||
raise RuntimeError(f"{start} 를 못 쓴다고 판정됨: {dialog.status.text()}")
|
||||
dialog.grab().save(str(ARTIFACTS / name))
|
||||
shots += 1
|
||||
finally:
|
||||
dialog.close()
|
||||
dialog.deleteLater()
|
||||
return f"{default_data_dir()} · 여유 {human_size(free_space(default_data_dir()))} · 스크린샷 {shots}장"
|
||||
|
||||
|
||||
# --- 전 파이프라인 점검 -----------------------------------------------------
|
||||
|
||||
@@ -360,6 +397,135 @@ def run_gpu_checks() -> None:
|
||||
# --- 빌드된 exe 점검 --------------------------------------------------------
|
||||
|
||||
|
||||
#: PyInstaller 가 진입 스크립트에서 예외가 났을 때 띄우는 창.
|
||||
_CRASH_MARKERS = ("unhandled exception", "failed to execute script")
|
||||
|
||||
|
||||
def _process_tree(pid: int) -> set[int]:
|
||||
"""onefile exe 는 압축을 푼 뒤 자식 프로세스로 진짜 앱을 띄운다.
|
||||
|
||||
그래서 창의 주인은 우리가 Popen 한 pid 가 아니라 그 자식이다. 부모만 보면
|
||||
창을 영영 못 찾는다.
|
||||
"""
|
||||
pids = {pid}
|
||||
try:
|
||||
import psutil
|
||||
|
||||
parent = psutil.Process(pid)
|
||||
pids.update(child.pid for child in parent.children(recursive=True))
|
||||
except Exception: # noqa: BLE001 - psutil 이 없어도 부모만으로 계속한다
|
||||
pass
|
||||
return pids
|
||||
|
||||
|
||||
def _visible_windows(pids: set[int]) -> list[str]:
|
||||
"""그 프로세스들이 띄운 보이는 최상위 창의 제목."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
titles: list[str] = []
|
||||
|
||||
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
def _collect(hwnd, _lparam):
|
||||
owner = wintypes.DWORD()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner))
|
||||
if owner.value in pids and user32.IsWindowVisible(hwnd):
|
||||
length = user32.GetWindowTextLengthW(hwnd)
|
||||
buf = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||
if buf.value:
|
||||
titles.append(buf.value)
|
||||
return True
|
||||
|
||||
user32.EnumWindows(_collect, 0)
|
||||
return titles
|
||||
|
||||
|
||||
def _terminate_tree(proc: subprocess.Popen) -> None:
|
||||
"""부모만 죽이면 안 된다.
|
||||
|
||||
onefile 의 부모는 압축을 푸는 부트로더일 뿐이고, 창과 로그 파일을 쥐고 있는
|
||||
것은 자식이다. 부모만 terminate 하면 자식이 살아남아 로그 파일을 계속
|
||||
잡고 있고, 임시 폴더를 지울 수 없어 점검이 엉뚱한 곳에서 실패한다.
|
||||
"""
|
||||
children = []
|
||||
try:
|
||||
import psutil
|
||||
|
||||
children = psutil.Process(proc.pid).children(recursive=True)
|
||||
except Exception: # noqa: BLE001 - psutil 이 없으면 부모만이라도 정리한다
|
||||
pass
|
||||
|
||||
for child in children:
|
||||
with contextlib.suppress(Exception):
|
||||
child.terminate()
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
for child in children:
|
||||
with contextlib.suppress(Exception):
|
||||
child.wait(timeout=10)
|
||||
with contextlib.suppress(Exception):
|
||||
if child.is_running():
|
||||
child.kill()
|
||||
|
||||
|
||||
def _launch_and_wait_for_window(
|
||||
exe: Path, appdata: Path, require: str, timeout_s: int = 90
|
||||
) -> str:
|
||||
"""exe 를 띄우고 **기다리던 창이 뜰 때까지** 기다린다.
|
||||
|
||||
예전에는 "8초 뒤에도 프로세스가 살아 있으면 통과" 였다. 그런데 진입
|
||||
스크립트가 터지면 PyInstaller 가 오류창을 띄우고 그대로 서 있는다 —
|
||||
프로세스는 멀쩡히 살아 있으므로 통과로 보고됐다. 실제로 그 상태의 exe 가
|
||||
릴리스까지 나갔다. 살아 있다는 것은 떴다는 뜻이 아니다.
|
||||
|
||||
"아무 창이나 뜨면 통과" 도 부족하다. 자막 오버레이는 제목이 앱 이름
|
||||
그대로라, 본 화면이 안 떠도 그게 잡혀 통과해 버린다. 그래서 어떤 창을
|
||||
기다리는지(`require`)를 호출하는 쪽이 지정한다.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
env["APPDATA"] = str(appdata)
|
||||
proc = subprocess.Popen(
|
||||
[str(exe)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:]
|
||||
raise RuntimeError(f"창이 뜨기 전에 종료 (코드 {proc.returncode}): {err}")
|
||||
|
||||
titles = _visible_windows(_process_tree(proc.pid))
|
||||
for title in titles:
|
||||
if any(marker in title.lower() for marker in _CRASH_MARKERS):
|
||||
raise RuntimeError(f"오류창이 떴습니다: {title!r}")
|
||||
if any(require in title for title in titles):
|
||||
return " / ".join(titles)
|
||||
time.sleep(1)
|
||||
raise RuntimeError(f"{timeout_s}초 안에 {require!r} 창이 뜨지 않았습니다")
|
||||
finally:
|
||||
_terminate_tree(proc)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _scratch_appdata():
|
||||
"""점검용 %APPDATA%.
|
||||
|
||||
치울 때 실패해도 점검을 실패시키지 않는다. Windows 는 방금 죽인 프로세스가
|
||||
파일 핸들을 놓는 데 잠깐 시간이 걸려서, 임시 폴더 삭제가 PermissionError 로
|
||||
튀는 일이 있다. 그건 exe 의 문제가 아니라 치우는 쪽 사정이다.
|
||||
"""
|
||||
tmp = Path(tempfile.mkdtemp(prefix="livesub-smoke-"))
|
||||
try:
|
||||
yield tmp
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def run_exe_check(exe: Path) -> None:
|
||||
print(f"\n=== 빌드된 exe 점검: {exe} ===")
|
||||
|
||||
@@ -372,21 +538,37 @@ def run_exe_check(exe: Path) -> None:
|
||||
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
|
||||
return f"{mb:.1f} MB"
|
||||
|
||||
@check("exe 실행 후 8초 생존")
|
||||
def _launch():
|
||||
# 창을 띄우고 잠깐 살아 있는지 본다. 바로 죽으면 의존성 누락이다.
|
||||
proc = subprocess.Popen([str(exe)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
time.sleep(8)
|
||||
alive = proc.poll() is None
|
||||
if not alive:
|
||||
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:]
|
||||
raise RuntimeError(f"즉시 종료 (코드 {proc.returncode}): {err}")
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
return "정상 기동"
|
||||
@check("exe 첫 실행 — 저장 위치 화면")
|
||||
def _first_run():
|
||||
# APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다.
|
||||
with _scratch_appdata() as tmp:
|
||||
titles = _launch_and_wait_for_window(exe, appdata=tmp, require="저장 위치")
|
||||
return f"창 {titles!r}"
|
||||
|
||||
@check("exe 두 번째 실행 — 본 화면")
|
||||
def _second_run():
|
||||
# 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다.
|
||||
with _scratch_appdata() as tmp:
|
||||
data = tmp / "data"
|
||||
data.mkdir()
|
||||
pointer = tmp / "LiveSub"
|
||||
pointer.mkdir()
|
||||
(pointer / "location.json").write_text(
|
||||
json.dumps({"data_dir": str(data)}), encoding="utf-8"
|
||||
)
|
||||
# 모델 미리 올리기를 꺼둔다. 켜두면 창을 확인하러 온 점검이
|
||||
# 수 GB 짜리 모델 내려받기를 시작했다가 중간에 죽인다.
|
||||
(data / "config.json").write_text(
|
||||
json.dumps({"models": {"preload_on_start": False}}), encoding="utf-8"
|
||||
)
|
||||
# 자막 오버레이는 제목이 앱 이름뿐이라 그것만으로는 본 화면이 떴다는
|
||||
# 증거가 못 된다. 본 화면 제목에만 있는 표어를 기다린다.
|
||||
from livesub.constants import APP_SLOGAN
|
||||
|
||||
titles = _launch_and_wait_for_window(exe, appdata=tmp, require=APP_SLOGAN)
|
||||
if any("저장 위치" in t for t in titles.split(" / ")):
|
||||
raise RuntimeError(f"위치를 이미 골랐는데 또 물어봤습니다: {titles!r}")
|
||||
return f"창 {titles!r}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -43,7 +43,10 @@ finetune = [
|
||||
"accelerate>=0.33",
|
||||
"bitsandbytes>=0.43; platform_system != 'Darwin'",
|
||||
]
|
||||
dev = ["pytest>=8.0", "ruff>=0.6"]
|
||||
# webrtcvad 는 포터블 exe 의존성이라 CI 환경에는 항상 있다. 개발 venv 에만 없으면
|
||||
# `_is_speech` 의 webrtcvad 경로가 로컬에서 통째로 안 돌고, 그 결과 같은 테스트가
|
||||
# 로컬에서는 통과하고 Windows CI 에서만 깨진다 (실제로 그랬다). 환경을 맞춘다.
|
||||
dev = ["pytest>=8.0", "ruff>=0.6", "webrtcvad-wheels>=2.0.14"]
|
||||
|
||||
[project.scripts]
|
||||
livesub = "livesub.app:main"
|
||||
|
||||
@@ -18,6 +18,24 @@ fi
|
||||
|
||||
rm -f "$D/qmp.sock"
|
||||
|
||||
# 설치가 끝나면 Windows ISO(4.8GB)는 필요 없어서 지운다. 그래도 유닛이 뜨도록
|
||||
# 없는 ISO 는 그냥 건너뛴다. 없는 파일을 -drive 로 주면 qemu 가 기동에 실패해
|
||||
# 재부팅 후 러너 호스트가 조용히 사라진다.
|
||||
CDROMS=()
|
||||
add_cd() { # 파일 버스 [bootindex]
|
||||
[ -f "$1" ] || { echo "cdrom 건너뜀 (없음): $1" >&2; return; }
|
||||
local id="cd$(basename "$1" | tr -cd 'a-z0-9')"
|
||||
CDROMS+=( -drive "file=$1,if=none,id=$id,media=cdrom,readonly=on" )
|
||||
if [ -n "${3:-}" ]; then
|
||||
CDROMS+=( -device "ide-cd,drive=$id,bus=$2,bootindex=$3" )
|
||||
else
|
||||
CDROMS+=( -device "ide-cd,drive=$id,bus=$2" )
|
||||
fi
|
||||
}
|
||||
add_cd "$D/iso/win11-ltsc.iso" ide.1 1
|
||||
add_cd "$D/iso/virtio-win.iso" ide.2
|
||||
add_cd "$D/unattend.iso" ide.3
|
||||
|
||||
exec qemu-system-x86_64 \
|
||||
-name win-ci,process=win-ci \
|
||||
-machine q35,smm=on,vmport=off \
|
||||
@@ -43,12 +61,7 @@ exec qemu-system-x86_64 \
|
||||
-device usb-kbd -device usb-tablet \
|
||||
-drive "file=$D/win-ci.qcow2,if=none,id=sysdisk,format=qcow2,cache=writeback,discard=unmap,aio=threads" \
|
||||
-device ide-hd,drive=sysdisk,bus=ide.0,bootindex=2 \
|
||||
-drive "file=$D/iso/win11-ltsc.iso,if=none,id=cdwin,media=cdrom,readonly=on" \
|
||||
-device ide-cd,drive=cdwin,bus=ide.1,bootindex=1 \
|
||||
-drive "file=$D/iso/virtio-win.iso,if=none,id=cdvirtio,media=cdrom,readonly=on" \
|
||||
-device ide-cd,drive=cdvirtio,bus=ide.2 \
|
||||
-drive "file=$D/unattend.iso,if=none,id=cdunattend,media=cdrom,readonly=on" \
|
||||
-device ide-cd,drive=cdunattend,bus=ide.3 \
|
||||
"${CDROMS[@]}" \
|
||||
-serial "file:$D/serial.log" \
|
||||
-qmp "unix:$D/qmp.sock,server=on,wait=off" \
|
||||
-vnc 127.0.0.1:19 \
|
||||
|
||||
@@ -5,22 +5,34 @@
|
||||
이 서버는 그 반대편이다.
|
||||
|
||||
GET /ping 게스트가 네트워크 올라왔는지 확인용
|
||||
GET /stage20.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로)
|
||||
GET /stage2.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로)
|
||||
GET /<file> serve/ 안의 아무 파일 (러너 등록 토큰 등)
|
||||
POST /log 게스트 로그 -> guest.log 에 append
|
||||
POST /artifact/<name> 게스트 명령 출력 원본 -> results/<name> 에 파일로 저장
|
||||
|
||||
/artifact 가 필요한 이유: 게스트 검증 결과를 사람이 화면에서 읽고 옮겨 적으면
|
||||
"고쳤다"를 증명할 기준선이 안 남는다. serial.log 는 VM 이 뜰 때마다 truncate 되고
|
||||
로그 채널은 마지막 몇 줄만 흘린다. pytest 트레이스백처럼 전문이 필요한 출력은
|
||||
반드시 이 엔드포인트로 호스트 파일에 떨어뜨린다.
|
||||
|
||||
127.0.0.1 만 바인딩한다. slirp 의 10.0.2.2 는 호스트 루프백으로 들어오므로
|
||||
외부에 열 필요가 없다.
|
||||
"""
|
||||
import http.server
|
||||
import os
|
||||
import posixpath
|
||||
import socketserver
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
ROOT = "/home/claude/win-ci/serve"
|
||||
LOG = "/home/claude/win-ci/guest.log"
|
||||
RESULTS = "/home/claude/win-ci/results"
|
||||
PORT = 8099
|
||||
# 로그 전문뿐 아니라 빌드된 포터블 exe(100MB+)도 이 통로로 회수한다.
|
||||
# 32MB 로 두면 배포할 exe 가 413 으로 튕긴다.
|
||||
MAX_ARTIFACT = 1024 * 1024 * 1024
|
||||
CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
@@ -47,9 +59,15 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path.rstrip("/") != "/log":
|
||||
path = self.path.rstrip("/")
|
||||
if path == "/log":
|
||||
self._recv_log()
|
||||
elif path.startswith("/artifact/"):
|
||||
self._recv_artifact(path[len("/artifact/") :])
|
||||
else:
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
def _recv_log(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
data = self.rfile.read(n).decode("utf-8", "replace").strip()
|
||||
with open(LOG, "a", encoding="utf-8") as f:
|
||||
@@ -57,9 +75,44 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
def _recv_artifact(self, name):
|
||||
# 게스트가 주는 이름을 그대로 경로에 쓰면 안 된다. 파일명만 취한다.
|
||||
name = posixpath.basename(name.replace("\\", "/"))
|
||||
if not name or name.startswith("."):
|
||||
self.send_error(400, "bad artifact name")
|
||||
return
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
if n > MAX_ARTIFACT:
|
||||
self.send_error(413, "artifact too large")
|
||||
return
|
||||
os.makedirs(RESULTS, exist_ok=True)
|
||||
# 통째로 read() 하지 않는다. exe 는 100MB 가 넘어서 메모리에 다 올리면
|
||||
# 봇 cgroup 의 메모리 상한을 건드린다. 조각으로 받아 바로 디스크에 쓴다.
|
||||
#
|
||||
# 그리고 임시 이름으로 받아 끝난 뒤에 옮긴다. 전송 중에 끊기면 반쪽짜리
|
||||
# 파일이 정상 결과물처럼 남아 그대로 릴리스에 올라갈 수 있다.
|
||||
final = os.path.join(RESULTS, name)
|
||||
tmp = final + ".part"
|
||||
left = n
|
||||
with open(tmp, "wb") as f:
|
||||
while left > 0:
|
||||
block = self.rfile.read(min(CHUNK, left))
|
||||
if not block:
|
||||
break
|
||||
f.write(block)
|
||||
left -= len(block)
|
||||
if left:
|
||||
os.remove(tmp)
|
||||
self.send_error(400, "artifact truncated")
|
||||
return
|
||||
os.replace(tmp, final)
|
||||
print(f"artifact {name} {n} bytes", flush=True)
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
# 접근 로그는 stdout 으로만. journald 가 받는다.
|
||||
sys.stdout.write("%s %s\n" % (self.address_string(), fmt % args))
|
||||
sys.stdout.write(f"{self.address_string()} {fmt % args}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
@@ -70,6 +123,7 @@ class Server(socketserver.ThreadingTCPServer):
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.makedirs(ROOT, exist_ok=True)
|
||||
os.makedirs(RESULTS, exist_ok=True)
|
||||
with Server(("127.0.0.1", PORT), Handler) as httpd:
|
||||
print(f"win-ci control server on 127.0.0.1:{PORT}, root={ROOT}", flush=True)
|
||||
httpd.serve_forever()
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
# 자식 프로세스 stdout 을 UTF-8 로 해독한다.
|
||||
#
|
||||
# 이게 없으면 PowerShell 이 자식의 출력을 콘솔 코드페이지(LTSC en-US = 437)로
|
||||
# 읽는다. python 쪽은 PYTHONUTF8=1 로 UTF-8 을 내보내므로 한글이 전부 깨져서
|
||||
# 호스트로 회수한 증거 파일의 실패 메시지를 읽을 수 없다. 여기서 바꾸는 건
|
||||
# '해독' 쪽이라 파일 저장 인코딩(StreamWriter 는 UTF-8 고정)과는 무관하다.
|
||||
try { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch { }
|
||||
|
||||
$Dl = 'C:\win-ci\dl'
|
||||
New-Item -ItemType Directory -Force -Path $Dl | Out-Null
|
||||
|
||||
@@ -105,6 +113,41 @@ if (Have 'cmake') {
|
||||
else { Log "WARN cmake 설치 실패 - 워크플로의 native 빌드 단계는 건너뛰어진다" }
|
||||
}
|
||||
|
||||
# ------------------------------------------------------ VS 2022 Build Tools (C++)
|
||||
# livesub_capture.exe (native\process_loopback) 는 WASAPI 프로세스 루프백을 쓴다.
|
||||
# 그 헤더(audioclientactivationparams.h)와 WRL 은 Windows SDK + MSVC 에만 있다.
|
||||
# cmake 만 깔면 "generator 는 있는데 컴파일러가 없다"로 configure 에서 죽는다.
|
||||
#
|
||||
# 왜 빠뜨리면 안 되나: 이 exe 가 없으면 포터블 빌드가 그대로 성공하되
|
||||
# 프로그램별 캡처가 장치 전체 캡처로 조용히 폴백된다. 즉 "게임 소리만 받는다"는
|
||||
# 핵심 기능이 빠진 채로 exe 가 나온다. 실제로 그 상태의 exe 가 한 번 나왔다.
|
||||
$VsWhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
|
||||
function Get-MsvcRoot {
|
||||
if (-not (Test-Path $VsWhere)) { return $null }
|
||||
$p = & $VsWhere -latest -products * `
|
||||
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
|
||||
-property installationPath 2>$null
|
||||
if ($p) { return ($p | Select-Object -First 1) }
|
||||
return $null
|
||||
}
|
||||
$msvc = Get-MsvcRoot
|
||||
if ($msvc) {
|
||||
Log "MSVC 이미 있음: $msvc"
|
||||
} else {
|
||||
$f = Get-File 'https://aka.ms/vs/17/release/vs_BuildTools.exe' 'vs_BuildTools.exe'
|
||||
Log "VS 2022 Build Tools 설치 중 (C++ 워크로드, 수 GB - 20분 이상 걸린다)"
|
||||
# --wait 없이 Start-Process -Wait 만 쓰면 부트스트래퍼가 먼저 반환해 버려
|
||||
# 설치가 끝나기 전에 다음 단계로 넘어간다. 둘 다 있어야 한다.
|
||||
$p = Start-Process $f -Wait -PassThru -ArgumentList `
|
||||
'--quiet', '--wait', '--norestart', '--nocache', `
|
||||
'--add', 'Microsoft.VisualStudio.Workload.VCTools', '--includeRecommended'
|
||||
# 3010 = 설치는 됐고 재부팅만 권고. 실패가 아니다.
|
||||
Log "Build Tools 설치기 종료코드 $($p.ExitCode)"
|
||||
$msvc = Get-MsvcRoot
|
||||
if ($msvc) { Log "MSVC 설치 완료: $msvc" }
|
||||
else { Log "WARN MSVC 설치 실패 - livesub_capture.exe 를 빌드할 수 없다" }
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ act_runner
|
||||
$RunnerDir = 'C:\gitea-runner'
|
||||
$RunnerExe = Join-Path $RunnerDir 'act_runner.exe'
|
||||
@@ -211,28 +254,80 @@ Log "=== stage2 완료 ==="
|
||||
# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다.
|
||||
# ==================================================================
|
||||
$Repo = 'C:\win-ci\live-app-translator'
|
||||
function Step($name, $block) {
|
||||
Log "--- [$name] 시작"
|
||||
$OutDir = 'C:\win-ci\out'
|
||||
|
||||
# 검증 대상 리비전. 'main' 이면 origin/main 최신.
|
||||
# 호스트에서 `./sync.sh <sha>` 를 쓰면 이 한 줄이 그 sha 로 바뀐 사본이 배포되고,
|
||||
# 내용이 바뀌므로 게스트 agent 가 해시 차이를 보고 20초 안에 다시 돈다.
|
||||
# = "이 커밋을 검증해라" 라는 요청이 곧 재실행 트리거다.
|
||||
$TargetRev = 'main'
|
||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
|
||||
# 출력 원본을 호스트 파일로 회수한다.
|
||||
#
|
||||
# 왜 필요한가: 로그 채널로는 마지막 몇 줄만 흘렸고 serial.log 는 VM 이 뜰 때마다
|
||||
# truncate 된다. 그래서 pytest 실패 트레이스백이 호스트에 한 줄도 안 남았고
|
||||
# 화면에서 읽어 옮겨 적는 상태였다. 파일로 된 기준선이 없으면 고친 뒤에
|
||||
# 고쳐졌다는 증명을 할 수 없다.
|
||||
function Send-Artifact($name, $path) {
|
||||
if (-not (Test-Path $path)) { return }
|
||||
try {
|
||||
& $block
|
||||
Log "--- [$name] 통과"
|
||||
return $true
|
||||
$bytes = [IO.File]::ReadAllBytes($path)
|
||||
# 백틱 줄바꿈을 쓰지 않는다. LF 로 저장된 스크립트에서 토크나이저가
|
||||
# 어긋나 스크립트 전체가 무효가 된 전례가 있다 (NOTES.md 참고).
|
||||
# 타임아웃은 넉넉히. 로그는 수십 KB 지만 포터블 exe 는 100MB 를 넘고
|
||||
# slirp(QEMU user networking) 는 그만큼 빠르지 않다.
|
||||
$req = @{ Uri = "http://10.0.2.2:8099/artifact/$name"; Method = 'Post'; TimeoutSec = 1800; Body = $bytes; ContentType = 'application/octet-stream' }
|
||||
Invoke-RestMethod @req | Out-Null
|
||||
Log " -> 호스트로 $name ($($bytes.Length) bytes)"
|
||||
} catch {
|
||||
Log "--- [$name] 실패: $($_.Exception.Message)"
|
||||
return $false
|
||||
Log " -> $name 업로드 실패: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
function Step($name, $block) {
|
||||
$log = Join-Path $OutDir "$name.log"
|
||||
Remove-Item $log -Force -ErrorAction SilentlyContinue
|
||||
Log "--- [$name] 시작"
|
||||
|
||||
# StreamWriter + AutoFlush 로 한 줄씩 즉시 디스크에 쓴다. 중간에 예외로
|
||||
# 죽어도 거기까지의 출력이 남아야 한다 (Tee-Object 는 그 보장이 없다).
|
||||
$sw = [IO.StreamWriter]::new($log, $false, [Text.UTF8Encoding]::new($false))
|
||||
$sw.AutoFlush = $true
|
||||
$tail = [System.Collections.Generic.Queue[string]]::new()
|
||||
$ok = $true
|
||||
try {
|
||||
& $block 2>&1 | ForEach-Object {
|
||||
$line = "$_"
|
||||
$sw.WriteLine($line)
|
||||
$tail.Enqueue($line)
|
||||
if ($tail.Count -gt 25) { [void]$tail.Dequeue() }
|
||||
}
|
||||
} catch {
|
||||
$ok = $false
|
||||
$sw.WriteLine("[step 예외] $($_.Exception.Message)")
|
||||
}
|
||||
$sw.Close()
|
||||
|
||||
foreach ($l in $tail) { Log " $l" }
|
||||
if ($ok) { Log "--- [$name] 통과" } else { Log "--- [$name] 실패" }
|
||||
Send-Artifact "$name.log" $log
|
||||
return $ok
|
||||
}
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
Sync-Path
|
||||
if (Test-Path $Repo) {
|
||||
Log "저장소 갱신"
|
||||
Push-Location $Repo; & git fetch --all -q; & git reset --hard origin/main -q; Pop-Location
|
||||
} else {
|
||||
if (-not (Test-Path $Repo)) {
|
||||
Log "저장소 clone"
|
||||
& git clone -q https://git.tkrmagid.kr/tkrmagid/live-app-translator $Repo
|
||||
}
|
||||
$ref = if ($TargetRev -eq 'main') { 'origin/main' } else { $TargetRev }
|
||||
Log "저장소 갱신 -> $ref"
|
||||
Push-Location $Repo
|
||||
& git fetch --all -q
|
||||
& git reset --hard $ref -q
|
||||
Pop-Location
|
||||
|
||||
Push-Location $Repo
|
||||
$py = Join-Path $Repo '.venv-ci\Scripts\python.exe'
|
||||
@@ -247,15 +342,81 @@ try {
|
||||
Log "포터블 조건(torch 없음) = $($LASTEXITCODE -eq 0)"
|
||||
|
||||
$env:PYTHONPATH = 'src'
|
||||
$r = @{}
|
||||
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q 2>&1 | Select-Object -Last 8 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
||||
$r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts 2>&1 | Select-Object -Last 5 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
||||
$r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
||||
$r['exe빌드'] = Step 'pyinstaller' { & $py -m pip install pyinstaller --quiet; & $py -m PyInstaller packaging\livesub.spec --noconfirm 2>&1 | Select-Object -Last 4 | ForEach-Object { Log " $_" }; if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }; Log " exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB" }
|
||||
$r['exe기동'] = Step 'exe 실행' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
||||
# Windows 콘솔 기본 인코딩(cp1252)에서는 한글 print 가 UnicodeEncodeError 로
|
||||
# 죽는다. 검증 스크립트가 한글을 찍으므로 UTF-8 로 고정한다.
|
||||
$env:PYTHONIOENCODING = 'utf-8'
|
||||
$env:PYTHONUTF8 = '1'
|
||||
# 출력을 자르지 않는다. 전문은 Step 이 호스트 results/<name>.log 로 올린다.
|
||||
# 여기서 truncate 하면 실패 원인이 있는 트레이스백 본문이 날아간다.
|
||||
$r = [ordered]@{}
|
||||
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q -rf --tb=long; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
||||
$r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
||||
|
||||
Log "=== Windows 검증 결과 ==="
|
||||
foreach ($k in $r.Keys) { Log ("{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })) }
|
||||
# 캡처 도우미를 먼저 만든다. windows_smoke 가 이 exe 의 존재를 점검하고,
|
||||
# livesub.spec 도 있을 때만 포터블 exe 안에 담는다. 순서가 바뀌면 둘 다
|
||||
# "없음"으로 통과해서 기능이 빠진 exe 가 나온다.
|
||||
$r['캡처도우미'] = Step 'native_capture' {
|
||||
& cmake -S native\process_loopback -B native\process_loopback\build -A x64
|
||||
if ($LASTEXITCODE -ne 0) { throw "cmake configure exit $LASTEXITCODE" }
|
||||
& cmake --build native\process_loopback\build --config Release
|
||||
if ($LASTEXITCODE -ne 0) { throw "cmake build exit $LASTEXITCODE" }
|
||||
$cap = 'src\livesub\resources\bin\livesub_capture.exe'
|
||||
if (-not (Test-Path $cap)) { throw "$cap 없음" }
|
||||
"livesub_capture.exe $((Get-Item $cap).Length) bytes"
|
||||
}
|
||||
|
||||
$r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
||||
|
||||
# 점검이 찍은 스크린샷을 묶어서 호스트로 가져온다. 화면 관련 회귀는 로그로는
|
||||
# 안 보이고, 문서에 넣을 그림도 실제 Windows 에서 찍은 것이어야 한다.
|
||||
try {
|
||||
$png = Join-Path $Repo 'artifacts'
|
||||
if (Test-Path $png) {
|
||||
$zip = Join-Path $OutDir 'screenshots.zip'
|
||||
Remove-Item $zip -Force -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path (Join-Path $png '*.png') -DestinationPath $zip -Force
|
||||
Send-Artifact 'screenshots.zip' $zip
|
||||
}
|
||||
} catch { Log "스크린샷 회수 실패: $($_.Exception.Message)" }
|
||||
|
||||
$r['exe빌드'] = Step 'pyinstaller' {
|
||||
& $py -m pip install pyinstaller --quiet
|
||||
# 이전 산출물을 반드시 지우고 시작한다. 남겨두면 두 가지가 겹쳐 터진다.
|
||||
# 1) 앞 회차의 exe기동 점검이 띄운 LiveSub 가 파일을 잡고 있어
|
||||
# PyInstaller 가 EXE 를 덮어쓰지 못하고 PermissionError 로 죽는다.
|
||||
# 2) 그런데 옛 exe 는 그대로 남아 있으니 Test-Path 는 통과한다.
|
||||
# 즉 빌드가 실패했는데 PASS 로 보고된다 - 실제로 그렇게 오보가 났다.
|
||||
Get-Process LiveSub -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
Start-Sleep -Seconds 2
|
||||
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
|
||||
& $py -m PyInstaller packaging\livesub.spec --noconfirm
|
||||
if ($LASTEXITCODE -ne 0) { throw "PyInstaller exit $LASTEXITCODE" }
|
||||
if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }
|
||||
"exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB"
|
||||
}
|
||||
$r['exe기동'] = Step 'exe_smoke' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
||||
|
||||
# 빌드와 기동이 둘 다 통과했을 때만 배포용으로 회수한다. 실패한 exe 를
|
||||
# 호스트로 올리면 그게 릴리스에 올라갈 위험이 있다.
|
||||
if ($r['exe빌드'] -and $r['exe기동']) {
|
||||
Send-Artifact 'LiveSub.exe' (Join-Path $Repo 'dist\LiveSub.exe')
|
||||
} else {
|
||||
Log "exe 회수 건너뜀 (빌드=$($r['exe빌드']) 기동=$($r['exe기동']))"
|
||||
}
|
||||
|
||||
# 판정 요약도 파일로 남긴다. 어떤 리비전을 검증한 결과인지 같이 박아야
|
||||
# 나중에 "언제 통과한 건지" 를 되짚을 수 있다.
|
||||
$rev = (& git rev-parse --short HEAD 2>&1 | Select-Object -First 1)
|
||||
$sum = Join-Path $OutDir 'summary.log'
|
||||
$lines = @("rev=$rev", "utc=$([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss'))")
|
||||
Log "=== Windows 검증 결과 (rev $rev) ==="
|
||||
foreach ($k in $r.Keys) {
|
||||
$line = "{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })
|
||||
Log $line
|
||||
$lines += $line
|
||||
}
|
||||
Set-Content -Path $sum -Value $lines -Encoding UTF8
|
||||
Send-Artifact 'summary.log' $sum
|
||||
Log "=== 검증 끝 ==="
|
||||
Pop-Location
|
||||
} catch {
|
||||
|
||||
35
scripts/win-ci/sync.sh
Executable file
35
scripts/win-ci/sync.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# 저장소의 win-ci 스크립트를 /home/claude/win-ci 로 반영한다.
|
||||
#
|
||||
# ./sync.sh stage2.ps1 을 그대로 배포 (origin/main 최신을 검증)
|
||||
# ./sync.sh <sha> 그 커밋을 검증하도록 $TargetRev 를 박아서 배포
|
||||
#
|
||||
# build.sh 를 다시 돌리면 안 된다. build.sh 의 fetch 는 지워둔 Windows ISO(4.8GB)를
|
||||
# 다시 내려받는다. 설치가 끝난 VM 에 스크립트만 갱신할 때는 이걸 쓴다.
|
||||
#
|
||||
# stage2.ps1 은 게스트 agent 가 20초마다 해시를 비교해 받아가므로, 복사만 하면
|
||||
# VM 재시작 없이 다음 폴링에 새 버전이 실행된다. 반대로 내용이 같으면 다시 돌지
|
||||
# 않는다 — 그래서 재검증을 요청할 때는 sha 를 넘겨 내용이 바뀌게 한다.
|
||||
# serve-host.py 는 호스트 프로세스라 유닛을 재시작해야 반영된다.
|
||||
set -euo pipefail
|
||||
|
||||
D="${WIN_CI_DIR:-/home/claude/win-ci}"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
REV="${1:-}"
|
||||
|
||||
mkdir -p "$D/serve" "$D/results"
|
||||
cp "$HERE/serve/stage2.ps1" "$D/serve/"
|
||||
if [ -n "$REV" ]; then
|
||||
sed -i "s/^\$TargetRev = 'main'$/\$TargetRev = '$REV'/" "$D/serve/stage2.ps1"
|
||||
grep -q "^\$TargetRev = '$REV'$" "$D/serve/stage2.ps1" || { echo "TargetRev 치환 실패"; exit 1; }
|
||||
echo "검증 대상 리비전 = $REV"
|
||||
fi
|
||||
cp "$HERE"/*.sh "$HERE/serve-host.py" "$D/"
|
||||
chmod +x "$D"/*.sh
|
||||
|
||||
if systemctl --user is-active --quiet win-ci-http.service; then
|
||||
systemctl --user restart win-ci-http.service
|
||||
echo "win-ci-http 재시작 (serve-host.py 반영)"
|
||||
fi
|
||||
|
||||
echo "stage2.ps1 sha256 = $(sha256sum "$D/serve/stage2.ps1" | cut -c1-12) (게스트가 20초 안에 받아간다)"
|
||||
12
scripts/win-ci/win-ci-http.service
Normal file
12
scripts/win-ci/win-ci-http.service
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=win-ci guest control channel (host -> Windows VM)
|
||||
Before=win-ci.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /home/claude/win-ci/serve-host.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
20
scripts/win-ci/win-ci.service
Normal file
20
scripts/win-ci/win-ci.service
Normal file
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=win-ci Windows CI VM (nested KVM)
|
||||
Wants=win-ci-http.service
|
||||
After=win-ci-http.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/claude/win-ci
|
||||
ExecStart=/bin/bash /home/claude/win-ci/run-vm.sh
|
||||
# 봇(ejclaw.service) cgroup 밖 - 안에서 돌면 MemoryHigh=16G 를 쳐서 봇이 OOM 으로 죽는다
|
||||
MemoryMax=12G
|
||||
# .5 의 운영 컨테이너 19개가 같은 물리 호스트를 쓴다. 4코어를 넘기면 .5 응답이 +140%
|
||||
CPUQuota=400%
|
||||
CPUWeight=50
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
TimeoutStopSec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,4 +1,14 @@
|
||||
from .app import main
|
||||
"""`python -m livesub` 와 포터블 exe 의 진입점.
|
||||
|
||||
**여기서는 상대 임포트를 쓰면 안 된다.** PyInstaller 는 이 파일을 패키지 없이
|
||||
최상위 스크립트로 실행한다(`__package__` 가 비어 있다). 그래서 `from .app import`
|
||||
는 그 자리에서 `attempted relative import with no known parent package` 로 죽는다.
|
||||
exe 가 켜지자마자 오류창만 띄우던 원인이 이것이었다.
|
||||
|
||||
`python -m livesub` 는 어느 형태든 동작하므로 둘 다 되는 절대 임포트로 둔다.
|
||||
"""
|
||||
|
||||
from livesub.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -11,11 +11,19 @@ from .config import AppConfig
|
||||
from .constants import APP_NAME, ORG_NAME, user_data_dir
|
||||
|
||||
|
||||
def setup_logging(verbose: bool = False) -> None:
|
||||
log_file = user_data_dir() / "livesub.log"
|
||||
def setup_logging(verbose: bool = False, to_file: bool = True) -> None:
|
||||
"""로그 설정.
|
||||
|
||||
`to_file=False` 는 저장 위치를 아직 고르기 전에 쓴다. 그 시점에 파일
|
||||
핸들러를 열면 사용자가 고르지도 않은 기본 폴더가 만들어지고 로그가
|
||||
거기에 남는다.
|
||||
"""
|
||||
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
|
||||
if to_file:
|
||||
with contextlib.suppress(OSError):
|
||||
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
|
||||
handlers.append(
|
||||
logging.FileHandler(user_data_dir() / "livesub.log", encoding="utf-8")
|
||||
)
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
@@ -30,7 +38,7 @@ def setup_logging(verbose: bool = False) -> None:
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = list(sys.argv if argv is None else argv)
|
||||
verbose = "--verbose" in argv or "-v" in argv
|
||||
setup_logging(verbose)
|
||||
setup_logging(verbose, to_file=False)
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QIcon
|
||||
@@ -48,6 +56,15 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if icon_path.is_file():
|
||||
app.setWindowIcon(QIcon(str(icon_path)))
|
||||
|
||||
# 설정을 읽기 전에 어디서 읽을지부터 정해야 한다. 한 번 고르면 다음 실행부터
|
||||
# 이 화면은 건너뛴다.
|
||||
from .storage import is_configured
|
||||
from .ui.data_location import ask_initial_location
|
||||
|
||||
if not is_configured() and not ask_initial_location():
|
||||
return 0
|
||||
setup_logging(verbose)
|
||||
|
||||
config = AppConfig.load()
|
||||
window = MainWindow(config)
|
||||
if not config.start_minimized:
|
||||
|
||||
@@ -7,17 +7,25 @@ import logging
|
||||
from ..config import AudioConfig
|
||||
from .base import AudioSource, CaptureBackend, CaptureError
|
||||
from .file_source import FileCapture
|
||||
from .input_device import InputDeviceCapture
|
||||
from .process_loopback import ProcessLoopbackCapture
|
||||
from .segmenter import Segment, Segmenter, SegmenterConfig
|
||||
from .wasapi_loopback import WasapiLoopbackCapture
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: 홈 화면 목록이 무엇을 보여줄지.
|
||||
SOURCE_MODE_OUTPUT = "output" # 게임 등 프로그램 소리 + 출력 장치
|
||||
SOURCE_MODE_INPUT = "input" # 마이크 등 입력 장치
|
||||
|
||||
__all__ = [
|
||||
"SOURCE_MODE_INPUT",
|
||||
"SOURCE_MODE_OUTPUT",
|
||||
"AudioSource",
|
||||
"CaptureBackend",
|
||||
"CaptureError",
|
||||
"FileCapture",
|
||||
"InputDeviceCapture",
|
||||
"ProcessLoopbackCapture",
|
||||
"Segment",
|
||||
"Segmenter",
|
||||
@@ -34,12 +42,20 @@ def capture_capabilities() -> dict[str, bool]:
|
||||
return {
|
||||
"process": ProcessLoopbackCapture.available(),
|
||||
"device": WasapiLoopbackCapture.available(),
|
||||
"input": InputDeviceCapture.available(),
|
||||
"file": True,
|
||||
}
|
||||
|
||||
|
||||
def list_sources() -> list[AudioSource]:
|
||||
"""프로그램 + 출력 장치를 한 목록으로."""
|
||||
def list_sources(mode: str = SOURCE_MODE_OUTPUT) -> list[AudioSource]:
|
||||
"""고른 쪽의 캡처 대상 목록.
|
||||
|
||||
출력(게임 소리)과 입력(마이크)을 한 목록에 섞으면 수십 줄이 되고, 정작
|
||||
찾으려던 게임이 마이크·라인입력 사이에 파묻힌다. 그래서 한 번에 한 쪽만
|
||||
보여주고 홈 화면에서 전환한다.
|
||||
"""
|
||||
if mode == SOURCE_MODE_INPUT:
|
||||
return InputDeviceCapture.list_sources()
|
||||
sources: list[AudioSource] = []
|
||||
if ProcessLoopbackCapture.available():
|
||||
sources.extend(ProcessLoopbackCapture.list_sources())
|
||||
@@ -53,6 +69,13 @@ def create_capture(config: AudioConfig) -> CaptureBackend:
|
||||
if backend == "file":
|
||||
return FileCapture(config.target_process_name)
|
||||
|
||||
if backend == "input":
|
||||
if not InputDeviceCapture.available():
|
||||
raise CaptureError(
|
||||
"입력 장치를 열 수 없습니다. Windows에서 PyAudioWPatch 설치 여부를 확인하세요."
|
||||
)
|
||||
return InputDeviceCapture(config.device_index)
|
||||
|
||||
want_process = backend in ("process", "auto") and config.target_pid > 0
|
||||
if want_process:
|
||||
if ProcessLoopbackCapture.available():
|
||||
|
||||
@@ -20,7 +20,8 @@ from ..constants import SAMPLE_RATE
|
||||
class AudioSource:
|
||||
"""UI에 노출되는 캡처 대상 한 줄."""
|
||||
|
||||
kind: str # "process" | "device"
|
||||
#: "process"=특정 프로그램 소리 · "device"=출력 장치 루프백 · "input"=마이크 등 입력 장치
|
||||
kind: str
|
||||
identifier: str # pid 문자열 또는 장치 인덱스
|
||||
label: str
|
||||
detail: str = ""
|
||||
@@ -32,7 +33,7 @@ class AudioSource:
|
||||
|
||||
@property
|
||||
def device_index(self) -> int:
|
||||
return int(self.identifier) if self.kind == "device" else -1
|
||||
return int(self.identifier) if self.kind in ("device", "input") else -1
|
||||
|
||||
|
||||
class CaptureError(RuntimeError):
|
||||
|
||||
106
src/livesub/audio/input_device.py
Normal file
106
src/livesub/audio/input_device.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""마이크 등 입력 장치 캡처 (Windows).
|
||||
|
||||
루프백(=스피커로 나가는 소리)이 아니라 **들어오는 소리**를 받는다. 통화나
|
||||
합주처럼 상대 목소리가 내 스피커로 나오지 않는 상황, 또는 내가 말하는 것을
|
||||
그대로 번역해 보고 싶을 때 쓴다.
|
||||
|
||||
WasapiLoopbackCapture 와 스트림을 여는 모양이 비슷하지만 합치지 않았다.
|
||||
장치 목록을 만드는 방법(루프백 전용 열거 vs 일반 입력 장치)과 기본 장치를
|
||||
고르는 방법이 서로 달라서, 한 클래스에 넣으면 분기가 코드의 절반이 된다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..constants import FRAME_SAMPLES
|
||||
from .base import AudioSource, CaptureBackend, CaptureError, to_mono_16k
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try: # pragma: no cover - 플랫폼 의존
|
||||
import pyaudiowpatch as pyaudio
|
||||
except ImportError: # pragma: no cover
|
||||
pyaudio = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class InputDeviceCapture(CaptureBackend):
|
||||
name = "input"
|
||||
per_process = False
|
||||
|
||||
def __init__(self, device_index: int = -1) -> None:
|
||||
super().__init__()
|
||||
self.device_index = device_index
|
||||
|
||||
@staticmethod
|
||||
def available() -> bool:
|
||||
return pyaudio is not None
|
||||
|
||||
@staticmethod
|
||||
def list_sources() -> list[AudioSource]:
|
||||
if pyaudio is None:
|
||||
return []
|
||||
sources: list[AudioSource] = []
|
||||
pa = pyaudio.PyAudio()
|
||||
try:
|
||||
default_index = -1
|
||||
with contextlib.suppress(OSError, LookupError):
|
||||
default_index = int(pa.get_default_input_device_info()["index"])
|
||||
for i in range(pa.get_device_count()):
|
||||
info = pa.get_device_info_by_index(i)
|
||||
if int(info.get("maxInputChannels", 0)) <= 0:
|
||||
continue
|
||||
# 루프백 장치는 '출력 장치' 목록에서 이미 보여주고 있다.
|
||||
# 여기서 또 내보내면 같은 것이 두 목록에 걸쳐 나온다.
|
||||
if info.get("isLoopbackDevice"):
|
||||
continue
|
||||
sources.append(
|
||||
AudioSource(
|
||||
kind="input",
|
||||
identifier=str(i),
|
||||
label=str(info["name"]),
|
||||
detail="기본 입력 장치" if i == default_index else "입력 장치",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
pa.terminate()
|
||||
return sources
|
||||
|
||||
def _resolve_device(self, pa) -> dict:
|
||||
if self.device_index >= 0:
|
||||
return pa.get_device_info_by_index(self.device_index)
|
||||
return pa.get_default_input_device_info()
|
||||
|
||||
def _run(self) -> None: # pragma: no cover - 실제 오디오 장치 필요
|
||||
if pyaudio is None:
|
||||
raise CaptureError("PyAudioWPatch가 설치되지 않았습니다.")
|
||||
pa = pyaudio.PyAudio()
|
||||
stream = None
|
||||
try:
|
||||
info = self._resolve_device(pa)
|
||||
if info is None:
|
||||
raise CaptureError("입력 장치를 찾지 못했습니다.")
|
||||
rate = int(info["defaultSampleRate"])
|
||||
channels = max(1, int(info["maxInputChannels"]))
|
||||
chunk = max(FRAME_SAMPLES, int(rate * 0.02))
|
||||
stream = pa.open(
|
||||
format=pyaudio.paFloat32,
|
||||
channels=channels,
|
||||
rate=rate,
|
||||
input=True,
|
||||
input_device_index=int(info["index"]),
|
||||
frames_per_buffer=chunk,
|
||||
)
|
||||
log.info("입력 장치 캡처 시작: %s (%dHz, %dch)", info["name"], rate, channels)
|
||||
while not self._stop.is_set():
|
||||
raw = stream.read(chunk, exception_on_overflow=False)
|
||||
data = np.frombuffer(raw, dtype=np.float32)
|
||||
self._emit(to_mono_16k(data, channels, rate))
|
||||
finally:
|
||||
if stream is not None:
|
||||
stream.stop_stream()
|
||||
stream.close()
|
||||
pa.terminate()
|
||||
@@ -12,7 +12,14 @@ from .constants import DEFAULT_TIER, user_data_dir
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_PATH = user_data_dir() / "config.json"
|
||||
|
||||
def config_path() -> Path:
|
||||
"""설정 파일 경로.
|
||||
|
||||
import 시점에 상수로 계산하면 안 된다. 그때는 사용자가 저장 위치를 아직
|
||||
고르지 않았고, 나중에 설정에서 위치를 바꿔도 옛 경로를 계속 가리킨다.
|
||||
"""
|
||||
return user_data_dir() / "config.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -28,6 +35,9 @@ class SubtitleStyle:
|
||||
outline_width: int = 3
|
||||
background_color: str = "#000000"
|
||||
background_opacity: int = 55 # 0-100
|
||||
#: 글자와 배경 상자 가장자리 사이 여백. 배경 상자는 글자를 감싸는 크기로
|
||||
#: 그려지므로 이 값이 곧 배경 크기를 정한다.
|
||||
padding: int = 18
|
||||
line_spacing: int = 6
|
||||
max_lines: int = 2
|
||||
show_source: bool = False # 원문 동시 표시
|
||||
@@ -46,7 +56,9 @@ class OverlayConfig:
|
||||
always_on_top: bool = True
|
||||
#: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다.
|
||||
click_through: bool = True
|
||||
locked: bool = False
|
||||
#: 기본은 잠금이다. 게임 중에 자막을 실수로 끌고 다니는 사고를 막는다.
|
||||
#: 옮기거나 크기를 바꿀 때는 자막 화면의 '자막 위치 조정'을 켠다.
|
||||
locked: bool = True
|
||||
#: 자막 표시 on/off (단축키로 토글)
|
||||
visible: bool = True
|
||||
#: 자막을 띄울 모니터 인덱스 (0부터)
|
||||
@@ -59,7 +71,10 @@ class OverlayConfig:
|
||||
|
||||
@dataclass
|
||||
class AudioConfig:
|
||||
backend: str = "auto" # auto | process | device | file
|
||||
backend: str = "auto" # auto | process | device | input | file
|
||||
#: 홈 화면 목록이 무엇을 보여줄지. "output"=프로그램과 출력 장치(게임 소리),
|
||||
#: "input"=마이크 같은 입력 장치. 고른 쪽을 기억한다.
|
||||
source_mode: str = "output"
|
||||
target_pid: int = 0
|
||||
target_process_name: str = ""
|
||||
device_index: int = -1 # -1 = 기본 출력 장치 루프백
|
||||
@@ -149,7 +164,7 @@ class AppConfig:
|
||||
return asdict(self)
|
||||
|
||||
def save(self, path: Path | None = None) -> Path:
|
||||
target = path or CONFIG_PATH
|
||||
target = path or config_path()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = target.with_suffix(".json.tmp")
|
||||
tmp.write_text(
|
||||
@@ -160,7 +175,7 @@ class AppConfig:
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path | None = None) -> AppConfig:
|
||||
target = path or CONFIG_PATH
|
||||
target = path or config_path()
|
||||
if not target.exists():
|
||||
return cls()
|
||||
try:
|
||||
|
||||
@@ -42,19 +42,15 @@ FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000
|
||||
|
||||
|
||||
def user_data_dir() -> Path:
|
||||
"""설정/로그/모델 캐시를 두는 사용자 디렉터리."""
|
||||
import os
|
||||
import sys
|
||||
"""설정/로그/모델 캐시를 두는 폴더 — 사용자가 첫 실행 때 고른 위치다.
|
||||
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
||||
elif sys.platform == "darwin":
|
||||
base = Path.home() / "Library" / "Application Support"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
path = base / APP_NAME
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
실제 결정은 storage 모듈이 한다. 모듈 최상단에서 import 하면 순환이 되므로
|
||||
함수 안에서 가져온다. 그리고 **미리 계산해 상수로 박아두면 안 된다** —
|
||||
설정에서 위치를 바꿨을 때 따라오지 않는다.
|
||||
"""
|
||||
from .storage import data_dir
|
||||
|
||||
return data_dir()
|
||||
|
||||
|
||||
def models_dir() -> Path:
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
from .asr import SpeechRecognizer, Transcript
|
||||
from .glossary import Glossary, GlossaryEntry
|
||||
from .manager import GpuInfo, ModelManager, detect_gpu
|
||||
from .manager import GpuInfo, ModelManager, detect_gpu, recommended_tier
|
||||
from .speech_level import (
|
||||
Politeness,
|
||||
SpeechLevel,
|
||||
apply_speech_level,
|
||||
detect_politeness,
|
||||
to_casual,
|
||||
)
|
||||
from .tiers import (
|
||||
DEFAULT_TIER,
|
||||
TIERS,
|
||||
@@ -12,13 +19,6 @@ from .tiers import (
|
||||
get_tier,
|
||||
ordered_tiers,
|
||||
)
|
||||
from .speech_level import (
|
||||
Politeness,
|
||||
SpeechLevel,
|
||||
apply_speech_level,
|
||||
detect_politeness,
|
||||
to_casual,
|
||||
)
|
||||
from .translator import Translator, build_seedx_prompt, create_translator
|
||||
|
||||
__all__ = [
|
||||
@@ -43,5 +43,6 @@ __all__ = [
|
||||
"detect_gpu",
|
||||
"get_tier",
|
||||
"ordered_tiers",
|
||||
"recommended_tier",
|
||||
"to_casual",
|
||||
]
|
||||
|
||||
@@ -64,7 +64,7 @@ class Glossary:
|
||||
|
||||
# --- 영속화 --------------------------------------------------------
|
||||
@classmethod
|
||||
def load(cls, path: str | Path, case_sensitive: bool = False) -> "Glossary":
|
||||
def load(cls, path: str | Path, case_sensitive: bool = False) -> Glossary:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return cls(case_sensitive=case_sensitive)
|
||||
|
||||
@@ -8,7 +8,7 @@ from dataclasses import dataclass, replace
|
||||
|
||||
from ..config import ModelConfig
|
||||
from .asr import SpeechRecognizer
|
||||
from .tiers import TIERS, MTBackend, Tier, get_tier
|
||||
from .tiers import DEFAULT_TIER, TIERS, MTBackend, Tier, get_tier
|
||||
from .translator import Translator, create_translator
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -57,7 +57,10 @@ def detect_gpu() -> GpuInfo:
|
||||
return GpuInfo(True, name, int(total), int(free),
|
||||
reason="PyTorch 미설치 — 정보만 조회했습니다.")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return GpuInfo(False, reason=f"GPU 조회 실패: {exc}")
|
||||
# 예외 문자열에는 실행한 명령줄이 통째로 들어 있다. 그대로 화면에
|
||||
# 띄우면 안내가 아니라 로그 덤프가 된다. 자세한 건 로그로 보낸다.
|
||||
log.warning("nvidia-smi 조회 실패: %s", exc)
|
||||
return GpuInfo(False, reason="nvidia-smi 로 GPU 정보를 읽지 못했습니다.")
|
||||
|
||||
|
||||
def apply_vram_limit(ratio: float, device_index: int = 0) -> bool:
|
||||
@@ -136,6 +139,32 @@ def tier_availability(tier: Tier, gpu: GpuInfo | None = None) -> tuple[bool, str
|
||||
return True, ""
|
||||
|
||||
|
||||
def recommended_tier(gpu: GpuInfo | None = None) -> str:
|
||||
"""이 환경에서 실제로 권할 만한 티어.
|
||||
|
||||
티어 표에 박아둔 `recommended` 는 "GPU 가 넉넉할 때" 를 전제로 한 값이라,
|
||||
그대로 쓰면 GPU 가 없는 사람에게 10GB 짜리를 추천하게 된다. 그래서 환경을
|
||||
보고 정한다.
|
||||
|
||||
GPU 가 없으면 CTranslate2 계열(1~3티어)만 후보에 둔다. LLM 계열은 CPU 에서
|
||||
한 문장에 수십 초가 걸려 실시간 자막으로 쓸 수 없다. 그중에서도 가장
|
||||
가벼운 쪽을 고른다 — CPU 에서는 연산량이 그대로 지연으로 돌아온다.
|
||||
"""
|
||||
info = detect_gpu() if gpu is None else gpu
|
||||
usable = [t for t in TIERS.values() if tier_availability(t, info)[0]]
|
||||
if not usable:
|
||||
return DEFAULT_TIER
|
||||
|
||||
if not info.available:
|
||||
light = [t for t in usable if t.mt.backend is MTBackend.CTRANSLATE2]
|
||||
return min(light or usable, key=lambda t: t.order).key
|
||||
|
||||
preferred = [t for t in usable if t.recommended]
|
||||
if preferred:
|
||||
return max(preferred, key=lambda t: t.order).key
|
||||
return max(usable, key=lambda t: t.order).key
|
||||
|
||||
|
||||
def first_available_tier(preferred: str) -> str:
|
||||
"""선호 티어가 불가능하면 쓸 수 있는 가장 좋은 티어로 내린다."""
|
||||
tier = get_tier(preferred)
|
||||
|
||||
213
src/livesub/storage.py
Normal file
213
src/livesub/storage.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""데이터를 어디에 둘지 — 사용자가 고르고, 그 선택을 기억한다.
|
||||
|
||||
닭과 달걀 문제가 하나 있다. 모델·설정·기록을 어디에 둘지는 사용자가 정하는데,
|
||||
그 "정한 위치" 자체는 어딘가 **고정된 곳**에 적어둬야 다음 실행 때 찾을 수 있다.
|
||||
그래서 OS 표준 설정 폴더에는 위치를 가리키는 쪽지 한 장(`location.json`)만 두고,
|
||||
실제 내용물(config.json · models/ · glossary.json · transcripts/ · livesub.log)은
|
||||
전부 사용자가 고른 폴더에 둔다.
|
||||
|
||||
왜 고르게 하나 — 모델이 품질 티어에 따라 2~16GB 를 차지한다. C 드라이브가 좁은
|
||||
사람은 다른 디스크에 둬야 하는데, `%APPDATA%` 에 박아두면 방법이 없다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
POINTER_NAME = "location.json"
|
||||
|
||||
#: 저장 위치를 옮길 때 따라가는 것들. 여기 없는 파일은 건드리지 않는다.
|
||||
#:
|
||||
#: livesub.log 는 일부러 뺐다. 실행 중에는 로그 핸들러가 파일을 잡고 있어서
|
||||
#: Windows 에서 이동이 실패한다. 로그는 다음 실행부터 새 위치에 쌓인다.
|
||||
DATA_ENTRIES = ("config.json", "glossary.json", "models", "adapters", "transcripts")
|
||||
|
||||
|
||||
def _os_config_dir() -> Path:
|
||||
"""OS 표준 설정 폴더. 여기에는 쪽지 한 장만 둔다."""
|
||||
from .constants import APP_NAME
|
||||
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
||||
elif sys.platform == "darwin":
|
||||
base = Path.home() / "Library" / "Application Support"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
return base / APP_NAME
|
||||
|
||||
|
||||
def pointer_path() -> Path:
|
||||
return _os_config_dir() / POINTER_NAME
|
||||
|
||||
|
||||
def default_data_dir() -> Path:
|
||||
"""아무것도 고르지 않았을 때 쓰는 위치.
|
||||
|
||||
OS 표준 폴더와 같은 곳이다. 위치 선택이 없던 이전 버전이 쓰던 자리라,
|
||||
기본값을 여기로 두면 이미 모델을 받아둔 사람이 다시 받지 않아도 된다.
|
||||
"""
|
||||
return _os_config_dir()
|
||||
|
||||
|
||||
def _read_pointer() -> Path | None:
|
||||
try:
|
||||
raw = json.loads(pointer_path().read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
value = raw.get("data_dir") if isinstance(raw, dict) else None
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
return Path(value)
|
||||
|
||||
|
||||
def stored_data_dir() -> Path | None:
|
||||
"""적어둔 위치. 아직 안 골랐거나 쪽지가 깨졌으면 None."""
|
||||
return _read_pointer()
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""위치 선택 화면을 건너뛰어도 되는지.
|
||||
|
||||
쪽지가 있으면 당연히 건너뛴다. 쪽지가 없어도 기본 위치에 이미 쓰던 흔적이
|
||||
있으면 이전 버전 사용자이므로 묻지 않는다 — 잘 쓰고 있던 사람에게 갑자기
|
||||
위치를 고르라고 하면 같은 자리를 다시 고르는 헛수고만 시킨다.
|
||||
"""
|
||||
if _read_pointer() is not None:
|
||||
return True
|
||||
base = default_data_dir()
|
||||
return any((base / name).exists() for name in DATA_ENTRIES)
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
"""실제로 쓰는 데이터 폴더. 없으면 만든다."""
|
||||
target = _read_pointer() or default_data_dir()
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
# 고른 디스크가 빠졌거나 권한이 사라진 경우. 조용히 죽는 것보다는
|
||||
# 기본 위치로 물러나 뜨는 편이 낫다.
|
||||
log.warning("저장 위치(%s)를 쓸 수 없어 기본 위치를 씁니다: %s", target, exc)
|
||||
target = default_data_dir()
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return target
|
||||
|
||||
|
||||
def set_data_dir(path: Path | str) -> Path:
|
||||
"""이 위치를 쓰겠다고 적어둔다."""
|
||||
target = Path(path).expanduser()
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
target = target.resolve()
|
||||
|
||||
pointer = pointer_path()
|
||||
pointer.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pointer.with_suffix(".json.tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"data_dir": str(target)}, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
tmp.replace(pointer)
|
||||
return target
|
||||
|
||||
|
||||
def _existing_ancestor(path: Path) -> Path | None:
|
||||
"""실제로 존재하는 가장 가까운 상위 폴더. 없으면 None."""
|
||||
probe = path
|
||||
while not probe.exists():
|
||||
if probe.parent == probe:
|
||||
return None
|
||||
probe = probe.parent
|
||||
return probe
|
||||
|
||||
|
||||
def check_target(path: Path | str, current: Path | None = None) -> str | None:
|
||||
"""고른 폴더를 쓸 수 있는지 본다.
|
||||
|
||||
문제가 없으면 None, 있으면 사용자에게 그대로 보여줄 한 줄을 돌려준다.
|
||||
"""
|
||||
text = str(path).strip()
|
||||
if not text:
|
||||
return "폴더를 골라주세요."
|
||||
try:
|
||||
candidate = Path(text).expanduser().resolve()
|
||||
except (OSError, ValueError):
|
||||
return "경로를 이해할 수 없습니다."
|
||||
|
||||
if current is not None:
|
||||
try:
|
||||
cur = Path(current).expanduser().resolve()
|
||||
except (OSError, ValueError):
|
||||
cur = None
|
||||
if cur is not None and cur != candidate and cur in candidate.parents:
|
||||
# 지금 위치 안쪽으로 옮기면 옮기는 도중에 자기 자신을 삼킨다.
|
||||
return "지금 쓰는 폴더의 하위 폴더는 고를 수 없습니다."
|
||||
|
||||
anchor = _existing_ancestor(candidate)
|
||||
if anchor is None:
|
||||
return "드라이브를 찾을 수 없습니다."
|
||||
if not anchor.is_dir():
|
||||
return "폴더가 아니라 파일을 고르셨습니다."
|
||||
if not os.access(anchor, os.W_OK):
|
||||
return "이 폴더에 쓸 권한이 없습니다."
|
||||
return None
|
||||
|
||||
|
||||
def free_space(path: Path | str) -> int:
|
||||
"""그 경로가 놓일 디스크의 남은 바이트. 알 수 없으면 0."""
|
||||
anchor = _existing_ancestor(Path(str(path)).expanduser())
|
||||
if anchor is None:
|
||||
return 0
|
||||
try:
|
||||
return shutil.disk_usage(anchor).free
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def human_size(num: float) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if num < 1024 or unit == "TB":
|
||||
return f"{num:,.1f} {unit}" if unit != "B" else f"{num:,.0f} B"
|
||||
num /= 1024
|
||||
return f"{num:,.1f} TB"
|
||||
|
||||
|
||||
def used_space(path: Path | str) -> int:
|
||||
"""그 폴더가 실제로 쓰고 있는 바이트. 읽을 수 없는 항목은 건너뛴다."""
|
||||
total = 0
|
||||
for root, _dirs, files in os.walk(str(path), onerror=lambda _e: None):
|
||||
for name in files:
|
||||
try:
|
||||
total += os.path.getsize(os.path.join(root, name))
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def move_data(old: Path | str, new: Path | str) -> list[str]:
|
||||
"""기존 데이터를 새 위치로 옮기고, 옮긴 항목 이름을 돌려준다."""
|
||||
src_root = Path(old).expanduser().resolve()
|
||||
dst_root = Path(new).expanduser()
|
||||
dst_root.mkdir(parents=True, exist_ok=True)
|
||||
dst_root = dst_root.resolve()
|
||||
if src_root == dst_root:
|
||||
return []
|
||||
|
||||
moved: list[str] = []
|
||||
for name in DATA_ENTRIES:
|
||||
src = src_root / name
|
||||
if not src.exists():
|
||||
continue
|
||||
dst = dst_root / name
|
||||
if dst.exists():
|
||||
# 새 위치에 이미 같은 이름이 있으면 덮어쓰지 않는다. 예전에 쓰던
|
||||
# 폴더를 다시 고른 경우일 수 있고, 그때 지우면 복구할 방법이 없다.
|
||||
log.info("이미 있어서 건너뜀: %s", dst)
|
||||
continue
|
||||
shutil.move(str(src), str(dst))
|
||||
moved.append(name)
|
||||
return moved
|
||||
211
src/livesub/ui/data_location.py
Normal file
211
src/livesub/ui/data_location.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""저장 위치를 고르는 화면.
|
||||
|
||||
첫 실행 때 한 번 묻고, 그 뒤로는 설정에서 바꿀 수 있다. 두 경우가 묻는 내용이
|
||||
같으므로 같은 대화상자를 쓰고 문구만 바꾼다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProgressDialog,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..constants import APP_NAME
|
||||
from ..storage import (
|
||||
DATA_ENTRIES,
|
||||
check_target,
|
||||
data_dir,
|
||||
default_data_dir,
|
||||
free_space,
|
||||
human_size,
|
||||
move_data,
|
||||
set_data_dir,
|
||||
)
|
||||
|
||||
#: 가장 무거운 티어까지 받았을 때를 기준으로 한 권장 여유 공간.
|
||||
RECOMMENDED_FREE = 20 * 1024**3
|
||||
|
||||
|
||||
class DataLocationDialog(QDialog):
|
||||
"""폴더 하나를 고르고, 쓸 수 있는 곳인지 즉시 알려준다."""
|
||||
|
||||
def __init__(self, start: Path, *, first_run: bool, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"{APP_NAME} 저장 위치")
|
||||
self.setMinimumWidth(520)
|
||||
self._first_run = first_run
|
||||
self._current = None if first_run else start
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(12)
|
||||
|
||||
title = QLabel("어디에 저장할까요?" if first_run else "저장 위치 바꾸기")
|
||||
title.setStyleSheet("font-size: 16px; font-weight: 700;")
|
||||
root.addWidget(title)
|
||||
|
||||
why = QLabel(
|
||||
"음성인식·번역 모델과 설정, 번역 기록이 이 폴더에 저장됩니다.\n"
|
||||
"모델은 품질 티어에 따라 2~16GB 를 차지하니 여유 있는 디스크를 고르세요.\n"
|
||||
"나중에 설정에서 언제든 바꿀 수 있습니다."
|
||||
)
|
||||
why.setObjectName("Hint")
|
||||
why.setWordWrap(True)
|
||||
root.addWidget(why)
|
||||
|
||||
row = QWidget()
|
||||
row.setObjectName("Row")
|
||||
row_layout = QHBoxLayout(row)
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.path_edit = QLineEdit(str(start))
|
||||
self.path_edit.textChanged.connect(self._revalidate)
|
||||
browse = QPushButton("찾아보기…")
|
||||
browse.clicked.connect(self._browse)
|
||||
row_layout.addWidget(self.path_edit, 1)
|
||||
row_layout.addWidget(browse)
|
||||
root.addWidget(row)
|
||||
|
||||
self.status = QLabel()
|
||||
self.status.setWordWrap(True)
|
||||
root.addWidget(self.status)
|
||||
|
||||
self.buttons = QDialogButtonBox()
|
||||
ok_text = "이 위치로 시작" if first_run else "이 위치로 바꾸기"
|
||||
self.ok_button = self.buttons.addButton(ok_text, QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
cancel_text = "종료" if first_run else "취소"
|
||||
self.buttons.addButton(cancel_text, QDialogButtonBox.ButtonRole.RejectRole)
|
||||
self.buttons.accepted.connect(self.accept)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
root.addWidget(self.buttons)
|
||||
|
||||
self._revalidate()
|
||||
|
||||
def selected(self) -> Path:
|
||||
return Path(self.path_edit.text().strip()).expanduser()
|
||||
|
||||
def _browse(self) -> None:
|
||||
start = self.path_edit.text().strip() or str(default_data_dir())
|
||||
picked = QFileDialog.getExistingDirectory(self, "저장할 폴더 선택", start)
|
||||
if picked:
|
||||
self.path_edit.setText(picked)
|
||||
|
||||
def _revalidate(self) -> None:
|
||||
text = self.path_edit.text().strip()
|
||||
problem = check_target(text, self._current)
|
||||
self.ok_button.setEnabled(problem is None)
|
||||
if problem is not None:
|
||||
self.status.setText(f"⚠ {problem}")
|
||||
self.status.setStyleSheet("color: #E5484D;")
|
||||
return
|
||||
|
||||
free = free_space(text)
|
||||
line = f"이 디스크의 남은 공간 {human_size(free)}"
|
||||
if free and free < RECOMMENDED_FREE:
|
||||
self.status.setText(
|
||||
f"{line} — 높은 티어 모델까지 받기에는 빠듯합니다. 그래도 진행할 수 있습니다."
|
||||
)
|
||||
self.status.setStyleSheet("color: #F5A623;")
|
||||
else:
|
||||
self.status.setText(line)
|
||||
self.status.setStyleSheet("")
|
||||
|
||||
|
||||
def ask_initial_location(parent: QWidget | None = None) -> bool:
|
||||
"""첫 실행 — 위치를 고르게 한다. 사용자가 종료를 누르면 False."""
|
||||
dialog = DataLocationDialog(default_data_dir(), first_run=True, parent=parent)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return False
|
||||
set_data_dir(dialog.selected())
|
||||
return True
|
||||
|
||||
|
||||
def ask_change_location(parent: QWidget | None = None) -> Path | None:
|
||||
"""설정 — 위치를 바꾼다. 바꿨으면 새 경로, 취소했으면 None."""
|
||||
current = data_dir()
|
||||
dialog = DataLocationDialog(current, first_run=False, parent=parent)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return None
|
||||
|
||||
target = dialog.selected()
|
||||
if target.expanduser().resolve() == current.resolve():
|
||||
return None
|
||||
|
||||
if _has_data(current) and not _move_with_progress(current, target, parent):
|
||||
return None
|
||||
|
||||
return set_data_dir(target)
|
||||
|
||||
|
||||
def _has_data(path: Path) -> bool:
|
||||
return any((path / name).exists() for name in DATA_ENTRIES)
|
||||
|
||||
|
||||
def _move_with_progress(old: Path, new: Path, parent: QWidget | None) -> bool:
|
||||
"""기존 데이터를 옮길지 묻고, 옮기기로 했으면 옮긴다. 취소면 False."""
|
||||
answer = QMessageBox.question(
|
||||
parent,
|
||||
"기존 데이터",
|
||||
f"지금 위치에 받아둔 모델과 설정이 있습니다.\n\n"
|
||||
f"{old}\n\n"
|
||||
"새 위치로 옮길까요? 옮기지 않으면 모델을 다시 받아야 합니다.",
|
||||
QMessageBox.StandardButton.Yes
|
||||
| QMessageBox.StandardButton.No
|
||||
| QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Yes,
|
||||
)
|
||||
if answer == QMessageBox.StandardButton.Cancel:
|
||||
return False
|
||||
if answer == QMessageBox.StandardButton.No:
|
||||
return True
|
||||
|
||||
# 수 GB 를 옮기는 동안 창이 얼어붙으면 Windows 가 "응답 없음"을 띄운다.
|
||||
# 별도 스레드로 옮기고 여기서는 진행 표시만 돌린다.
|
||||
worker = _MoveWorker(old, new)
|
||||
progress = QProgressDialog("기존 데이터를 옮기는 중입니다…", "", 0, 0, parent)
|
||||
progress.setWindowTitle("저장 위치 변경")
|
||||
progress.setCancelButton(None) # 옮기는 도중에 끊으면 데이터가 갈라진다
|
||||
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
worker.finished.connect(progress.close)
|
||||
worker.start()
|
||||
progress.exec()
|
||||
worker.wait()
|
||||
|
||||
if worker.error:
|
||||
QMessageBox.warning(
|
||||
parent,
|
||||
"옮기지 못했습니다",
|
||||
f"기존 데이터를 옮기는 중 문제가 생겼습니다.\n\n{worker.error}\n\n"
|
||||
"저장 위치는 바꾸지 않았습니다.",
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class _MoveWorker(QThread):
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, old: Path, new: Path):
|
||||
super().__init__()
|
||||
self._old = old
|
||||
self._new = new
|
||||
self.moved: list[str] = []
|
||||
self.error = ""
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.moved = move_data(self._old, self._new)
|
||||
except OSError as exc:
|
||||
self.error = str(exc)
|
||||
self.done.emit()
|
||||
@@ -162,8 +162,12 @@ class MainWindow(QMainWindow):
|
||||
self.settings_page.restart_needed.connect(self._on_restart_needed)
|
||||
self.settings_page.hotkeys_changed.connect(self._install_hotkeys)
|
||||
|
||||
self.subtitle_page.edit_mode_requested.connect(self.overlay.set_edit_mode)
|
||||
|
||||
self.overlay.geometry_changed.connect(self._on_overlay_moved)
|
||||
self.overlay.visibility_changed.connect(self.subtitle_page.overlay_check.setChecked)
|
||||
self.overlay.edit_mode_changed.connect(self.subtitle_page.set_edit_mode)
|
||||
self.overlay.font_size_changed.connect(self._on_overlay_font_resized)
|
||||
self.overlay.closed.connect(
|
||||
lambda: self.subtitle_page.overlay_check.setChecked(False)
|
||||
)
|
||||
@@ -278,6 +282,11 @@ class MainWindow(QMainWindow):
|
||||
self.subtitle_page.sync_from_config()
|
||||
self._schedule_save()
|
||||
|
||||
def _on_overlay_font_resized(self, size: int) -> None:
|
||||
"""모서리를 잡아 크기를 바꾸면 글자 크기도 같이 바뀐다."""
|
||||
self.subtitle_page.set_font_size(size)
|
||||
self._schedule_save()
|
||||
|
||||
def _on_glossary_changed(self) -> None:
|
||||
self.engine.reload_glossary()
|
||||
self._schedule_save()
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
|
||||
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
|
||||
|
||||
조작:
|
||||
평소에는 **잠겨 있다.** 게임 중에 자막을 실수로 끌고 다니는 일이 없어야 하기
|
||||
때문이다. 자막 화면의 '자막 위치 조정'을 켜야 아래 조작이 열린다.
|
||||
|
||||
위치 조정 모드에서:
|
||||
* 드래그 — 위치 이동
|
||||
* 우하단 모서리 — 크기 조절
|
||||
* 네 모서리 — 크기 조절 (글자 크기도 같은 비율로 따라온다)
|
||||
* 휠 — 글자 크기 조절
|
||||
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
|
||||
"""
|
||||
@@ -38,17 +41,16 @@ def paint_subtitle(
|
||||
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)))
|
||||
@@ -61,33 +63,60 @@ def paint_subtitle(
|
||||
if not rows:
|
||||
return
|
||||
|
||||
margin = 18
|
||||
available = rect.width() - margin * 2
|
||||
pad = max(0, st.padding)
|
||||
available = rect.width() - pad * 2
|
||||
blocks: list[tuple[list[str], QColor, QFont]] = []
|
||||
total_h = 0
|
||||
text_w = 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)
|
||||
fm = QFontMetrics(font)
|
||||
lines = _wrap(text, fm, available, st.max_lines)
|
||||
blocks.append((lines, color, font))
|
||||
total_h += len(lines) * (QFontMetrics(font).height() + st.line_spacing)
|
||||
total_h += len(lines) * (fm.height() + st.line_spacing)
|
||||
for line in lines:
|
||||
text_w = max(text_w, fm.horizontalAdvance(line))
|
||||
|
||||
y = rect.y() + max(margin, (rect.height() - total_h) // 2)
|
||||
box = _content_box(rect, text_w, total_h, pad, st.align)
|
||||
|
||||
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(box, 12, 12)
|
||||
|
||||
y = box.y() + pad
|
||||
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
|
||||
x = box.x() + pad
|
||||
elif st.align == "right":
|
||||
x = rect.right() - margin - width
|
||||
x = box.right() - pad - width
|
||||
else:
|
||||
x = rect.x() + (rect.width() - width) // 2
|
||||
x = box.x() + (box.width() - width) // 2
|
||||
_draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st)
|
||||
y += fm.height() + st.line_spacing
|
||||
|
||||
|
||||
def _content_box(rect: QRect, text_w: int, text_h: int, pad: int, align: str) -> QRect:
|
||||
"""글자를 감싸는 배경 상자. 창 밖으로는 넘지 않는다."""
|
||||
width = min(rect.width(), text_w + pad * 2)
|
||||
height = min(rect.height(), text_h + pad * 2)
|
||||
if align == "left":
|
||||
x = rect.x()
|
||||
elif align == "right":
|
||||
x = rect.right() - width + 1
|
||||
else:
|
||||
x = rect.x() + (rect.width() - width) // 2
|
||||
y = rect.y() + (rect.height() - height) // 2
|
||||
return QRect(x, y, width, height)
|
||||
|
||||
|
||||
def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None:
|
||||
"""외곽선 있는 글자.
|
||||
|
||||
@@ -145,6 +174,10 @@ class SubtitleOverlay(QWidget):
|
||||
geometry_changed = Signal(int, int, int, int)
|
||||
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
|
||||
visibility_changed = Signal(bool)
|
||||
#: 크기 조정으로 글자 크기가 같이 바뀌었을 때 (설정 화면이 따라와야 한다)
|
||||
font_size_changed = Signal(int)
|
||||
#: 위치 조정 모드가 켜지고 꺼질 때
|
||||
edit_mode_changed = Signal(bool)
|
||||
closed = Signal()
|
||||
|
||||
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
|
||||
@@ -156,7 +189,13 @@ class SubtitleOverlay(QWidget):
|
||||
self._partial_text = ""
|
||||
self._source_text = ""
|
||||
self._drag_from: QPoint | None = None
|
||||
self._resize_from: QRect | None = None
|
||||
#: 위치 조정 모드. 설정에 저장하지 않는다 — 다음 실행 때는 다시 잠긴
|
||||
#: 상태로 시작해야 게임 중에 자막을 실수로 끌고 다니지 않는다.
|
||||
self._edit_mode = False
|
||||
self._resize_corner = ""
|
||||
self._resize_geo = QRect()
|
||||
self._resize_mouse = QPoint()
|
||||
self._resize_font = 0
|
||||
|
||||
self._fade = QTimer(self)
|
||||
self._fade.setSingleShot(True)
|
||||
@@ -181,8 +220,11 @@ class SubtitleOverlay(QWidget):
|
||||
else flags & ~Qt.WindowType.WindowStaysOnTopHint
|
||||
)
|
||||
self.setWindowFlags(flags)
|
||||
# 위치 조정 중에는 클릭 통과를 반드시 꺼야 한다. 켜둔 채로는 마우스가
|
||||
# 창을 그냥 지나가서 잡을 수가 없다.
|
||||
self.setAttribute(
|
||||
Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through
|
||||
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
|
||||
cfg.click_through and not self._edit_mode,
|
||||
)
|
||||
|
||||
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
|
||||
@@ -192,6 +234,50 @@ class SubtitleOverlay(QWidget):
|
||||
self.setVisible(cfg.visible)
|
||||
self.update()
|
||||
|
||||
# --- 위치 조정 모드 --------------------------------------------------
|
||||
@property
|
||||
def edit_mode(self) -> bool:
|
||||
return self._edit_mode
|
||||
|
||||
def set_edit_mode(self, on: bool) -> None:
|
||||
"""자막을 옮기고 크기를 바꿀 수 있는 상태로 전환한다.
|
||||
|
||||
평소에는 잠겨 있다. 게임 중에 자막을 잘못 건드려 끌고 다니는 일이
|
||||
없어야 하기 때문이다. 이 모드에서만 드래그·모서리 크기 조절·휠이
|
||||
동작하고, 켜는 동안에는 자막이 안 보여도 잡을 수 있게 창을 띄운다.
|
||||
"""
|
||||
if on == self._edit_mode:
|
||||
return
|
||||
self._edit_mode = on
|
||||
if on and not self.isVisible():
|
||||
self.setVisible(True)
|
||||
self.setAttribute(
|
||||
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
|
||||
self.overlay_cfg.click_through and not on,
|
||||
)
|
||||
self.setCursor(
|
||||
Qt.CursorShape.SizeAllCursor if on else Qt.CursorShape.ArrowCursor
|
||||
)
|
||||
if not on:
|
||||
self._drag_from = None
|
||||
self._resize_corner = ""
|
||||
self.update()
|
||||
self.edit_mode_changed.emit(on)
|
||||
|
||||
@property
|
||||
def _interactive(self) -> bool:
|
||||
return self._edit_mode or not self.overlay_cfg.locked
|
||||
|
||||
def _corner_at(self, pos: QPoint) -> str:
|
||||
"""모서리 손잡이를 집었는지. 집었으면 어느 모서리인지."""
|
||||
left = pos.x() <= GRIP
|
||||
right = pos.x() >= self.width() - GRIP
|
||||
top = pos.y() <= GRIP
|
||||
bottom = pos.y() >= self.height() - GRIP
|
||||
if (left or right) and (top or bottom):
|
||||
return ("top" if top else "bottom") + ("left" if left else "right")
|
||||
return ""
|
||||
|
||||
def snap_to_anchor(self, anchor: str | None = None, save_only: bool = False) -> None:
|
||||
"""선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식)."""
|
||||
cfg = self.overlay_cfg
|
||||
@@ -248,53 +334,94 @@ class SubtitleOverlay(QWidget):
|
||||
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)
|
||||
if self._edit_mode:
|
||||
self._draw_edit_chrome(painter)
|
||||
|
||||
def _draw_placeholder(self, painter) -> None:
|
||||
painter.setPen(QColor(255, 255, 255, 90))
|
||||
def _draw_edit_chrome(self, painter) -> None:
|
||||
"""위치 조정 중이라는 것과 어디를 잡으면 되는지 보여준다."""
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.setPen(QPen(QColor(90, 170, 255, 220), 2, Qt.PenStyle.DashLine))
|
||||
painter.drawRect(self.rect().adjusted(1, 1, -2, -2))
|
||||
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor(90, 170, 255, 235))
|
||||
for rect in self._corner_handles():
|
||||
painter.drawRect(rect)
|
||||
|
||||
if not (self._final_text or self._partial_text):
|
||||
painter.setPen(QColor(255, 255, 255, 170))
|
||||
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))
|
||||
def _corner_handles(self) -> list[QRect]:
|
||||
w, h = self.width(), self.height()
|
||||
for offset in (5, 10, 15):
|
||||
painter.drawLine(w - offset, h - 4, w - 4, h - offset)
|
||||
return [
|
||||
QRect(0, 0, GRIP, GRIP),
|
||||
QRect(w - GRIP, 0, GRIP, GRIP),
|
||||
QRect(0, h - GRIP, GRIP, GRIP),
|
||||
QRect(w - GRIP, h - GRIP, GRIP, GRIP),
|
||||
]
|
||||
|
||||
# --- 마우스 ---------------------------------------------------------
|
||||
def mousePressEvent(self, event) -> None: # noqa: N802
|
||||
if self.overlay_cfg.locked or event.button() != Qt.MouseButton.LeftButton:
|
||||
if not self._interactive 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())
|
||||
corner = self._corner_at(event.position().toPoint())
|
||||
if corner:
|
||||
self._resize_corner = corner
|
||||
self._resize_geo = QRect(self.geometry())
|
||||
self._resize_mouse = event.globalPosition().toPoint()
|
||||
self._resize_font = self.style_cfg.font_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()),
|
||||
)
|
||||
if self._resize_corner:
|
||||
self._resize_to(event.globalPosition().toPoint())
|
||||
elif self._drag_from is not None:
|
||||
self.move(event.globalPosition().toPoint() - self._drag_from)
|
||||
|
||||
def _resize_to(self, mouse: QPoint) -> None:
|
||||
"""잡은 모서리만 움직이고 반대쪽 모서리는 제자리에 둔다.
|
||||
|
||||
글자 크기는 높이 비율을 그대로 따라간다. 상자만 커지고 글자는 그대로면
|
||||
여백만 늘어나므로, 크기 조절이 '자막을 키우는' 조작이 되지 않는다.
|
||||
"""
|
||||
start = self._resize_geo
|
||||
dx = mouse.x() - self._resize_mouse.x()
|
||||
dy = mouse.y() - self._resize_mouse.y()
|
||||
left, top = start.left(), start.top()
|
||||
right, bottom = start.right(), start.bottom()
|
||||
|
||||
if "left" in self._resize_corner:
|
||||
left = min(left + dx, right - self.minimumWidth())
|
||||
else:
|
||||
right = max(right + dx, left + self.minimumWidth())
|
||||
if "top" in self._resize_corner:
|
||||
top = min(top + dy, bottom - self.minimumHeight())
|
||||
else:
|
||||
bottom = max(bottom + dy, top + self.minimumHeight())
|
||||
|
||||
rect = QRect(QPoint(left, top), QPoint(right, bottom))
|
||||
self.setGeometry(rect)
|
||||
|
||||
scaled = round(self._resize_font * rect.height() / max(1, start.height()))
|
||||
size = max(12, min(120, scaled))
|
||||
if size != self.style_cfg.font_size:
|
||||
self.style_cfg.font_size = size
|
||||
self.font_size_changed.emit(size)
|
||||
self.update()
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None: # noqa: N802
|
||||
if self._drag_from is None and self._resize_from is None:
|
||||
if self._drag_from is None and not self._resize_corner:
|
||||
return
|
||||
self._drag_from = None
|
||||
self._resize_from = None
|
||||
self._resize_corner = ""
|
||||
|
||||
cfg = self.overlay_cfg
|
||||
g = self.geometry()
|
||||
@@ -309,10 +436,13 @@ class SubtitleOverlay(QWidget):
|
||||
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
|
||||
|
||||
def wheelEvent(self, event) -> None: # noqa: N802
|
||||
if self.overlay_cfg.locked:
|
||||
if not self._interactive:
|
||||
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))
|
||||
size = max(12, min(120, self.style_cfg.font_size + step))
|
||||
if size != self.style_cfg.font_size:
|
||||
self.style_cfg.font_size = size
|
||||
self.font_size_changed.emit(size)
|
||||
self.update()
|
||||
|
||||
def contextMenuEvent(self, event) -> None: # noqa: N802
|
||||
@@ -343,7 +473,11 @@ class SubtitleOverlay(QWidget):
|
||||
|
||||
def _set_click_through(self, value: bool) -> None:
|
||||
self.overlay_cfg.click_through = value
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, value)
|
||||
# 위치 조정 중이면 통과를 켜지 않는다. 켜는 순간 창을 못 잡게 된다.
|
||||
self.setAttribute(
|
||||
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
|
||||
value and not self._edit_mode,
|
||||
)
|
||||
self.update()
|
||||
|
||||
def _set_always_on_top(self, value: bool) -> None:
|
||||
|
||||
@@ -14,7 +14,12 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...audio import capture_capabilities, list_sources
|
||||
from ...audio import (
|
||||
SOURCE_MODE_INPUT,
|
||||
SOURCE_MODE_OUTPUT,
|
||||
capture_capabilities,
|
||||
list_sources,
|
||||
)
|
||||
from ...config import AppConfig
|
||||
from ...constants import LANGUAGES
|
||||
from ...core.events import EngineState, EngineStatus, TranslationLine
|
||||
@@ -65,12 +70,34 @@ class HomePage(QWidget):
|
||||
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
|
||||
layout.addWidget(self.source_combo, 1)
|
||||
|
||||
# 마이크 등 입력 장치는 같은 목록에 섞지 않고 전환해서 보여준다.
|
||||
# 한 목록에 다 넣으면 정작 찾으려던 게임이 라인입력들 사이에 파묻힌다.
|
||||
self.mode_button = QPushButton()
|
||||
self.mode_button.setMinimumWidth(170)
|
||||
self.mode_button.clicked.connect(self._toggle_source_mode)
|
||||
layout.addWidget(self.mode_button)
|
||||
|
||||
refresh = QPushButton("새로고침")
|
||||
refresh.clicked.connect(self.refresh_sources)
|
||||
layout.addWidget(refresh)
|
||||
card.add(row)
|
||||
return card
|
||||
|
||||
@property
|
||||
def _mode(self) -> str:
|
||||
return (
|
||||
SOURCE_MODE_INPUT
|
||||
if self.config.audio.source_mode == SOURCE_MODE_INPUT
|
||||
else SOURCE_MODE_OUTPUT
|
||||
)
|
||||
|
||||
def _toggle_source_mode(self) -> None:
|
||||
self.config.audio.source_mode = (
|
||||
SOURCE_MODE_OUTPUT if self._mode == SOURCE_MODE_INPUT else SOURCE_MODE_INPUT
|
||||
)
|
||||
self.refresh_sources()
|
||||
self.config_changed.emit()
|
||||
|
||||
def _build_control_card(self) -> Card:
|
||||
card = Card()
|
||||
row = QWidget()
|
||||
@@ -145,19 +172,39 @@ class HomePage(QWidget):
|
||||
# --- 동작 -----------------------------------------------------------
|
||||
def refresh_sources(self) -> None:
|
||||
caps = capture_capabilities()
|
||||
self._sources = list_sources()
|
||||
mode = self._mode
|
||||
self._sources = list_sources(mode)
|
||||
|
||||
icons = {"process": "🎮", "device": "🔊", "input": "🎤"}
|
||||
self.source_combo.blockSignals(True)
|
||||
self.source_combo.clear()
|
||||
for src in self._sources:
|
||||
prefix = "🎮" if src.kind == "process" else "🔊"
|
||||
prefix = icons.get(src.kind, "🔊")
|
||||
self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src)
|
||||
if not self._sources:
|
||||
self.source_combo.addItem("사용 가능한 오디오 대상이 없습니다", None)
|
||||
self.source_combo.addItem(
|
||||
"마이크 등 입력 장치를 찾지 못했습니다"
|
||||
if mode == SOURCE_MODE_INPUT
|
||||
else "사용 가능한 오디오 대상이 없습니다",
|
||||
None,
|
||||
)
|
||||
self._select_saved_source()
|
||||
self.source_combo.blockSignals(False)
|
||||
self._on_source_changed()
|
||||
|
||||
# 버튼 글자는 '지금 상태'가 아니라 '누르면 가는 곳'을 적는다.
|
||||
if mode == SOURCE_MODE_INPUT:
|
||||
self.mode_button.setText("프로그램 소리에서 고르기")
|
||||
self.mode_button.setToolTip("게임 등 프로그램과 출력 장치 목록으로 돌아갑니다.")
|
||||
msg = (
|
||||
"마이크 등 입력 장치로 듣습니다. 통화 상대 목소리나 내가 말하는 것을 "
|
||||
"번역할 때 쓰세요."
|
||||
if caps["input"]
|
||||
else "이 환경에서는 입력 장치를 열 수 없습니다. Windows에서 실행하세요."
|
||||
)
|
||||
else:
|
||||
self.mode_button.setText("마이크 입력에서 고르기")
|
||||
self.mode_button.setToolTip("목록을 마이크 등 입력 장치로 바꿉니다.")
|
||||
if caps["process"]:
|
||||
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
|
||||
elif caps["device"]:
|
||||
@@ -176,7 +223,11 @@ class HomePage(QWidget):
|
||||
if cfg.backend == "process" and src.kind == "process" and src.pid == cfg.target_pid:
|
||||
self.source_combo.setCurrentIndex(i)
|
||||
return
|
||||
if cfg.backend == "device" and src.kind == "device" and src.device_index == cfg.device_index:
|
||||
if (
|
||||
cfg.backend in ("device", "input")
|
||||
and src.kind == cfg.backend
|
||||
and src.device_index == cfg.device_index
|
||||
):
|
||||
self.source_combo.setCurrentIndex(i)
|
||||
return
|
||||
|
||||
@@ -190,7 +241,7 @@ class HomePage(QWidget):
|
||||
cfg.target_pid = src.pid
|
||||
cfg.target_process_name = src.label
|
||||
else:
|
||||
cfg.backend = "device"
|
||||
cfg.backend = src.kind # "device" 또는 "input"
|
||||
cfg.device_index = src.device_index
|
||||
cfg.target_pid = 0
|
||||
self.config_changed.emit()
|
||||
@@ -226,7 +277,12 @@ class HomePage(QWidget):
|
||||
self.status_pill.set_status(status.message or status.state.value, kind)
|
||||
self.level_meter.set_level(status.level)
|
||||
|
||||
for widget in (self.source_combo, self.source_lang, self.target_lang):
|
||||
for widget in (
|
||||
self.source_combo,
|
||||
self.mode_button,
|
||||
self.source_lang,
|
||||
self.target_lang,
|
||||
):
|
||||
widget.setEnabled(not running)
|
||||
self.speech_combo.setEnabled(
|
||||
not running and self.config.models.target_lang == "ko"
|
||||
|
||||
@@ -17,8 +17,8 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ...config import AppConfig
|
||||
from ...models import Tier, detect_gpu, ordered_tiers
|
||||
from ...models.manager import GpuInfo, tier_availability
|
||||
from ...models import Tier, detect_gpu, get_tier, ordered_tiers
|
||||
from ...models.manager import GpuInfo, recommended_tier, tier_availability
|
||||
from ..theme import SPACING, palette
|
||||
from ..widgets import Card, PageHeader
|
||||
|
||||
@@ -26,7 +26,13 @@ from ..widgets import Card, PageHeader
|
||||
class TierCard(QFrame):
|
||||
"""티어 하나를 라디오 버튼 카드로."""
|
||||
|
||||
def __init__(self, tier: Tier, gpu: GpuInfo, parent: QWidget | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
tier: Tier,
|
||||
gpu: GpuInfo,
|
||||
recommended: bool = False,
|
||||
parent: QWidget | None = None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.tier = tier
|
||||
gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
|
||||
@@ -49,8 +55,10 @@ class TierCard(QFrame):
|
||||
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))
|
||||
# '추천'은 표에 박힌 값이 아니라 이 PC 기준으로 계산해서 붙인다.
|
||||
# GPU 가 없는데 10GB 짜리에 추천 배지가 달려 있으면 안내가 아니라 함정이다.
|
||||
if recommended:
|
||||
head.addWidget(_badge("CPU 추천" if not gpu.available else "추천", p.accent))
|
||||
if tier.best_after_finetune:
|
||||
head.addWidget(_badge("추가학습 최적", p.success))
|
||||
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
|
||||
@@ -106,6 +114,8 @@ class ModelsPage(QWidget):
|
||||
# 티어 카드마다 조회하면 nvidia-smi 를 다섯 번 부른다. 한 번만 본다.
|
||||
self._gpu = detect_gpu()
|
||||
self._gpu_vram_gb = self._gpu.total_vram_mb / 1024 if self._gpu.available else 0.0
|
||||
#: 이 PC 기준 추천 티어. GPU 가 없으면 CPU 에서 실제로 돌아가는 쪽을 권한다.
|
||||
self._recommended = recommended_tier(self._gpu)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -123,7 +133,7 @@ class ModelsPage(QWidget):
|
||||
self._group = QButtonGroup(self)
|
||||
self._group.setExclusive(True)
|
||||
for tier in ordered_tiers():
|
||||
card = TierCard(tier, self._gpu)
|
||||
card = TierCard(tier, self._gpu, recommended=tier.key == self._recommended)
|
||||
self._group.addButton(card.radio, tier.order)
|
||||
card.radio.toggled.connect(
|
||||
lambda checked, t=tier: self._on_tier_selected(t) if checked else None
|
||||
@@ -163,6 +173,10 @@ class ModelsPage(QWidget):
|
||||
self.device_combo = QComboBox()
|
||||
self.device_combo.addItem("GPU (CUDA)", "cuda")
|
||||
self.device_combo.addItem("CPU", "cpu")
|
||||
# GPU 가 없으면 콤보를 비활성화만 하고 "GPU (CUDA)" 를 그대로 띄워두면
|
||||
# 실제로는 CPU 로 도는데 화면은 GPU 라고 말하는 상태가 된다. 맞춰둔다.
|
||||
if not gpu.available:
|
||||
self.config.models.device = "cpu"
|
||||
self.device_combo.setCurrentIndex(
|
||||
max(0, self.device_combo.findData(self.config.models.device))
|
||||
)
|
||||
@@ -171,8 +185,42 @@ class ModelsPage(QWidget):
|
||||
layout.addWidget(self.device_combo)
|
||||
|
||||
card.add(row)
|
||||
|
||||
if not gpu.available:
|
||||
card.add(self._build_cpu_advice_row())
|
||||
return card
|
||||
|
||||
def _build_cpu_advice_row(self) -> QWidget:
|
||||
"""GPU 를 못 찾았을 때 CPU 에서 실제로 쓸 만한 설정을 권한다."""
|
||||
tier = get_tier(self._recommended)
|
||||
row = QWidget()
|
||||
row.setObjectName("Row")
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(10)
|
||||
|
||||
advice = QLabel(
|
||||
f"CPU 에서는 '{tier.order}. {tier.name}' 를 권합니다 — "
|
||||
f"CPU 로 실시간 자막이 따라오는 가장 무거운 구성입니다. "
|
||||
f"상위 티어는 한 문장에 수십 초가 걸려 실시간으로 쓸 수 없습니다."
|
||||
)
|
||||
advice.setObjectName("Hint")
|
||||
advice.setWordWrap(True)
|
||||
layout.addWidget(advice, 1)
|
||||
|
||||
apply_button = QPushButton("추천대로 맞추기")
|
||||
apply_button.clicked.connect(self._apply_recommended)
|
||||
layout.addWidget(apply_button)
|
||||
return row
|
||||
|
||||
def _apply_recommended(self) -> None:
|
||||
self.config.models.device = "cpu"
|
||||
self.device_combo.blockSignals(True)
|
||||
self.device_combo.setCurrentIndex(max(0, self.device_combo.findData("cpu")))
|
||||
self.device_combo.blockSignals(False)
|
||||
self._apply_selection(self._recommended)
|
||||
self._on_tier_selected(get_tier(self._recommended))
|
||||
|
||||
def _build_lora_card(self) -> Card:
|
||||
card = Card(
|
||||
"추가학습 어댑터 (선택)",
|
||||
|
||||
@@ -9,6 +9,7 @@ from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
@@ -19,6 +20,7 @@ from PySide6.QtWidgets import (
|
||||
from ...config import AppConfig
|
||||
from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
|
||||
from ...models import detect_gpu
|
||||
from ...storage import free_space, human_size
|
||||
from ..theme import SPACING, palette
|
||||
from ..widgets import Card, PageHeader
|
||||
|
||||
@@ -45,6 +47,7 @@ class SettingsPage(QWidget):
|
||||
root.addWidget(self._build_hotkey_card())
|
||||
root.addWidget(self._build_audio_card())
|
||||
root.addWidget(self._build_behavior_card())
|
||||
root.addWidget(self._build_storage_card())
|
||||
root.addWidget(self._build_about_card())
|
||||
root.addStretch(1)
|
||||
|
||||
@@ -258,20 +261,61 @@ class SettingsPage(QWidget):
|
||||
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_storage_card(self) -> Card:
|
||||
card = Card(
|
||||
"저장 위치",
|
||||
"모델과 설정, 번역 기록이 여기에 저장됩니다. 모델은 품질 티어에 따라 "
|
||||
"2~16GB 를 차지하므로 여유 있는 디스크가 좋습니다.",
|
||||
)
|
||||
self.storage_label = QLabel()
|
||||
self.storage_label.setObjectName("Hint")
|
||||
self.storage_label.setWordWrap(True)
|
||||
self.storage_label.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
card.add(self.storage_label)
|
||||
|
||||
row = QWidget()
|
||||
row_layout = QHBoxLayout(row)
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
change = QPushButton("위치 변경…")
|
||||
change.clicked.connect(self._change_storage)
|
||||
open_folder = QPushButton("폴더 열기")
|
||||
open_folder.clicked.connect(self._open_folder)
|
||||
row_layout.addWidget(change)
|
||||
row_layout.addWidget(open_folder)
|
||||
row_layout.addStretch(1)
|
||||
card.add(row)
|
||||
|
||||
self._refresh_storage_label()
|
||||
return card
|
||||
|
||||
def _refresh_storage_label(self) -> None:
|
||||
path = user_data_dir()
|
||||
self.storage_label.setText(f"{path}\n이 디스크의 남은 공간 {human_size(free_space(path))}")
|
||||
|
||||
def _change_storage(self) -> None:
|
||||
from ..data_location import ask_change_location
|
||||
|
||||
target = ask_change_location(self)
|
||||
if target is None:
|
||||
return
|
||||
# 옮기지 않기를 골랐다면 새 위치에 설정 파일이 없다. 지금 상태를 써둔다.
|
||||
self.config.save()
|
||||
self._refresh_storage_label()
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"저장 위치를 바꿨습니다",
|
||||
f"이제 여기를 씁니다.\n\n{target}\n\n"
|
||||
"이미 메모리에 올라가 있는 모델은 그대로 쓰고, 다음에 받는 것부터 "
|
||||
"새 위치에 저장됩니다. 로그 파일은 다음 실행부터 옮겨집니다.",
|
||||
)
|
||||
|
||||
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
|
||||
return Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN)
|
||||
|
||||
# --- 동작 -----------------------------------------------------------
|
||||
def _on_audio_changed(self, *_) -> None:
|
||||
|
||||
@@ -61,6 +61,8 @@ class SubtitlePage(QWidget):
|
||||
overlay_toggled = Signal(bool)
|
||||
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜
|
||||
placement_changed = Signal()
|
||||
#: '자막 위치 조정' 버튼 — 오버레이를 조정 가능한 상태로 열고 닫는다
|
||||
edit_mode_requested = Signal(bool)
|
||||
|
||||
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
@@ -88,7 +90,7 @@ class SubtitlePage(QWidget):
|
||||
card = Card(
|
||||
"표시 위치",
|
||||
"모니터를 고르고 네모칸을 누르면 그 자리에 자막이 붙습니다. "
|
||||
"직접 드래그해서 옮기면 그 위치를 그대로 기억합니다.",
|
||||
"칸에 없는 자리에 두고 싶으면 아래 '자막 위치 조정'으로 직접 옮기세요.",
|
||||
)
|
||||
|
||||
# --- on/off + 단축키 안내 ---
|
||||
@@ -159,9 +161,55 @@ class SubtitlePage(QWidget):
|
||||
margin_layout.addStretch(1)
|
||||
card.add(margin_row)
|
||||
|
||||
# --- 직접 옮기기 ---
|
||||
# 자막 창은 기본적으로 잠겨 있다. 게임 중에 잘못 건드려 끌고 다니는
|
||||
# 사고를 막기 위해서다. 옮기거나 크기를 바꿀 때만 여기서 연다.
|
||||
adjust_row = QWidget()
|
||||
adjust_layout = QHBoxLayout(adjust_row)
|
||||
adjust_layout.setContentsMargins(0, 0, 0, 0)
|
||||
adjust_layout.setSpacing(10)
|
||||
|
||||
self.adjust_button = QPushButton("자막 위치 조정")
|
||||
self.adjust_button.setCheckable(True)
|
||||
self.adjust_button.setMinimumWidth(150)
|
||||
self.adjust_button.toggled.connect(self.edit_mode_requested.emit)
|
||||
adjust_layout.addWidget(self.adjust_button)
|
||||
|
||||
self.adjust_hint = QLabel()
|
||||
self.adjust_hint.setObjectName("Hint")
|
||||
self.adjust_hint.setWordWrap(True)
|
||||
adjust_layout.addWidget(self.adjust_hint, 1)
|
||||
card.add(adjust_row)
|
||||
self.set_edit_mode(False)
|
||||
|
||||
self.refresh_screens()
|
||||
return card
|
||||
|
||||
def set_edit_mode(self, on: bool) -> None:
|
||||
"""오버레이의 위치 조정 상태를 버튼과 안내에 반영한다."""
|
||||
self.adjust_button.blockSignals(True)
|
||||
self.adjust_button.setChecked(on)
|
||||
self.adjust_button.blockSignals(False)
|
||||
self.adjust_button.setText("조정 끝내기" if on else "자막 위치 조정")
|
||||
self.adjust_button.setObjectName("Danger" if on else "")
|
||||
self.adjust_button.style().polish(self.adjust_button)
|
||||
self.adjust_hint.setText(
|
||||
"자막 창을 끌어서 옮기고, 네 모서리를 잡아 크기를 바꾸세요. "
|
||||
"크기를 바꾸면 글자 크기도 같이 따라옵니다."
|
||||
if on
|
||||
else "평소에는 자막이 잠겨 있어 게임 중에 실수로 움직이지 않습니다."
|
||||
)
|
||||
|
||||
def set_font_size(self, size: int) -> None:
|
||||
"""자막 창에서 크기를 바꿨을 때 설정 화면 숫자를 맞춘다."""
|
||||
if size == self.size_spin.value():
|
||||
return
|
||||
self.size_spin.blockSignals(True)
|
||||
self.size_spin.setValue(size)
|
||||
self.size_spin.blockSignals(False)
|
||||
self.config.subtitle.font_size = size
|
||||
self._sync_preview()
|
||||
|
||||
def _build_preview_card(self) -> Card:
|
||||
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
|
||||
self.preview = SubtitlePreview(self.config.subtitle)
|
||||
@@ -238,6 +286,17 @@ class SubtitlePage(QWidget):
|
||||
layout.addWidget(self.opacity_value)
|
||||
form.addRow("배경 투명도", opacity_row)
|
||||
|
||||
self.padding_spin = QSpinBox()
|
||||
self.padding_spin.setRange(0, 80)
|
||||
self.padding_spin.setSuffix(" px")
|
||||
self.padding_spin.setValue(st.padding)
|
||||
self.padding_spin.setToolTip(
|
||||
"배경 상자는 글자를 감싸는 크기로 그려집니다. 이 값이 글자와 상자 "
|
||||
"가장자리 사이 간격, 곧 배경 크기를 정합니다."
|
||||
)
|
||||
self.padding_spin.valueChanged.connect(self._on_change)
|
||||
form.addRow("배경 여백", self.padding_spin)
|
||||
|
||||
self.align_combo = QComboBox()
|
||||
for label, value in (("가운데", "center"), ("왼쪽", "left"), ("오른쪽", "right")):
|
||||
self.align_combo.addItem(label, value)
|
||||
@@ -345,6 +404,7 @@ class SubtitlePage(QWidget):
|
||||
st.outline_width = self.outline_spin.value()
|
||||
st.background_color = self.bg_color.color
|
||||
st.background_opacity = self.opacity_slider.value()
|
||||
st.padding = self.padding_spin.value()
|
||||
st.align = self.align_combo.currentData()
|
||||
st.max_lines = self.lines_spin.value()
|
||||
st.fade_out_ms = self.fade_spin.value()
|
||||
|
||||
75
tests/test_audio_sources.py
Normal file
75
tests/test_audio_sources.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""캡처 대상 목록 — 출력(게임 소리)과 입력(마이크) 전환."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from livesub import audio
|
||||
from livesub.audio import (
|
||||
SOURCE_MODE_INPUT,
|
||||
SOURCE_MODE_OUTPUT,
|
||||
AudioSource,
|
||||
CaptureError,
|
||||
InputDeviceCapture,
|
||||
create_capture,
|
||||
list_sources,
|
||||
)
|
||||
from livesub.config import AudioConfig
|
||||
|
||||
MIC = AudioSource(kind="input", identifier="3", label="마이크 (Realtek)", detail="기본 입력 장치")
|
||||
SPEAKER = AudioSource(kind="device", identifier="7", label="스피커", detail="기본 출력 장치")
|
||||
GAME = AudioSource(kind="process", identifier="4242", label="Overwatch.exe")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_devices(monkeypatch):
|
||||
monkeypatch.setattr(InputDeviceCapture, "available", staticmethod(lambda: True))
|
||||
monkeypatch.setattr(InputDeviceCapture, "list_sources", staticmethod(lambda: [MIC]))
|
||||
monkeypatch.setattr(
|
||||
audio.WasapiLoopbackCapture, "list_sources", staticmethod(lambda: [SPEAKER])
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
audio.ProcessLoopbackCapture, "available", staticmethod(lambda: True)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
audio.ProcessLoopbackCapture, "list_sources", staticmethod(lambda: [GAME])
|
||||
)
|
||||
|
||||
|
||||
def test_기본은_프로그램과_출력_장치를_보여준다(fake_devices):
|
||||
kinds = [s.kind for s in list_sources()]
|
||||
assert kinds == ["process", "device"]
|
||||
assert list_sources() == list_sources(SOURCE_MODE_OUTPUT)
|
||||
|
||||
|
||||
def test_입력_모드는_마이크만_보여준다(fake_devices):
|
||||
"""두 목록을 섞으면 찾으려던 게임이 라인입력 사이에 파묻힌다."""
|
||||
sources = list_sources(SOURCE_MODE_INPUT)
|
||||
assert [s.kind for s in sources] == ["input"]
|
||||
assert sources[0].label == "마이크 (Realtek)"
|
||||
|
||||
|
||||
def test_입력_장치도_장치_번호를_준다():
|
||||
assert MIC.device_index == 3
|
||||
assert MIC.pid == 0
|
||||
|
||||
|
||||
def test_입력_백엔드로_캡처를_만든다(fake_devices):
|
||||
cfg = AudioConfig(backend="input", device_index=3)
|
||||
capture = create_capture(cfg)
|
||||
assert isinstance(capture, InputDeviceCapture)
|
||||
assert capture.device_index == 3
|
||||
|
||||
|
||||
def test_입력_장치를_못_열면_이유를_알려준다(monkeypatch):
|
||||
monkeypatch.setattr(InputDeviceCapture, "available", staticmethod(lambda: False))
|
||||
with pytest.raises(CaptureError, match="입력 장치"):
|
||||
create_capture(AudioConfig(backend="input", device_index=3))
|
||||
|
||||
|
||||
def test_입력_가능_여부가_환경_보고에_들어간다():
|
||||
assert "input" in audio.capture_capabilities()
|
||||
|
||||
|
||||
def test_고른_목록_쪽을_설정에_기억한다():
|
||||
assert AudioConfig().source_mode == SOURCE_MODE_OUTPUT
|
||||
61
tests/test_data_location.py
Normal file
61
tests/test_data_location.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""저장 위치 대화상자 — 못 쓰는 폴더를 고르면 진행을 막는지."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from livesub import storage # noqa: E402
|
||||
from livesub.ui.data_location import DataLocationDialog # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def os_dir(tmp_path, monkeypatch):
|
||||
base = tmp_path / "appdata" / "LiveSub"
|
||||
monkeypatch.setattr(storage, "_os_config_dir", lambda: base)
|
||||
return base
|
||||
|
||||
|
||||
def test_first_run_dialog_starts_on_the_default_folder(qapp, os_dir):
|
||||
dialog = DataLocationDialog(storage.default_data_dir(), first_run=True)
|
||||
try:
|
||||
assert dialog.selected() == os_dir
|
||||
assert dialog.ok_button.isEnabled()
|
||||
finally:
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_empty_path_blocks_confirmation(qapp, os_dir):
|
||||
dialog = DataLocationDialog(storage.default_data_dir(), first_run=True)
|
||||
try:
|
||||
dialog.path_edit.setText(" ")
|
||||
assert not dialog.ok_button.isEnabled()
|
||||
dialog.path_edit.setText(str(os_dir.parent / "elsewhere"))
|
||||
assert dialog.ok_button.isEnabled()
|
||||
finally:
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_change_dialog_blocks_a_subfolder_of_the_current_one(qapp, tmp_path):
|
||||
current = tmp_path / "current"
|
||||
current.mkdir()
|
||||
dialog = DataLocationDialog(current, first_run=False)
|
||||
try:
|
||||
dialog.path_edit.setText(str(current / "inside"))
|
||||
assert not dialog.ok_button.isEnabled()
|
||||
dialog.path_edit.setText(str(tmp_path / "other"))
|
||||
assert dialog.ok_button.isEnabled()
|
||||
finally:
|
||||
dialog.deleteLater()
|
||||
@@ -190,3 +190,24 @@ def test_ct2_티어는_gpu_조회_없이도_통과한다():
|
||||
assert tier_availability(TIERS[key])[0]
|
||||
finally:
|
||||
mgr.detect_gpu = original
|
||||
|
||||
|
||||
def test_진입_스크립트는_패키지_없이도_임포트된다():
|
||||
"""PyInstaller 는 진입 스크립트를 **패키지 없이** 최상위로 실행한다.
|
||||
|
||||
그래서 그 파일에 상대 임포트가 하나라도 있으면 exe 가 켜지자마자
|
||||
`attempted relative import with no known parent package` 로 죽는다.
|
||||
실제로 그 상태의 exe 가 릴리스까지 나갔고, "8초 뒤에도 살아 있으면 통과"
|
||||
였던 점검은 오류창이 떠 있는 것을 정상으로 봤다.
|
||||
|
||||
`runpy.run_path` 가 바로 그 조건(패키지 문맥 없음)을 만들어준다.
|
||||
run_name 을 `__main__` 이 아닌 것으로 주면 GUI 는 뜨지 않고 임포트만 돈다.
|
||||
"""
|
||||
import runpy
|
||||
|
||||
root = SPEC.resolve().parents[1]
|
||||
entry = root / "src" / "livesub" / "__main__.py"
|
||||
# spec 이 실제로 이 파일을 진입점으로 쓰는지 확인한다. 아니면 엉뚱한 파일을
|
||||
# 지키고 있는 셈이 된다.
|
||||
assert '"livesub" / "__main__.py"' in SPEC.read_text(encoding="utf-8")
|
||||
runpy.run_path(str(entry), run_name="__livesub_frozen_probe__")
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from livesub.audio import segmenter as segmenter_mod
|
||||
from livesub.audio.segmenter import Segmenter, SegmenterConfig
|
||||
from livesub.constants import SAMPLE_RATE
|
||||
|
||||
#: webrtcvad 는 선택 의존성이다. 있으면 아래 게이트 테스트가 돌고 없으면 skip 된다.
|
||||
REAL_WEBRTCVAD = segmenter_mod.webrtcvad
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _energy_vad_only(monkeypatch):
|
||||
"""이 파일의 테스트는 에너지 VAD 경로로 고정한다.
|
||||
|
||||
입력이 220Hz 순음이기 때문이다. webrtcvad 는 사람 목소리로 학습돼 있어
|
||||
순음을 발화로 보지 않고, `_is_speech` 는 에너지 판정과 webrtcvad 를 AND 로
|
||||
묶는다. 그래서 webrtcvad 가 깔린 환경에서는 이 파일의 테스트 5개가 깨졌다 —
|
||||
포터블 exe 의 requirements 에 webrtcvad-wheels 가 들어 있어 Windows CI 가
|
||||
정확히 그 환경이고, 개발용 venv 에는 없어서 여태 안 드러났다.
|
||||
|
||||
고칠 대상은 판정기가 아니라 테스트의 숨은 전제다. 여기서 보려는 건 분할
|
||||
상태 기계이므로 선택 의존성을 끊어 환경과 무관하게 같은 경로를 시험한다.
|
||||
게이트 동작 자체는 test_webrtcvad_gate_filters_pure_tone 이 본다.
|
||||
"""
|
||||
monkeypatch.setattr(segmenter_mod, "webrtcvad", None)
|
||||
|
||||
|
||||
def tone(ms: int, amplitude: float = 0.3) -> np.ndarray:
|
||||
n = SAMPLE_RATE * ms // 1000
|
||||
@@ -76,3 +98,24 @@ def test_lead_in_is_prepended():
|
||||
assert len(finals) == 1
|
||||
# 0.6초 발화 + 최대 0.2초 리드인
|
||||
assert finals[0].duration_s > 0.6
|
||||
|
||||
|
||||
@pytest.mark.skipif(REAL_WEBRTCVAD is None, reason="webrtcvad 미설치 — 게이트 경로가 없다")
|
||||
def test_webrtcvad_gate_filters_pure_tone(monkeypatch):
|
||||
"""webrtcvad 가 깔리면 순음은 에너지 VAD 만일 때보다 덜 통과한다.
|
||||
|
||||
포터블 exe 와 Windows CI 가 이 경로를 쓴다. 두 경로가 실제로 다르게
|
||||
동작한다는 사실을 테스트로 고정해 둔다 — 이게 깨져 있는 걸 몰라서
|
||||
같은 테스트가 플랫폼에 따라 다른 결과를 냈다.
|
||||
"""
|
||||
energy_only = Segmenter(config())
|
||||
energy_out = energy_only.push(tone(800)) + energy_only.push(silence(600))
|
||||
|
||||
monkeypatch.setattr(segmenter_mod, "webrtcvad", REAL_WEBRTCVAD)
|
||||
gated = Segmenter(config())
|
||||
gated_out = gated.push(tone(800)) + gated.push(silence(600))
|
||||
|
||||
energy_total = sum(s.duration_s for s in energy_out if s.is_final)
|
||||
gated_total = sum(s.duration_s for s in gated_out if s.is_final)
|
||||
assert energy_total > 0.7 # 에너지 VAD 는 순음을 발화로 본다
|
||||
assert gated_total < energy_total # webrtcvad 는 사람 목소리가 아니라고 걸러낸다
|
||||
|
||||
124
tests/test_storage.py
Normal file
124
tests/test_storage.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""저장 위치 선택 — 고른 곳에 실제로 쌓이고, 다음 실행 때 그대로 따라가는지."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from livesub import storage
|
||||
from livesub.config import AppConfig, config_path
|
||||
from livesub.constants import models_dir, user_data_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def os_dir(tmp_path, monkeypatch):
|
||||
"""OS 표준 설정 폴더(쪽지가 놓이는 곳)를 임시 폴더로 바꾼다."""
|
||||
base = tmp_path / "appdata" / "LiveSub"
|
||||
monkeypatch.setattr(storage, "_os_config_dir", lambda: base)
|
||||
return base
|
||||
|
||||
|
||||
def test_first_run_is_not_configured(os_dir):
|
||||
assert storage.is_configured() is False
|
||||
assert storage.stored_data_dir() is None
|
||||
|
||||
|
||||
def test_choice_is_remembered(os_dir, tmp_path):
|
||||
chosen = tmp_path / "D" / "LiveSub"
|
||||
storage.set_data_dir(chosen)
|
||||
|
||||
assert storage.is_configured() is True
|
||||
assert storage.stored_data_dir() == chosen.resolve()
|
||||
assert storage.data_dir() == chosen.resolve()
|
||||
|
||||
|
||||
def test_everything_lands_in_the_chosen_folder(os_dir, tmp_path):
|
||||
"""쪽지만 OS 폴더에 남고 내용물은 고른 곳으로 가야 한다."""
|
||||
chosen = tmp_path / "games" / "LiveSub"
|
||||
storage.set_data_dir(chosen)
|
||||
|
||||
assert user_data_dir() == chosen.resolve()
|
||||
assert config_path().parent == chosen.resolve()
|
||||
assert models_dir() == chosen.resolve() / "models"
|
||||
|
||||
AppConfig().save()
|
||||
assert (chosen / "config.json").is_file()
|
||||
assert not (os_dir / "config.json").exists()
|
||||
assert list(os_dir.iterdir()) == [os_dir / storage.POINTER_NAME]
|
||||
|
||||
|
||||
def test_existing_install_is_not_asked_again(os_dir):
|
||||
"""위치 선택이 없던 버전에서 올라온 사람에게 다시 묻지 않는다."""
|
||||
os_dir.mkdir(parents=True)
|
||||
(os_dir / "models").mkdir()
|
||||
|
||||
assert storage.is_configured() is True
|
||||
assert storage.stored_data_dir() is None # 쪽지는 없지만
|
||||
assert storage.data_dir() == os_dir # 쓰던 자리를 그대로 쓴다
|
||||
|
||||
|
||||
def test_broken_pointer_falls_back_to_default(os_dir):
|
||||
os_dir.mkdir(parents=True)
|
||||
(os_dir / storage.POINTER_NAME).write_text("{ 깨진 json", encoding="utf-8")
|
||||
|
||||
assert storage.stored_data_dir() is None
|
||||
assert storage.data_dir() == os_dir
|
||||
|
||||
|
||||
def test_unusable_location_falls_back_instead_of_crashing(os_dir, tmp_path):
|
||||
"""고른 디스크가 빠졌거나 경로가 막혔을 때 죽지 않고 기본 위치로 물러난다."""
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("파일이라 하위 폴더를 만들 수 없다", encoding="utf-8")
|
||||
os_dir.mkdir(parents=True)
|
||||
(os_dir / storage.POINTER_NAME).write_text(
|
||||
json.dumps({"data_dir": str(blocker / "LiveSub")}), encoding="utf-8"
|
||||
)
|
||||
|
||||
assert storage.data_dir() == os_dir
|
||||
|
||||
|
||||
def test_check_target_rejects_unusable_choices(tmp_path):
|
||||
current = tmp_path / "current"
|
||||
current.mkdir()
|
||||
|
||||
assert storage.check_target("") is not None
|
||||
assert storage.check_target(current / "inside", current) is not None # 자기 하위
|
||||
assert storage.check_target(current, current) is None # 같은 곳은 문제 아님
|
||||
assert storage.check_target(tmp_path / "new") is None # 아직 없어도 만들 수 있으면 OK
|
||||
|
||||
|
||||
def test_move_data_carries_models_and_config(tmp_path):
|
||||
old, new = tmp_path / "old", tmp_path / "new"
|
||||
(old / "models" / "whisper").mkdir(parents=True)
|
||||
(old / "models" / "whisper" / "model.bin").write_bytes(b"x" * 16)
|
||||
(old / "config.json").write_text("{}", encoding="utf-8")
|
||||
(old / "livesub.log").write_text("로그는 안 옮긴다", encoding="utf-8")
|
||||
|
||||
moved = storage.move_data(old, new)
|
||||
|
||||
assert sorted(moved) == ["config.json", "models"]
|
||||
assert (new / "models" / "whisper" / "model.bin").read_bytes() == b"x" * 16
|
||||
assert not (old / "models").exists()
|
||||
assert (old / "livesub.log").is_file() # 실행 중 잠겨 있으므로 제외 대상
|
||||
|
||||
|
||||
def test_move_data_never_overwrites_existing(tmp_path):
|
||||
old, new = tmp_path / "old", tmp_path / "new"
|
||||
old.mkdir()
|
||||
new.mkdir()
|
||||
(old / "config.json").write_text("옛날 것", encoding="utf-8")
|
||||
(new / "config.json").write_text("새 위치에 이미 있던 것", encoding="utf-8")
|
||||
|
||||
assert storage.move_data(old, new) == []
|
||||
assert (new / "config.json").read_text(encoding="utf-8") == "새 위치에 이미 있던 것"
|
||||
assert (old / "config.json").is_file()
|
||||
|
||||
|
||||
def test_move_data_to_same_place_does_nothing(tmp_path):
|
||||
same = tmp_path / "same"
|
||||
same.mkdir()
|
||||
(same / "config.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert storage.move_data(same, same) == []
|
||||
assert (same / "config.json").is_file()
|
||||
149
tests/test_subtitle_box.py
Normal file
149
tests/test_subtitle_box.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""자막 배경 상자(padding)와 위치 조정 모드."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QPoint, QRect # noqa: E402
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from livesub.config import OverlayConfig, SubtitleStyle # noqa: E402
|
||||
from livesub.ui.overlay import SubtitleOverlay, _content_box # noqa: E402
|
||||
|
||||
WINDOW = QRect(0, 0, 1000, 400)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
# --- 배경 상자 -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_배경은_글자를_감싼다():
|
||||
"""창 전체가 아니라 글자 크기에 맞춰야 여백이 곧 배경 크기가 된다."""
|
||||
box = _content_box(WINDOW, text_w=300, text_h=60, pad=18, align="center")
|
||||
assert box.width() == 300 + 18 * 2
|
||||
assert box.height() == 60 + 18 * 2
|
||||
assert box.width() < WINDOW.width()
|
||||
|
||||
|
||||
def test_여백을_키우면_배경이_커진다():
|
||||
small = _content_box(WINDOW, 300, 60, pad=8, align="center")
|
||||
large = _content_box(WINDOW, 300, 60, pad=40, align="center")
|
||||
assert large.width() - small.width() == (40 - 8) * 2
|
||||
assert large.height() - small.height() == (40 - 8) * 2
|
||||
|
||||
|
||||
def test_배경은_창_밖으로_넘지_않는다():
|
||||
box = _content_box(WINDOW, text_w=5000, text_h=5000, pad=40, align="center")
|
||||
assert box.width() == WINDOW.width()
|
||||
assert box.height() == WINDOW.height()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("align", "expected_left"),
|
||||
[("left", 0), ("center", (1000 - 336) // 2), ("right", 1000 - 336)],
|
||||
)
|
||||
def test_정렬에_따라_상자가_붙는_쪽이_달라진다(align, expected_left):
|
||||
box = _content_box(WINDOW, text_w=300, text_h=60, pad=18, align=align)
|
||||
assert box.width() == 336
|
||||
assert abs(box.x() - expected_left) <= 1
|
||||
|
||||
|
||||
def test_기본_여백이_설정에_있다():
|
||||
assert SubtitleStyle().padding > 0
|
||||
|
||||
|
||||
# --- 위치 조정 모드 --------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def overlay(qapp):
|
||||
cfg = OverlayConfig()
|
||||
cfg.visible = False
|
||||
widget = SubtitleOverlay(SubtitleStyle(), cfg)
|
||||
yield widget
|
||||
widget.close()
|
||||
|
||||
|
||||
def test_기본은_잠겨_있어_움직이지_않는다(overlay):
|
||||
"""게임 중에 자막을 실수로 끌고 다니는 사고를 막는 기본값이다."""
|
||||
assert OverlayConfig().locked is True
|
||||
assert overlay.edit_mode is False
|
||||
assert overlay._interactive is False
|
||||
|
||||
|
||||
def test_위치_조정을_켜면_잡을_수_있다(overlay):
|
||||
overlay.set_edit_mode(True)
|
||||
assert overlay.edit_mode is True
|
||||
assert overlay._interactive is True
|
||||
# 클릭 통과가 켜져 있으면 마우스가 창을 그냥 지나가 잡을 수가 없다.
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
assert not overlay.testAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
|
||||
overlay.set_edit_mode(False)
|
||||
assert overlay._interactive is False
|
||||
|
||||
|
||||
def test_모서리를_집었는지_알아본다(overlay):
|
||||
overlay.setGeometry(0, 0, 600, 200)
|
||||
assert overlay._corner_at(QPoint(2, 2)) == "topleft"
|
||||
assert overlay._corner_at(QPoint(598, 2)) == "topright"
|
||||
assert overlay._corner_at(QPoint(2, 198)) == "bottomleft"
|
||||
assert overlay._corner_at(QPoint(598, 198)) == "bottomright"
|
||||
# 가운데는 이동용이지 크기 조절이 아니다
|
||||
assert overlay._corner_at(QPoint(300, 100)) == ""
|
||||
|
||||
|
||||
def test_크기를_키우면_글자도_같이_커진다(overlay):
|
||||
overlay.set_edit_mode(True)
|
||||
overlay.setGeometry(100, 100, 600, 200)
|
||||
overlay.style_cfg.font_size = 40
|
||||
|
||||
overlay._resize_corner = "bottomright"
|
||||
overlay._resize_geo = QRect(overlay.geometry())
|
||||
overlay._resize_mouse = QPoint(700, 300)
|
||||
overlay._resize_font = 40
|
||||
overlay._resize_to(QPoint(700, 500)) # 높이 200 -> 400
|
||||
|
||||
assert overlay.height() == 400
|
||||
assert overlay.style_cfg.font_size == 80
|
||||
|
||||
|
||||
def test_반대쪽_모서리는_제자리에_있는다(overlay):
|
||||
"""왼쪽 위를 끌면 오른쪽 아래는 움직이지 않아야 한다."""
|
||||
overlay.set_edit_mode(True)
|
||||
overlay.setGeometry(200, 200, 600, 200)
|
||||
start = QRect(overlay.geometry())
|
||||
|
||||
overlay._resize_corner = "topleft"
|
||||
overlay._resize_geo = start
|
||||
overlay._resize_mouse = QPoint(200, 200)
|
||||
overlay._resize_font = overlay.style_cfg.font_size
|
||||
overlay._resize_to(QPoint(300, 250))
|
||||
|
||||
assert overlay.geometry().right() == start.right()
|
||||
assert overlay.geometry().bottom() == start.bottom()
|
||||
assert overlay.geometry().x() == 300
|
||||
|
||||
|
||||
def test_최소_크기_아래로는_줄지_않는다(overlay):
|
||||
overlay.set_edit_mode(True)
|
||||
overlay.setGeometry(0, 0, 600, 200)
|
||||
overlay._resize_corner = "bottomright"
|
||||
overlay._resize_geo = QRect(overlay.geometry())
|
||||
overlay._resize_mouse = QPoint(600, 200)
|
||||
overlay._resize_font = overlay.style_cfg.font_size
|
||||
overlay._resize_to(QPoint(-5000, -5000))
|
||||
|
||||
assert overlay.width() >= overlay.minimumWidth()
|
||||
assert overlay.height() >= overlay.minimumHeight()
|
||||
@@ -49,3 +49,21 @@ def test_unknown_key_falls_back_to_default():
|
||||
|
||||
def test_default_tier_exists():
|
||||
assert DEFAULT_TIER in TIERS
|
||||
|
||||
|
||||
def test_gpu_가_없으면_cpu_에서_도는_티어를_추천한다():
|
||||
"""표에 박힌 '추천'은 GPU 가 넉넉할 때 얘기다. 없는 사람에게 10GB 짜리를
|
||||
권하면 안내가 아니라 함정이 된다."""
|
||||
from livesub.models.manager import GpuInfo, recommended_tier
|
||||
|
||||
key = recommended_tier(GpuInfo(False, reason="GPU 없음"))
|
||||
tier = get_tier(key)
|
||||
assert tier.mt.backend is MTBackend.CTRANSLATE2
|
||||
assert tier.order == 1 # CPU 에서는 연산량이 그대로 지연이 된다
|
||||
|
||||
|
||||
def test_vram_이_모자라면_추천도_내려간다():
|
||||
from livesub.models.manager import GpuInfo, recommended_tier
|
||||
|
||||
small = get_tier(recommended_tier(GpuInfo(True, "RTX 5050", 7680, 7000)))
|
||||
assert small.min_vram_gb <= 7.5
|
||||
|
||||
Reference in New Issue
Block a user