Compare commits
38 Commits
ec1bc70b04
...
v0.3.0-tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec5e285bd | ||
|
|
7f678fba7b | ||
|
|
628280b227 | ||
|
|
88effe0e58 | ||
|
|
81e0271b69 | ||
|
|
f79293d893 | ||
|
|
2028b0a651 | ||
|
|
4fd8fabcb8 | ||
|
|
ca096a69c3 | ||
|
|
0a1389a1d9 | ||
|
|
a66eedee8b | ||
|
|
dbe1370425 | ||
|
|
03a2ca50c0 | ||
|
|
e234ad4401 | ||
|
|
162540b025 | ||
|
|
f37da0cbe2 | ||
|
|
89b0ee0cf7 | ||
|
|
f9fd86464d | ||
|
|
ae295dc308 | ||
|
|
e41d0cb441 | ||
|
|
d0028abdac | ||
|
|
fb88cea427 | ||
|
|
8a86e7e7ac | ||
|
|
48b7106ac6 | ||
|
|
88b9d931eb | ||
|
|
bb385c1d7b | ||
|
|
8d91087f85 | ||
|
|
bef9eaf19b | ||
|
|
4af97a68f2 | ||
|
|
2fcd961f8f | ||
|
|
bede94ee48 | ||
|
|
71436843d8 | ||
|
|
e4a048c8d5 | ||
|
|
a7eafd03e1 | ||
|
|
8b36b244ff | ||
|
|
5596f905d1 | ||
|
|
1a87ec6677 | ||
|
|
c24e5f938d |
88
.gitea/workflows/windows-gpu.yml
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Windows GPU 검증 — GPU 가 달린 러너에서만
|
||||||
|
#
|
||||||
|
# GPU 는 한 VM 만 독점한다(컨슈머 NVIDIA 는 vGPU 미지원). 그래서 GPU 없는
|
||||||
|
# 상시 CI VM(runs-on: windows)에서는 CUDA·VRAM·4~5티어를 확인할 수 없다.
|
||||||
|
# 그 항목만 떼어내 별도 라벨로 돌린다.
|
||||||
|
#
|
||||||
|
# 러너 라벨: windows-gpu:host
|
||||||
|
# - GPU 가 달린 Windows 머신에 등록한다 (두 번째 GPU를 꽂은 VM, 또는 실물 PC)
|
||||||
|
# - 상시로 띄울 필요 없다. 필요할 때만 daemon 을 켜면 된다
|
||||||
|
#
|
||||||
|
# 그래서 트리거도 **수동 실행만** 이다. 러너가 없으면 큐에 쌓이기만 하고
|
||||||
|
# 아무 일도 일어나지 않는다. 자동으로 돌리고 싶으면 push 트리거를 켜면 된다.
|
||||||
|
|
||||||
|
name: Windows GPU
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# GPU 는 나눠 쓸 수 없다. 두 작업이 동시에 VRAM 을 잡으면 둘 다 OOM 으로
|
||||||
|
# 죽거나, 더 나쁘게는 하나가 조용히 느려져 원인을 못 찾는다.
|
||||||
|
# 이건 같은 저장소 안에서만 막아준다. **다른 저장소까지 포함한 진짜 보장은
|
||||||
|
# 러너의 capacity: 1** 이다 (docs/WINDOWS-TESTING.md 참고).
|
||||||
|
concurrency:
|
||||||
|
group: windows-gpu
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gpu:
|
||||||
|
runs-on: windows-gpu
|
||||||
|
timeout-minutes: 90
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# 라벨이 안 맞으면 Gitea 가 기본 ubuntu 컨테이너로 떨어뜨린다.
|
||||||
|
# GPU 를 확인하겠다고 부른 작업이 엉뚱한 데서 도는 일은 막아야 한다.
|
||||||
|
- name: Windows 인지 확인
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
Write-Host "RUNNER_OS=$env:RUNNER_OS"
|
||||||
|
if ($env:RUNNER_OS -ne "Windows") {
|
||||||
|
Write-Host "::error::Windows 러너가 아닙니다 ($env:RUNNER_OS)."
|
||||||
|
Write-Host "::error::러너를 'windows-gpu:host' 라벨로 등록했는지 확인하세요."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: NVIDIA GPU 가 실제로 보이는지
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
nvidia-smi --query-gpu=name,memory.total --format=csv,noheader
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "::error::nvidia-smi 실패 — 이 러너에는 GPU 가 없습니다."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: 소스 받기
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 포터블 CI 와 달리 여기서는 torch CUDA 빌드가 필요하다.
|
||||||
|
# 4~5티어(Seed-X / Qwen3)가 torch 위에서 돌기 때문이다.
|
||||||
|
- name: 의존성 설치 (torch CUDA 포함)
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
python -m venv .venv-gpu
|
||||||
|
.\.venv-gpu\Scripts\python -m pip install --upgrade pip --quiet
|
||||||
|
.\.venv-gpu\Scripts\python -m pip install torch --index-url https://download.pytorch.org/whl/cu128
|
||||||
|
.\.venv-gpu\Scripts\python -m pip install -r requirements.txt
|
||||||
|
.\.venv-gpu\Scripts\python -c "import torch; assert torch.cuda.is_available(), 'CUDA 안 잡힘 — CPU 빌드가 깔렸는지 확인'; print('CUDA OK:', torch.cuda.get_device_name(0))"
|
||||||
|
|
||||||
|
# 4티어(Seed-X AWQ Int4)에만 필요하고 Windows 휠이 없을 때가 있다.
|
||||||
|
# 실패해도 멈추지 않는다 — 점검기가 autoawq 유무를 보고 4티어를
|
||||||
|
# 건너뛰면서 "건너뜀: 정밀(autoawq 없음)" 이라고 분명히 남긴다.
|
||||||
|
- name: autoawq (4티어 전용, 선택)
|
||||||
|
shell: pwsh
|
||||||
|
continue-on-error: true
|
||||||
|
run: .\.venv-gpu\Scripts\python -m pip install autoawq
|
||||||
|
|
||||||
|
- name: GPU 전용 점검
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: .\.venv-gpu\Scripts\python packaging\windows_smoke.py --gpu
|
||||||
|
|
||||||
|
- name: 결과물 업로드
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: LiveSub-gpu-report
|
||||||
|
path: artifacts/*.txt
|
||||||
|
retention-days: 14
|
||||||
116
.gitea/workflows/windows.yml
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Windows 검증 — Gitea Actions
|
||||||
|
#
|
||||||
|
# main 에 push 하거나 Actions 탭에서 수동 실행하면 Windows 러너가 자동으로
|
||||||
|
# 1) 테스트를 돌리고
|
||||||
|
# 2) 포터블 exe 를 빌드하고
|
||||||
|
# 3) 앱이 실제로 뜨는지 확인한 뒤
|
||||||
|
# 4) exe 와 스크린샷을 artifact 로 올립니다.
|
||||||
|
#
|
||||||
|
# 러너 등록 방법은 docs/WINDOWS-TESTING.md 참고.
|
||||||
|
# 러너가 없으면 이 워크플로는 그냥 대기 상태로 남고 아무 일도 하지 않습니다.
|
||||||
|
|
||||||
|
name: Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: windows
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Gitea 는 runs-on 이 어떤 러너 라벨과도 안 맞으면 작업을 실패시키지 않고
|
||||||
|
# 기본 ubuntu 컨테이너에서 돌린다. 라벨을 잘못 등록하면 Windows 검증이
|
||||||
|
# 조용히 리눅스에서 돌아가 "통과했다"는 착각을 준다. 그래서 먼저 막는다.
|
||||||
|
#
|
||||||
|
# bash 가 아니라 pwsh 를 쓴다. Windows 에 Git Bash 가 깔려 있어도
|
||||||
|
# bash.exe 는 기본 PATH 에 없어서 (Git\cmd 만 등록된다) 첫 단계부터
|
||||||
|
# "bash not found" 로 죽는다. 라벨을 잘못 걸어 리눅스 컨테이너로
|
||||||
|
# 떨어진 경우에는 pwsh 가 없어서 역시 실패하므로, 어느 쪽이든
|
||||||
|
# 조용히 통과하는 일은 없다.
|
||||||
|
- name: Windows 에서 돌고 있는지 확인
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
Write-Host "RUNNER_OS=$env:RUNNER_OS"
|
||||||
|
if ($env:RUNNER_OS -ne "Windows") {
|
||||||
|
Write-Host "::error::Windows 러너가 아닙니다 ($env:RUNNER_OS)."
|
||||||
|
Write-Host "::error::러너를 'windows:host' 라벨로 등록했는지 확인하세요."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: 소스 받기
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Python 준비
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
python --version
|
||||||
|
python -m venv .venv-ci
|
||||||
|
.\.venv-ci\Scripts\python -m pip install --upgrade pip --quiet
|
||||||
|
|
||||||
|
# torch 없이 설치한다. 포터블 빌드와 같은 조건을 만들어야
|
||||||
|
# "포터블에서 1~3티어가 된다"는 주장을 실제로 검증할 수 있다.
|
||||||
|
- name: 의존성 설치 (포터블 조건 = torch 없음)
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
.\.venv-ci\Scripts\python -m pip install -r packaging\requirements-portable.txt
|
||||||
|
.\.venv-ci\Scripts\python -m pip install pytest ruff
|
||||||
|
.\.venv-ci\Scripts\python -c "import importlib.util; assert importlib.util.find_spec('torch') is None; print('torch 없음 - 포터블 조건 OK')"
|
||||||
|
|
||||||
|
- name: 테스트
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
.\.venv-ci\Scripts\python -m pytest tests -q
|
||||||
|
.\.venv-ci\Scripts\python -m ruff check src tests scripts
|
||||||
|
|
||||||
|
# 여기부터가 리눅스에서 못 하던 것들이다.
|
||||||
|
- name: Windows 전용 경로 확인 (오디오 장치·단축키·프로세스 열거)
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: .\.venv-ci\Scripts\python packaging\windows_smoke.py
|
||||||
|
|
||||||
|
# 부품이 다 통과해도 이어 붙이면 안 되는 경우가 있다. 실제 음성 파일을
|
||||||
|
# 넣어 한국어 자막이 나오는지 끝까지 확인한다. CPU 로 돌므로 GPU 없는
|
||||||
|
# 서버 VM 에서도 그대로 된다. 모델은 처음 한 번만 받고 이후 캐시를 쓴다.
|
||||||
|
- name: 전 파이프라인 (오디오 -> 자막)
|
||||||
|
shell: pwsh
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: .\.venv-ci\Scripts\python packaging\windows_smoke.py --pipeline
|
||||||
|
|
||||||
|
- name: 프로그램별 캡처 보조 프로그램 빌드
|
||||||
|
shell: pwsh
|
||||||
|
continue-on-error: true # VS Build Tools 가 없는 러너도 있을 수 있다
|
||||||
|
run: |
|
||||||
|
cmake -S native\process_loopback -B native\process_loopback\build -A x64
|
||||||
|
cmake --build native\process_loopback\build --config Release
|
||||||
|
|
||||||
|
- name: 포터블 exe 빌드
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
.\.venv-ci\Scripts\python -m pip install pyinstaller
|
||||||
|
.\.venv-ci\Scripts\python -m PyInstaller packaging\livesub.spec --noconfirm
|
||||||
|
$exe = Get-Item dist\LiveSub.exe
|
||||||
|
"빌드 완료: {0:N1} MB" -f ($exe.Length / 1MB)
|
||||||
|
|
||||||
|
- name: 빌드된 exe 가 실제로 실행되는지
|
||||||
|
shell: pwsh
|
||||||
|
run: .\.venv-ci\Scripts\python packaging\windows_smoke.py --exe dist\LiveSub.exe
|
||||||
|
|
||||||
|
- name: 결과물 업로드
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: LiveSub-portable
|
||||||
|
path: |
|
||||||
|
dist/LiveSub.exe
|
||||||
|
artifacts/*.png
|
||||||
|
artifacts/*.txt
|
||||||
|
retention-days: 14
|
||||||
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# 네이티브 빌드 산출물
|
||||||
|
#
|
||||||
|
# ⚠️ 패키지 이름은 livesub 다. 예전 이름(hearo)이 그대로 남아 있어서 빌드한
|
||||||
|
# livesub_capture.exe 가 무시 대상에서 빠져 있었다.
|
||||||
|
native/process_loopback/build/
|
||||||
|
src/livesub/resources/bin/*.exe
|
||||||
|
src/livesub/resources/bin/*.pdb
|
||||||
|
|
||||||
|
# 모델 캐시 / 학습 산출물 / 사용자 데이터
|
||||||
|
#
|
||||||
|
# ⚠️ 반드시 슬래시로 시작해 저장소 루트에 고정할 것. `models/` 로 쓰면 깊이에
|
||||||
|
# 상관없이 같은 이름의 폴더를 전부 먹는다. 실제로 이것 때문에 소스 패키지
|
||||||
|
# src/livesub/models/ 8개 파일이 한 번도 커밋되지 않았고, Gitea 에서 clone 한
|
||||||
|
# 쪽은 앱이 아예 임포트되지 않았다. Windows CI VM 첫 검증에서 잡혔다.
|
||||||
|
/models/
|
||||||
|
/adapters/
|
||||||
|
/data/
|
||||||
|
*.jsonl
|
||||||
|
config.json
|
||||||
|
glossary.json
|
||||||
|
transcripts/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 에디터
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
artifacts/
|
||||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 tkrmagid
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
324
README.md
@@ -1,2 +1,324 @@
|
|||||||
# live-app-translator
|
# LiveSub — 게임 소리를 실시간 자막으로
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
게임이나 프로그램에서 나오는 소리를 실시간으로 받아 번역해, 화면 위에 자막으로 얹어줍니다.
|
||||||
|
디스코드 오버레이처럼 동작하고, 번역은 전부 **내 컴퓨터의 GPU에서** 돌아갑니다.
|
||||||
|
인터넷도, API 키도 필요 없습니다.
|
||||||
|
|
||||||
|
한국어 · English · 日本語 · 中文 사이를 번역합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 무엇을 하는 프로그램인가
|
||||||
|
|
||||||
|
게임을 하는데 영어 음성만 나올 때, 해외 방송을 보는데 자막이 없을 때,
|
||||||
|
그 프로그램을 골라서 `번역 시작`만 누르면 화면에 한국어 자막이 뜹니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
게임 소리 → 음성인식 → 번역 → 화면 위 자막
|
||||||
|
(Whisper) (Seed-X / NLLB)
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- **프로그램 단위 소리 캡처** — 게임 소리만 받고 디스코드 음성은 안 받습니다
|
||||||
|
- **게임과 같이 써도 안 무겁게** — GPU를 얼마나 양보할지 직접 정합니다 (아래 참고)
|
||||||
|
- **디스코드식 자막 배치** — 모니터를 고르고 9칸 중 하나를 누르면 그 자리에 붙습니다
|
||||||
|
- **전역 단축키** — 게임 중에 `Ctrl+Alt+S` 로 자막을 껐다 켭니다
|
||||||
|
- **게임별 기본 용어집 내장** — 옵치2·워독스·배그·R6·롤·발로란트·에이펙스 등 **540개** (필수 게임은 기본 켜짐)
|
||||||
|
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지
|
||||||
|
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수
|
||||||
|
- **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다
|
||||||
|
- **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다
|
||||||
|
- **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고)
|
||||||
|
- **완전 로컬** — 음성이 외부로 나가지 않습니다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 게임과 같이 쓰기 (GPU 절약)
|
||||||
|
|
||||||
|
게임이 먼저입니다. `설정` 화면의 **저부하 모드**(기본 켜짐)가 이렇게 동작합니다.
|
||||||
|
|
||||||
|
| 항목 | 기본값 | 하는 일 |
|
||||||
|
|---|---|---|
|
||||||
|
| 연산 정밀도 | int8 | VRAM 사용량과 연산량을 줄입니다 |
|
||||||
|
| VRAM 상한 | 35% | 게임이 쓸 VRAM을 먼저 확보합니다 |
|
||||||
|
| GPU 양보 시간 | 60ms | 문장 하나 처리 후 GPU를 잠깐 놓습니다 |
|
||||||
|
| 유휴 시 모델 해제 | 300초 | 조용하면 모델을 내려 VRAM을 통째로 반납합니다 |
|
||||||
|
| 중간 결과 | 꺼짐 | 같은 오디오를 두 번 인식하지 않습니다 |
|
||||||
|
|
||||||
|
8GB GPU 기준으로 자막이 약 2.8GB만 쓰고 나머지 5GB 이상을 게임에 남깁니다.
|
||||||
|
게임이 여전히 버벅이면 VRAM 상한을 낮추거나 모델 티어를 `1. 번개`로 내리세요.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 말투 — 존댓말 / 반말
|
||||||
|
|
||||||
|
홈 화면에서 고릅니다. **기본은 존댓말**입니다.
|
||||||
|
|
||||||
|
| 상황 | 존댓말 모드 | 반말 모드 |
|
||||||
|
|---|---|---|
|
||||||
|
| 영어·중국어 원문 (높임 없음) | 존댓말 | 반말 |
|
||||||
|
| 일본어 원문이 `です/ます` | 존댓말 | **존댓말** |
|
||||||
|
| 일본어 원문이 반말 | 존댓말 | 반말 |
|
||||||
|
|
||||||
|
**원문이 실제로 존댓말이면 반말 모드여도 존댓말로 나옵니다.** 상대가 정중하게
|
||||||
|
말했는데 자막이 반말이면 뉘앙스가 통째로 뒤집히기 때문입니다.
|
||||||
|
영어·중국어는 문법적 높임이 없어서 항상 고른 모드를 따릅니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
Enemy coming from the left 존댓말 → 적이 왼쪽에서 옵니다
|
||||||
|
반말 → 적이 왼쪽에서 와
|
||||||
|
左から来ます (정중) 존댓말 → 적이 왼쪽에서 옵니다
|
||||||
|
반말 → 적이 왼쪽에서 옵니다 ← 원문이 존댓말
|
||||||
|
```
|
||||||
|
|
||||||
|
## 자막 위치 잡기
|
||||||
|
|
||||||
|
`자막` 화면에서 **모니터를 고르고 네모칸을 누르면** 그 자리에 붙습니다.
|
||||||
|
디스코드 오버레이와 같은 방식입니다. 미세 조정은 자막을 직접 드래그하면 되고,
|
||||||
|
그러면 그 위치를 그대로 기억합니다.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| 단축키 | 동작 |
|
||||||
|
|---|---|
|
||||||
|
| `Ctrl+Alt+S` | 자막 켜기 / 끄기 |
|
||||||
|
| `Ctrl+Alt+D` | 번역 시작 / 정지 |
|
||||||
|
|
||||||
|
전역 단축키라 **게임 창이 떠 있어도 동작합니다** (Windows 전용). `설정`에서 바꿀 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 설치 — 둘 중 하나
|
||||||
|
|
||||||
|
| | 포터블 (권장) | 일반 설치 |
|
||||||
|
|---|---|---|
|
||||||
|
| 준비물 | `LiveSub.exe` 하나 | Python + PyTorch |
|
||||||
|
| 크기 | 약 500~700MB | 약 3GB |
|
||||||
|
| 쓸 수 있는 티어 | 1~3 | 1~5 전부 |
|
||||||
|
| 번역 품질 | NLLB | NLLB + Seed-X / Qwen3 |
|
||||||
|
|
||||||
|
포터블은 PyTorch를 빼서 만듭니다 (토크나이저용 `transformers`는 포함). PyTorch+CUDA만 2.5GB라 한 파일로 묶으면
|
||||||
|
**실행할 때마다** 그걸 임시폴더에 푸느라 1분 넘게 걸리기 때문입니다.
|
||||||
|
음성인식도 번역도 CTranslate2 위에서 돌아가 PyTorch가 필요 없으므로,
|
||||||
|
1~3티어는 포터블에서 그대로 다 됩니다.
|
||||||
|
|
||||||
|
### 포터블 만들기
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
`dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고, **첫 실행 때
|
||||||
|
고른 폴더**로 내려받습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 일반 설치
|
||||||
|
|
||||||
|
### 1. 사전 준비
|
||||||
|
|
||||||
|
- Windows 10 (2004 이상) 또는 Windows 11
|
||||||
|
- NVIDIA GPU (권장 VRAM 6GB 이상) + 최신 드라이버
|
||||||
|
- Python 3.10 ~ 3.12
|
||||||
|
|
||||||
|
### 2. PyTorch (CUDA 빌드) 먼저
|
||||||
|
|
||||||
|
CPU 버전이 깔리면 GPU를 못 씁니다. 반드시 이 순서로 설치하세요.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
|
||||||
|
# Blackwell(RTX 50 시리즈)은 cu128, 그 이전 세대는 cu124
|
||||||
|
pip install torch --index-url https://download.pytorch.org/whl/cu128
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 나머지 설치
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 프로그램별 캡처 켜기 (선택, 권장)
|
||||||
|
|
||||||
|
이걸 빌드하지 않으면 출력 장치 전체 소리를 받습니다 (다른 앱 소리도 섞임).
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Visual Studio 2022 Build Tools (C++ 데스크톱) + CMake 필요
|
||||||
|
winget install Microsoft.VisualStudio.2022.BuildTools
|
||||||
|
winget install Kitware.CMake
|
||||||
|
|
||||||
|
powershell -ExecutionPolicy Bypass -File native\process_loopback\build.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 실행
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
livesub
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 `python -m livesub`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 처음 쓸 때
|
||||||
|
|
||||||
|
### 먼저 저장 위치를 고릅니다
|
||||||
|
|
||||||
|
모델이 티어에 따라 2~17GB를 차지합니다. C 드라이브가 좁으면 곤란하니
|
||||||
|
**처음 켜면 어디에 저장할지 물어봅니다.** 고른 폴더에 모델·설정·번역 기록이
|
||||||
|
전부 들어갑니다.
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
### 그다음
|
||||||
|
|
||||||
|
1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다
|
||||||
|
2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다
|
||||||
|
3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다)
|
||||||
|
4. **자막** 화면에서 모니터와 위치를 정합니다
|
||||||
|
5. `번역 시작` — 또는 게임 중에 `Ctrl+Alt+D`
|
||||||
|
|
||||||
|
모델 티어 기본값은 `2. 신속`입니다. 게임과 같이 돌리기 좋은 지점이라 그렇게 잡았고,
|
||||||
|
VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다.
|
||||||
|
|
||||||
|
모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름).
|
||||||
|
저장 위치는 처음 켤 때 고른 그 폴더입니다.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 모델 티어
|
||||||
|
|
||||||
|
| # | 이름 | VRAM | 지연 | 설명 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | 번개 | 2GB | 0.6초 | 속도 최우선. 저사양 GPU |
|
||||||
|
| 2 | **신속** | 4GB | 0.9초 | **기본값.** 인식률은 최상급, 번역만 경량 |
|
||||||
|
| 3 | 균형 | 6GB | 1.2초 | 게임을 안 켜고 볼 때 무난 |
|
||||||
|
| 4 | **정밀** | 10GB | 2.0초 | **추천.** 현 시점 최고 번역 품질 |
|
||||||
|
| 5 | 극한 | 16GB | 3.0초 | 추가학습(LoRA)에 최적 |
|
||||||
|
|
||||||
|
어떤 모델을 왜 골랐는지는 [docs/MODELS.md](docs/MODELS.md)에 정리했습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 용어집
|
||||||
|
|
||||||
|
게임 고유명사가 이상하게 번역될 때 씁니다. **학습이 필요 없고 즉시 적용됩니다.**
|
||||||
|
|
||||||
|
### 기본 제공 용어집
|
||||||
|
|
||||||
|
**처음 설치하면 `FPS 공통 · 오버워치2 · 배그 · R6 · 워독스`가 이미 켜져 있습니다.**
|
||||||
|
안 하는 게임은 체크를 끄면 됩니다.
|
||||||
|
|
||||||
|
| 팩 | 개수 | 내용 |
|
||||||
|
|---|---|---|
|
||||||
|
| 리그 오브 레전드 | 74 | 바론·갱·억제기·한타·오브젝트 콜 |
|
||||||
|
| 레인보우 식스 시즈 | 67 | 보강·하드브리치·앵커/로머·디퓨저·파밸 |
|
||||||
|
| 배틀그라운드 | 65 | 자기장·어부·파밍·낙하·스쿼드 콜 |
|
||||||
|
| 발로란트 | 59 | 스파이크·설치·해체·이코·리테이크 |
|
||||||
|
| 워치독스 | 59 | 데드섹·ctOS·블룸·해킹/잠입 용어 |
|
||||||
|
| 오버워치 2 | 49 | 궁 게이지·거점·화물·역할군 |
|
||||||
|
| FPS 공통 | 46 | 장르 전반의 총기·교전·파티 콜 |
|
||||||
|
| 에이펙스 레전드 | 45 | 링·배너·리스폰 비컨·스쿼드 콜 |
|
||||||
|
| 마인크래프트 | 38 | 블록·몹·차원 이름 (한국어판 공식 번역어) |
|
||||||
|
| 방송·스트리밍 | 38 | 후원·구독·클립·메타 등 방송 말투 |
|
||||||
|
|
||||||
|
#### 여러 팩을 같이 켜면
|
||||||
|
|
||||||
|
적용 순서는 **장르 공통 → 게임 전용 → 내가 등록한 것** 입니다. 뒤에 오는 쪽이 이기므로
|
||||||
|
`knocked`는 배그를 켰으면 항상 "눕혔다"(배그)가 되고, 체크한 순서에 휘둘리지 않습니다.
|
||||||
|
|
||||||
|
게임 전용 팩끼리 역어가 갈리는 경우(예: `payload`가 오버워치는 "화물", 워독스는 "페이로드")는
|
||||||
|
용어집 화면이 어떤 단어인지 알려줍니다. 안 하는 게임을 끄거나, 원하는 역어를 직접
|
||||||
|
등록하면 됩니다.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 직접 추가하기
|
||||||
|
|
||||||
|
`용어집` 화면 표에 입력하거나 CSV로 가져오세요.
|
||||||
|
**같은 단어를 직접 등록하면 기본 팩보다 항상 우선합니다.**
|
||||||
|
|
||||||
|
```csv
|
||||||
|
원문 용어,한국어 역어,English 역어,日本語 역어,中文 역어,메모
|
||||||
|
Nexus,넥서스,Nexus,ネクサス,基地,LoL
|
||||||
|
baron,바론,Baron,バロン,男爵,LoL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 추가학습
|
||||||
|
|
||||||
|
말투나 문장 구조까지 바꾸고 싶다면 LoRA로 학습시킬 수 있습니다.
|
||||||
|
언어쌍당 문장 1,000~3,000개가 필요합니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pip install -e ".[finetune]"
|
||||||
|
python scripts\finetune_mt.py --data data\game.jsonl --tier ultimate --output .\adapters\game-ko
|
||||||
|
```
|
||||||
|
|
||||||
|
데이터를 어떻게 모으고 어느 모델을 학습시켜야 하는지는
|
||||||
|
[docs/FINETUNING.md](docs/FINETUNING.md)를 참고하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자막 창 조작
|
||||||
|
|
||||||
|
| 동작 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| `Ctrl+Alt+S` | 자막 켜기 / 끄기 (게임 중에도 동작) |
|
||||||
|
| 드래그 | 위치 이동 (자유 배치로 전환) |
|
||||||
|
| 우하단 모서리 드래그 | 크기 조절 |
|
||||||
|
| 마우스 휠 | 글자 크기 |
|
||||||
|
| 우클릭 | 잠금 / 클릭 통과 / 항상 위에 / 숨기기 |
|
||||||
|
|
||||||
|
클릭 통과는 기본으로 켜져 있어, 자막 위를 클릭해도 게임으로 전달됩니다.
|
||||||
|
위치를 옮기려면 `자막` 화면에서 클릭 통과를 잠깐 끄세요.
|
||||||
|
|
||||||
|
전체화면 게임에서 자막이 안 보이면 게임을 **테두리 없는 창 모드**로 바꾸세요.
|
||||||
|
독점 전체화면(exclusive fullscreen)에서는 어떤 오버레이도 표시되지 않습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개발
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
pytest # GPU·오디오 장치 없이 실행됩니다
|
||||||
|
ruff check src tests scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
구조는 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)를 보세요.
|
||||||
|
|
||||||
|
### Windows 자동 검증
|
||||||
|
|
||||||
|
개발은 리눅스에서 하므로 오디오 캡처·전역 단축키·exe 빌드는 여기서 테스트할 수
|
||||||
|
없습니다. Windows 러너를 붙이면 push할 때마다 자동으로 확인되고 exe와
|
||||||
|
스크린샷이 artifact로 올라옵니다 — [docs/WINDOWS-TESTING.md](docs/WINDOWS-TESTING.md).
|
||||||
|
|
||||||
|
러너 없이 그때그때 확인만 하려면:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv\Scripts\python packaging\windows_smoke.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 라이선스
|
||||||
|
|
||||||
|
MIT. 사용하는 모델은 각자의 라이선스를 따릅니다
|
||||||
|
(Whisper: MIT, NLLB: CC-BY-NC, Seed-X: OpenMDW, Qwen3: Apache-2.0).
|
||||||
|
**NLLB는 비상업적 이용만 허용됩니다.** 상업적으로 쓰려면 4~5티어를 사용하세요.
|
||||||
|
|||||||
154
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# 구조
|
||||||
|
|
||||||
|
## 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[캡처 스레드] [분할 스레드] [추론 스레드] [GUI 스레드]
|
||||||
|
livesub_capture.exe → Segmenter (VAD) → Whisper → 번역 모델 → 자막 오버레이
|
||||||
|
또는 WASAPI 발화 단위로 절단 용어집 적용 컨트롤 창 로그
|
||||||
|
↓ ↓ ↓ ↑
|
||||||
|
오디오 큐 구간 큐(최대 32) TranslationLine Qt Signal
|
||||||
|
(16kHz mono f32) 확정 / 중간결과
|
||||||
|
```
|
||||||
|
|
||||||
|
세 워커 스레드는 모두 큐로만 연결된다. 어느 한 단계가 밀려도 나머지가 멈추지 않는다.
|
||||||
|
큐가 가득 차면 **오래된 오디오를 버린다** — 실시간 자막에서는 밀린 소리보다 지금 나는
|
||||||
|
소리가 항상 더 가치 있기 때문이다.
|
||||||
|
|
||||||
|
## 패키지
|
||||||
|
|
||||||
|
```
|
||||||
|
src/livesub/
|
||||||
|
constants.py 전역 상수, 언어 표, 경로
|
||||||
|
config.py 설정 dataclass + JSON 영속화
|
||||||
|
app.py 진입점
|
||||||
|
|
||||||
|
audio/
|
||||||
|
base.py CaptureBackend 추상 클래스, 리샘플링
|
||||||
|
process_loopback.py 프로그램별 캡처 (네이티브 보조 프로그램 구동)
|
||||||
|
wasapi_loopback.py 출력 장치 전체 캡처 (PyAudioWPatch)
|
||||||
|
file_source.py WAV 재생 (테스트·데모용)
|
||||||
|
segmenter.py 적응형 VAD로 발화 구간 절단
|
||||||
|
|
||||||
|
models/
|
||||||
|
tiers.py 5단계 티어 + 프롬프트 형식 정의
|
||||||
|
asr.py faster-whisper 래퍼
|
||||||
|
translator.py CTranslate2 / Transformers 두 백엔드
|
||||||
|
glossary.py 용어집 (플레이스홀더 보호 + 프롬프트 주입)
|
||||||
|
packs.py 게임별 기본 용어집 팩 로드·병합
|
||||||
|
manager.py 티어별 모델 쌍 로드·해제, GPU 탐지, VRAM 상한
|
||||||
|
|
||||||
|
core/
|
||||||
|
engine.py 파이프라인 오케스트레이션 (Qt 비의존)
|
||||||
|
events.py TranslationLine, EngineStatus
|
||||||
|
|
||||||
|
ui/
|
||||||
|
theme.py 디자인 토큰 + QSS
|
||||||
|
overlay.py 자막 창 + 공용 렌더링 함수
|
||||||
|
placement.py 모니터 목록 + 9분할 배치 계산 (Qt 비의존)
|
||||||
|
hotkeys.py 전역 단축키 (Windows RegisterHotKey)
|
||||||
|
main_window.py 사이드바 + 페이지 스택
|
||||||
|
pages/ 홈 / 모델 / 자막 / 용어집 / 설정
|
||||||
|
widgets/ Card, StatusPill, LevelMeter, AnchorGrid
|
||||||
|
|
||||||
|
resources/glossaries/ 게임별 기본 용어집 JSON (롤·발로란트·옵치2·마크 등)
|
||||||
|
|
||||||
|
native/process_loopback/ WASAPI process loopback C++ 보조 프로그램
|
||||||
|
scripts/finetune_mt.py LoRA 추가학습
|
||||||
|
```
|
||||||
|
|
||||||
|
## 설계 판단
|
||||||
|
|
||||||
|
### 캡처를 별도 실행 파일로 뺀 이유
|
||||||
|
|
||||||
|
WASAPI의 `ActivateAudioInterfaceAsync` + `AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK`
|
||||||
|
은 COM 비동기 콜백을 요구한다. ctypes로 COM vtable을 흉내 내는 것보다 C++ 200줄이
|
||||||
|
훨씬 안전하고 디버깅이 쉽다. 파이프로 생 PCM만 주고받으므로 인터페이스도 단순하다.
|
||||||
|
|
||||||
|
보조 프로그램이 없으면 `capture_capabilities()` 가 이를 알려주고 장치 루프백으로
|
||||||
|
자동 폴백한다. 빌드 없이도 프로그램은 동작한다.
|
||||||
|
|
||||||
|
### 게임이 먼저다
|
||||||
|
|
||||||
|
이 프로그램은 게임 위에 얹혀 도는 것이 기본 용도라, 자막 품질보다 게임 프레임을
|
||||||
|
지키는 쪽이 대체로 더 중요하다. 저부하 모드는 네 방향에서 GPU를 양보한다.
|
||||||
|
|
||||||
|
1. **연산 정밀도 강등** (`ModelManager.effective_*_spec`) — float16 → int8.
|
||||||
|
티어 정의 자체를 건드리지 않고 파생 사양만 바꾸므로, 모드를 끄면 즉시 원복된다.
|
||||||
|
2. **VRAM 상한** (`apply_vram_limit`) — 모델을 올리기 *전에* 걸어야 실제로 먹는다.
|
||||||
|
게임과 동시에 돌 때 프레임을 죽이는 주범은 연산 시간이 아니라 VRAM 고갈이다.
|
||||||
|
게임이 쓸 메모리를 우리가 먹으면 텍스처 스트리밍이 시스템 메모리로 밀려난다.
|
||||||
|
3. **추론 후 양보** — 문장 하나를 처리하고 수십 ms 쉰다. 연속 발화 때 GPU를
|
||||||
|
독점하지 않기 위해서다.
|
||||||
|
4. **유휴 언로드** — 조용한 구간이 길어지면 모델을 통째로 내린다. 그래서
|
||||||
|
`_infer_loop` 은 모델 참조를 캐시하지 않고 매번 `models.recognizer()` 를 부른다.
|
||||||
|
|
||||||
|
### 중간 결과는 번역하지 않는다
|
||||||
|
|
||||||
|
말하는 도중에도 인식 결과를 흐리게 보여주면 체감 지연이 크게 줄어든다. 하지만
|
||||||
|
매번 번역까지 돌리면 GPU가 몇 배로 바빠지고, 문장이 완성되기 전 번역은 어차피
|
||||||
|
틀린다. 그래서 **중간 결과는 원문만, 확정 문장만 번역**한다.
|
||||||
|
|
||||||
|
### 용어집 팩은 계층으로 겹친다
|
||||||
|
|
||||||
|
여러 게임 팩을 동시에 켜면 같은 단어의 역어가 갈린다 (`knocked`는 FPS 공통에서
|
||||||
|
"기절", 배그에서 "눕혔다"). 단순히 켠 순서대로 덮어쓰면 체크박스를 누른 순서가
|
||||||
|
결과를 바꾸는데, 사용자는 그 순서를 볼 수도 바꿀 수도 없다.
|
||||||
|
|
||||||
|
그래서 팩에 `scope`("generic" / "game")를 두고 **범용 → 게임 전용 → 사용자 항목**
|
||||||
|
순으로 적용한다. 게임 전용이 항상 범용을 이기므로 결과가 결정적이다.
|
||||||
|
게임 팩끼리 갈리는 것은 사람이 판단할 문제라, `find_conflicts()` 로 뽑아 UI에
|
||||||
|
그대로 보여준다.
|
||||||
|
|
||||||
|
데이터 쪽 함정도 하나 있다. 용어 목록은 최장일치를 위해 길이 내림차순으로
|
||||||
|
정렬하지만, 이는 **같은 시작 위치**에서만 통한다. "the blue"(조각)가 등록돼
|
||||||
|
있으면 "the blue zone"에서 더 앞 위치라는 이유로 "blue zone"을 이겨 "자기장
|
||||||
|
자기장"이 된다. `test_each_term_protects_as_a_single_unit` 이 모든 팩을 켠
|
||||||
|
상태로 이 겹침을 잡는다.
|
||||||
|
|
||||||
|
### 용어집은 실제 모델로 검증해야 한다
|
||||||
|
|
||||||
|
이 기능은 단위 테스트만으로는 검증되지 않는다. "번역 모델이 자리표시자를
|
||||||
|
그대로 통과시킨다"는 전제 자체가 틀릴 수 있기 때문이다. 실제 NLLB 를 돌려
|
||||||
|
보고 나서야 드러난 것들:
|
||||||
|
|
||||||
|
1. **자리표시자 형식.** 처음 쓰던 `⟦0⟧` 는 NLLB 가 괄호를 통째로 날려
|
||||||
|
`0` 만 남겼다(생존률 0/3). 후보 8종을 실측해 `#0#` 로 바꿨다(3/3).
|
||||||
|
그전까지 용어가 자막에서 그냥 사라지고 있었다.
|
||||||
|
2. **소실 대비.** 모델이 문장 일부를 통째로 누락하면 그 안의 자리표시자도
|
||||||
|
사라진다. 그대로 복원하면 용어가 증발하는데 이건 용어집을 안 쓴 것보다
|
||||||
|
나쁘다. 그래서 하나라도 사라지면 보호 없이 한 번 더 번역한다.
|
||||||
|
3. **서술어는 문장 전체일 때만.** "need ammo" 를 문장 한가운데서 치환하면
|
||||||
|
모델이 문법을 세울 근거를 잃어 "탄 필요해와 구급상자" 가 된다. 명사만
|
||||||
|
바꾸면 "탄약과 구급상자가 필요합니다" 로 제대로 나온다. 그래서 절·서술어
|
||||||
|
항목 56개는 `whole_only` 로 두어 그 말만 단독으로 나왔을 때만 적용한다.
|
||||||
|
4. **조사 어긋남.** 역어의 받침이 자리표시자와 다르면 "자기장를" 이 남는다.
|
||||||
|
받침은 한글 코드에서 바로 읽히므로 `fix_particles()` 로 정확히 고친다.
|
||||||
|
|
||||||
|
### 배치 계산을 Qt에서 떼어냈다
|
||||||
|
|
||||||
|
`placement.py` 는 `ScreenInfo` 라는 순수 데이터만 받아 좌표를 계산한다. 덕분에
|
||||||
|
모니터가 없는 CI 환경에서도 "두 번째 모니터 하단 중앙", "음수 좌표 모니터",
|
||||||
|
"화면보다 큰 자막 창" 같은 경우를 전부 테스트할 수 있다 (`tests/test_placement.py`).
|
||||||
|
자막이 화면 밖으로 나가 영영 안 보이는 사고는 여기서 막는다.
|
||||||
|
|
||||||
|
### UI 코드와 렌더링 코드 분리
|
||||||
|
|
||||||
|
자막 설정 미리보기와 실제 오버레이가 `paint_subtitle()` 하나를 공유한다.
|
||||||
|
QSS로 미리보기를 흉내 내면 `text-shadow` 미지원 같은 이유로 실제와 어긋난다.
|
||||||
|
|
||||||
|
### 엔진은 Qt를 모른다
|
||||||
|
|
||||||
|
`core/engine.py` 는 콜백만 받는다. Qt 의존은 `MainWindow` 가 콜백을 Signal로
|
||||||
|
다시 던지는 지점에만 있다. 덕분에 GPU도 오디오 장치도 없는 환경에서 파이프라인
|
||||||
|
전체를 테스트할 수 있다 (`tests/test_pipeline.py`).
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
|
||||||
|
`pytest` 전체가 GPU·오디오 장치 없이 돈다.
|
||||||
|
|
||||||
|
- `test_segmenter.py` — 합성 사인파로 VAD 절단 검증
|
||||||
|
- `test_pipeline.py` — 가짜 캡처/인식/번역으로 엔진 종단 검증
|
||||||
|
- `test_glossary.py` — 최장일치, 단어경계, 왕복 변환
|
||||||
|
- `test_config.py` — 스키마 변경 내성
|
||||||
|
- `test_ui_smoke.py` — offscreen 렌더링으로 화면 생성 및 자막 픽셀 검증
|
||||||
142
docs/FINETUNING.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# 게임·방송 용어 추가학습
|
||||||
|
|
||||||
|
## 결론부터
|
||||||
|
|
||||||
|
**가능합니다.** 다만 대부분의 경우 **추가학습보다 용어집이 먼저입니다.**
|
||||||
|
|
||||||
|
| 방법 | 준비 | 효과가 나타나는 시점 | 무엇에 좋은가 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **1. 용어집** (구현 완료) | 단어 목록만 | **즉시** | 고유명사, 스킬명, 아이템명, 캐릭터명 |
|
||||||
|
| **2. LoRA 추가학습** | 문장 쌍 1,000~3,000개 | 30분~3시간 학습 | 말투, 문장 구조, 도메인 어조 |
|
||||||
|
| **3. 풀 파인튜닝** | 문장 쌍 10,000개 이상 | 수 시간~하루 | 번역 스타일 전면 교체 |
|
||||||
|
|
||||||
|
용어집과 추가학습은 **경쟁 관계가 아니라 보완 관계**입니다. 둘 다 켜는 게 가장 좋습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 용어집 — 먼저 이것부터
|
||||||
|
|
||||||
|
`용어집` 화면에서 "원문 → 각 언어 역어"를 등록하면 끝입니다. 학습 없이 바로 적용됩니다.
|
||||||
|
|
||||||
|
동작 방식은 번역 모델에 따라 다릅니다.
|
||||||
|
|
||||||
|
- **1~4티어 (NLLB, Seed-X)** — 등록 단어를 `⟦0⟧` 같은 토큰으로 바꿔치기해 모델이 아예
|
||||||
|
건드리지 못하게 한 뒤, 번역이 끝나면 지정한 역어로 되돌립니다.
|
||||||
|
- **5티어 (Qwen3)** — 그 문장에 실제로 나온 용어만 골라 프롬프트에
|
||||||
|
"이 용어는 이렇게 옮겨라"로 넣어줍니다. 조사·어미까지 문맥에 맞게 붙어 더 자연스럽습니다.
|
||||||
|
|
||||||
|
4티어 Seed-X가 LLM인데도 프롬프트 방식을 못 쓰는 이유는, 이 모델이 chat template 없는
|
||||||
|
**번역 전용 completion 모델**이기 때문입니다. 지시문을 이해하지 못할뿐더러 모델 카드가
|
||||||
|
정한 고정 프롬프트 형식(끝의 `<ko>` 같은 언어 태그 포함)을 벗어나면 품질이 무너집니다.
|
||||||
|
용어집을 문맥까지 자연스럽게 반영하고 싶다면 5티어를 쓰세요.
|
||||||
|
|
||||||
|
CSV로 한 번에 가져올 수 있습니다.
|
||||||
|
|
||||||
|
```csv
|
||||||
|
원문 용어,한국어 역어,English 역어,日本語 역어,中文 역어,메모
|
||||||
|
Nexus,넥서스,Nexus,ネクサス,基地,LoL
|
||||||
|
baron,바론,Baron,バロン,男爵,LoL
|
||||||
|
ult,궁,ultimate,アルティメット,大招,궁극기
|
||||||
|
gg,잘 싸웠다,gg,gg,打得好,
|
||||||
|
```
|
||||||
|
|
||||||
|
**게임 하나당 200~500개만 등록해도 체감 품질이 크게 올라갑니다.** 추가학습으로
|
||||||
|
같은 효과를 내려면 훨씬 많은 데이터가 필요합니다. 용어집을 먼저 채우세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. LoRA 추가학습 — 용어집으로 안 되는 것
|
||||||
|
|
||||||
|
용어집은 "이 단어를 저 단어로"만 고칩니다. 아래는 못 잡습니다.
|
||||||
|
|
||||||
|
- 말투 — "You're getting rolled" → "탈탈 털리고 있네" (직역 아닌 게임 말투)
|
||||||
|
- 생략된 주어·목적어 복원 — 게임 음성에는 생략이 많습니다
|
||||||
|
- 방송 특유의 감탄·리액션 어조
|
||||||
|
|
||||||
|
이건 문장 쌍으로 학습시켜야 합니다.
|
||||||
|
|
||||||
|
### 필요한 데이터 양
|
||||||
|
|
||||||
|
공개 연구 기준으로,
|
||||||
|
|
||||||
|
- 언어쌍당 **약 650~1,000 문장**만으로도 LoRA 도메인 적응이 동작합니다.
|
||||||
|
- 언어쌍당 **2,000 문장 / 15~20 epoch** 수준에서 chrF++ 평균 **+6.1점** 개선이 보고됩니다.
|
||||||
|
|
||||||
|
즉 **언어쌍당 1,000~3,000 문장이 현실적인 목표**입니다. 4개 언어 전부가 아니라,
|
||||||
|
실제로 많이 쓰는 방향(예: 영어→한국어) 하나만 먼저 하는 게 효율적입니다.
|
||||||
|
|
||||||
|
### 데이터 만드는 법
|
||||||
|
|
||||||
|
1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다.
|
||||||
|
2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬.
|
||||||
|
3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면
|
||||||
|
저장 위치 폴더의 `transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
|
||||||
|
**틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다.
|
||||||
|
4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로.
|
||||||
|
|
||||||
|
형식은 JSONL 한 줄에 한 쌍입니다.
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{"source": "Enemy missing from mid", "target": "미드 실종", "source_lang": "en", "target_lang": "ko"}
|
||||||
|
{"source": "I'll take baron", "target": "바론 내가 먹을게", "source_lang": "en", "target_lang": "ko"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 학습 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[finetune]"
|
||||||
|
|
||||||
|
python scripts/finetune_mt.py \
|
||||||
|
--data data/game_terms.jsonl \
|
||||||
|
--tier ultimate \
|
||||||
|
--output ./adapters/game-ko \
|
||||||
|
--epochs 3
|
||||||
|
```
|
||||||
|
|
||||||
|
끝나면 `모델` 화면의 **추가학습 어댑터** 칸에 `./adapters/game-ko` 를 넣으면 적용됩니다.
|
||||||
|
|
||||||
|
### 티어별 난이도
|
||||||
|
|
||||||
|
| 티어 | 번역 모델 | 방식 | 학습에 필요한 VRAM | 난이도 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1~2 | NLLB-600M | 풀 파인튜닝 가능 | ~8GB | ★ 쉬움 |
|
||||||
|
| 3 | NLLB-1.3B | LoRA | ~10GB | ★★ |
|
||||||
|
| 4 | Seed-X-PPO-7B | LoRA (권장 안 함) | ~16GB | ★★★ 까다로움 |
|
||||||
|
| 5 | **Qwen3-8B** | **LoRA** | **~16GB** | **★ 쉬움 (권장)** |
|
||||||
|
|
||||||
|
**4티어 Seed-X는 추가학습 대상으로 권하지 않습니다.** 이미 PPO(강화학습)까지 마친
|
||||||
|
모델이라 그 위에 SFT를 얹으면 기존 번역 품질이 무너지기 쉽습니다 (catastrophic forgetting).
|
||||||
|
Int4 양자화 가중치라 학습 자체도 번거롭습니다.
|
||||||
|
|
||||||
|
**추가학습을 할 거라면 5티어(Qwen3-8B)**, **VRAM이 부족하면 1~2티어(NLLB-600M 풀 파인튜닝)**
|
||||||
|
로 가는 게 맞습니다.
|
||||||
|
|
||||||
|
### 권장 하이퍼파라미터
|
||||||
|
|
||||||
|
연구에서 널리 쓰이는 설정입니다. `scripts/finetune_mt.py` 의 기본값이기도 합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
LoRA rank r = 16, alpha = 32, dropout = 0.05
|
||||||
|
대상 모듈: attention 의 q_proj, v_proj
|
||||||
|
learning rate = 2e-4 (LoRA) / 5e-5 (풀 파인튜닝)
|
||||||
|
epochs = 3 (데이터 2,000개 이상) / 10~20 (수백 개)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 어느 것부터 할지
|
||||||
|
|
||||||
|
```
|
||||||
|
1주차 용어집 300개 등록 → 이것만으로 충분한지 확인
|
||||||
|
2주차 번역 기록 켜고 실사용, 틀린 문장 수집
|
||||||
|
3주차 고친 문장 1,000개 모이면 LoRA 학습
|
||||||
|
```
|
||||||
|
|
||||||
|
**용어집만으로 만족스러우면 추가학습은 안 해도 됩니다.** 실제로 고유명사 오역이
|
||||||
|
체감 불만의 대부분입니다.
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- [Fine-Tuning NLLB-200 with LoRA on a 650-Sentence Corpus](https://medium.com/@meinnps/fine-tuning-nllb-200-with-lora-on-a-650-sentence-turkmen-english-corpus-082f68bdec71)
|
||||||
|
- [SemiAdapt / SemiLoRA: Efficient Domain Adaptation for Low-Resource MT (arXiv)](https://arxiv.org/pdf/2510.18725)
|
||||||
|
- [How to fine-tune a NLLB-200 model](https://cointegrated.medium.com/how-to-fine-tune-a-nllb-200-model-for-translating-a-new-language-a37fc706b865)
|
||||||
105
docs/MODELS.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# 모델 선정 근거
|
||||||
|
|
||||||
|
## 왜 두 단계인가
|
||||||
|
|
||||||
|
"소리 → 번역"을 한 모델로 끝내는 방법도 있다. Whisper에는 `translate` 태스크가 있고
|
||||||
|
Voxtral·Qwen-Omni 같은 음성 LLM도 있다. 하지만 이 프로그램에는 맞지 않는다.
|
||||||
|
|
||||||
|
- Whisper의 `translate`는 **X → 영어만** 된다. 한국어로 받아볼 수 없다.
|
||||||
|
- 음성 LLM 한 방에 처리하면 번역만 따로 교체하거나 추가학습시킬 수 없다.
|
||||||
|
- 용어집을 꽂아 넣을 지점이 사라진다.
|
||||||
|
|
||||||
|
그래서 **음성인식(ASR) → 번역(MT)** 2단 구조로 간다. 두 단계를 따로 고를 수 있어서
|
||||||
|
"인식은 최고급, 번역은 경량" 같은 조합이 가능하고, 이게 실제로 가장 가성비가 좋다.
|
||||||
|
|
||||||
|
## 음성인식
|
||||||
|
|
||||||
|
| 후보 | 판단 |
|
||||||
|
|---|---|
|
||||||
|
| **Whisper large-v3-turbo** | 디코더를 32층 → 4층으로 줄여 large-v3 대비 약 6배 빠르면서 정확도 손실은 1~2%. 809M 파라미터, int8이면 VRAM 1.6GB. **한/영/일/중 모두 지원.** |
|
||||||
|
| Whisper large-v3 | 가장 정확하지만 느리다. 최상위 티어에만. |
|
||||||
|
| distil-whisper | 빠르지만 **영어 전용**이라 탈락. |
|
||||||
|
| NVIDIA Parakeet | 실시간성은 최고지만 **영어 전용**이라 탈락. |
|
||||||
|
|
||||||
|
실행은 `faster-whisper`(CTranslate2)로 한다. 순정 `openai-whisper` 대비 4배 빠르고
|
||||||
|
VRAM을 절반만 쓴다.
|
||||||
|
|
||||||
|
→ **2~4티어의 기본은 large-v3-turbo.** 4개 언어를 모두 지원하면서 실시간을 만족하는
|
||||||
|
유일한 지점이다.
|
||||||
|
|
||||||
|
## 번역
|
||||||
|
|
||||||
|
| 후보 | VRAM | 판단 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Seed-X-PPO-7B** (ByteDance) | ~5.6GB (Int4) | 28개 언어 **번역 전용**으로 학습된 7B. 자체 평가에서 Gemma3-27B, Llama4-Scout, Qwen3-235B를 앞서고 사람 평가에서 GPT-4o·Claude-3.5·Gemini-2.5-Pro와 대등. 한/영/일/중이 모두 주력 언어. |
|
||||||
|
| **Qwen3-8B** | ~11GB (fp16) | 범용 LLM이라 Seed-X보다 기본 번역은 약간 아래. 대신 **추가학습(LoRA) 생태계가 가장 두껍고**, 프롬프트 지시를 잘 따른다. |
|
||||||
|
| **NLLB-200-distilled 600M / 1.3B** | 0.8~2.9GB | 200개 언어 seq2seq. 구어체는 LLM보다 딱딱하지만 압도적으로 빠르고 가볍다. 게다가 **풀 파인튜닝이 소비자 GPU에서 된다.** |
|
||||||
|
| Gemma 3 12B | ~8GB | 140개 언어로 넓게 강하지만, 한/영/일/중만 필요한 우리에게는 Seed-X가 더 낫다. |
|
||||||
|
| opus-mt (Helsinki) | ~0.3GB | 언어쌍마다 모델이 따로라 4×3=12개를 관리해야 한다. 티어 구조와 안 맞아 탈락. |
|
||||||
|
|
||||||
|
## 다섯 티어
|
||||||
|
|
||||||
|
| # | 이름 | 음성인식 | 번역 | VRAM | 지연 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 번개 | whisper-small (int8) | NLLB-600M (int8) | ~2GB | 0.6초 |
|
||||||
|
| 2 | 신속 | large-v3-turbo (int8) | NLLB-600M (fp16) | ~4GB | 0.9초 |
|
||||||
|
| 3 | **균형** (기본) | large-v3-turbo (fp16) | NLLB-1.3B (fp16) | ~6GB | 1.2초 |
|
||||||
|
| 4 | **정밀** (추천) | large-v3-turbo (fp16) | Seed-X-PPO-7B (Int4) | ~10GB | 2.0초 |
|
||||||
|
| 5 | 극한 | large-v3 (fp16) | Qwen3-8B (fp16) + LoRA | ~16GB | 3.0초 |
|
||||||
|
|
||||||
|
## 결론: 무엇을 고를 것인가
|
||||||
|
|
||||||
|
**추가학습을 안 한다면 → 4티어 "정밀"이 최선이다.**
|
||||||
|
Seed-X는 번역만 하도록 만들어진 모델이라 7B치고 품질이 비정상적으로 좋다.
|
||||||
|
게임 대사처럼 짧고 구어체인 문장에서 NLLB 계열과 체감 차이가 크다.
|
||||||
|
|
||||||
|
**추가학습을 전제로 하면 → 5티어 "극한"(Qwen3-8B + LoRA)으로 간다.**
|
||||||
|
이유는 세 가지다.
|
||||||
|
|
||||||
|
1. Seed-X는 이미 PPO(강화학습)로 조율이 끝난 모델이다. 그 위에 다시 SFT를 얹으면
|
||||||
|
기존 품질이 깨지기 쉽다. 반면 Qwen3-8B는 instruct 베이스라 추가 학습을 전제로 만들어졌다.
|
||||||
|
2. peft / unsloth / TRL 등 LoRA 도구가 Qwen 계열에 가장 잘 맞춰져 있다.
|
||||||
|
3. 용어집을 프롬프트로 직접 지시할 수 있어 "학습 + 프롬프트" 이중으로 용어를 잡을 수 있다.
|
||||||
|
|
||||||
|
**VRAM이 8GB 이하라면 → 3티어 "균형".** 그리고 이 경우의 추가학습은
|
||||||
|
NLLB-1.3B를 **풀 파인튜닝**하는 쪽이 오히려 유리하다 (docs/FINETUNING.md 참고).
|
||||||
|
|
||||||
|
## Seed-X를 쓸 때 반드시 지켜야 하는 것
|
||||||
|
|
||||||
|
Seed-X는 일반 챗 모델이 아니라 **번역만 하도록 학습된 completion 모델**입니다.
|
||||||
|
모델 카드가 명시하는 제약이 세 가지 있고, 구현에 그대로 반영했습니다.
|
||||||
|
|
||||||
|
1. **프롬프트 끝의 언어 태그는 필수입니다.** PPO 학습에 쓰인 신호라 빠지면 품질이
|
||||||
|
크게 흔들립니다. 형식은 정확히 이렇습니다.
|
||||||
|
```
|
||||||
|
Translate the following English sentence into Korean:
|
||||||
|
May the force be with you <ko>
|
||||||
|
```
|
||||||
|
2. **chat template이 없습니다.** `apply_chat_template`을 쓰거나 멀티턴 대화 형식으로
|
||||||
|
넣으면 안 됩니다.
|
||||||
|
3. **지시문을 끼워 넣으면 안 됩니다.** "구어체로 자연스럽게 옮겨라" 같은 문장을 붙이면
|
||||||
|
학습 분포를 벗어납니다. 그래서 4티어는 용어집도 프롬프트가 아니라
|
||||||
|
플레이스홀더 치환으로 넣습니다.
|
||||||
|
|
||||||
|
이 제약 때문에 프롬프트 빌더를 `build_seedx_prompt()`로 분리하고, 형식이 바뀌면
|
||||||
|
바로 깨지도록 `tests/test_prompts.py`에 회귀 테스트를 걸어놨습니다.
|
||||||
|
|
||||||
|
참고로 `AWQ-Int4`/`GPTQ-Int8`은 ByteDance가 직접 배포하는 **공식** 양자화본입니다
|
||||||
|
(모델 카드가 경고하는 것은 제3자 양자화본입니다). 다만 AWQ 가중치를 읽으려면
|
||||||
|
`pip install autoawq`가 필요합니다.
|
||||||
|
|
||||||
|
## 주의 — 벤치마크를 곧이곧대로 믿지 말 것
|
||||||
|
|
||||||
|
위 비교는 공개 벤치마크와 모델 카드에 근거한 것이고, **실제 게임/방송 음성에서의
|
||||||
|
체감 품질은 다를 수 있다.** 특히 Seed-X의 우위는 자체 발표 수치에 크게 기대고 있다.
|
||||||
|
그래서 프로그램에 5개 티어를 모두 넣었다. 실제 쓰는 콘텐츠로 3·4·5티어를 직접
|
||||||
|
번갈아 써보고 정하는 것이 가장 정확하다.
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- [Best open source STT model in 2026 (benchmarks)](https://northflank.com/blog/best-open-source-speech-to-text-stt-model-in-2026-benchmarks)
|
||||||
|
- [Whisper Large-v3 vs Turbo: Speed, WER & Cost](https://vexascribe.com/whisper-large-v3-vs-turbo)
|
||||||
|
- [Seed-X: Building Strong Multilingual Translation LLM with 7B Parameters (arXiv)](https://arxiv.org/html/2507.13618v1)
|
||||||
|
- [ByteDance-Seed/Seed-X-PPO-7B (Hugging Face)](https://huggingface.co/ByteDance-Seed/Seed-X-PPO-7B)
|
||||||
|
- [Local translation benchmark 2026 (cctrans)](https://github.com/kargnas/cctrans/blob/main/docs/local-translation-benchmark-2026.md)
|
||||||
|
- [CTranslate2 지원 모델](https://opennmt.net/CTranslate2/guides/transformers.html)
|
||||||
587
docs/WINDOWS-TESTING.md
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
# Windows에서 자동 검증 붙이기
|
||||||
|
|
||||||
|
개발은 리눅스에서 하고 있어서 **Windows 전용 경로는 테스트가 불가능**합니다.
|
||||||
|
오디오 캡처, 전역 단축키, 모니터 열거, exe 빌드가 전부 여기에 해당합니다.
|
||||||
|
그래서 "리눅스에서 다 통과했는데 윈도우에서 안 되더라"가 나올 수 있습니다.
|
||||||
|
|
||||||
|
Windows 러너를 하나 붙이면 `main` 에 push할 때(또는 Actions 탭에서 수동
|
||||||
|
실행할 때) 이 경로들이 자동으로 검증됩니다.
|
||||||
|
|
||||||
|
## 어디에 올릴까 — 서버 Windows VM 권장
|
||||||
|
|
||||||
|
개인 PC를 안 쓰고 서버에 올려 완전 자동화하는 게 가장 깔끔합니다.
|
||||||
|
**GPU가 없어도 됩니다.** 실제로 확인해봤습니다.
|
||||||
|
|
||||||
|
| 검증 항목 | GPU 필요? | GPU 없는 서버 VM |
|
||||||
|
|---|---|---|
|
||||||
|
| 단위 테스트 189개 | ❌ | ✅ |
|
||||||
|
| WASAPI 장치·프로그램 열거 | ❌ | ✅ |
|
||||||
|
| 전역 단축키 | ❌ | ✅ |
|
||||||
|
| 모니터 열거·자막 배치 | ❌ | ✅ |
|
||||||
|
| 포터블 exe 빌드·기동 | ❌ | ✅ |
|
||||||
|
| **오디오 → 한국어 자막 (전 과정)** | ❌ CPU로 됨 | ✅ |
|
||||||
|
| CUDA 인식·VRAM 상한 | ✅ | ⚠️ 경고만 (빌드 통과) |
|
||||||
|
|
||||||
|
### GPU 검증은 `.9`(리눅스)에서 — 이미 돌아갑니다
|
||||||
|
|
||||||
|
GPU는 `.9`에 고정입니다. 그래서 GPU 검증은 Windows가 아니라 **여기서** 합니다.
|
||||||
|
한 줄이면 됩니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/gpu-check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
RTX 5050 실측 결과입니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[OK] CUDA 사용 가능 RTX 5050 · 7.5GB
|
||||||
|
[OK] VRAM 상한이 실제로 걸리는지 35% 상한 적용됨
|
||||||
|
[OK] GPU 로 실제 번역 '적 왼쪽에서 오는'
|
||||||
|
[OK] 티어 게이트가 뭐라고 하는지 정밀=autoawq 없음 / 극한=VRAM 부족(16GB 필요, 7.5GB)
|
||||||
|
6/6 통과
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 GPU로는 4·5티어를 쓸 수 없습니다.** 정밀은 `autoawq`가 필요하고
|
||||||
|
(`pip install autoawq`), 극한은 16GB가 필요한데 카드가 7.5GB입니다.
|
||||||
|
극한은 카드를 바꾸지 않는 한 영영 불가능합니다. 1~3티어는 정상 동작합니다.
|
||||||
|
|
||||||
|
### GPU가 필요한 테스트는 어떻게 하나
|
||||||
|
|
||||||
|
**GPU는 한 VM만 독점합니다.** 컨슈머 NVIDIA는 vGPU/SR-IOV를 지원하지 않아서
|
||||||
|
쪼개 쓸 방법이 없습니다. 지금 RTX 5050은 `.9`(리눅스)에 물려 있습니다.
|
||||||
|
|
||||||
|
그래서 러너를 **라벨로 둘로 나눠뒀습니다.** 지금은 앞의 것만 있으면 되고,
|
||||||
|
나중에 GPU 머신이 생기면 뒤의 것을 붙이기만 하면 됩니다. 워크플로는 이미
|
||||||
|
그렇게 짜여 있습니다.
|
||||||
|
|
||||||
|
| 러너 라벨 | 어디에 | 언제 도나 | 무엇을 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `windows:host` | GPU 없는 서버 VM | `main` push마다 자동 | 테스트·Windows 경로·오디오→자막·exe 빌드 |
|
||||||
|
| `windows-gpu:host` | GPU 달린 머신 | **수동 실행만** | CUDA 인식·VRAM 상한·4·5티어·GPU 번역 |
|
||||||
|
|
||||||
|
GPU 러너를 상시로 띄울 필요는 없습니다. 필요할 때만 `daemon`을 켜고
|
||||||
|
Actions 탭에서 `Windows GPU` → `Run workflow` 하면 됩니다. 러너가 없으면
|
||||||
|
큐에 쌓이기만 하고 아무 일도 일어나지 않습니다.
|
||||||
|
|
||||||
|
GPU 워크플로는 GPU가 없으면 **분명히 실패합니다.** GPU를 확인하겠다고 부른
|
||||||
|
작업이 조용히 통과하면 안 되니까요. `nvidia-smi`가 없으면 그 자리에서 멈춥니다.
|
||||||
|
|
||||||
|
#### 여러 프로젝트가 GPU 러너를 같이 쓸 때 — 동시에 돌리면 안 됩니다
|
||||||
|
|
||||||
|
**이게 가장 중요합니다.** GPU는 나눠 쓸 수 없어서, 두 작업이 동시에 VRAM을
|
||||||
|
잡으면 둘 다 OOM으로 죽거나 하나가 조용히 느려져 원인을 못 찾습니다.
|
||||||
|
CPU 러너와 달리 GPU 러너는 **반드시 한 번에 한 작업만** 돌아야 합니다.
|
||||||
|
|
||||||
|
보장 장치가 두 겹입니다.
|
||||||
|
|
||||||
|
1. **러너 `capacity: 1`** — 진짜 보장입니다. 저장소가 달라도 막힙니다.
|
||||||
|
`act_runner` 설정 파일(`config.yaml`)에서 확인하세요. 기본값이 1이지만
|
||||||
|
GPU 러너에서는 반드시 확인하고 넘어가세요.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runner:
|
||||||
|
capacity: 1 # GPU 러너는 절대 올리지 말 것
|
||||||
|
```
|
||||||
|
|
||||||
|
설정 파일이 없으면 만들고 `--config config.yaml` 로 띄웁니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\act_runner.exe generate-config > config.yaml
|
||||||
|
.\act_runner.exe daemon --config config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **워크플로 `concurrency`** — 같은 저장소 안에서 겹치는 것만 막습니다.
|
||||||
|
`windows-gpu.yml` 에 이미 들어 있습니다. 다른 저장소에는 효력이 없으니
|
||||||
|
1번을 대신하지 못합니다.
|
||||||
|
|
||||||
|
한 대에 러너 프로세스를 **여러 개 띄우지도 마세요.** capacity 1짜리 러너가
|
||||||
|
둘이면 결국 동시에 두 작업이 돕니다.
|
||||||
|
|
||||||
|
#### GPU 머신을 어디서 구하나
|
||||||
|
|
||||||
|
다른 프로젝트도 이 러너에서 GPU 테스트를 돌리실 거라면 셋 중 하나입니다.
|
||||||
|
|
||||||
|
1. **서버에 GPU 한 장 더** — 가장 깔끔합니다. PCIe 슬롯과 파워 여유만 있으면
|
||||||
|
그 GPU를 Windows VM에 패스스루하고 `windows-gpu` 라벨로 등록하면 끝입니다.
|
||||||
|
이후로는 두 러너가 각자 계속 돕니다.
|
||||||
|
2. **RTX 5050을 Windows VM으로 옮기기** — `.9`가 GPU를 포기해야 합니다.
|
||||||
|
`.9`에서 하던 GPU 작업(이미지 생성 등)을 못 하게 되니 득실을 따져보세요.
|
||||||
|
3. **필요할 때만 실물 PC에 GPU 러너** — 평소엔 꺼두고 GPU 검증이 필요할 때만
|
||||||
|
켭니다. 수동 실행이라 게임 중에 갑자기 도는 일은 없습니다.
|
||||||
|
|
||||||
|
1번이 가능한지는 서버에서 이걸로 확인할 수 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lspci | grep -i vga # GPU가 몇 장 꽂혀 있나
|
||||||
|
dmidecode -t slot | grep -i "in use" # 빈 PCIe 슬롯이 있나
|
||||||
|
```
|
||||||
|
|
||||||
|
### 선택지 A — `.9` 안에 Windows VM (중첩 가상화)
|
||||||
|
|
||||||
|
Proxmox를 건드리지 않고 `.9`(우분투) 안에서 바로 Windows VM을 돌릴 수 있습니다.
|
||||||
|
**실제로 확인했습니다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
/dev/kvm 존재
|
||||||
|
kvm_intel nested Y
|
||||||
|
qemu -enable-kvm kvm support: enabled ← 실측
|
||||||
|
여유 RAM / 디스크 25GB / 294GB
|
||||||
|
```
|
||||||
|
|
||||||
|
**가장 큰 장점은 사람 손이 안 간다는 것입니다.** `.9` 안이라 에이전트가 VM
|
||||||
|
생성부터 러너 등록까지 직접 합니다. Proxmox 자격증명도 필요 없습니다.
|
||||||
|
|
||||||
|
**단, GPU는 여전히 안 됩니다.** 중첩 패스스루가 불가능합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
IOMMU 그룹 수 0개
|
||||||
|
커널 cmdline iommu 옵션 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
되게 하려면 Proxmox가 `.9`에 vIOMMU를 열어주고 `.9` 커널에 `intel_iommu=on`을
|
||||||
|
넣은 뒤 GPU를 `vfio-pci`에 묶어야 하는데, 그러면 **`.9`가 GPU를 잃습니다.**
|
||||||
|
결국 "GPU를 누가 가질 것인가" 문제로 되돌아오므로 중첩으로는 해결되지 않습니다.
|
||||||
|
|
||||||
|
#### 만드는 법 — 스크립트 한 줄, 사람 손 없음
|
||||||
|
|
||||||
|
`scripts/win-ci/` 에 전부 들어 있습니다. Windows 설치 화면을 사람이 클릭할
|
||||||
|
방법이 없으므로 **무인 설치**로 짰습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/win-ci/build.sh # ISO 받기 + 무인설치 ISO 굽기 + 디스크 생성
|
||||||
|
/home/claude/win-ci/start-vm.sh # VM + 제어 서버 기동
|
||||||
|
/home/claude/win-ci/boot-cd.sh reset # CD 부팅 프롬프트 넘기기 (최초 1회)
|
||||||
|
/home/claude/win-ci/status.sh # 진행 상황
|
||||||
|
/home/claude/win-ci/shot.sh a.png # 화면 스냅샷
|
||||||
|
```
|
||||||
|
|
||||||
|
네 부분이 이어달리기를 합니다.
|
||||||
|
|
||||||
|
| 파일 | 어디서 도나 | 하는 일 |
|
||||||
|
|---|---|---|
|
||||||
|
| `unattend/autounattend.xml` | Windows Setup | 디스크 분할·설치·OOBE 건너뛰기·`ci` 자동 로그온 |
|
||||||
|
| `unattend/setup/provision.ps1` | 첫 로그온 1회 | virtio 드라이버(네트워크·화면), 화면꺼짐/잠금 해제 |
|
||||||
|
| `unattend/setup/agent.ps1` | 로그온마다 상주 | 호스트에서 `stage2.ps1` 을 받아 실행 |
|
||||||
|
| `serve/stage2.ps1` | 호스트가 HTTP 로 제공 | Git·pwsh7·Python·Node·CMake·act_runner 설치 |
|
||||||
|
|
||||||
|
**왜 설치 내용을 ISO 에 안 넣었나** — ISO 는 한 번 구우면 못 고칩니다. 설치
|
||||||
|
스크립트를 ISO 에 박으면 한 줄 고칠 때마다 Windows 를 다시 깔아야 합니다.
|
||||||
|
그래서 ISO 에는 껍데기(agent)만 넣고, 실제 설치 내용은 호스트의 HTTP 로
|
||||||
|
가져옵니다. `serve/stage2.ps1` 을 고치면 게스트가 20초 안에 알아서 새로
|
||||||
|
받아 실행합니다.
|
||||||
|
|
||||||
|
호스트와 게스트를 잇는 통로는 두 개입니다.
|
||||||
|
|
||||||
|
| 방향 | 통로 | 왜 |
|
||||||
|
|---|---|---|
|
||||||
|
| 호스트 → 게스트 | HTTP `10.0.2.2:8099` (QEMU user networking) | 스크립트·러너 토큰 전달 |
|
||||||
|
| 게스트 → 호스트 | 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 를
|
||||||
|
포기합니다. 무인인데 누를 사람이 없어서 QMP `send-key` 로 밀어 넣습니다.
|
||||||
|
단, **설치가 시작된 뒤에는 절대 키를 더 보내면 안 됩니다.** 남는 Enter 가
|
||||||
|
설치 화면의 `Cancel` 을 눌러 "Are you sure you want to quit?" 를 띄웁니다.
|
||||||
|
반대로 설치가 끝난 뒤 재부팅 때는 아무 키도 안 누르는 게 정답입니다.
|
||||||
|
그래야 CD 를 건너뛰고 디스크로 부팅합니다.
|
||||||
|
- **시스템 디스크는 SATA(AHCI)** 로 둡니다. virtio-blk/scsi 로 하면 Windows
|
||||||
|
Setup 이 디스크 자체를 못 봐서 WinPE 단계 드라이버 주입이 필요해집니다.
|
||||||
|
네트워크·화면만 virtio 로 가고, 그 드라이버는 설치 후 ISO 에서 오프라인으로
|
||||||
|
넣습니다.
|
||||||
|
- **Windows ISO 는 UDF** 입니다. ISO9660 층에는 `README.TXT` 하나뿐이라
|
||||||
|
`xorriso` 로 열면 비어 보입니다. `mount -t udf` 로 봐야 합니다.
|
||||||
|
- `install.wim` 의 **인덱스 1** 이 `Windows 11 Enterprise LTSC 2024 Evaluation`
|
||||||
|
입니다(이미지 2개). `wiminfo` 로 확인하고 박았습니다.
|
||||||
|
- **LTSC 에는 winget 이 없습니다.** 스토어가 빠져 있어서 설치 파일을 직접
|
||||||
|
받습니다.
|
||||||
|
- **Node.js 가 필요합니다.** `act_runner` 의 host 모드는 `actions/checkout`,
|
||||||
|
`upload-artifact` 같은 JS 액션을 PATH 의 `node` 로 돌립니다. 빠뜨리면 그
|
||||||
|
단계들만 조용히 실패합니다.
|
||||||
|
- **러너는 서비스가 아니라 로그온 예약 작업**이어야 합니다. 서비스로 돌리면
|
||||||
|
데스크톱 세션이 없어 GUI 테스트와 스크린샷이 전부 실패합니다.
|
||||||
|
|
||||||
|
#### VM 에 얼마를 줄까 — 실측 근거
|
||||||
|
|
||||||
|
**vCPU 4개 / RAM 8GB / 디스크 80GB.** 추측이 아니라 재보고 정한 값입니다.
|
||||||
|
|
||||||
|
CPU 는 4개에서 포화합니다. 더 줘도 빨라지지 않습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
파이프라인(음성→자막) 2코어 13초 · 4코어 10초 · 8코어 11초
|
||||||
|
테스트 190개 4코어 5.9초 · 8코어 5.8초
|
||||||
|
```
|
||||||
|
|
||||||
|
CI 작업 대부분이 단일 스레드라 코어를 늘려도 효과가 없습니다. 음성인식만
|
||||||
|
병렬인데 그것도 4개에서 멈춥니다.
|
||||||
|
|
||||||
|
RAM 8GB 는 Windows 11 이 쾌적하게 도는 최소선이고, 그 안에서 도는
|
||||||
|
음성인식+번역이 2~3GB 를 씁니다. 디스크 80GB 는 Windows 30GB + 모델 캐시
|
||||||
|
+ 빌드 산출물 기준입니다.
|
||||||
|
|
||||||
|
#### VM 의 Windows 는 진짜 Windows 인가
|
||||||
|
|
||||||
|
**맞습니다. 같은 Windows 이고 같은 바이너리입니다.** 우리가 검증하려는 것들은
|
||||||
|
코드 경로가 동일합니다.
|
||||||
|
|
||||||
|
| | VM 에서 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| WASAPI 오디오 캡처 API | 동일 | 에뮬레이트된 사운드 장치를 잡을 뿐 API 는 같음 |
|
||||||
|
| 전역 단축키(`RegisterHotKey`) | 동일 | |
|
||||||
|
| 모니터 열거 API | 동일 | `max_outputs=2` 로 화면 2개를 줄 수 있음 |
|
||||||
|
| PyInstaller exe 빌드·실행 | 동일 | |
|
||||||
|
| CPU 명령어 집합 | 동일 | 중첩 KVM 이 호스트 CPU 기능을 그대로 노출 |
|
||||||
|
|
||||||
|
다른 점은 셋입니다.
|
||||||
|
|
||||||
|
- **GPU 없음** — CUDA·VRAM 검증은 `.9`(리눅스)에서 합니다. 위 참고.
|
||||||
|
- **실제 게임 소리 없음** — 게임을 띄워 진짜 캡처하는 건 못 합니다.
|
||||||
|
대신 음성 파일을 넣어 자막까지 나오는지 자동 검증합니다.
|
||||||
|
- **속도/지연이 다름** — 체감 지연 측정값은 실제 PC 기준이 아닙니다.
|
||||||
|
|
||||||
|
모니터 선택을 검증하려면 화면을 2개 줘야 합니다. `--vga std` 는 1개뿐입니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
-device virtio-vga,max_outputs=2 # KVM 기동 확인 완료
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 쪽에 virtio-gpu 드라이버(virtio-win ISO)가 깔려 있어야 2개로 잡힙니다.
|
||||||
|
|
||||||
|
#### 100% 부하에서도 괜찮은가 — 재봤습니다
|
||||||
|
|
||||||
|
**VM 4 vCPU 까지는 안전하고, `.9` 의 CPU 를 늘리면 오히려 해롭습니다.**
|
||||||
|
|
||||||
|
`.9` 안에서 VM 이 4코어를 100% 써도 영향은 작습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
부하 없음 VM 4코어 100%
|
||||||
|
테스트 5.9초 -> 6.1초 (+3%)
|
||||||
|
파이프라인 9.8초 -> 11.0초 (+12%)
|
||||||
|
```
|
||||||
|
|
||||||
|
문제는 호스트입니다. 물리 호스트는 i7-11700(8코어/**16스레드**)뿐인데
|
||||||
|
`.5` 에서 운영 컨테이너 19개가 돌고 있습니다. `.5` 의 응답 속도를 재봤습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
.9 가 한가할 때 616~628ms 기준
|
||||||
|
.9 가 4코어 사용 657~678ms +7% ← VM 이 100% 인 실제 상황
|
||||||
|
.9 가 8코어 전부 사용 1487~1491ms +140% ← 봇까지 동시에 바쁠 때
|
||||||
|
```
|
||||||
|
|
||||||
|
**그래서 `.9` 의 vCPU 는 늘리면 안 됩니다.** 늘릴수록 호스트 지분이 커져
|
||||||
|
`.5` 의 운영 서비스가 느려집니다. 지금 8개가 적정 상한입니다.
|
||||||
|
최악(8코어 전부)을 아예 막으려면 VM 을 CPU 상한과 함께 띄웁니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemd-run --user --scope -p CPUQuota=400% -p CPUWeight=50 --unit win-ci qemu-system-x86_64 -enable-kvm ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`CPUWeight=50` 은 봇과 경합할 때 VM 이 양보한다는 뜻입니다.
|
||||||
|
`CPUQuota=200%` 로 재봤을 때 `.5` 응답이 649~653ms 로 기준선과 같았습니다.
|
||||||
|
|
||||||
|
RAM 은 최악을 다 더해도 여유가 있습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
ejclaw 봇 (실측 최대) 7.2 GB
|
||||||
|
Windows VM 게스트 8.0 GB
|
||||||
|
QEMU 오버헤드 0.4 GB
|
||||||
|
GPU 작업 동시 실행 3.0 GB
|
||||||
|
tmpfs 워치독 경보선 3.0 GB
|
||||||
|
─────────────────────────────────────
|
||||||
|
최악 합계 21.6 GB / 30 GB (여유 8.4 GB)
|
||||||
|
```
|
||||||
|
|
||||||
|
**결론: CPU·RAM 모두 증설 없이 진행하면 됩니다.** RAM 만 여유를 두고 싶다면
|
||||||
|
30 → 38GB 까지는 호스트가 감당합니다(호스트 62.6GB, 여유 약 24GB).
|
||||||
|
CPU 는 늘리지 마세요.
|
||||||
|
|
||||||
|
#### ⚠️ 메모리 cgroup 함정 — 반드시 `systemd-run --scope`
|
||||||
|
|
||||||
|
에이전트 턴에서 그냥 `qemu`를 띄우면 VM이 **봇의 cgroup 안에서** 돕니다.
|
||||||
|
`ejclaw.service`는 `MemoryHigh=16G`이고 평상시 5G를 쓰므로, 8G짜리 VM이
|
||||||
|
그 안에 들어가면 상한에 부딪혀 봇이 OOM으로 죽습니다(전례 있음).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 봇 cgroup 밖(app.slice 형제)에 VM 을 띄운다 — 실측으로 확인됨
|
||||||
|
systemd-run --user --scope -p MemoryMax=10G --unit win-ci qemu-system-x86_64 -enable-kvm -m 8192 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A vs B 비교
|
||||||
|
|
||||||
|
| | A: `.9` 안 중첩 VM | B: Proxmox VM |
|
||||||
|
|---|---|---|
|
||||||
|
| 만드는 사람 | **에이전트가 직접** | 사용자 |
|
||||||
|
| GPU | ❌ | ❌ (별도 GPU 추가 시 ⭕) |
|
||||||
|
| 속도 | 중첩이라 다소 느림 | 네이티브 |
|
||||||
|
| `.9` 재부팅 시 | 같이 죽음 | 영향 없음 |
|
||||||
|
| RAM | `.9`의 30GB를 나눠 씀 | 독립 |
|
||||||
|
|
||||||
|
CI는 타임아웃이 30분인데 파이프라인이 27초라 속도 여유가 충분합니다.
|
||||||
|
**먼저 A로 띄워보고, 부족하면 B로 옮기는 편을 권합니다.**
|
||||||
|
|
||||||
|
### 선택지 B — Proxmox에 Windows VM 만들기
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Proxmox 호스트에서 (VMID 는 비어 있는 번호로)
|
||||||
|
qm create 920 --name win-ci --memory 8192 --cores 4 --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-single --scsi0 local-lvm:80 --ostype win11 --machine q35 --bios ovmf --efidisk0 local-lvm:1,efitype=4m,pre-enrolled-keys=1 --tpmstate0 local-lvm:1,version=v2.0 --ide2 local:iso/Win11.iso,media=cdrom --ide0 local:iso/virtio-win.iso,media=cdrom --audio0 device=ich9-intel-hda,driver=none --vga std
|
||||||
|
```
|
||||||
|
|
||||||
|
RAM 8GB / 디스크 80GB면 충분합니다(모델 캐시 포함). 아래 둘을 꼭 넣으세요.
|
||||||
|
|
||||||
|
- `--audio0 ...` — **없으면 오디오 장치가 하나도 안 잡혀** WASAPI 점검이 실패합니다
|
||||||
|
- `--vga std` — 화면이 있어야 자막 오버레이를 그리고 스크린샷을 찍습니다
|
||||||
|
|
||||||
|
### VM 안에서 꼭 해야 하는 두 가지
|
||||||
|
|
||||||
|
**1. 자동 로그온** — 러너가 서비스로 돌면 데스크톱 세션이 없어 GUI 테스트와
|
||||||
|
스크린샷이 전부 실패합니다. 자동 로그온 + 로그온 시 실행이어야 합니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 관리자 PowerShell (VM 안이므로 부담 적음)
|
||||||
|
$k = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
|
||||||
|
Set-ItemProperty $k AutoAdminLogon 1
|
||||||
|
Set-ItemProperty $k DefaultUserName "ci"
|
||||||
|
Set-ItemProperty $k DefaultPassword "<비밀번호>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 화면 꺼짐·잠금 끄기** — 잠기면 GUI 테스트가 실패합니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powercfg /change monitor-timeout-ac 0
|
||||||
|
powercfg /change standby-timeout-ac 0
|
||||||
|
```
|
||||||
|
|
||||||
|
그 다음은 아래 일반 설치 절차와 같습니다. 러너는 **서비스가 아니라 로그온
|
||||||
|
예약 작업**으로 등록하세요(아래 "항상 켜두고 싶다면" 참고).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 무엇이 자동으로 확인되나
|
||||||
|
|
||||||
|
| 항목 | 리눅스에서 | Windows 러너에서 |
|
||||||
|
|---|---|---|
|
||||||
|
| 단위 테스트 189개 | ✅ | ✅ |
|
||||||
|
| WASAPI 루프백 장치 열거 | ❌ | ✅ |
|
||||||
|
| 소리 내는 프로그램 열거 | ❌ | ✅ |
|
||||||
|
| 전역 단축키 등록/해제 | ❌ | ✅ |
|
||||||
|
| 모니터 열거·자막 배치 | 가상 화면만 | ✅ 실제 모니터 |
|
||||||
|
| 포터블 exe 빌드 | ❌ | ✅ |
|
||||||
|
| exe가 실제로 뜨는지 | ❌ | ✅ |
|
||||||
|
| GPU 인식 (CUDA) | 이 서버 GPU | ✅ 실제 게임 PC GPU |
|
||||||
|
| 실제 화면 스크린샷 | offscreen | ✅ 진짜 화면 |
|
||||||
|
|
||||||
|
결과물(exe, 스크린샷, 로그)은 Gitea Actions의 artifact로 올라오므로
|
||||||
|
원격에서도 눈으로 확인할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 설치 (약 10분, 한 번만)
|
||||||
|
|
||||||
|
### 1. 필요한 것 설치
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget install Git.Git # 소스 받기 (checkout) 에 필요
|
||||||
|
winget install Microsoft.PowerShell # 워크플로가 pwsh 7 을 씁니다
|
||||||
|
winget install Python.Python.3.12
|
||||||
|
winget install Kitware.CMake
|
||||||
|
winget install Microsoft.VisualStudio.2022.BuildTools # C++ 데스크톱 워크로드 선택
|
||||||
|
```
|
||||||
|
|
||||||
|
`Git.Git` 과 `Microsoft.PowerShell` 은 빠뜨리기 쉬운데 둘 다 필수입니다.
|
||||||
|
Git 이 없으면 소스를 못 받고, Windows 에 기본으로 깔린 PowerShell 은 5.1 이라
|
||||||
|
워크플로가 쓰는 `pwsh`(7.x) 가 없으면 단계마다 실패합니다.
|
||||||
|
설치 후 새 PowerShell 창을 열어 `git --version`, `pwsh --version` 이
|
||||||
|
나오는지 확인하세요.
|
||||||
|
|
||||||
|
### 2. Gitea 러너 내려받기
|
||||||
|
|
||||||
|
<https://gitea.com/gitea/act_runner/releases> 에서
|
||||||
|
`act_runner-<버전>-windows-amd64.exe` 를 받아 `C:\gitea-runner\act_runner.exe` 로 둡니다.
|
||||||
|
|
||||||
|
### 3. 등록 토큰 받기
|
||||||
|
|
||||||
|
Gitea 웹에서 저장소 → **설정 → Actions → 러너 → 등록 토큰 생성**
|
||||||
|
(주소: `https://git.tkrmagid.kr/tkrmagid/live-app-translator/settings/actions/runners`)
|
||||||
|
|
||||||
|
### 4. 등록하고 실행
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\gitea-runner
|
||||||
|
.\act_runner.exe register --no-interactive `
|
||||||
|
--instance https://git.tkrmagid.kr `
|
||||||
|
--token <위에서 받은 토큰> `
|
||||||
|
--name windows-test `
|
||||||
|
--labels windows:host
|
||||||
|
|
||||||
|
.\act_runner.exe daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
`--labels windows:host` 가 중요합니다. 워크플로의 `runs-on: windows` 와 이름이
|
||||||
|
맞아야 하고, `:host` 는 컨테이너가 아니라 그 PC에서 직접 돌리라는 뜻입니다
|
||||||
|
(생략해도 기본값이 `host` 지만 명시하는 편이 낫습니다).
|
||||||
|
|
||||||
|
이름이 안 맞으면 Gitea 는 작업을 실패시키지 않고 **기본 우분투 컨테이너에서
|
||||||
|
돌립니다.** Windows 검증이 조용히 리눅스에서 돌아가는 셈이라, 워크플로 첫
|
||||||
|
단계에서 그걸 막아 뒀습니다.
|
||||||
|
|
||||||
|
### 5. 확인
|
||||||
|
|
||||||
|
워크플로는 **`main` 에 push될 때** 또는 **Actions 탭에서 수동 실행**할 때 돕니다.
|
||||||
|
(`.gitea/workflows/windows.yml` 의 `on:` 항목)
|
||||||
|
|
||||||
|
저장소 **Actions** 탭 → `Windows` → `Run workflow` 로 바로 한 번 돌려보세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 항상 켜두고 싶다면 (선택)
|
||||||
|
|
||||||
|
**서비스(`sc.exe create`)로 등록하지 마세요.** 그렇게 하면 SYSTEM 권한으로
|
||||||
|
돌아가 러너가 PC 전체를 건드릴 수 있게 됩니다. 로그인할 때 **그 계정 권한으로**
|
||||||
|
뜨게 하는 편이 안전합니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 관리자 아님 — 그냥 평소 PowerShell
|
||||||
|
$action = New-ScheduledTaskAction -Execute "C:\gitea-runner\act_runner.exe" `
|
||||||
|
-Argument "daemon" -WorkingDirectory "C:\gitea-runner"
|
||||||
|
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
||||||
|
Register-ScheduledTask -TaskName "gitea-runner" -Action $action -Trigger $trigger
|
||||||
|
```
|
||||||
|
|
||||||
|
게임할 때 방해되면 그냥 꺼두고, 확인이 필요할 때만 `daemon` 을 켜도 됩니다.
|
||||||
|
러너가 꺼져 있으면 워크플로는 대기만 하고 아무 일도 하지 않습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 안전에 대해 — 정확히 어떤 권한인가
|
||||||
|
|
||||||
|
**솔직하게 말하면, 이건 "그 PC에서 명령을 실행할 수 있는 권한"입니다.**
|
||||||
|
|
||||||
|
러너는 저장소의 워크플로 파일을 실행합니다. 그런데 저(Claude)는 이 저장소의
|
||||||
|
`main`에 push할 수 있으므로, 워크플로 파일을 고쳐서 커밋하면 결과적으로 그
|
||||||
|
PC에서 원하는 명령을 돌릴 수 있습니다. "커밋된 파일만 실행하니 안전하다"는
|
||||||
|
말은 정확하지 않습니다.
|
||||||
|
|
||||||
|
실제로 보장되는 것은 이것뿐입니다.
|
||||||
|
|
||||||
|
- **숨길 수 없습니다.** 실행되는 모든 것은 `.gitea/workflows/` 안에 있고
|
||||||
|
git 기록에 남습니다. 몰래 뭘 했는지 나중에 전부 확인할 수 있습니다.
|
||||||
|
- **러너를 끄면 즉시 멈춥니다.** `daemon` 을 종료하면 그만입니다.
|
||||||
|
- 러너는 **자기 계정 권한**으로 돕니다. 관리자로 띄우지 마세요.
|
||||||
|
|
||||||
|
그래서 권하는 방식은 이렇습니다.
|
||||||
|
|
||||||
|
| 방법 | 위험 | 얻는 것 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Windows VM** (Proxmox 등) | 거의 없음 | 빌드·단축키·모니터·UI·exe 실행 |
|
||||||
|
| **전용 Windows 계정** | 낮음 — 그 계정 파일만 노출 | 위 전부 + 실제 GPU |
|
||||||
|
| 평소 쓰는 계정 | 개인 파일·브라우저 세션 노출 | 위 전부 + 실제 게임 소리 |
|
||||||
|
|
||||||
|
**VM이나 전용 계정을 권합니다.** GPU와 실제 게임 소리 확인은 못 하지만,
|
||||||
|
지금 미검증인 항목의 대부분(빌드·단축키·모니터·UI·exe 기동)은 거기서도
|
||||||
|
그대로 검증됩니다. GPU/오디오는 필요할 때만 평소 계정에서 한 번씩 확인하는
|
||||||
|
편이 낫습니다.
|
||||||
|
|
||||||
|
부담스러우면 러너를 붙이지 않고 아래 "러너 없이 직접 돌려보기"만 하셔도
|
||||||
|
충분합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 러너 없이 직접 돌려보기
|
||||||
|
|
||||||
|
러너를 안 붙이고 그때그때 확인만 하고 싶다면 이것만 실행하고 결과를
|
||||||
|
보내주셔도 됩니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://git.tkrmagid.kr/tkrmagid/live-app-translator
|
||||||
|
cd live-app-translator
|
||||||
|
py -3.12 -m venv .venv
|
||||||
|
.\.venv\Scripts\python -m pip install -r packaging\requirements-portable.txt pytest
|
||||||
|
$env:PYTHONPATH="src"
|
||||||
|
.\.venv\Scripts\python -m pytest tests -q
|
||||||
|
.\.venv\Scripts\python packaging\windows_smoke.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`artifacts\windows-smoke.txt` 와 `artifacts\*.png` 를 보내주시면 됩니다.
|
||||||
BIN
docs/images/banner.png
Normal file
|
After Width: | Height: | Size: 387 KiB |
BIN
docs/images/change-location.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
docs/images/first-run.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/images/glossary.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
docs/images/home.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/images/models.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
docs/images/overlay.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
docs/images/settings.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
docs/images/subtitle.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
22
native/process_loopback/CMakeLists.txt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
project(livesub_capture LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
add_executable(livesub_capture main.cpp)
|
||||||
|
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(livesub_capture PRIVATE /W3 /permissive- /EHsc)
|
||||||
|
target_compile_definitions(livesub_capture PRIVATE _WIN32_WINNT=0x0A00 NOMINMAX)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
target_link_libraries(livesub_capture PRIVATE ole32 mmdevapi)
|
||||||
|
|
||||||
|
# 빌드 결과를 파이썬 패키지가 찾는 위치로 복사한다.
|
||||||
|
add_custom_command(TARGET livesub_capture POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||||
|
"${CMAKE_SOURCE_DIR}/../../src/livesub/resources/bin"
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"$<TARGET_FILE:livesub_capture>"
|
||||||
|
"${CMAKE_SOURCE_DIR}/../../src/livesub/resources/bin/")
|
||||||
21
native/process_loopback/build.ps1
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 프로그램별 오디오 캡처 보조 프로그램 빌드 (Windows 전용)
|
||||||
|
#
|
||||||
|
# 필요: Visual Studio 2022 Build Tools (C++ 데스크톱 워크로드) + CMake
|
||||||
|
# winget install Microsoft.VisualStudio.2022.BuildTools
|
||||||
|
# winget install Kitware.CMake
|
||||||
|
#
|
||||||
|
# 사용: powershell -ExecutionPolicy Bypass -File native\process_loopback\build.ps1
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$build = Join-Path $here "build"
|
||||||
|
|
||||||
|
cmake -S $here -B $build -A x64
|
||||||
|
cmake --build $build --config Release
|
||||||
|
|
||||||
|
$out = Join-Path $here "..\..\src\livesub\resources\bin\livesub_capture.exe"
|
||||||
|
if (Test-Path $out) {
|
||||||
|
Write-Host "빌드 완료: $out" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Error "빌드는 끝났지만 결과물을 찾지 못했습니다."
|
||||||
|
}
|
||||||
234
native/process_loopback/main.cpp
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
// livesub_capture.exe — 특정 프로세스(와 그 자식 프로세스)의 렌더 오디오만
|
||||||
|
// 뽑아내 stdout 으로 흘려보내는 보조 프로그램.
|
||||||
|
//
|
||||||
|
// 출력 포맷: 16000Hz / mono / 32-bit float, 헤더 없는 생 PCM.
|
||||||
|
// 사용법: livesub_capture.exe --pid 1234 [--exclude]
|
||||||
|
// --exclude 를 주면 해당 프로세스 트리를 "제외한" 나머지 소리를 받는다.
|
||||||
|
//
|
||||||
|
// Windows 10 2004(build 19041) 이상에서 동작하는 WASAPI process loopback API를
|
||||||
|
// 사용한다. Microsoft 의 ApplicationLoopback 샘플이 기반이다.
|
||||||
|
|
||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <audioclient.h>
|
||||||
|
#include <audioclientactivationparams.h>
|
||||||
|
#include <mmdeviceapi.h>
|
||||||
|
#include <wrl/implements.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <io.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#pragma comment(lib, "ole32.lib")
|
||||||
|
#pragma comment(lib, "mmdevapi.lib")
|
||||||
|
|
||||||
|
using Microsoft::WRL::ComPtr;
|
||||||
|
using Microsoft::WRL::RuntimeClass;
|
||||||
|
using Microsoft::WRL::RuntimeClassFlags;
|
||||||
|
using Microsoft::WRL::ClassicCom;
|
||||||
|
using Microsoft::WRL::FtmBase;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kSampleRate = 16000;
|
||||||
|
constexpr int kChannels = 1;
|
||||||
|
|
||||||
|
std::atomic<bool> g_stop{false};
|
||||||
|
|
||||||
|
BOOL WINAPI ConsoleHandler(DWORD type) {
|
||||||
|
if (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT || type == CTRL_CLOSE_EVENT) {
|
||||||
|
g_stop = true;
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateAudioInterfaceAsync 완료 콜백. 결과를 이벤트로 넘겨준다.
|
||||||
|
//
|
||||||
|
// FtmBase 는 플래그가 아니라 섞어 넣는 클래스다. RuntimeClassFlags 안에
|
||||||
|
// `ClassicCom | FtmBase` 로 넣으면 MSVC 가 C2275 로 거부한다(타입을 값 자리에
|
||||||
|
// 썼으므로). 인터페이스 목록 쪽에 나열해야 한다 — ApplicationLoopback 샘플과
|
||||||
|
// 같은 형태다.
|
||||||
|
class ActivationHandler
|
||||||
|
: public RuntimeClass<RuntimeClassFlags<ClassicCom>, FtmBase,
|
||||||
|
IActivateAudioInterfaceCompletionHandler> {
|
||||||
|
public:
|
||||||
|
explicit ActivationHandler(HANDLE done) : done_(done) {}
|
||||||
|
|
||||||
|
STDMETHODIMP ActivateCompleted(IActivateAudioInterfaceAsyncOperation* op) override {
|
||||||
|
HRESULT activate_hr = S_OK;
|
||||||
|
ComPtr<IUnknown> unknown;
|
||||||
|
HRESULT hr = op->GetActivateResult(&activate_hr, &unknown);
|
||||||
|
if (SUCCEEDED(hr) && SUCCEEDED(activate_hr)) {
|
||||||
|
unknown.As(&client_);
|
||||||
|
}
|
||||||
|
result_ = FAILED(hr) ? hr : activate_hr;
|
||||||
|
SetEvent(done_);
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT result() const { return result_; }
|
||||||
|
ComPtr<IAudioClient> client() const { return client_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
HANDLE done_;
|
||||||
|
HRESULT result_ = E_FAIL;
|
||||||
|
ComPtr<IAudioClient> client_;
|
||||||
|
};
|
||||||
|
|
||||||
|
void Fail(const char* what, HRESULT hr) {
|
||||||
|
std::fprintf(stderr, "[livesub_capture] %s (hr=0x%08lX)\n", what, static_cast<unsigned long>(hr));
|
||||||
|
}
|
||||||
|
|
||||||
|
int Run(DWORD pid, bool exclude) {
|
||||||
|
HANDLE activated = CreateEventW(nullptr, FALSE, FALSE, nullptr);
|
||||||
|
HANDLE sample_ready = CreateEventW(nullptr, FALSE, FALSE, nullptr);
|
||||||
|
if (!activated || !sample_ready) {
|
||||||
|
Fail("이벤트 생성 실패", HRESULT_FROM_WIN32(GetLastError()));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
AUDIOCLIENT_ACTIVATION_PARAMS params{};
|
||||||
|
params.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK;
|
||||||
|
params.ProcessLoopbackParams.TargetProcessId = pid;
|
||||||
|
params.ProcessLoopbackParams.ProcessLoopbackMode =
|
||||||
|
exclude ? PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE
|
||||||
|
: PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE;
|
||||||
|
|
||||||
|
PROPVARIANT activate_params{};
|
||||||
|
activate_params.vt = VT_BLOB;
|
||||||
|
activate_params.blob.cbSize = sizeof(params);
|
||||||
|
activate_params.blob.pBlobData = reinterpret_cast<BYTE*>(¶ms);
|
||||||
|
|
||||||
|
auto handler = Microsoft::WRL::Make<ActivationHandler>(activated);
|
||||||
|
ComPtr<IActivateAudioInterfaceAsyncOperation> async_op;
|
||||||
|
HRESULT hr = ActivateAudioInterfaceAsync(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK,
|
||||||
|
__uuidof(IAudioClient), &activate_params,
|
||||||
|
handler.Get(), &async_op);
|
||||||
|
if (FAILED(hr)) {
|
||||||
|
Fail("ActivateAudioInterfaceAsync 실패", hr);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (WaitForSingleObject(activated, 5000) != WAIT_OBJECT_0 || FAILED(handler->result())) {
|
||||||
|
Fail("오디오 인터페이스 활성화 실패 (Windows 10 2004 이상 필요)", handler->result());
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
ComPtr<IAudioClient> client = handler->client();
|
||||||
|
if (!client) {
|
||||||
|
Fail("IAudioClient 를 받지 못했습니다", E_POINTER);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// process loopback 경로에는 GetMixFormat 이 없으므로 포맷을 직접 지정한다.
|
||||||
|
WAVEFORMATEX format{};
|
||||||
|
format.wFormatTag = WAVE_FORMAT_IEEE_FLOAT;
|
||||||
|
format.nChannels = kChannels;
|
||||||
|
format.nSamplesPerSec = kSampleRate;
|
||||||
|
format.wBitsPerSample = 32;
|
||||||
|
format.nBlockAlign = format.nChannels * format.wBitsPerSample / 8;
|
||||||
|
format.nAvgBytesPerSec = format.nSamplesPerSec * format.nBlockAlign;
|
||||||
|
format.cbSize = 0;
|
||||||
|
|
||||||
|
constexpr REFERENCE_TIME kBufferDuration = 2 * 10'000'000LL; // 200ms
|
||||||
|
hr = client->Initialize(AUDCLNT_SHAREMODE_SHARED,
|
||||||
|
AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK,
|
||||||
|
kBufferDuration, 0, &format, nullptr);
|
||||||
|
if (FAILED(hr)) {
|
||||||
|
Fail("IAudioClient::Initialize 실패", hr);
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
ComPtr<IAudioCaptureClient> capture;
|
||||||
|
hr = client->GetService(IID_PPV_ARGS(&capture));
|
||||||
|
if (FAILED(hr)) {
|
||||||
|
Fail("IAudioCaptureClient 획득 실패", hr);
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
hr = client->SetEventHandle(sample_ready);
|
||||||
|
if (FAILED(hr)) {
|
||||||
|
Fail("SetEventHandle 실패", hr);
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
hr = client->Start();
|
||||||
|
if (FAILED(hr)) {
|
||||||
|
Fail("스트림 시작 실패", hr);
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fprintf(stderr, "[livesub_capture] pid=%lu 캡처 시작 (%dHz mono f32)\n",
|
||||||
|
static_cast<unsigned long>(pid), kSampleRate);
|
||||||
|
std::fflush(stderr);
|
||||||
|
|
||||||
|
while (!g_stop) {
|
||||||
|
// 무음 구간에서는 이벤트가 오지 않으므로 타임아웃으로 빠져나와 종료 여부를 확인한다.
|
||||||
|
DWORD wait = WaitForSingleObject(sample_ready, 200);
|
||||||
|
if (wait == WAIT_TIMEOUT) continue;
|
||||||
|
if (wait != WAIT_OBJECT_0) break;
|
||||||
|
|
||||||
|
UINT32 packet = 0;
|
||||||
|
while (SUCCEEDED(capture->GetNextPacketSize(&packet)) && packet > 0) {
|
||||||
|
BYTE* data = nullptr;
|
||||||
|
UINT32 frames = 0;
|
||||||
|
DWORD flags = 0;
|
||||||
|
hr = capture->GetBuffer(&data, &frames, &flags, nullptr, nullptr);
|
||||||
|
if (FAILED(hr)) break;
|
||||||
|
|
||||||
|
const size_t bytes = static_cast<size_t>(frames) * format.nBlockAlign;
|
||||||
|
if (flags & AUDCLNT_BUFFERFLAGS_SILENT) {
|
||||||
|
static thread_local std::vector<BYTE> zeros;
|
||||||
|
zeros.assign(bytes, 0);
|
||||||
|
std::fwrite(zeros.data(), 1, bytes, stdout);
|
||||||
|
} else if (data && bytes) {
|
||||||
|
std::fwrite(data, 1, bytes, stdout);
|
||||||
|
}
|
||||||
|
std::fflush(stdout);
|
||||||
|
capture->ReleaseBuffer(frames);
|
||||||
|
|
||||||
|
if (std::ferror(stdout)) { // 부모 프로세스가 파이프를 닫음
|
||||||
|
g_stop = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client->Stop();
|
||||||
|
CloseHandle(activated);
|
||||||
|
CloseHandle(sample_ready);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
DWORD pid = 0;
|
||||||
|
bool exclude = false;
|
||||||
|
for (int i = 1; i < argc; ++i) {
|
||||||
|
if (std::strcmp(argv[i], "--pid") == 0 && i + 1 < argc) {
|
||||||
|
pid = static_cast<DWORD>(std::strtoul(argv[++i], nullptr, 10));
|
||||||
|
} else if (std::strcmp(argv[i], "--exclude") == 0) {
|
||||||
|
exclude = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pid == 0) {
|
||||||
|
std::fprintf(stderr, "사용법: livesub_capture.exe --pid <PID> [--exclude]\n");
|
||||||
|
return 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setmode(_fileno(stdout), _O_BINARY);
|
||||||
|
SetConsoleCtrlHandler(ConsoleHandler, TRUE);
|
||||||
|
|
||||||
|
HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||||
|
if (FAILED(hr)) {
|
||||||
|
Fail("CoInitializeEx 실패", hr);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
int rc = Run(pid, exclude);
|
||||||
|
CoUninitialize();
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
51
packaging/build-portable.ps1
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 포터블 LiveSub.exe 빌드 (Windows 전용)
|
||||||
|
#
|
||||||
|
# powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1
|
||||||
|
#
|
||||||
|
# 결과: dist\LiveSub.exe — 설치 없이 이 파일 하나만 있으면 실행됩니다.
|
||||||
|
# 모델은 exe 에 없고 첫 실행 때 %APPDATA%\LiveSub\models 로 내려받습니다.
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
Push-Location $root
|
||||||
|
|
||||||
|
try {
|
||||||
|
$venv = Join-Path $root ".venv-portable"
|
||||||
|
if (-not (Test-Path $venv)) {
|
||||||
|
Write-Host "포터블 전용 가상환경을 만듭니다 (torch 없이)..." -ForegroundColor Cyan
|
||||||
|
py -3.12 -m venv $venv
|
||||||
|
}
|
||||||
|
$py = Join-Path $venv "Scripts\python.exe"
|
||||||
|
|
||||||
|
& $py -m pip install --upgrade pip --quiet
|
||||||
|
& $py -m pip install -r packaging\requirements-portable.txt
|
||||||
|
|
||||||
|
# torch 가 섞여 들어가면 exe 가 3GB 로 불어난다. 빌드 전에 확인한다.
|
||||||
|
& $py -c "import importlib.util,sys; sys.exit(1 if importlib.util.find_spec('torch') else 0)"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "이 환경에 torch 가 있습니다. 포터블 빌드는 torch 없는 별도 venv 에서 하세요."
|
||||||
|
}
|
||||||
|
|
||||||
|
# 프로세스별 캡처 보조 프로그램이 있으면 같이 담긴다 (없어도 빌드는 된다).
|
||||||
|
$capture = Join-Path $root "src\livesub\resources\bin\livesub_capture.exe"
|
||||||
|
if (-not (Test-Path $capture)) {
|
||||||
|
Write-Warning "livesub_capture.exe 가 없습니다. 포터블은 '출력 장치 전체' 캡처만 됩니다."
|
||||||
|
Write-Warning "프로그램별 캡처를 넣으려면 native\process_loopback\build.ps1 을 먼저 실행하세요."
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
|
||||||
|
& $py -m PyInstaller packaging\livesub.spec --noconfirm
|
||||||
|
|
||||||
|
$out = Join-Path $root "dist\LiveSub.exe"
|
||||||
|
if (Test-Path $out) {
|
||||||
|
$mb = [math]::Round((Get-Item $out).Length / 1MB, 1)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "빌드 완료: $out ($mb MB)" -ForegroundColor Green
|
||||||
|
Write-Host "이 파일 하나만 복사하면 됩니다. 설치 필요 없습니다." -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Error "빌드는 끝났지만 dist\LiveSub.exe 를 찾지 못했습니다."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
98
packaging/livesub.spec
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
"""포터블 LiveSub 빌드 명세 (PyInstaller).
|
||||||
|
|
||||||
|
설치 없이 exe 하나만 두고 실행하는 것이 목표다. 다만 그냥 전부 넣으면
|
||||||
|
안 된다. torch+CUDA 만 2.5GB 라 --onefile 로 묶으면 **실행할 때마다**
|
||||||
|
그 2.5GB 를 임시폴더에 풀어야 해서 시작에 1분 넘게 걸린다.
|
||||||
|
|
||||||
|
그래서 포터블 빌드는 torch 를 뺀다. 음성인식(faster-whisper)과 번역
|
||||||
|
(NLLB) 모두 CTranslate2 위에서 돌아가고 CTranslate2 는 torch 를 요구하지
|
||||||
|
않기 때문에, 1~3티어는 그대로 다 동작한다.
|
||||||
|
|
||||||
|
다만 NLLB 토크나이저는 transformers 의 AutoTokenizer 를 쓰므로 transformers
|
||||||
|
자체는 넣어야 한다. transformers 는 torch 가 없으면 "토크나이저/설정만 사용
|
||||||
|
가능" 모드로 뜨며, 우리가 쓰는 용도가 정확히 그것이다 (실측 확인). 4~5티어(Seed-X / Qwen3)만
|
||||||
|
빠지며, 앱이 '모델' 화면에서 그 사실을 표시한다.
|
||||||
|
|
||||||
|
빌드:
|
||||||
|
pip install -r packaging/requirements-portable.txt pyinstaller
|
||||||
|
pyinstaller packaging/livesub.spec --noconfirm
|
||||||
|
|
||||||
|
결과:
|
||||||
|
dist/LiveSub.exe (약 400~600MB, 첫 실행 20~40초 / 이후 10초 내외)
|
||||||
|
|
||||||
|
모델은 exe 에 넣지 않는다. 처음 쓸 때 %APPDATA%\\LiveSub\\models 로
|
||||||
|
내려받으므로 exe 를 USB 로 옮겨도 모델은 그 PC 에 남는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_dynamic_libs
|
||||||
|
|
||||||
|
BLOCK_CIPHER = None
|
||||||
|
ROOT = Path(SPECPATH).parent # noqa: F821 - PyInstaller 가 주입
|
||||||
|
SRC = ROOT / "src"
|
||||||
|
|
||||||
|
# 게임 용어집 JSON 과 (빌드돼 있다면) 프로세스 캡처 보조 프로그램을 함께 담는다.
|
||||||
|
datas = [(str(SRC / "livesub" / "resources" / "glossaries"), "livesub/resources/glossaries")]
|
||||||
|
capture_exe = SRC / "livesub" / "resources" / "bin" / "livesub_capture.exe"
|
||||||
|
if capture_exe.is_file():
|
||||||
|
datas.append((str(capture_exe), "livesub/resources/bin"))
|
||||||
|
|
||||||
|
# CTranslate2 는 CUDA/cuDNN DLL 을 곁에 두고 로드한다. 자동 수집에서 빠지기 쉽다.
|
||||||
|
binaries = collect_dynamic_libs("ctranslate2")
|
||||||
|
|
||||||
|
hiddenimports = [
|
||||||
|
"livesub.audio.process_loopback",
|
||||||
|
"livesub.audio.wasapi_loopback",
|
||||||
|
"ctranslate2",
|
||||||
|
"faster_whisper",
|
||||||
|
"tokenizers",
|
||||||
|
"sentencepiece",
|
||||||
|
"transformers",
|
||||||
|
"transformers.models.nllb",
|
||||||
|
"huggingface_hub",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 넣으면 용량만 키우고 쓰지 않는 것들. torch 계열은 의도적으로 제외한다.
|
||||||
|
excludes = [
|
||||||
|
# transformers 는 제외하지 않는다. NLLB 토크나이저(AutoTokenizer)가 필요하고,
|
||||||
|
# torch 없이 토크나이저 전용으로 동작하는 것을 실제로 확인했다.
|
||||||
|
"torch", "torchvision", "torchaudio", "peft",
|
||||||
|
"accelerate", "datasets", "bitsandbytes", "autoawq",
|
||||||
|
"matplotlib", "scipy", "pandas", "IPython", "notebook",
|
||||||
|
"tkinter", "PySide6.QtWebEngineCore", "PySide6.Qt3DCore",
|
||||||
|
"PySide6.QtCharts", "PySide6.QtDataVisualization", "PySide6.QtMultimedia",
|
||||||
|
"PySide6.QtQuick", "PySide6.QtQml", "PySide6.QtPdf", "PySide6.QtDesigner",
|
||||||
|
]
|
||||||
|
|
||||||
|
a = Analysis( # noqa: F821
|
||||||
|
[str(SRC / "livesub" / "__main__.py")],
|
||||||
|
pathex=[str(SRC)],
|
||||||
|
binaries=binaries,
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=hiddenimports,
|
||||||
|
# contrib 훅이 빌드를 중단시키는 것을 덮어쓴다 (hook-webrtcvad.py 주석 참고).
|
||||||
|
hookspath=[str(ROOT / "packaging" / "pyinstaller-hooks")],
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=excludes,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=BLOCK_CIPHER) # noqa: F821
|
||||||
|
|
||||||
|
exe = EXE( # noqa: F821
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name="LiveSub",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=False, # UPX 로 압축하면 백신이 오탐하는 일이 잦다
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False, # 콘솔 창 없이 GUI 로만
|
||||||
|
icon=str(ROOT / "src" / "livesub" / "resources" / "icon.ico"),
|
||||||
|
version=None,
|
||||||
|
)
|
||||||
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
|
||||||
27
packaging/requirements-portable.txt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 포터블 exe 전용 의존성 — torch 를 넣지 않는다.
|
||||||
|
#
|
||||||
|
# 음성인식(faster-whisper)도 번역(NLLB)도 CTranslate2 위에서 돌고
|
||||||
|
# CTranslate2 는 torch 를 요구하지 않는다. 덕분에 exe 가 3GB -> 500MB 가
|
||||||
|
# 된다. 대신 4~5티어(Seed-X / Qwen3)는 빠지며 앱이 그렇게 표시한다.
|
||||||
|
#
|
||||||
|
# 4~5티어까지 쓰려면 포터블이 아니라 일반 설치(requirements.txt)를 쓰세요.
|
||||||
|
|
||||||
|
PySide6>=6.6
|
||||||
|
numpy>=1.24
|
||||||
|
|
||||||
|
faster-whisper>=1.0
|
||||||
|
ctranslate2>=4.4
|
||||||
|
tokenizers>=0.19
|
||||||
|
sentencepiece>=0.2
|
||||||
|
# NLLB 토크나이저용. torch 없이 토크나이저 전용 모드로 동작한다.
|
||||||
|
transformers>=4.44
|
||||||
|
huggingface-hub>=0.24
|
||||||
|
|
||||||
|
# Windows 오디오 캡처
|
||||||
|
PyAudioWPatch>=0.2.12.7; sys_platform == "win32"
|
||||||
|
pycaw>=20240210; sys_platform == "win32"
|
||||||
|
comtypes>=1.4; sys_platform == "win32"
|
||||||
|
psutil>=5.9; sys_platform == "win32"
|
||||||
|
|
||||||
|
webrtcvad-wheels>=2.0.14
|
||||||
|
pyinstaller>=6.6
|
||||||
619
packaging/windows_smoke.py
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Windows 전용 경로 점검.
|
||||||
|
|
||||||
|
리눅스 개발 환경에서는 확인이 불가능한 것들만 모아놨다. 여기서 실패하면
|
||||||
|
"리눅스에서 테스트 다 통과했는데 윈도우에서 안 되더라" 가 되는 지점이다.
|
||||||
|
|
||||||
|
python packaging/windows_smoke.py # 소스에서 점검
|
||||||
|
python packaging/windows_smoke.py --exe dist\\LiveSub.exe # 빌드된 exe 점검
|
||||||
|
|
||||||
|
결과는 artifacts/ 에 로그와 스크린샷으로 남는다. CI 가 그걸 업로드하므로
|
||||||
|
원격에서도 실제 화면을 눈으로 확인할 수 있다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ARTIFACTS = ROOT / "artifacts"
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
|
||||||
|
# Qt 플랫폼 플러그인은 QApplication 을 만들기 *전에* 정해야 한다.
|
||||||
|
# 표시할 화면이 없는데 그냥 두면 플러그인 로드 실패로 SIGABRT 가 나면서
|
||||||
|
# 아무 결과도 못 남기고 죽는다. 점검기가 그렇게 죽으면 쓸모가 없다.
|
||||||
|
if sys.platform != "win32" and not (
|
||||||
|
os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
|
||||||
|
):
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
_results: list[tuple[str, bool, str]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _qt_app():
|
||||||
|
"""QApplication 을 한 번만 만들어 재사용한다."""
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str):
|
||||||
|
"""점검 하나. 예외가 나도 나머지는 계속 돈다."""
|
||||||
|
|
||||||
|
def wrapper(fn):
|
||||||
|
try:
|
||||||
|
detail = fn() or ""
|
||||||
|
ok = True
|
||||||
|
except Exception as exc: # noqa: BLE001 - 점검 결과로 기록하는 게 목적
|
||||||
|
detail, ok = f"{type(exc).__name__}: {exc}", False
|
||||||
|
_results.append((name, ok, str(detail)))
|
||||||
|
print(f" [{'OK ' if ok else '실패'}] {name} {detail}")
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# --- 소스 기준 점검 ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run_source_checks() -> None:
|
||||||
|
print(f"\n=== Windows 전용 경로 (실행 플랫폼: {sys.platform}) ===")
|
||||||
|
if sys.platform != "win32":
|
||||||
|
print(" 주의: Windows 가 아니라 Windows 전용 항목은 당연히 실패합니다.")
|
||||||
|
|
||||||
|
@check("WASAPI 루프백 장치 열거")
|
||||||
|
def _devices():
|
||||||
|
from livesub.audio.wasapi_loopback import WasapiLoopbackCapture
|
||||||
|
|
||||||
|
if not WasapiLoopbackCapture.available():
|
||||||
|
raise RuntimeError("PyAudioWPatch 없음 — 오디오 캡처 불가")
|
||||||
|
sources = WasapiLoopbackCapture.list_sources()
|
||||||
|
if not sources:
|
||||||
|
raise RuntimeError("루프백 장치가 하나도 없음")
|
||||||
|
return f"{len(sources)}개: {sources[0].label}"
|
||||||
|
|
||||||
|
@check("소리 내는 프로그램 열거 (pycaw)")
|
||||||
|
def _processes():
|
||||||
|
from livesub.audio.process_loopback import ProcessLoopbackCapture
|
||||||
|
|
||||||
|
sources = ProcessLoopbackCapture.list_sources()
|
||||||
|
return f"{len(sources)}개" + (f" (예: {sources[0].label})" if sources else " (지금 소리 내는 앱 없음)")
|
||||||
|
|
||||||
|
@check("프로그램별 캡처 보조 프로그램")
|
||||||
|
def _helper():
|
||||||
|
from livesub.audio.process_loopback import helper_path
|
||||||
|
|
||||||
|
path = helper_path()
|
||||||
|
if path is None:
|
||||||
|
raise RuntimeError("livesub_capture.exe 없음 — 장치 전체 캡처로 폴백됨")
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
@check("전역 단축키 등록/해제")
|
||||||
|
def _hotkey():
|
||||||
|
from livesub.ui.hotkeys import GlobalHotkeys
|
||||||
|
|
||||||
|
_qt_app()
|
||||||
|
keys = GlobalHotkeys()
|
||||||
|
if not keys.supported:
|
||||||
|
raise RuntimeError("이 플랫폼은 전역 단축키 미지원")
|
||||||
|
fired = []
|
||||||
|
if not keys.register("Ctrl+Alt+F12", lambda: fired.append(1)):
|
||||||
|
raise RuntimeError(keys.last_error)
|
||||||
|
keys.unregister_all()
|
||||||
|
return "Ctrl+Alt+F12 등록·해제 성공"
|
||||||
|
|
||||||
|
@check("모니터 열거")
|
||||||
|
def _screens():
|
||||||
|
from livesub.ui.placement import list_screens
|
||||||
|
|
||||||
|
_qt_app()
|
||||||
|
screens = list_screens()
|
||||||
|
if not screens:
|
||||||
|
raise RuntimeError("모니터를 찾지 못함")
|
||||||
|
return " / ".join(s.label for s in screens)
|
||||||
|
|
||||||
|
@check("GPU 인식")
|
||||||
|
def _gpu():
|
||||||
|
from livesub.models import detect_gpu
|
||||||
|
|
||||||
|
gpu = detect_gpu()
|
||||||
|
if not gpu.available:
|
||||||
|
raise RuntimeError(gpu.reason)
|
||||||
|
return f"{gpu.name} · {gpu.total_vram_mb / 1024:.1f}GB"
|
||||||
|
|
||||||
|
@check("사용 가능한 모델 티어")
|
||||||
|
def _tiers():
|
||||||
|
from livesub.models.manager import has_torch, tier_availability
|
||||||
|
from livesub.models.tiers import ordered_tiers
|
||||||
|
|
||||||
|
usable = [t.name for t in ordered_tiers() if tier_availability(t)[0]]
|
||||||
|
return f"torch={'있음' if has_torch() else '없음'} → {', '.join(usable)}"
|
||||||
|
|
||||||
|
@check("자막 오버레이 실제 렌더링")
|
||||||
|
def _overlay():
|
||||||
|
from livesub.config import AppConfig
|
||||||
|
from livesub.ui.main_window import MainWindow
|
||||||
|
|
||||||
|
app = _qt_app()
|
||||||
|
cfg = AppConfig()
|
||||||
|
cfg.models.preload_on_start = False
|
||||||
|
win = MainWindow(cfg)
|
||||||
|
try:
|
||||||
|
win.show()
|
||||||
|
win.overlay.set_subtitle_visible(True)
|
||||||
|
win.overlay.set_line("적이 왼쪽에서 들어온다, 지금 바로 빠져!", "Enemy from the left")
|
||||||
|
for _ in range(10):
|
||||||
|
app.processEvents()
|
||||||
|
time.sleep(0.05)
|
||||||
|
ARTIFACTS.mkdir(exist_ok=True)
|
||||||
|
win.grab().save(str(ARTIFACTS / "windows-main.png"))
|
||||||
|
win.overlay.grab().save(str(ARTIFACTS / "windows-overlay.png"))
|
||||||
|
finally:
|
||||||
|
win.engine.shutdown()
|
||||||
|
win.overlay.close()
|
||||||
|
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}장"
|
||||||
|
|
||||||
|
|
||||||
|
# --- 전 파이프라인 점검 -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline_check(timeout_s: int = 900) -> None:
|
||||||
|
"""오디오 파일 하나를 넣어 한국어 자막이 나오는지 끝까지 본다.
|
||||||
|
|
||||||
|
개별 부품이 다 통과해도 이어 붙이면 안 되는 경우가 있다. 이 점검이
|
||||||
|
통과하면 "음성인식 -> 번역 -> 자막" 이 실제로 동작한다는 뜻이다.
|
||||||
|
|
||||||
|
GPU 없이 CPU 로 돌린다. 서버의 GPU 없는 Windows VM 에서도 그대로 된다.
|
||||||
|
모델은 처음 한 번만 받고 그 뒤로는 캐시를 쓴다.
|
||||||
|
"""
|
||||||
|
print(f"\n=== 전 파이프라인 (오디오 -> 자막), 플랫폼: {sys.platform} ===")
|
||||||
|
|
||||||
|
@check("오디오 파일 -> 한국어 자막")
|
||||||
|
def _pipeline():
|
||||||
|
from livesub.config import AppConfig
|
||||||
|
from livesub.core.engine import TranslationEngine
|
||||||
|
|
||||||
|
clip = ROOT / "tests" / "fixtures" / "en_callout_16k.wav"
|
||||||
|
if not clip.is_file():
|
||||||
|
raise RuntimeError(f"테스트 음성이 없습니다: {clip}")
|
||||||
|
|
||||||
|
cfg = AppConfig()
|
||||||
|
cfg.models.tier = "lightning" # CI 에서는 가장 가벼운 티어로 충분하다
|
||||||
|
cfg.models.device = "cpu" # GPU 없는 VM 에서도 돌아야 한다
|
||||||
|
cfg.models.preload_on_start = True
|
||||||
|
cfg.audio.backend = "file"
|
||||||
|
cfg.audio.target_process_name = str(clip)
|
||||||
|
cfg.audio.silence_ms = 400
|
||||||
|
cfg.performance.game_mode = False
|
||||||
|
|
||||||
|
lines: list = []
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
def on_status(status) -> None:
|
||||||
|
if status.state.value == "error":
|
||||||
|
errors.append(status.message)
|
||||||
|
|
||||||
|
engine = TranslationEngine(cfg, on_line=lines.append, on_status=on_status)
|
||||||
|
engine.start()
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
try:
|
||||||
|
while time.time() < deadline and not errors:
|
||||||
|
if any(line.is_final for line in lines):
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
finally:
|
||||||
|
engine.shutdown()
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise RuntimeError(f"엔진 오류: {errors[0]}")
|
||||||
|
finals = [line for line in lines if line.is_final]
|
||||||
|
if not finals:
|
||||||
|
raise RuntimeError(f"{timeout_s}초 안에 자막이 나오지 않았습니다")
|
||||||
|
|
||||||
|
line = finals[0]
|
||||||
|
if "enemy" not in line.source_text.lower():
|
||||||
|
raise RuntimeError(f"음성인식이 빗나갔습니다: {line.source_text!r}")
|
||||||
|
if not any("\uac00" <= ch <= "\ud7a3" for ch in line.translated_text):
|
||||||
|
raise RuntimeError(f"한국어 자막이 아닙니다: {line.translated_text!r}")
|
||||||
|
|
||||||
|
ARTIFACTS.mkdir(exist_ok=True)
|
||||||
|
(ARTIFACTS / "pipeline.txt").write_text(
|
||||||
|
f"원문: {line.source_text}\n자막: {line.translated_text}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return f"{line.source_text!r} -> {line.translated_text!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- GPU 전용 점검 ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run_gpu_checks() -> None:
|
||||||
|
"""GPU 가 있는 머신에서만 의미가 있는 것들.
|
||||||
|
|
||||||
|
GPU 는 한 VM 만 독점하므로(컨슈머 NVIDIA 는 vGPU 미지원) GPU 없는 CI VM
|
||||||
|
에서는 확인할 수 없다. 그래서 별도 러너 라벨(windows-gpu)로 떼어놨다.
|
||||||
|
여기서는 GPU 가 없으면 **분명히 실패**해야 한다 — GPU 를 확인하겠다고
|
||||||
|
부른 작업이 조용히 통과하면 안 된다.
|
||||||
|
"""
|
||||||
|
print(f"\n=== GPU 전용 (플랫폼: {sys.platform}) ===")
|
||||||
|
|
||||||
|
@check("CUDA 사용 가능")
|
||||||
|
def _cuda():
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise RuntimeError(
|
||||||
|
"torch 가 CUDA 를 못 씁니다. CPU 빌드가 깔렸는지 확인하세요 "
|
||||||
|
"(pip install torch --index-url https://download.pytorch.org/whl/cu128)"
|
||||||
|
)
|
||||||
|
idx = torch.cuda.current_device()
|
||||||
|
free, total = torch.cuda.mem_get_info(idx)
|
||||||
|
return f"{torch.cuda.get_device_name(idx)} · {total / 1024**3:.1f}GB (여유 {free / 1024**3:.1f}GB)"
|
||||||
|
|
||||||
|
@check("앱의 GPU 인식")
|
||||||
|
def _detect():
|
||||||
|
from livesub.models import detect_gpu
|
||||||
|
|
||||||
|
gpu = detect_gpu()
|
||||||
|
if not gpu.available:
|
||||||
|
raise RuntimeError(gpu.reason)
|
||||||
|
return f"{gpu.name} · {gpu.total_vram_mb / 1024:.1f}GB"
|
||||||
|
|
||||||
|
@check("VRAM 상한이 실제로 걸리는지")
|
||||||
|
def _vram():
|
||||||
|
from livesub.models.manager import apply_vram_limit
|
||||||
|
|
||||||
|
if not apply_vram_limit(0.35):
|
||||||
|
raise RuntimeError("상한 적용 실패 — 게임과 같이 쓸 때 VRAM 을 못 막습니다")
|
||||||
|
return "35% 상한 적용됨"
|
||||||
|
|
||||||
|
@check("티어 게이트가 뭐라고 하는지")
|
||||||
|
def _gate():
|
||||||
|
"""앱이 각 torch 티어를 쓸 수 있다고 하는지, 아니면 왜 막는지.
|
||||||
|
|
||||||
|
여기서는 판정하지 않고 그대로 보고만 한다. VRAM 이 작은 GPU 에서
|
||||||
|
4·5티어가 막히는 건 정상이라 실패로 처리하면 안 된다. 진짜 판정은
|
||||||
|
아래 "실제 로드" 가 한다 — 게이트가 된다고 한 걸 못 올리면 그게 버그다.
|
||||||
|
"""
|
||||||
|
from livesub.models.manager import detect_gpu, has_torch, tier_availability
|
||||||
|
from livesub.models.tiers import MTBackend, ordered_tiers
|
||||||
|
|
||||||
|
if not has_torch():
|
||||||
|
raise RuntimeError("torch 없음 — GPU 검증에는 일반 설치가 필요합니다")
|
||||||
|
gpu = detect_gpu()
|
||||||
|
parts = []
|
||||||
|
for t in ordered_tiers():
|
||||||
|
if t.mt.backend is not MTBackend.TRANSFORMERS:
|
||||||
|
continue
|
||||||
|
ok, reason = tier_availability(t, gpu)
|
||||||
|
parts.append(f"{t.name}={'가능' if ok else reason}")
|
||||||
|
return " / ".join(parts)
|
||||||
|
|
||||||
|
@check("게이트가 된다고 한 티어가 실제로 올라가는지")
|
||||||
|
def _real_load():
|
||||||
|
"""게이트 판정을 **실물로 검증**한다.
|
||||||
|
|
||||||
|
게이트가 "가능"이라 한 torch 티어를 실제로 올려 번역까지 시킨다.
|
||||||
|
여기서 죽으면 앱이 사용자에게 거짓말을 하고 있다는 뜻이다.
|
||||||
|
|
||||||
|
게이트가 전부 막았다면 검증할 대상이 없다. 그건 이 GPU 의 한계이지
|
||||||
|
회귀가 아니므로 실패로 처리하지 않고 이유를 남긴다.
|
||||||
|
"""
|
||||||
|
from livesub.models.glossary import Glossary
|
||||||
|
from livesub.models.manager import detect_gpu, tier_availability
|
||||||
|
from livesub.models.tiers import MTBackend, ordered_tiers
|
||||||
|
from livesub.models.translator import create_translator
|
||||||
|
|
||||||
|
gpu = detect_gpu()
|
||||||
|
allowed, blocked = [], []
|
||||||
|
for t in ordered_tiers():
|
||||||
|
if t.mt.backend is not MTBackend.TRANSFORMERS:
|
||||||
|
continue
|
||||||
|
ok, reason = tier_availability(t, gpu)
|
||||||
|
(allowed if ok else blocked).append(t if ok else f"{t.name}({reason})")
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
return (
|
||||||
|
"검증 대상 없음 — 이 GPU 로는 torch 티어를 못 씁니다. "
|
||||||
|
f"{'; '.join(blocked)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
tier = allowed[-1] # 열린 것 중 가장 무거운 것
|
||||||
|
translator = create_translator(tier.mt, device="cuda")
|
||||||
|
translator.load()
|
||||||
|
try:
|
||||||
|
out = translator.translate("Enemy coming from the left.", "en", "ko", Glossary())
|
||||||
|
finally:
|
||||||
|
translator.unload()
|
||||||
|
if not any("\uac00" <= ch <= "\ud7a3" for ch in out):
|
||||||
|
raise RuntimeError(f"{tier.name}: 한국어가 아닙니다 — {out!r}")
|
||||||
|
return f"{tier.name} 로드·번역 성공 {out!r}"
|
||||||
|
|
||||||
|
@check("GPU 로 실제 번역 (1~3티어 CUDA 경로)")
|
||||||
|
def _translate():
|
||||||
|
from livesub.models.glossary import Glossary
|
||||||
|
from livesub.models.packs import build_glossary
|
||||||
|
from livesub.models.tiers import get_tier
|
||||||
|
from livesub.models.translator import create_translator
|
||||||
|
|
||||||
|
tier = get_tier("swift") # CT2 지만 device=cuda 로 GPU 경로를 탄다
|
||||||
|
translator = create_translator(tier.mt, device="cuda")
|
||||||
|
translator.load()
|
||||||
|
glossary = build_glossary(Glossary(), ["fps-common", "pubg"])
|
||||||
|
try:
|
||||||
|
out = translator.translate("Enemy coming from the left.", "en", "ko", glossary)
|
||||||
|
finally:
|
||||||
|
translator.unload()
|
||||||
|
if not any("\uac00" <= ch <= "\ud7a3" for ch in out):
|
||||||
|
raise RuntimeError(f"한국어가 아닙니다: {out!r}")
|
||||||
|
ARTIFACTS.mkdir(exist_ok=True)
|
||||||
|
(ARTIFACTS / "gpu-translate.txt").write_text(out + "\n", encoding="utf-8")
|
||||||
|
return repr(out)
|
||||||
|
|
||||||
|
|
||||||
|
# --- 빌드된 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} ===")
|
||||||
|
|
||||||
|
@check("exe 존재와 크기")
|
||||||
|
def _size():
|
||||||
|
if not exe.is_file():
|
||||||
|
raise RuntimeError("파일 없음")
|
||||||
|
mb = exe.stat().st_size / 1024 / 1024
|
||||||
|
if mb > 1500:
|
||||||
|
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
|
||||||
|
return f"{mb:.1f} MB"
|
||||||
|
|
||||||
|
@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:
|
||||||
|
parser = argparse.ArgumentParser(description="Windows 전용 점검")
|
||||||
|
parser.add_argument("--exe", type=Path, help="빌드된 exe 를 점검")
|
||||||
|
parser.add_argument(
|
||||||
|
"--pipeline", action="store_true",
|
||||||
|
help="오디오 파일로 전 파이프라인 점검 (모델을 내려받으므로 느립니다)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--gpu", action="store_true",
|
||||||
|
help="GPU 가 있는 머신에서만 되는 항목 점검 (CUDA·VRAM 상한·4/5티어)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.exe:
|
||||||
|
run_exe_check(args.exe)
|
||||||
|
elif args.gpu:
|
||||||
|
run_gpu_checks()
|
||||||
|
elif args.pipeline:
|
||||||
|
run_pipeline_check()
|
||||||
|
else:
|
||||||
|
run_source_checks()
|
||||||
|
|
||||||
|
ARTIFACTS.mkdir(exist_ok=True)
|
||||||
|
report = "\n".join(
|
||||||
|
f"[{'OK' if ok else 'FAIL'}] {name}: {detail}" for name, ok, detail in _results
|
||||||
|
)
|
||||||
|
(ARTIFACTS / "windows-smoke.txt").write_text(report, encoding="utf-8")
|
||||||
|
|
||||||
|
failed = [n for n, ok, _ in _results if not ok]
|
||||||
|
print(f"\n{len(_results) - len(failed)}/{len(_results)} 통과")
|
||||||
|
if failed:
|
||||||
|
print("실패:", ", ".join(failed))
|
||||||
|
# 오디오 장치·보조 프로그램·GPU 는 러너 환경에 따라 없을 수 있어 경고로만 둔다.
|
||||||
|
# 다만 --gpu 로 부른 경우엔 GPU 가 없는 것 자체가 실패다.
|
||||||
|
lenient = {
|
||||||
|
"프로그램별 캡처 보조 프로그램",
|
||||||
|
"소리 내는 프로그램 열거 (pycaw)",
|
||||||
|
"WASAPI 루프백 장치 열거",
|
||||||
|
"GPU 인식",
|
||||||
|
}
|
||||||
|
fatal = failed if args.gpu else [n for n in failed if n not in lenient]
|
||||||
|
return 1 if fatal else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
71
pyproject.toml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "livesub"
|
||||||
|
version = "0.3.0"
|
||||||
|
description = "게임 소리를 실시간으로 번역해 화면 위 자막으로 보여주는 도구"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
authors = [{ name = "tkrmagid" }]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"PySide6>=6.6",
|
||||||
|
"numpy>=1.24",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# GPU 추론 (실사용에 필요). torch 는 CUDA 빌드를 따로 설치해야 한다 — README 참고.
|
||||||
|
gpu = [
|
||||||
|
"faster-whisper>=1.0",
|
||||||
|
"ctranslate2>=4.4",
|
||||||
|
"transformers>=4.44",
|
||||||
|
"huggingface-hub>=0.24",
|
||||||
|
"sentencepiece>=0.2",
|
||||||
|
]
|
||||||
|
# 4티어(Seed-X AWQ Int4) 전용 커널. 다른 티어에는 필요 없다.
|
||||||
|
awq = ["autoawq>=0.2.6"]
|
||||||
|
# Windows 오디오 캡처
|
||||||
|
windows = [
|
||||||
|
"PyAudioWPatch>=0.2.12.7",
|
||||||
|
"pycaw>=20240210",
|
||||||
|
"comtypes>=1.4",
|
||||||
|
"psutil>=5.9",
|
||||||
|
]
|
||||||
|
# 더 정확한 발화 구간 판정 (없어도 동작)
|
||||||
|
vad = ["webrtcvad-wheels>=2.0.14"]
|
||||||
|
# 추가학습 (LoRA)
|
||||||
|
finetune = [
|
||||||
|
"peft>=0.12",
|
||||||
|
"datasets>=2.20",
|
||||||
|
"accelerate>=0.33",
|
||||||
|
"bitsandbytes>=0.43; platform_system != 'Darwin'",
|
||||||
|
]
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
livesub = ["resources/bin/*", "resources/glossaries/*.json", "resources/icon.*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py310"
|
||||||
|
src = ["src", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
|
||||||
|
ignore = ["E501", "B008"]
|
||||||
25
requirements.txt
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 기본 실행에 필요한 최소 구성
|
||||||
|
PySide6>=6.6
|
||||||
|
numpy>=1.24
|
||||||
|
|
||||||
|
# --- GPU 추론 ---------------------------------------------------------
|
||||||
|
# torch 는 반드시 CUDA 빌드로 먼저 설치하세요 (README 참고):
|
||||||
|
# pip install torch --index-url https://download.pytorch.org/whl/cu128
|
||||||
|
faster-whisper>=1.0
|
||||||
|
ctranslate2>=4.4
|
||||||
|
transformers>=4.44
|
||||||
|
huggingface-hub>=0.24
|
||||||
|
sentencepiece>=0.2
|
||||||
|
accelerate>=0.33
|
||||||
|
|
||||||
|
# --- Windows 오디오 캡처 ---------------------------------------------
|
||||||
|
PyAudioWPatch>=0.2.12.7; sys_platform == "win32"
|
||||||
|
pycaw>=20240210; sys_platform == "win32"
|
||||||
|
comtypes>=1.4; sys_platform == "win32"
|
||||||
|
psutil>=5.9; sys_platform == "win32"
|
||||||
|
|
||||||
|
# --- 선택 ------------------------------------------------------------
|
||||||
|
webrtcvad-wheels>=2.0.14
|
||||||
|
|
||||||
|
# 4티어(Seed-X AWQ Int4)를 쓸 때만 필요합니다:
|
||||||
|
# pip install autoawq
|
||||||
269
scripts/finetune_mt.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""번역 모델 추가학습 (게임·방송 용어).
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python scripts/finetune_mt.py --data data/game.jsonl --tier ultimate \
|
||||||
|
--output ./adapters/game-ko --epochs 3
|
||||||
|
|
||||||
|
데이터 형식 (JSONL, 한 줄에 한 쌍):
|
||||||
|
{"source": "...", "target": "...", "source_lang": "en", "target_lang": "ko"}
|
||||||
|
|
||||||
|
자세한 배경은 docs/FINETUNING.md 참고.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||||
|
|
||||||
|
from livesub.constants import LANGUAGES, models_dir # noqa: E402
|
||||||
|
from livesub.models.tiers import ( # noqa: E402
|
||||||
|
MTBackend,
|
||||||
|
PromptStyle,
|
||||||
|
get_tier,
|
||||||
|
ordered_tiers,
|
||||||
|
)
|
||||||
|
from livesub.models.translator import build_seedx_prompt # noqa: E402
|
||||||
|
|
||||||
|
#: NLLB(seq2seq)와 Qwen(causal LM)에서 공통으로 존재하는 attention 투영 이름
|
||||||
|
LORA_TARGETS = ["q_proj", "v_proj"]
|
||||||
|
|
||||||
|
|
||||||
|
def load_pairs(path: Path) -> list[dict]:
|
||||||
|
pairs = []
|
||||||
|
with path.open(encoding="utf-8") as fh:
|
||||||
|
for lineno, raw in enumerate(fh, 1):
|
||||||
|
raw = raw.strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
item = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise SystemExit(f"{path}:{lineno} JSON 오류: {exc}") from exc
|
||||||
|
for key in ("source", "target"):
|
||||||
|
if not item.get(key):
|
||||||
|
raise SystemExit(f"{path}:{lineno} '{key}' 가 비어 있습니다")
|
||||||
|
item.setdefault("source_lang", "en")
|
||||||
|
item.setdefault("target_lang", "ko")
|
||||||
|
for key in ("source_lang", "target_lang"):
|
||||||
|
if item[key] not in LANGUAGES:
|
||||||
|
raise SystemExit(f"{path}:{lineno} 지원하지 않는 언어: {item[key]}")
|
||||||
|
pairs.append(item)
|
||||||
|
if not pairs:
|
||||||
|
raise SystemExit(f"{path} 에 학습 데이터가 없습니다")
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(item: dict, style: PromptStyle = PromptStyle.INSTRUCT) -> tuple[str, str]:
|
||||||
|
"""LLM 학습용 (프롬프트, 정답) 쌍.
|
||||||
|
|
||||||
|
**추론 때 쓰는 프롬프트와 반드시 같아야 한다.** 형식이 어긋나면 학습은
|
||||||
|
정상적으로 끝나지만 실제 번역에서는 효과가 거의 나오지 않는다. 그래서
|
||||||
|
Seed-X 형식은 translator.py 의 함수를 그대로 재사용한다.
|
||||||
|
"""
|
||||||
|
if style is PromptStyle.SEEDX:
|
||||||
|
prompt = build_seedx_prompt(
|
||||||
|
item["source"], item["source_lang"], item["target_lang"]
|
||||||
|
)
|
||||||
|
return prompt, "\n" + item["target"]
|
||||||
|
|
||||||
|
src = LANGUAGES[item["source_lang"]]["english"]
|
||||||
|
tgt = LANGUAGES[item["target_lang"]]["english"]
|
||||||
|
prompt = (
|
||||||
|
f"Translate the following {src} text into {tgt}. "
|
||||||
|
"It is a live spoken line from a game or broadcast, so keep the tone "
|
||||||
|
"casual and natural. Output only the translation.\n\n"
|
||||||
|
f"{src}: {item['source']}\n{tgt}:"
|
||||||
|
)
|
||||||
|
return prompt, " " + item["target"]
|
||||||
|
|
||||||
|
|
||||||
|
def train_llm(pairs, repo, output: Path, epochs: int, lr: float, rank: int, batch: int,
|
||||||
|
style: PromptStyle = PromptStyle.INSTRUCT):
|
||||||
|
import torch
|
||||||
|
from datasets import Dataset
|
||||||
|
from peft import LoraConfig, get_peft_model
|
||||||
|
from transformers import (
|
||||||
|
AutoModelForCausalLM,
|
||||||
|
AutoTokenizer,
|
||||||
|
DataCollatorForLanguageModeling,
|
||||||
|
Trainer,
|
||||||
|
TrainingArguments,
|
||||||
|
)
|
||||||
|
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(repo, cache_dir=str(models_dir()))
|
||||||
|
if tokenizer.pad_token is None:
|
||||||
|
tokenizer.pad_token = tokenizer.eos_token
|
||||||
|
|
||||||
|
def encode(item):
|
||||||
|
prompt, answer = build_prompt(item, style)
|
||||||
|
prompt_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
|
||||||
|
answer_ids = tokenizer(answer, add_special_tokens=False)["input_ids"]
|
||||||
|
answer_ids.append(tokenizer.eos_token_id)
|
||||||
|
ids = (prompt_ids + answer_ids)[:512]
|
||||||
|
# 프롬프트 부분은 손실에서 제외해야 '번역 결과'만 학습된다.
|
||||||
|
labels = ([-100] * len(prompt_ids) + answer_ids)[:512]
|
||||||
|
return {"input_ids": ids, "labels": labels, "attention_mask": [1] * len(ids)}
|
||||||
|
|
||||||
|
dataset = Dataset.from_list([encode(p) for p in pairs])
|
||||||
|
|
||||||
|
model = AutoModelForCausalLM.from_pretrained(
|
||||||
|
repo,
|
||||||
|
cache_dir=str(models_dir()),
|
||||||
|
torch_dtype=torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16,
|
||||||
|
device_map="auto",
|
||||||
|
)
|
||||||
|
model.enable_input_require_grads()
|
||||||
|
model = get_peft_model(
|
||||||
|
model,
|
||||||
|
LoraConfig(
|
||||||
|
r=rank,
|
||||||
|
lora_alpha=rank * 2,
|
||||||
|
lora_dropout=0.05,
|
||||||
|
bias="none",
|
||||||
|
task_type="CAUSAL_LM",
|
||||||
|
target_modules=LORA_TARGETS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
model.print_trainable_parameters()
|
||||||
|
|
||||||
|
Trainer(
|
||||||
|
model=model,
|
||||||
|
args=TrainingArguments(
|
||||||
|
output_dir=str(output / "checkpoints"),
|
||||||
|
num_train_epochs=epochs,
|
||||||
|
per_device_train_batch_size=batch,
|
||||||
|
gradient_accumulation_steps=max(1, 8 // batch),
|
||||||
|
learning_rate=lr,
|
||||||
|
warmup_ratio=0.05,
|
||||||
|
logging_steps=20,
|
||||||
|
save_strategy="no",
|
||||||
|
gradient_checkpointing=True,
|
||||||
|
report_to=[],
|
||||||
|
),
|
||||||
|
train_dataset=dataset,
|
||||||
|
data_collator=DataCollatorForLanguageModeling(tokenizer, mlm=False),
|
||||||
|
).train()
|
||||||
|
|
||||||
|
model.save_pretrained(output)
|
||||||
|
tokenizer.save_pretrained(output)
|
||||||
|
|
||||||
|
|
||||||
|
def train_seq2seq(pairs, repo, output: Path, epochs: int, lr: float, rank: int, batch: int):
|
||||||
|
"""NLLB 계열. CTranslate2 변환 모델이 아니라 원본 HF 모델로 학습해야 한다."""
|
||||||
|
from datasets import Dataset
|
||||||
|
from peft import LoraConfig, get_peft_model
|
||||||
|
from transformers import (
|
||||||
|
AutoModelForSeq2SeqLM,
|
||||||
|
AutoTokenizer,
|
||||||
|
DataCollatorForSeq2Seq,
|
||||||
|
Seq2SeqTrainer,
|
||||||
|
Seq2SeqTrainingArguments,
|
||||||
|
)
|
||||||
|
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(repo, cache_dir=str(models_dir()))
|
||||||
|
|
||||||
|
def encode(item):
|
||||||
|
tokenizer.src_lang = LANGUAGES[item["source_lang"]]["nllb"]
|
||||||
|
tokenizer.tgt_lang = LANGUAGES[item["target_lang"]]["nllb"]
|
||||||
|
batch_enc = tokenizer(
|
||||||
|
item["source"], text_target=item["target"], truncation=True, max_length=256
|
||||||
|
)
|
||||||
|
return dict(batch_enc)
|
||||||
|
|
||||||
|
dataset = Dataset.from_list([encode(p) for p in pairs])
|
||||||
|
model = AutoModelForSeq2SeqLM.from_pretrained(repo, cache_dir=str(models_dir()))
|
||||||
|
model = get_peft_model(
|
||||||
|
model,
|
||||||
|
LoraConfig(
|
||||||
|
r=rank,
|
||||||
|
lora_alpha=rank * 2,
|
||||||
|
lora_dropout=0.05,
|
||||||
|
bias="none",
|
||||||
|
task_type="SEQ_2_SEQ_LM",
|
||||||
|
target_modules=LORA_TARGETS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
model.print_trainable_parameters()
|
||||||
|
|
||||||
|
Seq2SeqTrainer(
|
||||||
|
model=model,
|
||||||
|
args=Seq2SeqTrainingArguments(
|
||||||
|
output_dir=str(output / "checkpoints"),
|
||||||
|
num_train_epochs=epochs,
|
||||||
|
per_device_train_batch_size=batch,
|
||||||
|
learning_rate=lr,
|
||||||
|
warmup_ratio=0.05,
|
||||||
|
logging_steps=20,
|
||||||
|
save_strategy="no",
|
||||||
|
report_to=[],
|
||||||
|
),
|
||||||
|
train_dataset=dataset,
|
||||||
|
data_collator=DataCollatorForSeq2Seq(tokenizer, model=model),
|
||||||
|
).train()
|
||||||
|
|
||||||
|
model.save_pretrained(output)
|
||||||
|
tokenizer.save_pretrained(output)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="번역 모델 LoRA 추가학습")
|
||||||
|
parser.add_argument("--data", required=True, type=Path, help="JSONL 학습 데이터")
|
||||||
|
parser.add_argument(
|
||||||
|
"--tier",
|
||||||
|
default="ultimate",
|
||||||
|
choices=[t.key for t in ordered_tiers()],
|
||||||
|
help="어느 티어의 번역 모델을 학습할지 (기본: ultimate)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", required=True, type=Path, help="어댑터 저장 폴더")
|
||||||
|
parser.add_argument("--epochs", type=int, default=3)
|
||||||
|
parser.add_argument("--lr", type=float, default=2e-4)
|
||||||
|
parser.add_argument("--rank", type=int, default=16)
|
||||||
|
parser.add_argument("--batch", type=int, default=2)
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-model",
|
||||||
|
default="",
|
||||||
|
help="티어 기본값 대신 쓸 HF 모델 (NLLB는 CT2 변환본이 아닌 원본을 지정해야 함)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
tier = get_tier(args.tier)
|
||||||
|
pairs = load_pairs(args.data)
|
||||||
|
args.output.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if tier.mt.finetune_ease >= 3:
|
||||||
|
print(
|
||||||
|
f"경고: '{tier.name}' 티어의 번역 모델은 추가학습에 적합하지 않습니다.\n"
|
||||||
|
" docs/FINETUNING.md 참고 — 'ultimate' 티어를 권장합니다.\n",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tier.mt.backend is MTBackend.TRANSFORMERS:
|
||||||
|
repo = args.base_model or tier.mt.repo
|
||||||
|
print(f"LLM LoRA 학습: {repo} · {len(pairs)}쌍 · {args.epochs} epoch")
|
||||||
|
train_llm(
|
||||||
|
pairs, repo, args.output, args.epochs, args.lr, args.rank, args.batch,
|
||||||
|
tier.mt.prompt_style,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# CT2 변환본에는 학습에 필요한 가중치가 없으므로 원본 리포로 바꾼다.
|
||||||
|
repo = args.base_model or _hf_original(tier.mt.repo)
|
||||||
|
print(f"seq2seq LoRA 학습: {repo} · {len(pairs)}쌍 · {args.epochs} epoch")
|
||||||
|
train_seq2seq(pairs, repo, args.output, args.epochs, args.lr, args.rank, args.batch)
|
||||||
|
|
||||||
|
print(f"\n완료. '모델' 화면의 추가학습 어댑터 칸에 입력하세요:\n {args.output.resolve()}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _hf_original(ct2_repo: str) -> str:
|
||||||
|
"""CTranslate2 변환 리포 이름에서 원본 facebook/ 리포를 추정한다."""
|
||||||
|
name = ct2_repo.split("/")[-1].replace("-ctranslate2", "")
|
||||||
|
return f"facebook/{name}"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
34
scripts/gpu-check.sh
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# GPU 검증 — RTX 5050 이 달린 .9(리눅스)에서 돌린다.
|
||||||
|
#
|
||||||
|
# GPU 는 한 VM 만 독점하므로 Windows CI VM 에는 GPU 가 없다. 그래서 CUDA
|
||||||
|
# 인식·VRAM 상한·티어 게이트·실제 GPU 번역은 GPU 가 실제로 있는 여기서
|
||||||
|
# 확인한다. Windows 전용 경로(오디오·단축키·exe)는 Windows 러너가 맡는다.
|
||||||
|
#
|
||||||
|
# scripts/gpu-check.sh
|
||||||
|
#
|
||||||
|
# 종료코드 0 이면 전부 통과. 항목 하나라도 실패하면 1.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
VENV="${GPU_VENV:-/home/claude/gpu-venv}"
|
||||||
|
|
||||||
|
if [[ ! -x "$VENV/bin/python" ]]; then
|
||||||
|
echo "GPU 용 파이썬이 없습니다: $VENV/bin/python" >&2
|
||||||
|
echo "GPU_VENV 로 경로를 지정하거나 torch(CUDA 빌드)가 깔린 venv 를 만드세요." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CTranslate2 가 libcublas.so.12 를 직접 찾는다. torch 가 딸려온 nvidia
|
||||||
|
# 패키지 안에 있으므로 경로를 잡아준다. 없으면
|
||||||
|
# "Library libcublas.so.12 is not found" 로 GPU 번역만 조용히 실패한다.
|
||||||
|
NV="$("$VENV/bin/python" -c 'import nvidia,os;print(os.path.dirname(nvidia.__file__))' 2>/dev/null || true)"
|
||||||
|
if [[ -n "$NV" ]]; then
|
||||||
|
for lib in cublas cudnn; do
|
||||||
|
[[ -d "$NV/$lib/lib" ]] && export LD_LIBRARY_PATH="$NV/$lib/lib:${LD_LIBRARY_PATH:-}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
export PYTHONPATH="src"
|
||||||
|
exec "$VENV/bin/python" packaging/windows_smoke.py --gpu
|
||||||
44
scripts/win-ci/boot-cd.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# "Press any key to boot from CD or DVD" 를 넘긴다.
|
||||||
|
#
|
||||||
|
# UEFI 부팅 시 Windows ISO 는 매번 이 프롬프트를 띄우고 약 5초 안에 키가
|
||||||
|
# 없으면 CD 부팅을 포기한다. 무인 설치인데 사람이 눌러줄 수 없으므로
|
||||||
|
# 리셋 직후부터 키를 계속 밀어 넣는다.
|
||||||
|
#
|
||||||
|
# ⚠️ 설치가 시작된 뒤에는 절대 실행하지 말 것. 남는 Enter 가 설치 화면의
|
||||||
|
# Cancel 버튼을 눌러 "Are you sure you want to quit?" 가 뜬다 (실제로 겪음).
|
||||||
|
# 설치가 끝난 뒤 재부팅 때는 키를 안 누르는 게 정답이다 - 그래야 CD 를
|
||||||
|
# 건너뛰고 디스크로 부팅한다.
|
||||||
|
#
|
||||||
|
# 사용법: ./boot-cd.sh [reset] - reset 을 주면 VM 을 먼저 리셋한다
|
||||||
|
set -euo pipefail
|
||||||
|
MODE="${1:-}"
|
||||||
|
python3 - "$MODE" <<'PY'
|
||||||
|
import json, socket, sys, time
|
||||||
|
mode = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
|
s = socket.socket(socket.AF_UNIX); s.settimeout(20)
|
||||||
|
s.connect("/home/claude/win-ci/qmp.sock")
|
||||||
|
f = s.makefile("rwb")
|
||||||
|
f.readline()
|
||||||
|
def cmd(o):
|
||||||
|
f.write((json.dumps(o) + "\n").encode()); f.flush()
|
||||||
|
while True:
|
||||||
|
line = f.readline()
|
||||||
|
if not line: raise SystemExit("qmp closed")
|
||||||
|
m = json.loads(line)
|
||||||
|
if "return" in m or "error" in m: return m
|
||||||
|
cmd({"execute": "qmp_capabilities"})
|
||||||
|
if mode == "reset":
|
||||||
|
print(cmd({"execute": "system_reset"}))
|
||||||
|
time.sleep(3)
|
||||||
|
# 프롬프트 창은 5초뿐이지만 펌웨어 POST 시간이 들쭉날쭉하다. 25초면 충분하고,
|
||||||
|
# 그 이상 밀면 설치 화면까지 Enter 가 새어 들어간다.
|
||||||
|
deadline = time.time() + 25
|
||||||
|
n = 0
|
||||||
|
while time.time() < deadline:
|
||||||
|
cmd({"execute": "send-key",
|
||||||
|
"arguments": {"keys": [{"type": "qcode", "data": "ret"}]}})
|
||||||
|
n += 1
|
||||||
|
time.sleep(0.4)
|
||||||
|
print("sent", n, "keys")
|
||||||
|
PY
|
||||||
64
scripts/win-ci/build.sh
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# win-ci Windows VM 을 처음부터 만든다 (.9 안 중첩 KVM, 무인 설치).
|
||||||
|
#
|
||||||
|
# ./build.sh ISO 내려받기 + 무인설치 ISO 굽기 + 디스크 생성
|
||||||
|
# ./start-vm.sh VM + 제어 서버 기동 (systemd 트랜지언트 유닛)
|
||||||
|
# ./boot-cd.sh reset "Press any key to boot from CD" 넘기기 (최초 1회만)
|
||||||
|
# ./status.sh 진행 상황
|
||||||
|
# ./shot.sh out.png 화면 스냅샷
|
||||||
|
#
|
||||||
|
# 사람이 클릭할 일은 없다. autounattend.xml 이 설치를 끝내고, provision.ps1 이
|
||||||
|
# virtio 드라이버를 넣고, agent.ps1 이 호스트에서 stage2.ps1 을 받아 개발도구와
|
||||||
|
# act_runner 를 깐다.
|
||||||
|
#
|
||||||
|
# 필요 패키지: qemu-system-x86 ovmf swtpm swtpm-tools xorriso
|
||||||
|
# 그리고 실행 계정이 kvm 그룹에 있어야 한다 (없으면 sg kvm -c '...' 로 감싼다).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
D="${WIN_CI_DIR:-/home/claude/win-ci}"
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
WIN_ISO_URL='https://go.microsoft.com/fwlink/p/?linkid=2289029' # Win11 Ent LTSC 2024 평가판(90일, 키 불필요)
|
||||||
|
VIRTIO_URL='https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso'
|
||||||
|
|
||||||
|
mkdir -p "$D/iso" "$D/serve" "$D/swtpm" "$D/unattend-root/setup"
|
||||||
|
|
||||||
|
fetch() { # url dest 최소크기MB
|
||||||
|
if [ -f "$2" ] && [ "$(stat -c%s "$2")" -gt $(( $3 * 1024 * 1024 )) ]; then
|
||||||
|
echo "이미 있음: $2"; return
|
||||||
|
fi
|
||||||
|
echo "다운로드: $2"
|
||||||
|
curl -fL --retry 3 -o "$2" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch "$WIN_ISO_URL" "$D/iso/win11-ltsc.iso" 4000
|
||||||
|
fetch "$VIRTIO_URL" "$D/iso/virtio-win.iso" 500
|
||||||
|
|
||||||
|
# 무인 설치 미디어. Windows Setup 이 붙어 있는 모든 드라이브 루트에서
|
||||||
|
# autounattend.xml 을 찾으므로 CD 로 물려주면 된다.
|
||||||
|
cp "$HERE/unattend/autounattend.xml" "$D/unattend-root/"
|
||||||
|
|
||||||
|
# ⚠️ .ps1 은 반드시 UTF-8 BOM 으로 넣는다.
|
||||||
|
# Windows PowerShell 5.1 은 BOM 이 없으면 현재 ANSI 코드페이지(en-US -> CP1252)로
|
||||||
|
# 읽는다. 한글 주석의 UTF-8 바이트가 CP1252 에서 스마트따옴표(“ ” ‘)로 변하고,
|
||||||
|
# PowerShell 은 그걸 문자열 시작으로 보고 스크립트 뒤를 전부 삼킨다. 증상은
|
||||||
|
# 엉뚱하게도 "Missing closing '}' in statement block" 이다. 실제로 이것 때문에
|
||||||
|
# 첫 설치의 provision.ps1 이 통째로 안 돌았다.
|
||||||
|
for f in "$HERE/unattend/setup/"*.ps1; do
|
||||||
|
out="$D/unattend-root/setup/$(basename "$f")"
|
||||||
|
printf '\xef\xbb\xbf' > "$out"
|
||||||
|
cat "$f" >> "$out"
|
||||||
|
done
|
||||||
|
xorriso -as mkisofs -V UNATTEND -J -joliet-long -R -o "$D/unattend.iso" "$D/unattend-root/"
|
||||||
|
|
||||||
|
# 호스트 제어 채널이 내려줄 스크립트
|
||||||
|
cp "$HERE/serve/stage2.ps1" "$D/serve/"
|
||||||
|
cp "$HERE"/*.sh "$HERE"/serve-host.py "$D/"
|
||||||
|
chmod +x "$D"/*.sh
|
||||||
|
|
||||||
|
[ -f "$D/win-ci.qcow2" ] || qemu-img create -f qcow2 "$D/win-ci.qcow2" 80G
|
||||||
|
# SecureBoot 용 - MS 키가 미리 들어 있는 변수 이미지여야 Windows 부트로더가 통과한다
|
||||||
|
[ -f "$D/OVMF_VARS.fd" ] || cp /usr/share/OVMF/OVMF_VARS_4M.ms.fd "$D/OVMF_VARS.fd"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "준비 끝. 다음: $D/start-vm.sh 그리고 $D/boot-cd.sh reset"
|
||||||
28
scripts/win-ci/click.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# VM 화면의 좌표를 클릭한다 (usb-tablet 절대좌표).
|
||||||
|
# 사용법: ./click.sh <x> <y> [화면폭 화면높이] 기본 1280x800
|
||||||
|
set -euo pipefail
|
||||||
|
python3 - "$@" <<'PY'
|
||||||
|
import json, socket, sys, time
|
||||||
|
x, y = int(sys.argv[1]), int(sys.argv[2])
|
||||||
|
W = int(sys.argv[3]) if len(sys.argv) > 3 else 1280
|
||||||
|
H = int(sys.argv[4]) if len(sys.argv) > 4 else 800
|
||||||
|
s = socket.socket(socket.AF_UNIX); s.settimeout(20)
|
||||||
|
s.connect("/home/claude/win-ci/qmp.sock"); f = s.makefile("rwb"); f.readline()
|
||||||
|
def cmd(o):
|
||||||
|
f.write((json.dumps(o)+"\n").encode()); f.flush()
|
||||||
|
while True:
|
||||||
|
m = json.loads(f.readline())
|
||||||
|
if "return" in m or "error" in m: return m
|
||||||
|
cmd({"execute": "qmp_capabilities"})
|
||||||
|
ax, ay = int(x / W * 32767), int(y / H * 32767)
|
||||||
|
ev = [{"type": "abs", "data": {"axis": "x", "value": ax}},
|
||||||
|
{"type": "abs", "data": {"axis": "y", "value": ay}}]
|
||||||
|
print(cmd({"execute": "input-send-event", "arguments": {"events": ev}}))
|
||||||
|
time.sleep(0.3)
|
||||||
|
print(cmd({"execute": "input-send-event", "arguments": {"events": [
|
||||||
|
{"type": "btn", "data": {"down": True, "button": "left"}}]}}))
|
||||||
|
time.sleep(0.15)
|
||||||
|
print(cmd({"execute": "input-send-event", "arguments": {"events": [
|
||||||
|
{"type": "btn", "data": {"down": False, "button": "left"}}]}}))
|
||||||
|
PY
|
||||||
68
scripts/win-ci/run-vm.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# win-ci Windows VM 기동 (.9 안 중첩 KVM)
|
||||||
|
#
|
||||||
|
# 이 스크립트를 직접 실행하지 말 것. start-vm.sh 가 systemd 트랜지언트 유닛으로
|
||||||
|
# 띄운다. 에이전트 턴에서 그냥 qemu 를 띄우면 ejclaw.service 의 cgroup 안에서
|
||||||
|
# 돌아 MemoryHigh=16G 상한을 쳐서 봇이 OOM 으로 죽는다 (2026-07-26 전례).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
D=/home/claude/win-ci
|
||||||
|
cd "$D"
|
||||||
|
|
||||||
|
# TPM 2.0 - Windows 11 요구사항
|
||||||
|
if [ ! -S "$D/swtpm/sock" ]; then
|
||||||
|
swtpm socket --tpmstate "dir=$D/swtpm" \
|
||||||
|
--ctrl "type=unixio,path=$D/swtpm/sock" --tpm2 -d
|
||||||
|
for i in $(seq 1 20); do [ -S "$D/swtpm/sock" ] && break; sleep 0.3; done
|
||||||
|
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 \
|
||||||
|
-accel kvm \
|
||||||
|
-cpu host,hv_relaxed,hv_spinlocks=0x1fff,hv_vapic,hv_time \
|
||||||
|
-smp 4,sockets=1,cores=4,threads=1 \
|
||||||
|
-m 8192 \
|
||||||
|
-rtc base=localtime,driftfix=slew \
|
||||||
|
-global driver=cfi.pflash01,property=secure,value=on \
|
||||||
|
-global ICH9-LPC.disable_s3=1 \
|
||||||
|
-drive if=pflash,format=raw,unit=0,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.secboot.fd \
|
||||||
|
-drive if=pflash,format=raw,unit=1,file="$D/OVMF_VARS.fd" \
|
||||||
|
-chardev "socket,id=chrtpm,path=$D/swtpm/sock" \
|
||||||
|
-tpmdev emulator,id=tpm0,chardev=chrtpm \
|
||||||
|
-device tpm-tis,tpmdev=tpm0 \
|
||||||
|
-device virtio-vga,max_outputs=2,xres=1920,yres=1080 \
|
||||||
|
-audiodev none,id=aud0 \
|
||||||
|
-device intel-hda \
|
||||||
|
-device hda-duplex,audiodev=aud0 \
|
||||||
|
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:13389-:3389 \
|
||||||
|
-device virtio-net-pci,netdev=net0 \
|
||||||
|
-device qemu-xhci,id=xhci \
|
||||||
|
-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 \
|
||||||
|
"${CDROMS[@]}" \
|
||||||
|
-serial "file:$D/serial.log" \
|
||||||
|
-qmp "unix:$D/qmp.sock,server=on,wait=off" \
|
||||||
|
-vnc 127.0.0.1:19 \
|
||||||
|
-boot menu=off,strict=on
|
||||||
129
scripts/win-ci/serve-host.py
Executable file
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""win-ci VM 과의 통신 채널.
|
||||||
|
|
||||||
|
게스트(Windows)는 QEMU user networking 안에 있어서 호스트를 10.0.2.2 로 본다.
|
||||||
|
이 서버는 그 반대편이다.
|
||||||
|
|
||||||
|
GET /ping 게스트가 네트워크 올라왔는지 확인용
|
||||||
|
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):
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
super().__init__(*a, directory=ROOT, **kw)
|
||||||
|
|
||||||
|
def guess_type(self, path):
|
||||||
|
# .ps1 은 charset 을 반드시 명시한다. 없으면 PowerShell 쪽에서
|
||||||
|
# ISO-8859-1 로 디코딩해 한글 주석이 깨지고, 깨진 바이트가 CP1252 의
|
||||||
|
# 스마트따옴표가 되어 파서가 스크립트를 통째로 삼킨다.
|
||||||
|
if str(path).endswith(".ps1"):
|
||||||
|
return "text/plain; charset=utf-8"
|
||||||
|
return super().guess_type(path)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.rstrip("/") == "/ping":
|
||||||
|
body = b"pong\n"
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/plain")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
super().do_GET()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
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)
|
||||||
|
|
||||||
|
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:
|
||||||
|
f.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} {data}\n")
|
||||||
|
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(f"{self.address_string()} {fmt % args}\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class Server(socketserver.ThreadingTCPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
425
scripts/win-ci/serve/stage2.ps1
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
# win-ci VM 설치 스크립트 (호스트가 HTTP 로 내려준다 - agent.ps1 이 실행)
|
||||||
|
#
|
||||||
|
# 이 파일은 호스트의 /home/claude/win-ci/serve/stage2.ps1 이다. 고치면 게스트가
|
||||||
|
# 20초 안에 알아서 새로 받아 실행한다. Windows 재설치 필요 없다.
|
||||||
|
#
|
||||||
|
# 규칙: 반드시 idempotent. 이미 깔린 건 건너뛴다. 중간에 죽어도 다시 돌 수 있다.
|
||||||
|
#
|
||||||
|
# LTSC 2024 에는 winget(스토어)이 없다. 그래서 설치 파일을 직접 받는다.
|
||||||
|
|
||||||
|
$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
|
||||||
|
|
||||||
|
# Write-Output 를 쓰면 안 된다. 함수 안에서 부르면 그 함수의 반환값에
|
||||||
|
# 로그 문자열이 섞여 들어가 $f 가 배열이 되고, Start-Process 가
|
||||||
|
# "Cannot convert System.Object[] to System.String" 으로 죽는다.
|
||||||
|
# 실제로 이것 때문에 Git 설치가 실패했다.
|
||||||
|
function Log($m) { Write-Host "$m" }
|
||||||
|
|
||||||
|
function Get-File($url, $name) {
|
||||||
|
$out = Join-Path $Dl $name
|
||||||
|
if ((Test-Path $out) -and (Get-Item $out).Length -gt 1mb) { Log "캐시 사용 $name"; return $out }
|
||||||
|
for ($i = 1; $i -le 3; $i++) {
|
||||||
|
try {
|
||||||
|
Log "다운로드 ($i/3) $name"
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile $out -UseBasicParsing -TimeoutSec 600
|
||||||
|
return $out
|
||||||
|
} catch {
|
||||||
|
Log "실패: $($_.Exception.Message)"
|
||||||
|
if ($i -eq 3) { throw }
|
||||||
|
Start-Sleep -Seconds 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sync-Path {
|
||||||
|
$m = [Environment]::GetEnvironmentVariable('Path', 'Machine')
|
||||||
|
$u = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||||
|
$env:Path = "$m;$u"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Have($exe) { Sync-Path; return [bool](Get-Command $exe -ErrorAction SilentlyContinue) }
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Git
|
||||||
|
# actions/checkout@v4 가 git 을 쓴다. 없으면 소스를 못 받는다.
|
||||||
|
if (Have 'git') {
|
||||||
|
Log "git 이미 있음: $(git --version)"
|
||||||
|
} else {
|
||||||
|
$f = Get-File 'https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.5/Git-2.55.0.5-64-bit.exe' 'git.exe'
|
||||||
|
Log "Git 설치 중"
|
||||||
|
Start-Process $f -ArgumentList '/VERYSILENT', '/NORESTART', '/NOCANCEL', '/SP-' -Wait
|
||||||
|
if (-not (Have 'git')) { throw 'git 설치 실패' }
|
||||||
|
Log "git 설치 완료: $(git --version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ pwsh 7
|
||||||
|
# 워크플로가 shell: pwsh 를 쓴다. Windows 기본은 5.1 이라 별도로 필요하다.
|
||||||
|
if (Have 'pwsh') {
|
||||||
|
Log "pwsh 이미 있음: $(pwsh --version)"
|
||||||
|
} else {
|
||||||
|
$f = Get-File 'https://github.com/PowerShell/PowerShell/releases/download/v7.4.6/PowerShell-7.4.6-win-x64.msi' 'pwsh.msi'
|
||||||
|
Log "PowerShell 7 설치 중"
|
||||||
|
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart', 'ADD_PATH=1' -Wait
|
||||||
|
if (-not (Have 'pwsh')) { throw 'pwsh 설치 실패' }
|
||||||
|
Log "pwsh 설치 완료: $(pwsh --version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Python 3.12
|
||||||
|
if (Have 'python') {
|
||||||
|
Log "python 이미 있음: $(python --version 2>&1)"
|
||||||
|
} else {
|
||||||
|
$f = Get-File 'https://www.python.org/ftp/python/3.12.10/python-3.12.10-amd64.exe' 'python.exe'
|
||||||
|
Log "Python 3.12 설치 중"
|
||||||
|
Start-Process $f -ArgumentList '/quiet', 'InstallAllUsers=1', 'PrependPath=1', `
|
||||||
|
'Include_test=0', 'Include_launcher=1', 'AssociateFiles=0' -Wait
|
||||||
|
if (-not (Have 'python')) { throw 'python 설치 실패' }
|
||||||
|
Log "python 설치 완료: $(python --version 2>&1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Node
|
||||||
|
# act_runner 의 host 모드는 JS 액션(checkout, upload-artifact)을 돌리려고
|
||||||
|
# PATH 의 node 를 쓴다. 없으면 그 단계들이 전부 실패한다.
|
||||||
|
if (Have 'node') {
|
||||||
|
Log "node 이미 있음: $(node --version)"
|
||||||
|
} else {
|
||||||
|
$f = Get-File 'https://nodejs.org/dist/v22.20.0/node-v22.20.0-x64.msi' 'node.msi'
|
||||||
|
Log "Node.js 설치 중"
|
||||||
|
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart' -Wait
|
||||||
|
if (-not (Have 'node')) { throw 'node 설치 실패' }
|
||||||
|
Log "node 설치 완료: $(node --version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ CMake
|
||||||
|
# native\process_loopback 빌드용. 워크플로에서 continue-on-error 지만 있으면 검증된다.
|
||||||
|
if (Have 'cmake') {
|
||||||
|
Log "cmake 이미 있음: $(cmake --version | Select-Object -First 1)"
|
||||||
|
} else {
|
||||||
|
$f = Get-File 'https://github.com/Kitware/CMake/releases/download/v3.31.6/cmake-3.31.6-windows-x86_64.msi' 'cmake.msi'
|
||||||
|
Log "CMake 설치 중"
|
||||||
|
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart', 'ADD_CMAKE_TO_PATH=System' -Wait
|
||||||
|
Sync-Path
|
||||||
|
if (Have 'cmake') { Log "cmake 설치 완료: $(cmake --version | Select-Object -First 1)" }
|
||||||
|
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'
|
||||||
|
New-Item -ItemType Directory -Force -Path $RunnerDir | Out-Null
|
||||||
|
if (Test-Path $RunnerExe) {
|
||||||
|
Log "act_runner 이미 있음"
|
||||||
|
} else {
|
||||||
|
Log "act_runner 다운로드"
|
||||||
|
Invoke-WebRequest -Uri 'https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-windows-amd64.exe' `
|
||||||
|
-OutFile $RunnerExe -UseBasicParsing -TimeoutSec 300
|
||||||
|
Log "act_runner 받음: $((Get-Item $RunnerExe).Length) bytes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# GPU 러너와 달리 이건 CPU 러너지만, 같은 원칙으로 capacity 1 로 고정한다.
|
||||||
|
# 이 VM 은 vCPU 4개뿐이라 두 작업이 동시에 돌면 둘 다 느려지고 타임아웃 위험이 생긴다.
|
||||||
|
$cfg = Join-Path $RunnerDir 'config.yaml'
|
||||||
|
if (-not (Test-Path $cfg)) {
|
||||||
|
@'
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
runner:
|
||||||
|
capacity: 1
|
||||||
|
timeout: 60m
|
||||||
|
shutdown_timeout: 3m
|
||||||
|
insecure: false
|
||||||
|
fetch_timeout: 5s
|
||||||
|
fetch_interval: 2s
|
||||||
|
container:
|
||||||
|
network: ""
|
||||||
|
privileged: false
|
||||||
|
force_pull: false
|
||||||
|
host:
|
||||||
|
workdir_parent: C:\gitea-runner\work
|
||||||
|
'@ | Set-Content -Path $cfg -Encoding ASCII
|
||||||
|
Log "config.yaml 생성 (capacity 1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------- 러너 등록 (토큰이 호스트에 있을 때만)
|
||||||
|
# 호스트의 serve/runner-token.txt 에 등록 토큰을 넣어주면 여기서 등록한다.
|
||||||
|
# 없으면 이 단계만 건너뛰고 나머지는 전부 완료로 본다.
|
||||||
|
$registered = Test-Path (Join-Path $RunnerDir '.runner')
|
||||||
|
if ($registered) {
|
||||||
|
Log "러너 이미 등록됨"
|
||||||
|
} else {
|
||||||
|
$tok = $null
|
||||||
|
try {
|
||||||
|
$tok = (Invoke-WebRequest -Uri 'http://10.0.2.2:8099/runner-token.txt' -UseBasicParsing -TimeoutSec 10).Content
|
||||||
|
if ($tok -is [byte[]]) { $tok = [Text.Encoding]::ASCII.GetString($tok) }
|
||||||
|
$tok = $tok.Trim()
|
||||||
|
} catch { $tok = $null }
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($tok)) {
|
||||||
|
Log "등록 토큰 없음 (serve/runner-token.txt) - 러너 등록은 건너뜀. 사전 설치는 전부 끝났다."
|
||||||
|
} else {
|
||||||
|
Log "러너 등록 시도 (토큰 길이 $($tok.Length))"
|
||||||
|
Push-Location $RunnerDir
|
||||||
|
$out = & $RunnerExe register --no-interactive `
|
||||||
|
--instance 'https://git.tkrmagid.kr' `
|
||||||
|
--token $tok `
|
||||||
|
--name 'win-ci' `
|
||||||
|
--labels 'windows:host' 2>&1 | Out-String
|
||||||
|
Pop-Location
|
||||||
|
Log "register -> $($out.Trim())"
|
||||||
|
if (-not (Test-Path (Join-Path $RunnerDir '.runner'))) { throw "러너 등록 실패" }
|
||||||
|
|
||||||
|
# 서비스로 만들지 않는다. SYSTEM 권한이 되고, 데스크톱 세션이 없어
|
||||||
|
# GUI 테스트/스크린샷이 전부 실패한다. 로그온 예약 작업이어야 한다.
|
||||||
|
& schtasks.exe /Create /TN 'gitea-runner' /SC ONLOGON /RL HIGHEST /F `
|
||||||
|
/TR "cmd.exe /c cd /d C:\gitea-runner && act_runner.exe daemon --config config.yaml" | Out-Null
|
||||||
|
& schtasks.exe /Run /TN 'gitea-runner' | Out-Null
|
||||||
|
Log "러너 등록 + 로그온 작업 등록 완료, daemon 시작"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 요약
|
||||||
|
Sync-Path
|
||||||
|
Log "=== 설치 요약 ==="
|
||||||
|
foreach ($c in 'git', 'pwsh', 'python', 'node', 'cmake') {
|
||||||
|
$v = try { (& $c --version 2>&1 | Select-Object -First 1) } catch { '없음' }
|
||||||
|
Log ("{0,-8} {1}" -f $c, $v)
|
||||||
|
}
|
||||||
|
Log "act_runner $(if (Test-Path $RunnerExe) { 'OK' } else { '없음' }) 등록=$(Test-Path (Join-Path $RunnerDir '.runner'))"
|
||||||
|
|
||||||
|
# 모니터 2개가 실제로 잡혔는지 - virtio-gpu 드라이버가 들어갔는지 확인하는 지표다.
|
||||||
|
try {
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
||||||
|
$screens = [System.Windows.Forms.Screen]::AllScreens
|
||||||
|
Log "화면 $($screens.Count)개: $(($screens | ForEach-Object { $_.Bounds.Width.ToString() + 'x' + $_.Bounds.Height }) -join ', ')"
|
||||||
|
$gpu = Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }
|
||||||
|
Log "비디오 어댑터: $($gpu -join ', ')"
|
||||||
|
} catch {
|
||||||
|
Log "화면 열거 실패: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
Log "=== stage2 완료 ==="
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# Windows 전용 경로 실제 검증
|
||||||
|
#
|
||||||
|
# 러너 등록 토큰이 없어도 여기서 워크플로와 똑같은 단계를 직접 돌린다.
|
||||||
|
# 이게 VM 을 만든 진짜 이유다 - 리눅스에서 못 돌리던 경로(WASAPI, 전역
|
||||||
|
# 단축키, 모니터 열거, PyInstaller exe)가 실제로 통과하는지 본다.
|
||||||
|
#
|
||||||
|
# 통째로 try/catch 로 감싼다. 검증이 실패해도 stage2 자체는 성공이어야
|
||||||
|
# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다.
|
||||||
|
# ==================================================================
|
||||||
|
$Repo = 'C:\win-ci\live-app-translator'
|
||||||
|
$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 {
|
||||||
|
$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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (-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'
|
||||||
|
if (-not (Test-Path $py)) { & python -m venv .venv-ci }
|
||||||
|
& $py -m pip install --upgrade pip --quiet
|
||||||
|
& $py -m pip install -r packaging\requirements-portable.txt --quiet
|
||||||
|
& $py -m pip install pytest ruff --quiet
|
||||||
|
|
||||||
|
# 워크플로와 같은 조건인지 - torch 가 없어야 "포터블에서 1~3티어가 된다"를
|
||||||
|
# 실제로 검증한 셈이 된다.
|
||||||
|
& $py -c "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('torch') is None else 1)"
|
||||||
|
Log "포터블 조건(torch 없음) = $($LASTEXITCODE -eq 0)"
|
||||||
|
|
||||||
|
$env:PYTHONPATH = 'src'
|
||||||
|
# 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" } }
|
||||||
|
|
||||||
|
# 캡처 도우미를 먼저 만든다. 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 {
|
||||||
|
Log "검증 준비 단계에서 실패: $($_.Exception.Message)"
|
||||||
|
try { Pop-Location } catch {}
|
||||||
|
}
|
||||||
31
scripts/win-ci/shot.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# win-ci VM 화면 스냅샷. QMP screendump 로 찍는다.
|
||||||
|
# 사용법: ./shot.sh [출력경로.png]
|
||||||
|
set -euo pipefail
|
||||||
|
OUT="${1:-/home/claude/EJClaw/data/attachments/generated/win-ci-$(date +%H%M%S).png}"
|
||||||
|
mkdir -p "$(dirname "$OUT")"
|
||||||
|
python3 - "$OUT" <<'PY'
|
||||||
|
import json, socket, sys, time
|
||||||
|
out = sys.argv[1]
|
||||||
|
s = socket.socket(socket.AF_UNIX)
|
||||||
|
s.settimeout(20)
|
||||||
|
s.connect("/home/claude/win-ci/qmp.sock")
|
||||||
|
f = s.makefile("rwb")
|
||||||
|
f.readline() # greeting
|
||||||
|
def cmd(o):
|
||||||
|
f.write((json.dumps(o) + "\n").encode()); f.flush()
|
||||||
|
while True:
|
||||||
|
line = f.readline()
|
||||||
|
if not line: raise SystemExit("qmp closed")
|
||||||
|
m = json.loads(line)
|
||||||
|
if "return" in m or "error" in m: return m
|
||||||
|
cmd({"execute": "qmp_capabilities"})
|
||||||
|
r = cmd({"execute": "screendump", "arguments": {"filename": out, "format": "png"}})
|
||||||
|
if "error" in r:
|
||||||
|
# 구버전 qemu 는 format 인자를 모른다 -> ppm 으로 받는다
|
||||||
|
r = cmd({"execute": "screendump", "arguments": {"filename": out + ".ppm"}})
|
||||||
|
print(json.dumps(r))
|
||||||
|
else:
|
||||||
|
print("ok", out)
|
||||||
|
PY
|
||||||
|
ls -l "$OUT"* 2>/dev/null
|
||||||
33
scripts/win-ci/start-vm.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# win-ci VM + 호스트 제어 서버를 systemd 트랜지언트 유닛으로 띄운다.
|
||||||
|
#
|
||||||
|
# 반드시 봇(ejclaw.service) cgroup 밖에서 돌아야 한다. 안에서 돌면 8GB VM 이
|
||||||
|
# MemoryHigh=16G 를 쳐서 봇이 OOM 으로 죽는다. --user 유닛으로 띄우면
|
||||||
|
# systemd --user 의 자식이 되어 app.slice 형제로 빠지고, 에이전트 턴이
|
||||||
|
# 끝나도 계속 살아 있는다.
|
||||||
|
#
|
||||||
|
# CPUQuota=400% / CPUWeight=50 - 물리 호스트가 i7-11700(16스레드)뿐이고 .5 에서
|
||||||
|
# 운영 컨테이너 19개가 돈다. 4코어를 넘기면 .5 응답이 +140% 느려진다(실측).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
systemctl --user reset-failed win-ci.service win-ci-http.service 2>/dev/null || true
|
||||||
|
|
||||||
|
# 게스트 <-> 호스트 제어 채널 (게스트는 10.0.2.2:8099 로 본다)
|
||||||
|
if ! systemctl --user is-active --quiet win-ci-http.service; then
|
||||||
|
systemd-run --user --unit=win-ci-http \
|
||||||
|
--description="win-ci guest control channel" \
|
||||||
|
-p Restart=always \
|
||||||
|
/usr/bin/python3 /home/claude/win-ci/serve-host.py
|
||||||
|
fi
|
||||||
|
|
||||||
|
# VM
|
||||||
|
systemd-run --user --unit=win-ci \
|
||||||
|
--description="win-ci Windows CI VM (nested KVM)" \
|
||||||
|
-p MemoryMax=10G \
|
||||||
|
-p CPUQuota=400% \
|
||||||
|
-p CPUWeight=50 \
|
||||||
|
-p WorkingDirectory=/home/claude/win-ci \
|
||||||
|
/bin/bash /home/claude/win-ci/run-vm.sh
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
systemctl --user --no-pager status win-ci-http.service win-ci.service | head -40
|
||||||
19
scripts/win-ci/status.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# win-ci VM 현재 상태를 한 번에 본다.
|
||||||
|
cd /home/claude/win-ci
|
||||||
|
echo "=== 유닛 ==="
|
||||||
|
systemctl --user is-active win-ci.service win-ci-http.service | paste -sd' ' -
|
||||||
|
systemctl --user show win-ci.service -p MemoryCurrent -p CPUUsageNSec --value | paste -sd' ' -
|
||||||
|
echo
|
||||||
|
echo "=== 디스크 사용량 (설치가 진행되면 커진다) ==="
|
||||||
|
du -h --apparent-size win-ci.qcow2 2>/dev/null | cut -f1
|
||||||
|
du -h win-ci.qcow2 | cut -f1
|
||||||
|
echo
|
||||||
|
echo "=== 게스트 로그 (HTTP, 최근 25줄) ==="
|
||||||
|
tail -n 25 guest.log 2>/dev/null || echo "(아직 없음 - 네트워크 올라오기 전)"
|
||||||
|
echo
|
||||||
|
echo "=== 시리얼 로그 (최근 25줄) ==="
|
||||||
|
tail -n 25 serial.log 2>/dev/null | tr -d '\r' || echo "(아직 없음)"
|
||||||
|
echo
|
||||||
|
echo "=== 러너 등록 토큰 ==="
|
||||||
|
if [ -s serve/runner-token.txt ]; then echo "있음 ($(wc -c < serve/runner-token.txt) bytes)"; else echo "없음 - serve/runner-token.txt 에 넣으면 게스트가 20초 안에 자동 등록"; fi
|
||||||
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초 안에 받아간다)"
|
||||||
67
scripts/win-ci/type.sh
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# VM 게스트에 키보드 입력을 보낸다 (QMP send-key).
|
||||||
|
#
|
||||||
|
# ./type.sh text "hello world" 문자열 입력
|
||||||
|
# ./type.sh key ret 단일 키 (qcode)
|
||||||
|
# ./type.sh key alt-f4 조합키 (하이픈으로 연결)
|
||||||
|
#
|
||||||
|
# 게스트에 네트워크가 없을 때 유일한 제어 수단이므로 지우지 말 것.
|
||||||
|
set -euo pipefail
|
||||||
|
python3 - "$@" <<'PY'
|
||||||
|
import json, socket, sys, time
|
||||||
|
|
||||||
|
MODE = sys.argv[1]
|
||||||
|
ARG = sys.argv[2]
|
||||||
|
|
||||||
|
s = socket.socket(socket.AF_UNIX); s.settimeout(30)
|
||||||
|
s.connect("/home/claude/win-ci/qmp.sock"); f = s.makefile("rwb"); f.readline()
|
||||||
|
|
||||||
|
def cmd(o):
|
||||||
|
f.write((json.dumps(o) + "\n").encode()); f.flush()
|
||||||
|
while True:
|
||||||
|
m = json.loads(f.readline())
|
||||||
|
if "return" in m or "error" in m:
|
||||||
|
if "error" in m: print("ERR", m, file=sys.stderr)
|
||||||
|
return m
|
||||||
|
|
||||||
|
cmd({"execute": "qmp_capabilities"})
|
||||||
|
|
||||||
|
def send(qcodes):
|
||||||
|
cmd({"execute": "send-key", "arguments": {
|
||||||
|
"keys": [{"type": "qcode", "data": q} for q in qcodes]}})
|
||||||
|
time.sleep(0.04)
|
||||||
|
|
||||||
|
SHIFTED = {
|
||||||
|
'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7',
|
||||||
|
'*': '8', '(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left',
|
||||||
|
'}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe',
|
||||||
|
'<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent',
|
||||||
|
}
|
||||||
|
PLAIN = {
|
||||||
|
' ': 'spc', '-': 'minus', '=': 'equal', '[': 'bracket_left',
|
||||||
|
']': 'bracket_right', '\\': 'backslash', ';': 'semicolon',
|
||||||
|
"'": 'apostrophe', ',': 'comma', '.': 'dot', '/': 'slash',
|
||||||
|
'`': 'grave_accent', '\n': 'ret', '\t': 'tab',
|
||||||
|
}
|
||||||
|
|
||||||
|
def char_keys(c):
|
||||||
|
if c.isalpha():
|
||||||
|
q = c.lower()
|
||||||
|
return ['shift', q] if c.isupper() else [q]
|
||||||
|
if c.isdigit():
|
||||||
|
return [c]
|
||||||
|
if c in SHIFTED:
|
||||||
|
return ['shift', SHIFTED[c]]
|
||||||
|
if c in PLAIN:
|
||||||
|
return [PLAIN[c]]
|
||||||
|
raise SystemExit("unsupported char: %r" % c)
|
||||||
|
|
||||||
|
if MODE == "text":
|
||||||
|
for c in ARG:
|
||||||
|
send(char_keys(c))
|
||||||
|
elif MODE == "key":
|
||||||
|
send(ARG.split("-"))
|
||||||
|
else:
|
||||||
|
raise SystemExit("usage: type.sh text <str> | type.sh key <qcode>")
|
||||||
|
print("ok")
|
||||||
|
PY
|
||||||
190
scripts/win-ci/unattend/autounattend.xml
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||||
|
|
||||||
|
<settings pass="windowsPE">
|
||||||
|
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<SetupUILanguage>
|
||||||
|
<UILanguage>en-US</UILanguage>
|
||||||
|
</SetupUILanguage>
|
||||||
|
<InputLocale>0409:00000409</InputLocale>
|
||||||
|
<SystemLocale>en-US</SystemLocale>
|
||||||
|
<UILanguage>en-US</UILanguage>
|
||||||
|
<UserLocale>en-US</UserLocale>
|
||||||
|
</component>
|
||||||
|
|
||||||
|
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
|
||||||
|
<!-- Win11 하드웨어 검사 우회. TPM/SecureBoot 는 실제로 제공하지만
|
||||||
|
중첩 가상화라 CPU/스토리지 검사가 튕길 수 있으므로 안전망으로 둔다. -->
|
||||||
|
<RunSynchronous>
|
||||||
|
<RunSynchronousCommand wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path>
|
||||||
|
</RunSynchronousCommand>
|
||||||
|
<RunSynchronousCommand wcm:action="add">
|
||||||
|
<Order>2</Order>
|
||||||
|
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path>
|
||||||
|
</RunSynchronousCommand>
|
||||||
|
<RunSynchronousCommand wcm:action="add">
|
||||||
|
<Order>3</Order>
|
||||||
|
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path>
|
||||||
|
</RunSynchronousCommand>
|
||||||
|
<RunSynchronousCommand wcm:action="add">
|
||||||
|
<Order>4</Order>
|
||||||
|
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 1 /f</Path>
|
||||||
|
</RunSynchronousCommand>
|
||||||
|
<RunSynchronousCommand wcm:action="add">
|
||||||
|
<Order>5</Order>
|
||||||
|
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassStorageCheck /t REG_DWORD /d 1 /f</Path>
|
||||||
|
</RunSynchronousCommand>
|
||||||
|
</RunSynchronous>
|
||||||
|
|
||||||
|
<DiskConfiguration>
|
||||||
|
<WillShowUI>OnError</WillShowUI>
|
||||||
|
<Disk wcm:action="add">
|
||||||
|
<DiskID>0</DiskID>
|
||||||
|
<WillWipeDisk>true</WillWipeDisk>
|
||||||
|
<CreatePartitions>
|
||||||
|
<CreatePartition wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<Type>EFI</Type>
|
||||||
|
<Size>512</Size>
|
||||||
|
</CreatePartition>
|
||||||
|
<CreatePartition wcm:action="add">
|
||||||
|
<Order>2</Order>
|
||||||
|
<Type>MSR</Type>
|
||||||
|
<Size>16</Size>
|
||||||
|
</CreatePartition>
|
||||||
|
<CreatePartition wcm:action="add">
|
||||||
|
<Order>3</Order>
|
||||||
|
<Type>Primary</Type>
|
||||||
|
<Extend>true</Extend>
|
||||||
|
</CreatePartition>
|
||||||
|
</CreatePartitions>
|
||||||
|
<ModifyPartitions>
|
||||||
|
<ModifyPartition wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<PartitionID>1</PartitionID>
|
||||||
|
<Label>System</Label>
|
||||||
|
<Format>FAT32</Format>
|
||||||
|
</ModifyPartition>
|
||||||
|
<ModifyPartition wcm:action="add">
|
||||||
|
<Order>2</Order>
|
||||||
|
<PartitionID>2</PartitionID>
|
||||||
|
</ModifyPartition>
|
||||||
|
<ModifyPartition wcm:action="add">
|
||||||
|
<Order>3</Order>
|
||||||
|
<PartitionID>3</PartitionID>
|
||||||
|
<Label>Windows</Label>
|
||||||
|
<Format>NTFS</Format>
|
||||||
|
<Letter>C</Letter>
|
||||||
|
</ModifyPartition>
|
||||||
|
</ModifyPartitions>
|
||||||
|
</Disk>
|
||||||
|
</DiskConfiguration>
|
||||||
|
|
||||||
|
<ImageInstall>
|
||||||
|
<OSImage>
|
||||||
|
<InstallFrom>
|
||||||
|
<MetaData wcm:action="add">
|
||||||
|
<Key>/IMAGE/INDEX</Key>
|
||||||
|
<Value>1</Value>
|
||||||
|
</MetaData>
|
||||||
|
</InstallFrom>
|
||||||
|
<InstallTo>
|
||||||
|
<DiskID>0</DiskID>
|
||||||
|
<PartitionID>3</PartitionID>
|
||||||
|
</InstallTo>
|
||||||
|
<WillShowUI>OnError</WillShowUI>
|
||||||
|
</OSImage>
|
||||||
|
</ImageInstall>
|
||||||
|
|
||||||
|
<UserData>
|
||||||
|
<AcceptEula>true</AcceptEula>
|
||||||
|
<FullName>ci</FullName>
|
||||||
|
<Organization>livesub</Organization>
|
||||||
|
</UserData>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
|
||||||
|
<settings pass="specialize">
|
||||||
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<ComputerName>WIN-CI</ComputerName>
|
||||||
|
<TimeZone>Korea Standard Time</TimeZone>
|
||||||
|
</component>
|
||||||
|
|
||||||
|
<!-- RDP 로 붙을 수 있게 열어둔다. 호스트에서 127.0.0.1:13389 로 포워딩된다. -->
|
||||||
|
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<fDenyTSConnections>false</fDenyTSConnections>
|
||||||
|
</component>
|
||||||
|
<component name="Microsoft-Windows-TerminalServices-RDP-WinStationExtensions" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<UserAuthentication>0</UserAuthentication>
|
||||||
|
</component>
|
||||||
|
<component name="Networking-MPSSVC-Svc" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<FirewallGroups>
|
||||||
|
<FirewallGroup wcm:action="add" wcm:keyValue="RemoteDesktop">
|
||||||
|
<Active>true</Active>
|
||||||
|
<Group>@FirewallAPI.dll,-28752</Group>
|
||||||
|
<Profile>all</Profile>
|
||||||
|
</FirewallGroup>
|
||||||
|
</FirewallGroups>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
|
||||||
|
<settings pass="oobeSystem">
|
||||||
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<OOBE>
|
||||||
|
<HideEULAPage>true</HideEULAPage>
|
||||||
|
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
|
||||||
|
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
|
||||||
|
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
|
||||||
|
<NetworkLocation>Work</NetworkLocation>
|
||||||
|
<ProtectYourPC>3</ProtectYourPC>
|
||||||
|
<SkipMachineOOBE>true</SkipMachineOOBE>
|
||||||
|
<SkipUserOOBE>true</SkipUserOOBE>
|
||||||
|
</OOBE>
|
||||||
|
|
||||||
|
<UserAccounts>
|
||||||
|
<LocalAccounts>
|
||||||
|
<LocalAccount wcm:action="add">
|
||||||
|
<Name>ci</Name>
|
||||||
|
<DisplayName>ci</DisplayName>
|
||||||
|
<Group>Administrators</Group>
|
||||||
|
<Password>
|
||||||
|
<Value>LiveSubCI!2026</Value>
|
||||||
|
<PlainText>true</PlainText>
|
||||||
|
</Password>
|
||||||
|
</LocalAccount>
|
||||||
|
</LocalAccounts>
|
||||||
|
</UserAccounts>
|
||||||
|
|
||||||
|
<AutoLogon>
|
||||||
|
<Username>ci</Username>
|
||||||
|
<Enabled>true</Enabled>
|
||||||
|
<LogonCount>999</LogonCount>
|
||||||
|
<Password>
|
||||||
|
<Value>LiveSubCI!2026</Value>
|
||||||
|
<PlainText>true</PlainText>
|
||||||
|
</Password>
|
||||||
|
</AutoLogon>
|
||||||
|
|
||||||
|
<FirstLogonCommands>
|
||||||
|
<SynchronousCommand wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<Description>provision windows ci runner</Description>
|
||||||
|
<RequiresUserInput>false</RequiresUserInput>
|
||||||
|
<CommandLine>cmd.exe /c "for %d in (D E F G H I J K L M) do @if exist %d:\setup\provision.ps1 start /wait powershell.exe -NoProfile -ExecutionPolicy Bypass -File %d:\setup\provision.ps1"</CommandLine>
|
||||||
|
</SynchronousCommand>
|
||||||
|
</FirstLogonCommands>
|
||||||
|
|
||||||
|
<TimeZone>Korea Standard Time</TimeZone>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
</unattend>
|
||||||
96
scripts/win-ci/unattend/setup/agent.ps1
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Windows CI VM 상주 에이전트
|
||||||
|
#
|
||||||
|
# 하는 일은 하나다: 호스트가 주는 stage2.ps1 을 받아서 돌린다.
|
||||||
|
#
|
||||||
|
# 왜 이렇게 하나 - ISO 는 한 번 구우면 못 고친다. 설치 스크립트를 ISO 에
|
||||||
|
# 박아두면 한 줄 고칠 때마다 Windows 를 다시 설치해야 한다. 그래서 ISO 에는
|
||||||
|
# 이 껍데기만 넣고, 실제 설치 내용은 호스트의 HTTP 로 가져온다.
|
||||||
|
# QEMU user networking 에서 호스트는 항상 10.0.2.2 다.
|
||||||
|
#
|
||||||
|
# 로그는 COM1(호스트 serial.log) + 호스트 HTTP(POST /log) + 로컬 파일.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
|
||||||
|
$Host_ = 'http://10.0.2.2:8099'
|
||||||
|
$WorkDir = 'C:\win-ci'
|
||||||
|
$Stage2 = Join-Path $WorkDir 'stage2.ps1'
|
||||||
|
$HashFile = Join-Path $WorkDir 'stage2.hash'
|
||||||
|
$LogFile = Join-Path $WorkDir 'agent.log'
|
||||||
|
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
||||||
|
|
||||||
|
function Say([string]$m) {
|
||||||
|
$line = "[agent $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $m"
|
||||||
|
try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 } catch {}
|
||||||
|
try {
|
||||||
|
$p = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One'
|
||||||
|
# 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다
|
||||||
|
$p.Encoding = [Text.Encoding]::UTF8
|
||||||
|
$p.Open(); $p.WriteLine($line); $p.Close()
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
Invoke-RestMethod -Uri "$Host_/log" -Method Post -TimeoutSec 5 `
|
||||||
|
-Body ([Text.Encoding]::UTF8.GetBytes($line)) -ContentType "text/plain; charset=utf-8" | Out-Null
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 같은 에이전트가 두 개 돌면 설치가 겹친다. 뮤텍스로 하나만 남긴다.
|
||||||
|
$mutex = New-Object System.Threading.Mutex($false, 'Global\win-ci-agent')
|
||||||
|
if (-not $mutex.WaitOne(0)) { Say "이미 다른 agent 가 돌고 있다. 종료."; exit 0 }
|
||||||
|
|
||||||
|
Say "agent 시작. user=$env:USERNAME"
|
||||||
|
|
||||||
|
# 네트워크가 올라오길 기다린다. virtio-net 드라이버 설치 직후라 몇 초 걸린다.
|
||||||
|
$netOk = $false
|
||||||
|
for ($i = 0; $i -lt 60; $i++) {
|
||||||
|
try {
|
||||||
|
Invoke-WebRequest -Uri "$Host_/ping" -TimeoutSec 4 -UseBasicParsing | Out-Null
|
||||||
|
$netOk = $true; break
|
||||||
|
} catch { Start-Sleep -Seconds 5 }
|
||||||
|
}
|
||||||
|
if ($netOk) { Say "호스트 연결 OK ($Host_)" }
|
||||||
|
else { Say "ERROR 5분 동안 호스트에 못 붙었다. virtio-net 드라이버를 확인해야 한다." }
|
||||||
|
|
||||||
|
# 이후로는 계속 돌면서 stage2 가 바뀔 때마다 실행한다.
|
||||||
|
while ($true) {
|
||||||
|
try {
|
||||||
|
# 반드시 바이트로 받아 UTF-8 로 직접 디코딩한다. Invoke-WebRequest 의
|
||||||
|
# .Content 는 Content-Type 에 charset 이 없으면 ISO-8859-1 로 디코딩해
|
||||||
|
# 한글 주석이 깨지고, 깨진 바이트가 CP1252 의 스마트따옴표(0x93/0x94)가
|
||||||
|
# 되어 PowerShell 파서가 그 뒤를 전부 문자열로 삼킨다. 실제로 겪은 사고다.
|
||||||
|
$wc = New-Object Net.WebClient
|
||||||
|
$body = [Text.Encoding]::UTF8.GetString($wc.DownloadData("$Host_/stage2.ps1"))
|
||||||
|
# 받은 쪽에 BOM 이 있으면 문자열 맨 앞에 U+FEFF 가 남는다. 아래에서
|
||||||
|
# Set-Content -Encoding UTF8 이 BOM 을 또 붙이므로 BOM 이 두 개가 되고,
|
||||||
|
# PowerShell 은 첫 개만 벗겨내 남은 U+FEFF 때문에 1행이 주석으로
|
||||||
|
# 인식되지 않는다. 그래서 여기서 반드시 떼어낸다.
|
||||||
|
$body = $body.TrimStart([char]0xFEFF)
|
||||||
|
|
||||||
|
$sha = [BitConverter]::ToString(
|
||||||
|
[Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($body))
|
||||||
|
).Replace('-', '')
|
||||||
|
|
||||||
|
$prev = if (Test-Path $HashFile) { (Get-Content $HashFile -Raw).Trim() } else { '' }
|
||||||
|
|
||||||
|
if ($sha -ne $prev) {
|
||||||
|
Say "stage2 새 버전 감지 ($($sha.Substring(0,12))) - 실행"
|
||||||
|
Set-Content -Path $Stage2 -Value $body -Encoding UTF8
|
||||||
|
try {
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Stage2 2>&1 |
|
||||||
|
ForEach-Object { Say "stage2| $_" }
|
||||||
|
if ($LASTEXITCODE -eq 0 -or $null -eq $LASTEXITCODE) {
|
||||||
|
# 성공했을 때만 해시를 저장한다. 중간에 죽었으면 다음 턴에 다시 돈다.
|
||||||
|
Set-Content -Path $HashFile -Value $sha
|
||||||
|
Say "stage2 성공"
|
||||||
|
} else {
|
||||||
|
Say "stage2 실패 exit=$LASTEXITCODE - 다음 폴링에 재시도"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Say "stage2 예외: $($_.Exception.Message) - 다음 폴링에 재시도"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Say "폴링 실패: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 20
|
||||||
|
}
|
||||||
110
scripts/win-ci/unattend/setup/provision.ps1
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Windows CI VM 최초 부팅 프로비저닝 (무인)
|
||||||
|
#
|
||||||
|
# 이 스크립트는 autounattend.xml 의 FirstLogonCommands 로 한 번만 실행된다.
|
||||||
|
# 네트워크가 아직 없는 상태에서 시작하므로, 순서가 중요하다.
|
||||||
|
# 1) virtio 드라이버 설치 (ISO 에서 오프라인) -> 이걸 해야 네트워크가 생긴다
|
||||||
|
# 2) 화면 꺼짐/잠금 끄기 (GUI 테스트가 잠금화면에서 실패하지 않게)
|
||||||
|
# 3) agent.ps1 을 C:\win-ci 에 심고 로그온 예약 작업으로 등록
|
||||||
|
# 4) agent 를 바로 실행 -> 이후 모든 설치는 호스트가 주는 stage2.ps1 이 한다
|
||||||
|
#
|
||||||
|
# 로그는 COM1(호스트의 serial.log)과 C:\win-ci\provision.log 양쪽에 남긴다.
|
||||||
|
# 네트워크가 없는 1) 단계를 호스트에서 관찰할 수 있어야 하기 때문이다.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
New-Item -ItemType Directory -Force -Path 'C:\win-ci' | Out-Null
|
||||||
|
|
||||||
|
$script:Port = $null
|
||||||
|
try {
|
||||||
|
$script:Port = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One'
|
||||||
|
# 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다
|
||||||
|
$script:Port.Encoding = [Text.Encoding]::UTF8
|
||||||
|
$script:Port.Open()
|
||||||
|
} catch { $script:Port = $null }
|
||||||
|
|
||||||
|
function Say([string]$m) {
|
||||||
|
$line = "[provision $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $m"
|
||||||
|
try { Add-Content -Path 'C:\win-ci\provision.log' -Value $line -Encoding UTF8 } catch {}
|
||||||
|
if ($script:Port) { try { $script:Port.WriteLine($line) } catch {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
Say "start. user=$env:USERNAME computer=$env:COMPUTERNAME"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 1) virtio
|
||||||
|
# virtio-win ISO 가 몇 번째 드라이브로 잡힐지 모른다. 전부 훑어 찾는다.
|
||||||
|
$virtio = $null
|
||||||
|
foreach ($d in [char[]](68..77)) { # D..M
|
||||||
|
$p = "${d}:\virtio-win-guest-tools.exe"
|
||||||
|
if (Test-Path $p) { $virtio = "${d}:"; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $virtio) {
|
||||||
|
Say "ERROR virtio-win ISO 를 못 찾음. 네트워크/화면 드라이버 없이 계속한다."
|
||||||
|
} else {
|
||||||
|
Say "virtio ISO = $virtio"
|
||||||
|
|
||||||
|
# 드라이버 스토어에 직접 넣는 쪽을 먼저 한다. 설치 프로그램보다 확실하다.
|
||||||
|
$infs = @(
|
||||||
|
"$virtio\NetKVM\w11\amd64\netkvm.inf", # 네트워크 - 가장 중요
|
||||||
|
"$virtio\viogpudo\w11\amd64\viogpudo.inf", # 화면 - 모니터 2개에 필요
|
||||||
|
"$virtio\Balloon\w11\amd64\balloon.inf",
|
||||||
|
"$virtio\vioserial\w11\amd64\vioser.inf",
|
||||||
|
"$virtio\viostor\w11\amd64\viostor.inf",
|
||||||
|
"$virtio\vioscsi\w11\amd64\vioscsi.inf",
|
||||||
|
"$virtio\vioinput\w11\amd64\vioinput.inf"
|
||||||
|
)
|
||||||
|
foreach ($inf in $infs) {
|
||||||
|
if (Test-Path $inf) {
|
||||||
|
$out = & pnputil.exe /add-driver $inf /install 2>&1 | Out-String
|
||||||
|
Say "pnputil $(Split-Path $inf -Leaf) -> $($out.Trim() -replace '\s+', ' ')"
|
||||||
|
} else {
|
||||||
|
Say "skip (없음) $inf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# guest-tools 는 balloon 서비스/qemu-ga 까지 붙여준다. 실패해도 위에서 드라이버는 들어갔다.
|
||||||
|
try {
|
||||||
|
Say "virtio-win-guest-tools 설치 시작"
|
||||||
|
$p = Start-Process -FilePath "$virtio\virtio-win-guest-tools.exe" `
|
||||||
|
-ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru
|
||||||
|
Say "virtio-win-guest-tools exit=$($p.ExitCode)"
|
||||||
|
} catch {
|
||||||
|
Say "virtio-win-guest-tools 실패: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------- 2) 화면 꺼짐 / 잠금 끄기
|
||||||
|
# 러너가 GUI 테스트와 스크린샷을 찍으므로 데스크톱이 살아 있어야 한다.
|
||||||
|
& powercfg.exe /change monitor-timeout-ac 0
|
||||||
|
& powercfg.exe /change standby-timeout-ac 0
|
||||||
|
& powercfg.exe /change disk-timeout-ac 0
|
||||||
|
& powercfg.exe /change hibernate-timeout-ac 0
|
||||||
|
Say "powercfg 적용 (모니터/대기/디스크/최대절전 전부 끔)"
|
||||||
|
|
||||||
|
# 화면 보호기 + 잠금화면
|
||||||
|
reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f | Out-Null
|
||||||
|
reg add "HKCU\Control Panel\Desktop" /v ScreenSaverIsSecure /t REG_SZ /d 0 /f | Out-Null
|
||||||
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
# 절전 시 암호 요구 끔
|
||||||
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Power\PowerSettings\0e796bdb-100d-47d6-a2d5-f7d2daa51f51" /v ACSettingIndex /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
Say "잠금화면/화면보호기 끔"
|
||||||
|
|
||||||
|
# 첫 부팅 때 뜨는 방해 요소들
|
||||||
|
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer" /v NoDriveTypeAutoRun /t REG_DWORD /d 255 /f | Out-Null
|
||||||
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoRebootWithLoggedOnUsers /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
Say "자동실행/업데이트 자동재부팅 끔"
|
||||||
|
|
||||||
|
# ------------------------------------------- 3) agent 설치 + 로그온 예약 작업
|
||||||
|
$src = $PSScriptRoot
|
||||||
|
Copy-Item -Path (Join-Path $src 'agent.ps1') -Destination 'C:\win-ci\agent.ps1' -Force
|
||||||
|
Say "agent.ps1 복사 완료"
|
||||||
|
|
||||||
|
$taskCmd = 'powershell.exe'
|
||||||
|
$taskArg = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\win-ci\agent.ps1'
|
||||||
|
& schtasks.exe /Create /TN 'win-ci-agent' /SC ONLOGON /RL HIGHEST /TR "$taskCmd $taskArg" /F 2>&1 | Out-String | ForEach-Object { Say "schtasks -> $($_.Trim())" }
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- 4) agent 기동
|
||||||
|
Say "agent 시작 - 이후 로그는 [agent] 접두어로 나온다"
|
||||||
|
Start-Process -FilePath $taskCmd -ArgumentList $taskArg -WindowStyle Hidden
|
||||||
|
|
||||||
|
Say "provision 끝"
|
||||||
|
if ($script:Port) { try { $script:Port.Close() } catch {} }
|
||||||
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
@@ -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
|
||||||
6
src/livesub/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""LiveSub — 프로그램 소리를 실시간으로 듣고 번역해 자막으로 보여주는 도구."""
|
||||||
|
|
||||||
|
from .constants import APP_NAME, APP_SLOGAN, APP_VERSION
|
||||||
|
|
||||||
|
__all__ = ["APP_NAME", "APP_SLOGAN", "APP_VERSION", "__version__"]
|
||||||
|
__version__ = APP_VERSION
|
||||||
14
src/livesub/__main__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""`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())
|
||||||
76
src/livesub/app.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""애플리케이션 진입점."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import AppConfig
|
||||||
|
from .constants import APP_NAME, ORG_NAME, user_data_dir
|
||||||
|
|
||||||
|
|
||||||
|
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(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",
|
||||||
|
handlers=handlers,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
# 모델 라이브러리들이 INFO로 너무 시끄럽다.
|
||||||
|
for noisy in ("urllib3", "filelock", "huggingface_hub", "transformers"):
|
||||||
|
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
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, to_file=False)
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from .ui import MainWindow
|
||||||
|
|
||||||
|
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
|
||||||
|
app = QApplication(argv)
|
||||||
|
app.setApplicationName(APP_NAME)
|
||||||
|
app.setOrganizationName(ORG_NAME)
|
||||||
|
app.setQuitOnLastWindowClosed(True)
|
||||||
|
|
||||||
|
icon_path = Path(__file__).resolve().parent / "resources" / "icon.png"
|
||||||
|
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:
|
||||||
|
window.show()
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
72
src/livesub/audio/__init__.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""오디오 캡처 레이어."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..config import AudioConfig
|
||||||
|
from .base import AudioSource, CaptureBackend, CaptureError
|
||||||
|
from .file_source import FileCapture
|
||||||
|
from .process_loopback import ProcessLoopbackCapture
|
||||||
|
from .segmenter import Segment, Segmenter, SegmenterConfig
|
||||||
|
from .wasapi_loopback import WasapiLoopbackCapture
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AudioSource",
|
||||||
|
"CaptureBackend",
|
||||||
|
"CaptureError",
|
||||||
|
"FileCapture",
|
||||||
|
"ProcessLoopbackCapture",
|
||||||
|
"Segment",
|
||||||
|
"Segmenter",
|
||||||
|
"SegmenterConfig",
|
||||||
|
"WasapiLoopbackCapture",
|
||||||
|
"capture_capabilities",
|
||||||
|
"create_capture",
|
||||||
|
"list_sources",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def capture_capabilities() -> dict[str, bool]:
|
||||||
|
"""현재 환경에서 어떤 캡처가 가능한지."""
|
||||||
|
return {
|
||||||
|
"process": ProcessLoopbackCapture.available(),
|
||||||
|
"device": WasapiLoopbackCapture.available(),
|
||||||
|
"file": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_sources() -> list[AudioSource]:
|
||||||
|
"""프로그램 + 출력 장치를 한 목록으로."""
|
||||||
|
sources: list[AudioSource] = []
|
||||||
|
if ProcessLoopbackCapture.available():
|
||||||
|
sources.extend(ProcessLoopbackCapture.list_sources())
|
||||||
|
sources.extend(WasapiLoopbackCapture.list_sources())
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def create_capture(config: AudioConfig) -> CaptureBackend:
|
||||||
|
"""설정에 맞는 캡처 백엔드를 만든다. 불가능하면 장치 루프백으로 폴백."""
|
||||||
|
backend = config.backend
|
||||||
|
if backend == "file":
|
||||||
|
return FileCapture(config.target_process_name)
|
||||||
|
|
||||||
|
want_process = backend in ("process", "auto") and config.target_pid > 0
|
||||||
|
if want_process:
|
||||||
|
if ProcessLoopbackCapture.available():
|
||||||
|
return ProcessLoopbackCapture(config.target_pid)
|
||||||
|
if backend == "process":
|
||||||
|
raise CaptureError(
|
||||||
|
"프로그램별 캡처를 쓰려면 livesub_capture.exe 가 필요합니다. "
|
||||||
|
"native/process_loopback/build.ps1 로 빌드하거나 '출력 장치 전체'를 선택하세요."
|
||||||
|
)
|
||||||
|
log.warning("프로그램별 캡처를 쓸 수 없어 장치 루프백으로 전환합니다.")
|
||||||
|
|
||||||
|
if not WasapiLoopbackCapture.available():
|
||||||
|
raise CaptureError(
|
||||||
|
"이 환경에서는 오디오 캡처를 할 수 없습니다. "
|
||||||
|
"Windows에서 PyAudioWPatch 설치 여부를 확인하세요."
|
||||||
|
)
|
||||||
|
return WasapiLoopbackCapture(config.device_index)
|
||||||
146
src/livesub/audio/base.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""오디오 캡처 백엔드 공통 인터페이스.
|
||||||
|
|
||||||
|
모든 백엔드는 16kHz / mono / float32(-1.0~1.0) 프레임을 큐로 흘려보낸다.
|
||||||
|
리샘플링과 다운믹스는 각 백엔드가 책임진다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..constants import SAMPLE_RATE
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AudioSource:
|
||||||
|
"""UI에 노출되는 캡처 대상 한 줄."""
|
||||||
|
|
||||||
|
kind: str # "process" | "device"
|
||||||
|
identifier: str # pid 문자열 또는 장치 인덱스
|
||||||
|
label: str
|
||||||
|
detail: str = ""
|
||||||
|
icon_path: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pid(self) -> int:
|
||||||
|
return int(self.identifier) if self.kind == "process" else 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_index(self) -> int:
|
||||||
|
return int(self.identifier) if self.kind == "device" else -1
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureError(RuntimeError):
|
||||||
|
"""캡처를 시작할 수 없을 때."""
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureBackend(abc.ABC):
|
||||||
|
"""오디오 캡처 백엔드 베이스."""
|
||||||
|
|
||||||
|
name = "base"
|
||||||
|
#: 특정 프로그램 소리만 분리해서 받을 수 있는가
|
||||||
|
per_process = False
|
||||||
|
|
||||||
|
def __init__(self, max_queue_frames: int = 400) -> None:
|
||||||
|
self._queue: queue.Queue[np.ndarray] = queue.Queue(maxsize=max_queue_frames)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._error: Exception | None = None
|
||||||
|
|
||||||
|
# --- 하위 클래스가 구현 -------------------------------------------
|
||||||
|
@abc.abstractmethod
|
||||||
|
def _run(self) -> None:
|
||||||
|
"""블로킹 캡처 루프. self._stop 이 set 될 때까지 _emit() 호출."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@abc.abstractmethod
|
||||||
|
def available() -> bool:
|
||||||
|
"""현재 환경에서 이 백엔드를 쓸 수 있는가."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@abc.abstractmethod
|
||||||
|
def list_sources() -> list[AudioSource]:
|
||||||
|
"""선택 가능한 캡처 대상 목록."""
|
||||||
|
|
||||||
|
# --- 공통 동작 -----------------------------------------------------
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
self._error = None
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._thread_main, name=f"capture-{self.name}", daemon=True
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def _thread_main(self) -> None:
|
||||||
|
try:
|
||||||
|
self._run()
|
||||||
|
except Exception as exc: # noqa: BLE001 - 워커 스레드 경계
|
||||||
|
self._error = exc
|
||||||
|
|
||||||
|
def stop(self, timeout: float = 2.0) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=timeout)
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return bool(self._thread and self._thread.is_alive())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error(self) -> Exception | None:
|
||||||
|
return self._error
|
||||||
|
|
||||||
|
def _emit(self, frame: np.ndarray) -> None:
|
||||||
|
"""캡처 프레임 투입. 큐가 가득 차면 가장 오래된 프레임을 버린다.
|
||||||
|
|
||||||
|
실시간 자막에서는 밀린 오디오보다 최신 오디오가 항상 더 가치 있다.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._queue.put_nowait(frame)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
self._queue.get_nowait()
|
||||||
|
self._queue.put_nowait(frame)
|
||||||
|
except (queue.Empty, queue.Full):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def read(self, timeout: float = 0.5) -> np.ndarray | None:
|
||||||
|
try:
|
||||||
|
return self._queue.get(timeout=timeout)
|
||||||
|
except queue.Empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def drain(self) -> None:
|
||||||
|
while not self._queue.empty():
|
||||||
|
try:
|
||||||
|
self._queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def to_mono_16k(data: np.ndarray, channels: int, src_rate: int) -> np.ndarray:
|
||||||
|
"""인터리브된 float32 PCM을 16kHz 모노로 변환."""
|
||||||
|
if data.size == 0:
|
||||||
|
return data.astype(np.float32, copy=False)
|
||||||
|
audio = data.astype(np.float32, copy=False)
|
||||||
|
if channels > 1:
|
||||||
|
usable = (audio.size // channels) * channels
|
||||||
|
audio = audio[:usable].reshape(-1, channels).mean(axis=1)
|
||||||
|
if src_rate != SAMPLE_RATE and audio.size:
|
||||||
|
# 선형 보간 리샘플. 음성인식 입력으로는 충분한 품질이며 의존성이 없다.
|
||||||
|
duration = audio.size / src_rate
|
||||||
|
target_len = max(1, int(duration * SAMPLE_RATE))
|
||||||
|
audio = np.interp(
|
||||||
|
np.linspace(0.0, audio.size - 1, target_len, dtype=np.float64),
|
||||||
|
np.arange(audio.size, dtype=np.float64),
|
||||||
|
audio,
|
||||||
|
).astype(np.float32)
|
||||||
|
return audio
|
||||||
66
src/livesub/audio/file_source.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""파일/합성 오디오 캡처 백엔드.
|
||||||
|
|
||||||
|
실제 오디오 장치 없이 파이프라인 전체를 돌려볼 수 있게 해준다.
|
||||||
|
개발·테스트·데모용이며 어느 OS에서나 동작한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..constants import FRAME_SAMPLES, SAMPLE_RATE
|
||||||
|
from .base import AudioSource, CaptureBackend, CaptureError, to_mono_16k
|
||||||
|
|
||||||
|
|
||||||
|
class FileCapture(CaptureBackend):
|
||||||
|
"""WAV 파일을 실시간 속도로 재생하듯 흘려보낸다."""
|
||||||
|
|
||||||
|
name = "file"
|
||||||
|
per_process = False
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path, loop: bool = False, realtime: bool = True) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.path = Path(path)
|
||||||
|
self.loop = loop
|
||||||
|
self.realtime = realtime
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def available() -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_sources() -> list[AudioSource]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _read_wav(self) -> np.ndarray:
|
||||||
|
if not self.path.is_file():
|
||||||
|
raise CaptureError(f"오디오 파일을 찾을 수 없습니다: {self.path}")
|
||||||
|
with wave.open(str(self.path), "rb") as wf:
|
||||||
|
channels = wf.getnchannels()
|
||||||
|
rate = wf.getframerate()
|
||||||
|
width = wf.getsampwidth()
|
||||||
|
raw = wf.readframes(wf.getnframes())
|
||||||
|
if width == 2:
|
||||||
|
data = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
elif width == 4:
|
||||||
|
data = np.frombuffer(raw, dtype=np.float32)
|
||||||
|
else:
|
||||||
|
raise CaptureError(f"지원하지 않는 샘플 폭입니다: {width * 8}bit")
|
||||||
|
return to_mono_16k(data, channels, rate)
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
audio = self._read_wav()
|
||||||
|
chunk = FRAME_SAMPLES * 5 # 100ms
|
||||||
|
while not self._stop.is_set():
|
||||||
|
for start in range(0, audio.size, chunk):
|
||||||
|
if self._stop.is_set():
|
||||||
|
return
|
||||||
|
self._emit(audio[start : start + chunk].copy())
|
||||||
|
if self.realtime:
|
||||||
|
time.sleep(chunk / SAMPLE_RATE)
|
||||||
|
if not self.loop:
|
||||||
|
return
|
||||||
168
src/livesub/audio/process_loopback.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""프로그램별 오디오 캡처 (Windows WASAPI process loopback).
|
||||||
|
|
||||||
|
`native/process_loopback` 에서 빌드한 `livesub_capture.exe` 를 자식 프로세스로 띄우고
|
||||||
|
stdout 으로 흘러나오는 16kHz mono float32 PCM 을 그대로 읽는다.
|
||||||
|
|
||||||
|
보조 프로그램이 없으면 `available()` 이 False 를 돌려주고, 상위 레이어는
|
||||||
|
장치 루프백으로 자동 폴백한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..constants import FRAME_SAMPLES
|
||||||
|
from .base import AudioSource, CaptureBackend, CaptureError
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HELPER_NAME = "livesub_capture.exe"
|
||||||
|
READ_BYTES = FRAME_SAMPLES * 4 * 5 # 100ms 분량
|
||||||
|
|
||||||
|
|
||||||
|
def helper_path() -> Path | None:
|
||||||
|
"""보조 프로그램 위치. 패키지 내부 → 저장소 빌드 폴더 순으로 찾는다."""
|
||||||
|
candidates = [
|
||||||
|
Path(__file__).resolve().parent.parent / "resources" / "bin" / HELPER_NAME,
|
||||||
|
Path(__file__).resolve().parents[3]
|
||||||
|
/ "native"
|
||||||
|
/ "process_loopback"
|
||||||
|
/ "build"
|
||||||
|
/ "Release"
|
||||||
|
/ HELPER_NAME,
|
||||||
|
]
|
||||||
|
for path in candidates:
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessLoopbackCapture(CaptureBackend):
|
||||||
|
name = "process"
|
||||||
|
per_process = True
|
||||||
|
|
||||||
|
def __init__(self, pid: int, exclude: bool = False) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.pid = pid
|
||||||
|
self.exclude = exclude
|
||||||
|
self._proc: subprocess.Popen[bytes] | None = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def available() -> bool:
|
||||||
|
return sys.platform == "win32" and helper_path() is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_sources() -> list[AudioSource]:
|
||||||
|
"""현재 소리를 내고 있는(또는 낼 수 있는) 프로세스 목록."""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
from pycaw.pycaw import AudioUtilities
|
||||||
|
except ImportError:
|
||||||
|
log.debug("pycaw 미설치 — 프로세스 목록을 만들 수 없습니다.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
seen: dict[int, AudioSource] = {}
|
||||||
|
for session in AudioUtilities.GetAllSessions():
|
||||||
|
proc = session.Process
|
||||||
|
if proc is None or proc.pid in seen:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
name = proc.name()
|
||||||
|
except Exception: # noqa: BLE001 - 종료된 프로세스
|
||||||
|
continue
|
||||||
|
title = _window_title(proc.pid) or name
|
||||||
|
seen[proc.pid] = AudioSource(
|
||||||
|
kind="process",
|
||||||
|
identifier=str(proc.pid),
|
||||||
|
label=title,
|
||||||
|
detail=f"{name} (PID {proc.pid})",
|
||||||
|
)
|
||||||
|
return sorted(seen.values(), key=lambda s: s.label.lower())
|
||||||
|
|
||||||
|
def _run(self) -> None: # pragma: no cover - Windows 전용
|
||||||
|
exe = helper_path()
|
||||||
|
if exe is None:
|
||||||
|
raise CaptureError(
|
||||||
|
f"{HELPER_NAME} 를 찾지 못했습니다. "
|
||||||
|
"native/process_loopback/build.ps1 로 먼저 빌드하세요."
|
||||||
|
)
|
||||||
|
cmd = [str(exe), "--pid", str(self.pid)]
|
||||||
|
if self.exclude:
|
||||||
|
cmd.append("--exclude")
|
||||||
|
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
bufsize=0,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
)
|
||||||
|
assert self._proc.stdout is not None
|
||||||
|
try:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
raw = self._proc.stdout.read(READ_BYTES)
|
||||||
|
if not raw:
|
||||||
|
code = self._proc.poll()
|
||||||
|
err = b""
|
||||||
|
if self._proc.stderr is not None:
|
||||||
|
err = self._proc.stderr.read() or b""
|
||||||
|
raise CaptureError(
|
||||||
|
f"캡처 보조 프로그램이 종료되었습니다(코드 {code}): "
|
||||||
|
f"{err.decode('utf-8', 'replace').strip()}"
|
||||||
|
)
|
||||||
|
# 4바이트 정렬이 깨지면 float32 해석이 밀린다.
|
||||||
|
usable = len(raw) - (len(raw) % 4)
|
||||||
|
self._emit(np.frombuffer(raw[:usable], dtype=np.float32).copy())
|
||||||
|
finally:
|
||||||
|
self._terminate()
|
||||||
|
|
||||||
|
def _terminate(self) -> None:
|
||||||
|
proc, self._proc = self._proc, None
|
||||||
|
if proc is None:
|
||||||
|
return
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
for pipe in (proc.stdout, proc.stderr):
|
||||||
|
if pipe is not None:
|
||||||
|
pipe.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _window_title(pid: int) -> str:
|
||||||
|
"""해당 PID의 최상위 창 제목. 실패하면 빈 문자열."""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
result: list[str] = []
|
||||||
|
|
||||||
|
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||||
|
def callback(hwnd, _lparam):
|
||||||
|
owner = wintypes.DWORD()
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner))
|
||||||
|
if owner.value != pid or not user32.IsWindowVisible(hwnd):
|
||||||
|
return True
|
||||||
|
length = user32.GetWindowTextLengthW(hwnd)
|
||||||
|
if length <= 0:
|
||||||
|
return True
|
||||||
|
buf = ctypes.create_unicode_buffer(length + 1)
|
||||||
|
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||||
|
result.append(buf.value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
user32.EnumWindows(callback, 0)
|
||||||
|
return result[0] if result else ""
|
||||||
|
except Exception: # noqa: BLE001 - 창 제목은 부가 정보일 뿐
|
||||||
|
return ""
|
||||||
188
src/livesub/audio/segmenter.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"""발화 구간 분할기.
|
||||||
|
|
||||||
|
캡처된 연속 오디오를 "한 문장" 단위로 잘라 음성인식에 넘긴다.
|
||||||
|
외부 의존성 없이 동작하도록 적응형 노이즈 플로어 기반 에너지 VAD를 쓴다.
|
||||||
|
webrtcvad 가 설치돼 있으면 더 정확한 판정을 위해 함께 사용한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..constants import FRAME_MS, FRAME_SAMPLES, SAMPLE_RATE
|
||||||
|
|
||||||
|
try: # pragma: no cover - 선택 의존성
|
||||||
|
import webrtcvad
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
webrtcvad = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Segment:
|
||||||
|
"""음성인식에 넘길 오디오 한 덩어리."""
|
||||||
|
|
||||||
|
audio: np.ndarray
|
||||||
|
started_at: float
|
||||||
|
ended_at: float
|
||||||
|
is_final: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_s(self) -> float:
|
||||||
|
return self.audio.size / SAMPLE_RATE
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SegmenterConfig:
|
||||||
|
silence_ms: int = 600
|
||||||
|
min_segment_ms: int = 400
|
||||||
|
max_segment_ms: int = 12_000
|
||||||
|
partial_interval_ms: int = 900
|
||||||
|
#: 노이즈 플로어 대비 몇 배 이상이면 발화로 볼지
|
||||||
|
speech_ratio: float = 3.0
|
||||||
|
#: 절대 무음 판정 하한 (RMS)
|
||||||
|
absolute_floor: float = 2e-4
|
||||||
|
vad_aggressiveness: int = 2
|
||||||
|
#: 발화 시작 앞쪽에 붙일 여유분 (앞 음절 잘림 방지)
|
||||||
|
lead_in_ms: int = 240
|
||||||
|
|
||||||
|
|
||||||
|
class Segmenter:
|
||||||
|
"""프레임을 받아 발화 구간을 뱉는 상태 기계.
|
||||||
|
|
||||||
|
`push()` 는 확정 구간(is_final=True) 또는 중간 결과(is_final=False)를
|
||||||
|
돌려주거나, 아직 내보낼 게 없으면 None 을 돌려준다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: SegmenterConfig | None = None) -> None:
|
||||||
|
self.config = config or SegmenterConfig()
|
||||||
|
self._vad = None
|
||||||
|
if webrtcvad is not None:
|
||||||
|
self._vad = webrtcvad.Vad(max(0, min(3, self.config.vad_aggressiveness)))
|
||||||
|
|
||||||
|
self._buffer: list[np.ndarray] = []
|
||||||
|
self._lead_in: list[np.ndarray] = []
|
||||||
|
self._pending = np.zeros(0, dtype=np.float32)
|
||||||
|
self._noise_floor = 1e-3
|
||||||
|
self._silence_ms = 0
|
||||||
|
self._speech_ms = 0
|
||||||
|
self._in_speech = False
|
||||||
|
self._started_at = 0.0
|
||||||
|
self._last_partial_ms = 0
|
||||||
|
self._lead_in_frames = max(1, self.config.lead_in_ms // FRAME_MS)
|
||||||
|
|
||||||
|
# --- 입력 ----------------------------------------------------------
|
||||||
|
def push(self, chunk: np.ndarray) -> list[Segment]:
|
||||||
|
"""임의 길이의 오디오를 넣고 완성된 구간 목록을 받는다."""
|
||||||
|
out: list[Segment] = []
|
||||||
|
if chunk.size:
|
||||||
|
self._pending = (
|
||||||
|
chunk.astype(np.float32, copy=False)
|
||||||
|
if self._pending.size == 0
|
||||||
|
else np.concatenate((self._pending, chunk))
|
||||||
|
)
|
||||||
|
while self._pending.size >= FRAME_SAMPLES:
|
||||||
|
frame = self._pending[:FRAME_SAMPLES]
|
||||||
|
self._pending = self._pending[FRAME_SAMPLES:]
|
||||||
|
segment = self._push_frame(frame)
|
||||||
|
if segment is not None:
|
||||||
|
out.append(segment)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def flush(self) -> Segment | None:
|
||||||
|
"""남은 버퍼를 강제로 확정 구간으로 만든다 (캡처 종료 시)."""
|
||||||
|
if not self._in_speech:
|
||||||
|
return None
|
||||||
|
return self._finish()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""발화 상태만 초기화한다.
|
||||||
|
|
||||||
|
`_pending` 은 건드리지 않는다. 아직 프레임으로 쪼개지 못한 '입력' 이라
|
||||||
|
여기서 버리면 한 구간을 확정한 직후의 오디오가 통째로 사라진다.
|
||||||
|
"""
|
||||||
|
self._buffer.clear()
|
||||||
|
self._lead_in.clear()
|
||||||
|
self._silence_ms = 0
|
||||||
|
self._speech_ms = 0
|
||||||
|
self._in_speech = False
|
||||||
|
self._last_partial_ms = 0
|
||||||
|
|
||||||
|
# --- 내부 ----------------------------------------------------------
|
||||||
|
def _push_frame(self, frame: np.ndarray) -> Segment | None:
|
||||||
|
speech = self._is_speech(frame)
|
||||||
|
cfg = self.config
|
||||||
|
|
||||||
|
if not self._in_speech:
|
||||||
|
self._lead_in.append(frame)
|
||||||
|
if len(self._lead_in) > self._lead_in_frames:
|
||||||
|
self._lead_in.pop(0)
|
||||||
|
if speech:
|
||||||
|
self._in_speech = True
|
||||||
|
self._started_at = time.monotonic()
|
||||||
|
self._buffer = list(self._lead_in)
|
||||||
|
self._lead_in = []
|
||||||
|
self._silence_ms = 0
|
||||||
|
self._speech_ms = FRAME_MS
|
||||||
|
self._last_partial_ms = 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._buffer.append(frame)
|
||||||
|
self._speech_ms += FRAME_MS
|
||||||
|
self._silence_ms = 0 if speech else self._silence_ms + FRAME_MS
|
||||||
|
|
||||||
|
buffered_ms = len(self._buffer) * FRAME_MS
|
||||||
|
if self._silence_ms >= cfg.silence_ms:
|
||||||
|
if buffered_ms - self._silence_ms < cfg.min_segment_ms:
|
||||||
|
self.reset() # 잡음 한 번 튄 것 — 버린다
|
||||||
|
return None
|
||||||
|
return self._finish()
|
||||||
|
|
||||||
|
if buffered_ms >= cfg.max_segment_ms:
|
||||||
|
return self._finish()
|
||||||
|
|
||||||
|
if cfg.partial_interval_ms > 0:
|
||||||
|
since = buffered_ms - self._last_partial_ms
|
||||||
|
if since >= cfg.partial_interval_ms and buffered_ms >= cfg.min_segment_ms:
|
||||||
|
self._last_partial_ms = buffered_ms
|
||||||
|
return Segment(
|
||||||
|
audio=np.concatenate(self._buffer),
|
||||||
|
started_at=self._started_at,
|
||||||
|
ended_at=time.monotonic(),
|
||||||
|
is_final=False,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _finish(self) -> Segment:
|
||||||
|
audio = np.concatenate(self._buffer) if self._buffer else np.zeros(0, np.float32)
|
||||||
|
segment = Segment(
|
||||||
|
audio=audio,
|
||||||
|
started_at=self._started_at,
|
||||||
|
ended_at=time.monotonic(),
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
self.reset()
|
||||||
|
return segment
|
||||||
|
|
||||||
|
def _is_speech(self, frame: np.ndarray) -> bool:
|
||||||
|
rms = float(np.sqrt(np.mean(np.square(frame), dtype=np.float64)))
|
||||||
|
cfg = self.config
|
||||||
|
|
||||||
|
if rms < cfg.absolute_floor:
|
||||||
|
self._noise_floor = min(self._noise_floor, max(rms, 1e-6))
|
||||||
|
return False
|
||||||
|
|
||||||
|
energetic = rms > max(self._noise_floor * cfg.speech_ratio, cfg.absolute_floor)
|
||||||
|
if not energetic:
|
||||||
|
# 무음 구간에서만 노이즈 플로어를 천천히 따라가게 한다.
|
||||||
|
self._noise_floor = 0.95 * self._noise_floor + 0.05 * rms
|
||||||
|
|
||||||
|
if self._vad is None:
|
||||||
|
return energetic
|
||||||
|
try:
|
||||||
|
pcm16 = (np.clip(frame, -1.0, 1.0) * 32767.0).astype(np.int16).tobytes()
|
||||||
|
return energetic and self._vad.is_speech(pcm16, SAMPLE_RATE)
|
||||||
|
except Exception: # noqa: BLE001 - webrtcvad 는 프레임 길이에 민감
|
||||||
|
return energetic
|
||||||
95
src/livesub/audio/wasapi_loopback.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""WASAPI 장치 루프백 캡처 (Windows).
|
||||||
|
|
||||||
|
출력 장치 전체의 소리를 받는다. 프로그램별 분리는 안 되지만 의존성이
|
||||||
|
`PyAudioWPatch` 하나뿐이라 어디서나 바로 동작하는 기본 백엔드다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 WasapiLoopbackCapture(CaptureBackend):
|
||||||
|
name = "device"
|
||||||
|
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 = pa.get_default_wasapi_loopback()["index"]
|
||||||
|
for info in pa.get_loopback_device_info_generator():
|
||||||
|
idx = int(info["index"])
|
||||||
|
sources.append(
|
||||||
|
AudioSource(
|
||||||
|
kind="device",
|
||||||
|
identifier=str(idx),
|
||||||
|
label=str(info["name"]).replace(" [Loopback]", ""),
|
||||||
|
detail="기본 출력 장치" if idx == 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_wasapi_loopback()
|
||||||
|
|
||||||
|
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 = int(info["maxInputChannels"]) or 2
|
||||||
|
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()
|
||||||
199
src/livesub/config.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""설정 모델과 JSON 영속화."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import asdict, dataclass, field, fields, is_dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .constants import DEFAULT_TIER, user_data_dir
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def config_path() -> Path:
|
||||||
|
"""설정 파일 경로.
|
||||||
|
|
||||||
|
import 시점에 상수로 계산하면 안 된다. 그때는 사용자가 저장 위치를 아직
|
||||||
|
고르지 않았고, 나중에 설정에서 위치를 바꿔도 옛 경로를 계속 가리킨다.
|
||||||
|
"""
|
||||||
|
return user_data_dir() / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SubtitleStyle:
|
||||||
|
"""자막 텍스트 표현 설정."""
|
||||||
|
|
||||||
|
font_family: str = "Pretendard"
|
||||||
|
font_size: int = 34
|
||||||
|
bold: bool = True
|
||||||
|
text_color: str = "#FFFFFF"
|
||||||
|
source_text_color: str = "#9AA4B2"
|
||||||
|
outline_color: str = "#000000"
|
||||||
|
outline_width: int = 3
|
||||||
|
background_color: str = "#000000"
|
||||||
|
background_opacity: int = 55 # 0-100
|
||||||
|
line_spacing: int = 6
|
||||||
|
max_lines: int = 2
|
||||||
|
show_source: bool = False # 원문 동시 표시
|
||||||
|
align: str = "center" # left | center | right
|
||||||
|
fade_out_ms: int = 4000 # 0이면 계속 유지
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OverlayConfig:
|
||||||
|
"""자막 창(오버레이) 위치/동작."""
|
||||||
|
|
||||||
|
x: int = -1 # -1 = 아직 배치 안 됨 (앵커로 계산)
|
||||||
|
y: int = -1
|
||||||
|
width: int = 1100
|
||||||
|
height: int = 200
|
||||||
|
always_on_top: bool = True
|
||||||
|
#: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다.
|
||||||
|
click_through: bool = True
|
||||||
|
locked: bool = False
|
||||||
|
#: 자막 표시 on/off (단축키로 토글)
|
||||||
|
visible: bool = True
|
||||||
|
#: 자막을 띄울 모니터 인덱스 (0부터)
|
||||||
|
screen_index: int = 0
|
||||||
|
#: 디스코드 오버레이식 9분할 위치. "custom"이면 드래그로 옮긴 자유 위치.
|
||||||
|
anchor: str = "bottom-center"
|
||||||
|
#: 화면 가장자리에서 띄울 간격
|
||||||
|
margin: int = 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioConfig:
|
||||||
|
backend: str = "auto" # auto | process | device | file
|
||||||
|
target_pid: int = 0
|
||||||
|
target_process_name: str = ""
|
||||||
|
device_index: int = -1 # -1 = 기본 출력 장치 루프백
|
||||||
|
input_gain: float = 1.0
|
||||||
|
vad_aggressiveness: int = 2 # 0-3
|
||||||
|
silence_ms: int = 600 # 이 시간만큼 조용하면 한 문장 종료
|
||||||
|
min_segment_ms: int = 400
|
||||||
|
max_segment_ms: int = 12_000
|
||||||
|
partial_interval_ms: int = 900 # 중간 결과 갱신 주기, 0이면 끔
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelConfig:
|
||||||
|
tier: str = DEFAULT_TIER
|
||||||
|
device: str = "cuda" # cuda | cpu
|
||||||
|
device_index: int = 0
|
||||||
|
source_lang: str = "auto" # auto 또는 언어코드
|
||||||
|
target_lang: str = "ko"
|
||||||
|
preload_on_start: bool = True
|
||||||
|
lora_adapter_path: str = "" # 추가학습 어댑터 (비어있으면 미사용)
|
||||||
|
#: 한국어 자막 말투. "polite"(존댓말, 기본) | "casual"(반말)
|
||||||
|
#: 반말이어도 원문이 실제로 존댓말이면 존댓말을 지킨다.
|
||||||
|
speech_level: str = "polite"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GlossaryConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
path: str = "" # 비어있으면 user_data_dir()/glossary.json
|
||||||
|
case_sensitive: bool = False
|
||||||
|
#: 처음 설치했을 때 켜져 있는 용어집 팩.
|
||||||
|
#: 체크를 안 해보고 그냥 쓰는 경우가 대부분이라 기본으로 켜둔다.
|
||||||
|
#: 게임 전용 팩끼리 역어가 갈리면 용어집 화면이 알려주므로, 안 하는 게임은
|
||||||
|
#: 꺼주면 된다.
|
||||||
|
enabled_packs: list[str] = field(
|
||||||
|
default_factory=lambda: ["fps-common", "overwatch", "pubg", "r6siege", "watchdogs"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PerformanceConfig:
|
||||||
|
"""게임과 동시에 돌릴 때 GPU를 얼마나 양보할지.
|
||||||
|
|
||||||
|
이 프로그램은 게임 위에 얹혀 돌아가는 것이 기본 용도라, 자막 품질보다
|
||||||
|
게임 프레임을 지키는 쪽이 대체로 더 중요하다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: 저부하 모드. 켜면 아래 항목들이 자동으로 보수적인 값으로 적용된다.
|
||||||
|
game_mode: bool = True
|
||||||
|
#: 프로세스가 쓸 수 있는 VRAM 상한 비율 (0이면 제한 없음).
|
||||||
|
#: 게임이 VRAM 부족으로 버벅이는 것을 막는다.
|
||||||
|
vram_limit_ratio: float = 0.35
|
||||||
|
#: 한 번 추론하고 나서 GPU를 놓아주는 시간. 게임 프레임 확보용.
|
||||||
|
yield_ms: int = 60
|
||||||
|
#: 이 시간 동안 말소리가 없으면 모델을 내려 VRAM을 통째로 반납한다. 0이면 끔.
|
||||||
|
idle_unload_s: int = 300
|
||||||
|
#: 저부하 모드에서 연산 정밀도를 int8로 낮출지
|
||||||
|
force_int8: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HotkeyConfig:
|
||||||
|
"""전역 단축키. 게임 창이 떠 있어도 동작한다."""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
#: 자막 표시 on/off
|
||||||
|
toggle_subtitle: str = "Ctrl+Alt+S"
|
||||||
|
#: 번역 엔진 시작/정지
|
||||||
|
toggle_engine: str = "Ctrl+Alt+D"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
audio: AudioConfig = field(default_factory=AudioConfig)
|
||||||
|
models: ModelConfig = field(default_factory=ModelConfig)
|
||||||
|
subtitle: SubtitleStyle = field(default_factory=SubtitleStyle)
|
||||||
|
overlay: OverlayConfig = field(default_factory=OverlayConfig)
|
||||||
|
glossary: GlossaryConfig = field(default_factory=GlossaryConfig)
|
||||||
|
performance: PerformanceConfig = field(default_factory=PerformanceConfig)
|
||||||
|
hotkeys: HotkeyConfig = field(default_factory=HotkeyConfig)
|
||||||
|
theme: str = "dark" # dark | light
|
||||||
|
start_minimized: bool = False
|
||||||
|
log_transcripts: bool = True
|
||||||
|
|
||||||
|
# --- 영속화 ---------------------------------------------------------
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
def save(self, path: Path | None = None) -> Path:
|
||||||
|
target = path or config_path()
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = target.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(
|
||||||
|
json.dumps(self.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
tmp.replace(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path | None = None) -> AppConfig:
|
||||||
|
target = path or config_path()
|
||||||
|
if not target.exists():
|
||||||
|
return cls()
|
||||||
|
try:
|
||||||
|
raw = json.loads(target.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
log.warning("설정 파일을 읽지 못해 기본값을 사용합니다: %s", exc)
|
||||||
|
return cls()
|
||||||
|
return _from_dict(cls, raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _from_dict(cls: type, raw: Any) -> Any:
|
||||||
|
"""알 수 없는 키는 버리고 누락된 키는 기본값으로 채우는 관대한 역직렬화.
|
||||||
|
|
||||||
|
기본값 인스턴스를 먼저 만든 뒤 알고 있는 필드만 덮어쓴다. 덕분에
|
||||||
|
설정 스키마가 바뀌어도 사용자의 기존 config.json이 깨지지 않는다.
|
||||||
|
"""
|
||||||
|
instance = cls()
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return instance
|
||||||
|
known = {f.name for f in fields(cls)}
|
||||||
|
for key, value in raw.items():
|
||||||
|
if key not in known:
|
||||||
|
continue
|
||||||
|
current = getattr(instance, key)
|
||||||
|
if is_dataclass(current) and not isinstance(current, type):
|
||||||
|
setattr(instance, key, _from_dict(type(current), value))
|
||||||
|
else:
|
||||||
|
setattr(instance, key, value)
|
||||||
|
return instance
|
||||||
59
src/livesub/constants.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""전역 상수. 제품명 변경은 이 파일만 고치면 됩니다."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP_NAME = "LiveSub"
|
||||||
|
APP_NAME_KO = "라이브섭"
|
||||||
|
APP_SLOGAN = "게임 소리를 실시간 자막으로"
|
||||||
|
APP_VERSION = "0.3.0"
|
||||||
|
ORG_NAME = "tkrmagid"
|
||||||
|
|
||||||
|
# 지원 언어 (1차: 4개)
|
||||||
|
#
|
||||||
|
# 각 키의 의미:
|
||||||
|
# label — UI 표시 이름
|
||||||
|
# english — 프롬프트에 쓰는 영어 언어명
|
||||||
|
# nllb — NLLB-200 언어 코드 (FLORES-200 형식)
|
||||||
|
# seedx — Seed-X 프롬프트 끝에 붙이는 태그. 모델 카드가 "언어 태그는 필수"라고
|
||||||
|
# 명시하며, 빠지면 번역 품질이 크게 흔들린다. 값이 우리 언어코드와
|
||||||
|
# 같아 보여도 우연이 아니라 Seed-X 표에 맞춘 것이므로 따로 적어둔다.
|
||||||
|
LANGUAGES: dict[str, dict[str, str]] = {
|
||||||
|
"ko": {"label": "한국어", "english": "Korean", "nllb": "kor_Hang", "seedx": "ko"},
|
||||||
|
"en": {"label": "English", "english": "English", "nllb": "eng_Latn", "seedx": "en"},
|
||||||
|
"ja": {"label": "日本語", "english": "Japanese", "nllb": "jpn_Jpan", "seedx": "ja"},
|
||||||
|
"zh": {"label": "中文", "english": "Chinese", "nllb": "zho_Hans", "seedx": "zh"},
|
||||||
|
}
|
||||||
|
LANGUAGE_CODES = tuple(LANGUAGES.keys())
|
||||||
|
|
||||||
|
# 기본 모델 품질 티어. 실제 티어 정의는 models/tiers.py 에 있지만,
|
||||||
|
# config 가 models 패키지를 import 하면 순환 의존이 생기므로 키만 여기에 둔다.
|
||||||
|
#
|
||||||
|
# 게임과 동시에 돌리는 것이 주 용도라 기본값을 '균형'(6GB)에서 '신속'(4GB)으로
|
||||||
|
# 낮췄다. 인식 품질은 그대로 최상급이고 번역만 경량이라 체감 손해가 가장 적다.
|
||||||
|
DEFAULT_TIER = "swift"
|
||||||
|
|
||||||
|
# 오디오 파이프라인 규격
|
||||||
|
SAMPLE_RATE = 16_000
|
||||||
|
CHANNELS = 1
|
||||||
|
FRAME_MS = 20
|
||||||
|
FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000
|
||||||
|
|
||||||
|
|
||||||
|
def user_data_dir() -> Path:
|
||||||
|
"""설정/로그/모델 캐시를 두는 폴더 — 사용자가 첫 실행 때 고른 위치다.
|
||||||
|
|
||||||
|
실제 결정은 storage 모듈이 한다. 모듈 최상단에서 import 하면 순환이 되므로
|
||||||
|
함수 안에서 가져온다. 그리고 **미리 계산해 상수로 박아두면 안 된다** —
|
||||||
|
설정에서 위치를 바꿨을 때 따라오지 않는다.
|
||||||
|
"""
|
||||||
|
from .storage import data_dir
|
||||||
|
|
||||||
|
return data_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def models_dir() -> Path:
|
||||||
|
path = user_data_dir() / "models"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
6
src/livesub/core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""파이프라인 코어."""
|
||||||
|
|
||||||
|
from .engine import TranslationEngine
|
||||||
|
from .events import EngineState, EngineStatus, TranslationLine
|
||||||
|
|
||||||
|
__all__ = ["EngineState", "EngineStatus", "TranslationEngine", "TranslationLine"]
|
||||||
351
src/livesub/core/engine.py
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
"""실시간 번역 파이프라인 오케스트레이션.
|
||||||
|
|
||||||
|
캡처 스레드 → [오디오 큐] → 분할 스레드 → [구간 큐] → 인식/번역 워커 → 콜백
|
||||||
|
|
||||||
|
UI 프레임워크에 의존하지 않는다. Qt 쪽은 콜백을 시그널로 다시 던져준다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..audio import (
|
||||||
|
CaptureBackend,
|
||||||
|
CaptureError,
|
||||||
|
Segment,
|
||||||
|
Segmenter,
|
||||||
|
SegmenterConfig,
|
||||||
|
create_capture,
|
||||||
|
)
|
||||||
|
from ..config import AppConfig
|
||||||
|
from ..constants import LANGUAGE_CODES, user_data_dir
|
||||||
|
from ..models import Glossary, ModelManager
|
||||||
|
from ..models.manager import apply_vram_limit
|
||||||
|
from ..models.packs import build_glossary
|
||||||
|
from ..models.speech_level import (
|
||||||
|
SpeechLevel,
|
||||||
|
apply_speech_level,
|
||||||
|
detect_politeness,
|
||||||
|
fix_particles,
|
||||||
|
)
|
||||||
|
from .events import EngineState, EngineStatus, TranslationLine
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
StatusCallback = Callable[[EngineStatus], None]
|
||||||
|
LineCallback = Callable[[TranslationLine], None]
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationEngine:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: AppConfig,
|
||||||
|
on_line: LineCallback | None = None,
|
||||||
|
on_status: StatusCallback | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._on_line = on_line
|
||||||
|
self._on_status = on_status
|
||||||
|
|
||||||
|
self.models = ModelManager(config.models, low_power=config.performance.game_mode)
|
||||||
|
self.glossary = self._load_glossary()
|
||||||
|
|
||||||
|
self._capture: CaptureBackend | None = None
|
||||||
|
self._segments: queue.Queue[Segment] = queue.Queue(maxsize=32)
|
||||||
|
self._threads: list[threading.Thread] = []
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._status = EngineStatus()
|
||||||
|
self._level = 0.0
|
||||||
|
#: 인식 정확도를 높이기 위해 직전 확정 문장을 프롬프트로 넘긴다.
|
||||||
|
self._last_final_text = ""
|
||||||
|
#: 마지막으로 실제 발화를 처리한 시각. 유휴 언로드 판단에 쓴다.
|
||||||
|
self._last_activity = time.monotonic()
|
||||||
|
self._models_unloaded = False
|
||||||
|
|
||||||
|
# --- 수명주기 -------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def status(self) -> EngineStatus:
|
||||||
|
return self._status
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._status.state in (EngineState.RUNNING, EngineState.LOADING)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self.running:
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
self._emit_status(EngineState.LOADING, "모델을 준비하고 있습니다…", 0.0)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=self._bootstrap, name="engine-boot", daemon=True)
|
||||||
|
thread.start()
|
||||||
|
self._threads = [thread]
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self._status.state is EngineState.IDLE:
|
||||||
|
return
|
||||||
|
self._emit_status(EngineState.STOPPING, "정지 중…")
|
||||||
|
self._stop.set()
|
||||||
|
if self._capture is not None:
|
||||||
|
self._capture.stop()
|
||||||
|
self._capture = None
|
||||||
|
for t in self._threads:
|
||||||
|
if t is not threading.current_thread():
|
||||||
|
t.join(timeout=3)
|
||||||
|
self._threads = []
|
||||||
|
self._drain_segments()
|
||||||
|
self._emit_status(EngineState.IDLE, "대기 중")
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self.stop()
|
||||||
|
self.models.unload()
|
||||||
|
|
||||||
|
def reload_glossary(self) -> None:
|
||||||
|
self.glossary = self._load_glossary()
|
||||||
|
|
||||||
|
def _speech_level(self) -> SpeechLevel:
|
||||||
|
"""설정 문자열을 열거형으로. 알 수 없는 값은 안전한 존댓말로."""
|
||||||
|
try:
|
||||||
|
return SpeechLevel(self.config.models.speech_level)
|
||||||
|
except ValueError:
|
||||||
|
return SpeechLevel.POLITE
|
||||||
|
|
||||||
|
def sync_performance(self) -> bool:
|
||||||
|
"""저부하 모드 설정을 모델 매니저에 반영한다. 바뀌었으면 True.
|
||||||
|
|
||||||
|
`ModelManager` 는 생성 시점의 `low_power` 로 연산 정밀도를 정한다.
|
||||||
|
설정에서 모드를 토글해도 이 값을 갱신하지 않으면 이미 올라가 있는
|
||||||
|
이전 정밀도 모델이 그대로 쓰여서, 껐는데 품질이 안 오르거나 켰는데
|
||||||
|
게임이 계속 버벅이는 상태가 된다. 그래서 값을 바꾸고 모델을 내려
|
||||||
|
다음 사용 때 새 사양으로 다시 올라가게 한다.
|
||||||
|
"""
|
||||||
|
want = self.config.performance.game_mode
|
||||||
|
if self.models.low_power == want:
|
||||||
|
return False
|
||||||
|
log.info("저부하 모드 %s — 모델을 새 정밀도로 다시 올립니다", "켬" if want else "끔")
|
||||||
|
self.models.unload()
|
||||||
|
self.models.low_power = want
|
||||||
|
return True
|
||||||
|
|
||||||
|
# --- 내부 스레드 ----------------------------------------------------
|
||||||
|
def _bootstrap(self) -> None:
|
||||||
|
try:
|
||||||
|
# 설정이 바뀐 채로 재시작됐을 수 있으므로 매 시작마다 맞춘다.
|
||||||
|
self.sync_performance()
|
||||||
|
perf = self.config.performance
|
||||||
|
if perf.game_mode and self.config.models.device == "cuda":
|
||||||
|
# 모델을 올리기 *전에* 걸어야 상한이 실제로 먹는다.
|
||||||
|
apply_vram_limit(perf.vram_limit_ratio, self.config.models.device_index)
|
||||||
|
if self.config.models.preload_on_start:
|
||||||
|
self.models.preload(
|
||||||
|
lambda msg, pct: self._emit_status(EngineState.LOADING, msg, pct)
|
||||||
|
)
|
||||||
|
self._capture = create_capture(self.config.audio)
|
||||||
|
self._capture.start()
|
||||||
|
|
||||||
|
workers = [
|
||||||
|
threading.Thread(target=self._segment_loop, name="engine-segment", daemon=True),
|
||||||
|
threading.Thread(target=self._infer_loop, name="engine-infer", daemon=True),
|
||||||
|
]
|
||||||
|
for w in workers:
|
||||||
|
w.start()
|
||||||
|
self._threads.extend(workers)
|
||||||
|
|
||||||
|
kind = "프로그램별 캡처" if self._capture.per_process else "출력 장치 전체"
|
||||||
|
self._emit_status(EngineState.RUNNING, f"듣는 중 · {kind}", 1.0)
|
||||||
|
except (CaptureError, OSError, RuntimeError, ImportError) as exc:
|
||||||
|
log.exception("엔진 시작 실패")
|
||||||
|
self._emit_status(EngineState.ERROR, str(exc))
|
||||||
|
|
||||||
|
def _segment_loop(self) -> None:
|
||||||
|
cfg = self.config.audio
|
||||||
|
# 중간 결과는 같은 오디오를 여러 번 인식하므로 GPU 작업이 거의 두 배가 된다.
|
||||||
|
# 게임과 같이 돌릴 때는 그만한 값어치가 없으므로 저부하 모드에서는 끈다.
|
||||||
|
partial_ms = 0 if self.config.performance.game_mode else cfg.partial_interval_ms
|
||||||
|
segmenter = Segmenter(
|
||||||
|
SegmenterConfig(
|
||||||
|
silence_ms=cfg.silence_ms,
|
||||||
|
min_segment_ms=cfg.min_segment_ms,
|
||||||
|
max_segment_ms=cfg.max_segment_ms,
|
||||||
|
partial_interval_ms=partial_ms,
|
||||||
|
vad_aggressiveness=cfg.vad_aggressiveness,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
while not self._stop.is_set():
|
||||||
|
capture = self._capture
|
||||||
|
if capture is None:
|
||||||
|
break
|
||||||
|
chunk = capture.read(timeout=0.3)
|
||||||
|
if chunk is None:
|
||||||
|
if capture.error is not None:
|
||||||
|
self._emit_status(EngineState.ERROR, str(capture.error))
|
||||||
|
return
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cfg.input_gain != 1.0:
|
||||||
|
chunk = np.clip(chunk * cfg.input_gain, -1.0, 1.0)
|
||||||
|
self._level = float(np.sqrt(np.mean(np.square(chunk, dtype=np.float64))))
|
||||||
|
|
||||||
|
for segment in segmenter.push(chunk):
|
||||||
|
self._enqueue(segment)
|
||||||
|
|
||||||
|
leftover = segmenter.flush()
|
||||||
|
if leftover is not None:
|
||||||
|
self._enqueue(leftover)
|
||||||
|
|
||||||
|
def _enqueue(self, segment: Segment) -> None:
|
||||||
|
try:
|
||||||
|
self._segments.put_nowait(segment)
|
||||||
|
except queue.Full:
|
||||||
|
# 밀렸다면 중간 결과부터 버린다. 확정 문장은 지켜야 한다.
|
||||||
|
if not segment.is_final:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._segments.get_nowait()
|
||||||
|
self._segments.put_nowait(segment)
|
||||||
|
except (queue.Empty, queue.Full):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _infer_loop(self) -> None:
|
||||||
|
mcfg = self.config.models
|
||||||
|
perf = self.config.performance
|
||||||
|
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
segment = self._segments.get(timeout=0.3)
|
||||||
|
except queue.Empty:
|
||||||
|
self._maybe_unload_idle()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 처리 중 더 새로운 확정 구간이 들어왔다면 오래된 중간 결과는 버린다.
|
||||||
|
if not segment.is_final and not self._segments.empty():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 모델 참조를 캐시하지 않는다. 유휴 언로드 뒤에는 새로 올려야 하고,
|
||||||
|
# 캐시된 참조를 쓰면 이미 내려간 모델을 붙잡고 있게 된다.
|
||||||
|
try:
|
||||||
|
self._process(
|
||||||
|
segment, self.models.recognizer(), self.models.translator(), mcfg
|
||||||
|
)
|
||||||
|
self._last_activity = time.monotonic()
|
||||||
|
self._models_unloaded = False
|
||||||
|
except Exception as exc: # noqa: BLE001 - 한 문장 실패로 엔진이 죽으면 안 된다
|
||||||
|
log.warning("구간 처리 실패: %s", exc, exc_info=log.isEnabledFor(logging.DEBUG))
|
||||||
|
|
||||||
|
# 게임에 GPU를 잠깐 돌려준다. 연속 발화 때 우리가 GPU를 독점하면
|
||||||
|
# 프레임이 눈에 띄게 끊긴다.
|
||||||
|
if perf.game_mode and perf.yield_ms > 0:
|
||||||
|
self._stop.wait(perf.yield_ms / 1000)
|
||||||
|
|
||||||
|
def _maybe_unload_idle(self) -> None:
|
||||||
|
"""한동안 말소리가 없으면 모델을 내려 VRAM을 통째로 돌려준다.
|
||||||
|
|
||||||
|
게임 중에 조용한 구간(로딩·메뉴·솔로 플레이)이 길게 이어지는데
|
||||||
|
그동안 수 GB를 붙잡고 있을 이유가 없다. 다음 발화가 오면 자동으로
|
||||||
|
다시 올라간다 — 그때 한 번 로딩 지연이 생기는 것이 트레이드오프다.
|
||||||
|
"""
|
||||||
|
perf = self.config.performance
|
||||||
|
if not perf.game_mode or perf.idle_unload_s <= 0 or self._models_unloaded:
|
||||||
|
return
|
||||||
|
if time.monotonic() - self._last_activity < perf.idle_unload_s:
|
||||||
|
return
|
||||||
|
if not self.models.ready:
|
||||||
|
self._models_unloaded = True
|
||||||
|
return
|
||||||
|
log.info("%d초간 조용해 모델을 내립니다 (VRAM 반납)", perf.idle_unload_s)
|
||||||
|
self.models.unload()
|
||||||
|
self._models_unloaded = True
|
||||||
|
self._emit_status(EngineState.RUNNING, "듣는 중 · 대기 (VRAM 반납됨)")
|
||||||
|
|
||||||
|
def _process(self, segment: Segment, recognizer, translator, mcfg) -> None:
|
||||||
|
source_lang = None if mcfg.source_lang == "auto" else mcfg.source_lang
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
transcript = recognizer.transcribe(
|
||||||
|
segment.audio,
|
||||||
|
language=source_lang,
|
||||||
|
fast=not segment.is_final,
|
||||||
|
prompt=self._last_final_text if segment.is_final else "",
|
||||||
|
)
|
||||||
|
asr_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
if not transcript.text:
|
||||||
|
return
|
||||||
|
|
||||||
|
detected = transcript.language if transcript.language in LANGUAGE_CODES else mcfg.target_lang
|
||||||
|
target = mcfg.target_lang
|
||||||
|
|
||||||
|
translated = ""
|
||||||
|
mt_ms = 0
|
||||||
|
if segment.is_final:
|
||||||
|
# 중간 결과는 원문만 흘려보낸다. 번역은 확정 문장에만 돌려 GPU를 아낀다.
|
||||||
|
t1 = time.perf_counter()
|
||||||
|
# 말투는 원문을 보고 정한다. 원문이 실제로 존댓말이면 반말 모드여도
|
||||||
|
# 존댓말을 지켜야 하므로, 번역 전에 원문에서 높임을 읽어둔다.
|
||||||
|
politeness = detect_politeness(transcript.text, detected)
|
||||||
|
translated = translator.translate(
|
||||||
|
transcript.text,
|
||||||
|
detected,
|
||||||
|
target,
|
||||||
|
self.glossary if self.config.glossary.enabled else None,
|
||||||
|
speech_level=self._speech_level(),
|
||||||
|
source_politeness=politeness,
|
||||||
|
)
|
||||||
|
translated = apply_speech_level(
|
||||||
|
translated, target, self._speech_level(), politeness
|
||||||
|
)
|
||||||
|
if target == "ko":
|
||||||
|
translated = fix_particles(translated)
|
||||||
|
mt_ms = int((time.perf_counter() - t1) * 1000)
|
||||||
|
self._last_final_text = transcript.text
|
||||||
|
|
||||||
|
line = TranslationLine(
|
||||||
|
source_text=transcript.text,
|
||||||
|
translated_text=translated,
|
||||||
|
source_lang=detected,
|
||||||
|
target_lang=target,
|
||||||
|
is_final=segment.is_final,
|
||||||
|
latency_s=time.monotonic() - segment.ended_at,
|
||||||
|
asr_ms=asr_ms,
|
||||||
|
mt_ms=mt_ms,
|
||||||
|
)
|
||||||
|
if self._on_line:
|
||||||
|
self._on_line(line)
|
||||||
|
self._status.level = self._level
|
||||||
|
self._status.queue_depth = self._segments.qsize()
|
||||||
|
|
||||||
|
# --- 유틸 -----------------------------------------------------------
|
||||||
|
def _load_glossary(self) -> Glossary:
|
||||||
|
"""사용자 용어집 + 켜둔 기본 팩을 합쳐서 하나로 만든다."""
|
||||||
|
cfg = self.config.glossary
|
||||||
|
path = cfg.path or str(user_data_dir() / "glossary.json")
|
||||||
|
user = Glossary.load(path, cfg.case_sensitive)
|
||||||
|
merged = build_glossary(user, cfg.enabled_packs, cfg.case_sensitive)
|
||||||
|
log.info(
|
||||||
|
"용어집 %d개 (사용자 %d + 팩 %s)",
|
||||||
|
len(merged), len(user), ", ".join(cfg.enabled_packs) or "없음",
|
||||||
|
)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def _drain_segments(self) -> None:
|
||||||
|
while not self._segments.empty():
|
||||||
|
try:
|
||||||
|
self._segments.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _emit_status(self, state: EngineState, message: str, progress: float | None = None) -> None:
|
||||||
|
self._status = EngineStatus(
|
||||||
|
state=state,
|
||||||
|
message=message,
|
||||||
|
progress=self._status.progress if progress is None else progress,
|
||||||
|
level=self._level,
|
||||||
|
queue_depth=self._segments.qsize(),
|
||||||
|
)
|
||||||
|
if self._on_status:
|
||||||
|
self._on_status(self._status)
|
||||||
45
src/livesub/core/events.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""엔진이 UI로 올려보내는 이벤트 타입."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class EngineState(str, Enum):
|
||||||
|
IDLE = "idle"
|
||||||
|
LOADING = "loading"
|
||||||
|
RUNNING = "running"
|
||||||
|
STOPPING = "stopping"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TranslationLine:
|
||||||
|
"""화면에 뿌릴 자막 한 줄."""
|
||||||
|
|
||||||
|
source_text: str
|
||||||
|
translated_text: str
|
||||||
|
source_lang: str
|
||||||
|
target_lang: str
|
||||||
|
is_final: bool = True
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
#: 발화가 끝난 시점부터 번역이 나오기까지 걸린 시간(초)
|
||||||
|
latency_s: float = 0.0
|
||||||
|
asr_ms: int = 0
|
||||||
|
mt_ms: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display(self) -> str:
|
||||||
|
return self.translated_text or self.source_text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EngineStatus:
|
||||||
|
state: EngineState = EngineState.IDLE
|
||||||
|
message: str = ""
|
||||||
|
progress: float = 0.0
|
||||||
|
#: 최근 입력 오디오 레벨 (0.0 ~ 1.0), VU 미터용
|
||||||
|
level: float = 0.0
|
||||||
|
queue_depth: int = 0
|
||||||
47
src/livesub/models/__init__.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""모델 레이어 (음성인식 · 번역 · 용어집)."""
|
||||||
|
|
||||||
|
from .asr import SpeechRecognizer, Transcript
|
||||||
|
from .glossary import Glossary, GlossaryEntry
|
||||||
|
from .manager import GpuInfo, ModelManager, detect_gpu
|
||||||
|
from .speech_level import (
|
||||||
|
Politeness,
|
||||||
|
SpeechLevel,
|
||||||
|
apply_speech_level,
|
||||||
|
detect_politeness,
|
||||||
|
to_casual,
|
||||||
|
)
|
||||||
|
from .tiers import (
|
||||||
|
DEFAULT_TIER,
|
||||||
|
TIERS,
|
||||||
|
MTBackend,
|
||||||
|
PromptStyle,
|
||||||
|
Tier,
|
||||||
|
get_tier,
|
||||||
|
ordered_tiers,
|
||||||
|
)
|
||||||
|
from .translator import Translator, build_seedx_prompt, create_translator
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_TIER",
|
||||||
|
"Glossary",
|
||||||
|
"GlossaryEntry",
|
||||||
|
"GpuInfo",
|
||||||
|
"MTBackend",
|
||||||
|
"ModelManager",
|
||||||
|
"Politeness",
|
||||||
|
"PromptStyle",
|
||||||
|
"SpeechLevel",
|
||||||
|
"SpeechRecognizer",
|
||||||
|
"TIERS",
|
||||||
|
"Tier",
|
||||||
|
"Transcript",
|
||||||
|
"Translator",
|
||||||
|
"apply_speech_level",
|
||||||
|
"build_seedx_prompt",
|
||||||
|
"detect_politeness",
|
||||||
|
"create_translator",
|
||||||
|
"detect_gpu",
|
||||||
|
"get_tier",
|
||||||
|
"ordered_tiers",
|
||||||
|
"to_casual",
|
||||||
|
]
|
||||||
95
src/livesub/models/asr.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""음성인식(ASR) 래퍼 — faster-whisper(CTranslate2) 기반."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..constants import models_dir
|
||||||
|
from .tiers import AsrSpec
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Transcript:
|
||||||
|
text: str
|
||||||
|
language: str
|
||||||
|
confidence: float = 0.0
|
||||||
|
duration_s: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechRecognizer:
|
||||||
|
"""faster-whisper 모델 한 개를 감싼다. 스레드 안전."""
|
||||||
|
|
||||||
|
def __init__(self, spec: AsrSpec, device: str = "cuda", device_index: int = 0) -> None:
|
||||||
|
self.spec = spec
|
||||||
|
self.device = device
|
||||||
|
self.device_index = device_index
|
||||||
|
self._model = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loaded(self) -> bool:
|
||||||
|
return self._model is not None
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
if self._model is not None:
|
||||||
|
return
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
compute_type = self.spec.compute_type
|
||||||
|
if self.device == "cpu" and compute_type.endswith("float16"):
|
||||||
|
compute_type = "int8" # CPU에서는 float16이 오히려 느리다
|
||||||
|
log.info("음성인식 모델 로드: %s (%s/%s)", self.spec.repo, self.device, compute_type)
|
||||||
|
self._model = WhisperModel(
|
||||||
|
self.spec.repo,
|
||||||
|
device=self.device,
|
||||||
|
device_index=self.device_index,
|
||||||
|
compute_type=compute_type,
|
||||||
|
download_root=str(models_dir()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def unload(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._model = None
|
||||||
|
|
||||||
|
def transcribe(
|
||||||
|
self,
|
||||||
|
audio: np.ndarray,
|
||||||
|
language: str | None = None,
|
||||||
|
fast: bool = False,
|
||||||
|
prompt: str = "",
|
||||||
|
) -> Transcript:
|
||||||
|
"""오디오 한 덩어리를 텍스트로.
|
||||||
|
|
||||||
|
`fast=True` 는 중간 결과용으로 beam search를 끄고 빠르게 돌린다.
|
||||||
|
"""
|
||||||
|
if self._model is None:
|
||||||
|
self.load()
|
||||||
|
assert self._model is not None
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
segments, info = self._model.transcribe(
|
||||||
|
audio.astype(np.float32, copy=False),
|
||||||
|
language=language,
|
||||||
|
beam_size=1 if fast else self.spec.beam_size,
|
||||||
|
temperature=0.0,
|
||||||
|
condition_on_previous_text=False,
|
||||||
|
initial_prompt=prompt or None,
|
||||||
|
vad_filter=not fast,
|
||||||
|
vad_parameters={"min_silence_duration_ms": 300},
|
||||||
|
word_timestamps=False,
|
||||||
|
)
|
||||||
|
parts = [s.text for s in segments]
|
||||||
|
|
||||||
|
text = " ".join(p.strip() for p in parts if p.strip()).strip()
|
||||||
|
return Transcript(
|
||||||
|
text=text,
|
||||||
|
language=getattr(info, "language", language or "") or "",
|
||||||
|
confidence=float(getattr(info, "language_probability", 0.0) or 0.0),
|
||||||
|
duration_s=float(getattr(info, "duration", 0.0) or 0.0),
|
||||||
|
)
|
||||||
235
src/livesub/models/glossary.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""용어집(고유명사·게임/방송 용어) 처리.
|
||||||
|
|
||||||
|
추가학습 없이도 즉시 효과를 내는 레이어다. 두 가지 방식으로 동작한다.
|
||||||
|
|
||||||
|
1. 플레이스홀더 보호 (`protect` / `restore`)
|
||||||
|
원문의 등록 용어를 `⟦0⟧` 같은 토큰으로 치환해 번역 모델이 건드리지 못하게 하고,
|
||||||
|
번역이 끝난 뒤 지정한 역어로 되돌린다. 어떤 모델에서도 쓸 수 있다.
|
||||||
|
2. 프롬프트 주입 (`prompt_hint`)
|
||||||
|
해당 문장에 등장한 용어만 골라 프롬프트에 "이 용어는 이렇게 옮겨라"로 넣는다.
|
||||||
|
조사·어미까지 문맥에 맞게 붙어 결과가 더 자연스럽다.
|
||||||
|
|
||||||
|
어느 쪽을 쓸지는 모델이 정한다. 2번은 **지시문을 이해하는 instruct 모델에서만**
|
||||||
|
동작한다. Seed-X 처럼 번역만 하도록 학습된 completion 모델은 지시문을 넣으면
|
||||||
|
학습 분포를 벗어나 오히려 번역이 망가지므로 1번을 쓴다. NLLB 같은 seq2seq 도
|
||||||
|
마찬가지다. 판정은 `MTSpec.supports_prompt_glossary` 가 한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
#: 번역 모델이 원문 그대로 통과시켜야 하는 자리표시자.
|
||||||
|
#:
|
||||||
|
#: 형식 선택은 취향이 아니라 실측 결과다. 처음에 쓰던 `⟦0⟧` 는 NLLB 가
|
||||||
|
#: 괄호를 통째로 날려 `0` 만 남겼고(생존률 0/3), 그 결과 용어가 자막에서
|
||||||
|
#: 사라졌다. 후보 8종을 실제 모델로 돌려 비교한 끝에 `#0#` 를 골랐다
|
||||||
|
#: (생존률 3/3, 다중 치환 4/5). 입력이 음성인식 결과라 `#` 가 자연스럽게
|
||||||
|
#: 등장할 일이 거의 없다는 점도 같이 봤다.
|
||||||
|
PLACEHOLDER = "#{}#" # #0#
|
||||||
|
PLACEHOLDER_RE = re.compile(r"#(\d+)#")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GlossaryEntry:
|
||||||
|
"""용어 하나. `targets` 는 언어코드 → 역어."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
targets: dict[str, str] = field(default_factory=dict)
|
||||||
|
note: str = ""
|
||||||
|
#: 비우면 모든 소스 언어에 적용
|
||||||
|
source_lang: str = ""
|
||||||
|
#: 문장 전체가 이 표현일 때만 치환한다.
|
||||||
|
#:
|
||||||
|
#: "get in the car" 같은 절을 긴 문장 한가운데서 통째로 치환하면
|
||||||
|
#: 번역 모델이 문법을 세울 근거를 잃어 "어부 들어오는, 차 타!" 같은
|
||||||
|
#: 결과가 나온다. 반대로 그 말만 단독으로 나왔을 때는 통째로 바꾸는
|
||||||
|
#: 것이 가장 자연스럽다. 그래서 적용 범위를 문장 전체로 한정한다.
|
||||||
|
whole_only: bool = False
|
||||||
|
|
||||||
|
def target_for(self, lang: str) -> str:
|
||||||
|
return self.targets.get(lang, "")
|
||||||
|
|
||||||
|
|
||||||
|
class Glossary:
|
||||||
|
def __init__(self, entries: list[GlossaryEntry] | None = None, case_sensitive: bool = False):
|
||||||
|
self.case_sensitive = case_sensitive
|
||||||
|
self.entries: list[GlossaryEntry] = list(entries or [])
|
||||||
|
self._pattern: re.Pattern[str] | None = None
|
||||||
|
self._index: dict[str, GlossaryEntry] = {}
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
# --- 영속화 --------------------------------------------------------
|
||||||
|
@classmethod
|
||||||
|
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)
|
||||||
|
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
entries = [
|
||||||
|
GlossaryEntry(
|
||||||
|
source=item["source"],
|
||||||
|
targets=dict(item.get("targets", {})),
|
||||||
|
note=item.get("note", ""),
|
||||||
|
source_lang=item.get("source_lang", ""),
|
||||||
|
whole_only=bool(item.get("whole_only", False)),
|
||||||
|
)
|
||||||
|
for item in raw.get("entries", [])
|
||||||
|
if item.get("source")
|
||||||
|
]
|
||||||
|
return cls(entries, case_sensitive=raw.get("case_sensitive", case_sensitive))
|
||||||
|
|
||||||
|
def save(self, path: str | Path) -> Path:
|
||||||
|
p = Path(path)
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {
|
||||||
|
"case_sensitive": self.case_sensitive,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"source": e.source,
|
||||||
|
"targets": e.targets,
|
||||||
|
"note": e.note,
|
||||||
|
"source_lang": e.source_lang,
|
||||||
|
"whole_only": e.whole_only,
|
||||||
|
}
|
||||||
|
for e in self.entries
|
||||||
|
],
|
||||||
|
}
|
||||||
|
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
# --- 편집 ----------------------------------------------------------
|
||||||
|
def add(self, entry: GlossaryEntry) -> None:
|
||||||
|
key = self._key(entry.source)
|
||||||
|
self.entries = [e for e in self.entries if self._key(e.source) != key]
|
||||||
|
self.entries.append(entry)
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
def remove(self, source: str) -> None:
|
||||||
|
key = self._key(source)
|
||||||
|
self.entries = [e for e in self.entries if self._key(e.source) != key]
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.entries)
|
||||||
|
|
||||||
|
# --- 사용 ----------------------------------------------------------
|
||||||
|
def matches(self, text: str, target_lang: str) -> list[GlossaryEntry]:
|
||||||
|
"""문장에 실제로 등장하고 해당 언어 역어가 있는 항목만."""
|
||||||
|
if not self._pattern or not text:
|
||||||
|
return []
|
||||||
|
found: list[GlossaryEntry] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for m in self._pattern.finditer(text):
|
||||||
|
entry = self._index.get(self._key(m.group(0)))
|
||||||
|
if entry is None or not entry.target_for(target_lang):
|
||||||
|
continue
|
||||||
|
key = self._key(entry.source)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
found.append(entry)
|
||||||
|
return found
|
||||||
|
|
||||||
|
def protect(self, text: str, target_lang: str) -> tuple[str, list[str]]:
|
||||||
|
"""등록 용어를 자리표시자로 치환. (치환된 문장, 역어 목록) 반환."""
|
||||||
|
replacements: list[str] = []
|
||||||
|
if not text:
|
||||||
|
return text, replacements
|
||||||
|
|
||||||
|
whole = self._match_whole(text, target_lang)
|
||||||
|
if whole is not None:
|
||||||
|
# 문장 전체가 등록된 표현 — 통째로 바꾸는 게 가장 자연스럽다.
|
||||||
|
return PLACEHOLDER.format(0), [whole]
|
||||||
|
|
||||||
|
if not self._pattern:
|
||||||
|
return text, replacements
|
||||||
|
|
||||||
|
def _sub(m: re.Match[str]) -> str:
|
||||||
|
entry = self._index.get(self._key(m.group(0)))
|
||||||
|
if entry is None:
|
||||||
|
return m.group(0)
|
||||||
|
target = entry.target_for(target_lang)
|
||||||
|
if not target:
|
||||||
|
return m.group(0)
|
||||||
|
replacements.append(target)
|
||||||
|
return PLACEHOLDER.format(len(replacements) - 1)
|
||||||
|
|
||||||
|
return self._pattern.sub(_sub, text), replacements
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def surviving_placeholders(text: str) -> set[int]:
|
||||||
|
"""번역 결과에 살아남은 자리표시자 번호."""
|
||||||
|
return {int(m) for m in PLACEHOLDER_RE.findall(text)}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def missing_placeholders(cls, text: str, replacements: list[str]) -> list[int]:
|
||||||
|
"""번역 중에 사라진 자리표시자 번호.
|
||||||
|
|
||||||
|
번역 모델이 문장 일부를 통째로 누락하면 자리표시자도 같이 사라진다.
|
||||||
|
그대로 복원하면 해당 용어가 자막에서 증발하므로, 호출부가 이걸로
|
||||||
|
확인해 용어집 없이 다시 번역하는 쪽을 택할 수 있게 한다.
|
||||||
|
"""
|
||||||
|
present = cls.surviving_placeholders(text)
|
||||||
|
return [i for i in range(len(replacements)) if i not in present]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def restore(text: str, replacements: list[str]) -> str:
|
||||||
|
"""플레이스홀더를 역어로 되돌린다."""
|
||||||
|
if not replacements:
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _sub(m: re.Match[str]) -> str:
|
||||||
|
idx = int(m.group(1))
|
||||||
|
return replacements[idx] if 0 <= idx < len(replacements) else ""
|
||||||
|
|
||||||
|
return PLACEHOLDER_RE.sub(_sub, text)
|
||||||
|
|
||||||
|
def prompt_hint(self, text: str, target_lang: str, limit: int = 12) -> str:
|
||||||
|
"""LLM 프롬프트에 붙일 용어 지시문. 해당 없으면 빈 문자열."""
|
||||||
|
hits = self.matches(text, target_lang)[:limit]
|
||||||
|
if not hits:
|
||||||
|
return ""
|
||||||
|
lines = [f"- {e.source} -> {e.target_for(target_lang)}" for e in hits]
|
||||||
|
return "다음 용어는 반드시 지정된 역어로 번역하세요:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
def _match_whole(self, text: str, target_lang: str) -> str | None:
|
||||||
|
"""문장 전체가 등록 표현과 같으면 그 역어를 돌려준다."""
|
||||||
|
stripped = self._key(text.strip().rstrip(".!?…~,"))
|
||||||
|
entry = self._index.get(stripped)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
return entry.target_for(target_lang) or None
|
||||||
|
|
||||||
|
# --- 내부 ----------------------------------------------------------
|
||||||
|
def _key(self, text: str) -> str:
|
||||||
|
return text if self.case_sensitive else text.casefold()
|
||||||
|
|
||||||
|
def _rebuild(self) -> None:
|
||||||
|
self._index = {self._key(e.source): e for e in self.entries}
|
||||||
|
if not self.entries:
|
||||||
|
self._pattern = None
|
||||||
|
return
|
||||||
|
# whole_only 항목은 부분 매칭에서 제외한다 (문장 전체일 때만 쓴다).
|
||||||
|
partial = [e.source for e in self.entries if not e.whole_only]
|
||||||
|
if not partial:
|
||||||
|
self._pattern = None
|
||||||
|
return
|
||||||
|
# 긴 용어를 먼저 매칭해야 "헤드샷"이 "헤드"에 잡아먹히지 않는다.
|
||||||
|
sources = sorted(partial, key=len, reverse=True)
|
||||||
|
flags = 0 if self.case_sensitive else re.IGNORECASE
|
||||||
|
self._pattern = re.compile(
|
||||||
|
"|".join(_bounded(re.escape(s)) for s in sources), flags
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded(escaped: str) -> str:
|
||||||
|
"""영문/숫자로 시작·끝나는 용어에만 단어 경계를 건다.
|
||||||
|
|
||||||
|
한국어·일본어·중국어에는 \\b 가 의미 없으므로 적용하지 않는다.
|
||||||
|
"""
|
||||||
|
prefix = r"\b" if re.match(r"[A-Za-z0-9]", escaped.replace("\\", "")) else ""
|
||||||
|
suffix = r"\b" if re.search(r"[A-Za-z0-9]$", escaped.replace("\\", "")) else ""
|
||||||
|
return f"{prefix}{escaped}{suffix}"
|
||||||
242
src/livesub/models/manager.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
"""티어에 맞는 ASR + MT 모델 쌍을 관리한다."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
|
from ..config import ModelConfig
|
||||||
|
from .asr import SpeechRecognizer
|
||||||
|
from .tiers import TIERS, MTBackend, Tier, get_tier
|
||||||
|
from .translator import Translator, create_translator
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GpuInfo:
|
||||||
|
available: bool
|
||||||
|
name: str = ""
|
||||||
|
total_vram_mb: int = 0
|
||||||
|
free_vram_mb: int = 0
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def detect_gpu() -> GpuInfo:
|
||||||
|
"""CUDA 사용 가능 여부와 VRAM. torch 가 없으면 nvidia-smi 로 대체 조회."""
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
idx = torch.cuda.current_device()
|
||||||
|
free, total = torch.cuda.mem_get_info(idx)
|
||||||
|
return GpuInfo(
|
||||||
|
available=True,
|
||||||
|
name=torch.cuda.get_device_name(idx),
|
||||||
|
total_vram_mb=total // (1024 * 1024),
|
||||||
|
free_vram_mb=free // (1024 * 1024),
|
||||||
|
)
|
||||||
|
return GpuInfo(False, reason="CUDA를 사용할 수 없습니다 (드라이버/빌드 확인).")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
smi = shutil.which("nvidia-smi")
|
||||||
|
if not smi:
|
||||||
|
return GpuInfo(False, reason="PyTorch와 nvidia-smi 모두 찾지 못했습니다.")
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
[smi, "--query-gpu=name,memory.total,memory.free",
|
||||||
|
"--format=csv,noheader,nounits"],
|
||||||
|
capture_output=True, text=True, timeout=5, check=True,
|
||||||
|
).stdout.strip().splitlines()
|
||||||
|
name, total, free = (v.strip() for v in out[0].split(","))
|
||||||
|
return GpuInfo(True, name, int(total), int(free),
|
||||||
|
reason="PyTorch 미설치 — 정보만 조회했습니다.")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return GpuInfo(False, reason=f"GPU 조회 실패: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_vram_limit(ratio: float, device_index: int = 0) -> bool:
|
||||||
|
"""이 프로세스가 쓸 수 있는 VRAM 상한을 건다.
|
||||||
|
|
||||||
|
게임과 동시에 돌 때 가장 큰 문제는 연산 시간이 아니라 **VRAM 고갈**이다.
|
||||||
|
게임이 쓸 메모리를 우리가 먹어버리면 텍스처 스트리밍이 시스템 메모리로
|
||||||
|
밀려나면서 프레임이 뚝뚝 끊긴다. 상한을 걸면 그 대신 우리 쪽이 먼저
|
||||||
|
OOM 을 맞으므로, 티어를 낮추라는 안내로 이어진다.
|
||||||
|
"""
|
||||||
|
if ratio <= 0 or ratio >= 1:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
return False
|
||||||
|
torch.cuda.set_per_process_memory_fraction(ratio, device_index)
|
||||||
|
log.info("VRAM 상한 적용: 전체의 %d%%", int(ratio * 100))
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001 - 상한 실패가 치명적이지는 않다
|
||||||
|
log.warning("VRAM 상한을 걸지 못했습니다: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def has_torch() -> bool:
|
||||||
|
"""PyTorch 가 설치돼 있는가.
|
||||||
|
|
||||||
|
포터블 exe 빌드는 torch 를 넣지 않는다. torch+CUDA 만 2.5GB 라
|
||||||
|
한 파일로 묶으면 실행할 때마다 그걸 임시폴더에 풀어야 해서 쓸 수 없다.
|
||||||
|
CTranslate2 기반 1~3티어는 torch 없이 동작하므로, 포터블은 그쪽만 담는다.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
return importlib.util.find_spec("torch") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def has_awq() -> bool:
|
||||||
|
"""4티어(AWQ Int4) 로드에 필요한 autoawq 가 있는지."""
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
return importlib.util.find_spec("awq") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def tier_availability(tier: Tier, gpu: GpuInfo | None = None) -> tuple[bool, str]:
|
||||||
|
"""이 설치 환경에서 해당 티어를 쓸 수 있는지와, 안 되면 그 이유.
|
||||||
|
|
||||||
|
torch 유무만 보면 부족하다. 실제로 막히는 경우가 둘 더 있다.
|
||||||
|
|
||||||
|
- 4티어(AWQ Int4)는 autoawq 가 있어야 로드된다. requirements.txt 에
|
||||||
|
없는 선택 의존성이라 없을 때가 많다.
|
||||||
|
- VRAM 이 모자라면 설치가 아무리 멀쩡해도 안 올라간다. 기존에도
|
||||||
|
min_vram_gb 로 경고 배지는 띄웠지만 선택은 막지 않아서, 골랐다가
|
||||||
|
로드에서 죽었다. 예: RTX 5050(7.5GB)은 정밀 10GB / 극한 16GB 라
|
||||||
|
둘 다 영영 못 쓴다.
|
||||||
|
|
||||||
|
"사용 가능"이라고 해놓고 로드에서 죽으면 사용자는 원인을 알 수 없다.
|
||||||
|
그래서 고를 수 있다고 말하기 전에 여기서 걸러낸다.
|
||||||
|
"""
|
||||||
|
if tier.mt.backend is not MTBackend.TRANSFORMERS:
|
||||||
|
return True, ""
|
||||||
|
if not has_torch():
|
||||||
|
return False, "PyTorch가 없어 사용할 수 없습니다 (포터블 버전은 1~3티어만 지원)"
|
||||||
|
if tier.mt.compute_type == "int4" and not has_awq():
|
||||||
|
return False, "autoawq가 없어 사용할 수 없습니다 (pip install autoawq)"
|
||||||
|
|
||||||
|
info = detect_gpu() if gpu is None else gpu
|
||||||
|
# free 가 아니라 total 로 본다. free 는 다른 프로그램 때문에 수시로
|
||||||
|
# 변해서, 그걸로 막으면 "아까는 되던 티어가 지금은 안 보인다"가 된다.
|
||||||
|
have_gb = info.total_vram_mb / 1024
|
||||||
|
if info.available and have_gb < tier.min_vram_gb:
|
||||||
|
return False, (
|
||||||
|
f"VRAM이 부족합니다 "
|
||||||
|
f"({tier.min_vram_gb:g}GB 필요, 이 GPU는 {have_gb:.1f}GB)"
|
||||||
|
)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def first_available_tier(preferred: str) -> str:
|
||||||
|
"""선호 티어가 불가능하면 쓸 수 있는 가장 좋은 티어로 내린다."""
|
||||||
|
tier = get_tier(preferred)
|
||||||
|
gpu = detect_gpu()
|
||||||
|
if tier_availability(tier, gpu)[0]:
|
||||||
|
return tier.key
|
||||||
|
usable = [t for t in TIERS.values() if tier_availability(t, gpu)[0]]
|
||||||
|
if not usable:
|
||||||
|
return preferred
|
||||||
|
return max(usable, key=lambda t: t.order).key
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade_compute(compute_type: str) -> str:
|
||||||
|
"""저부하 모드에서 쓸 더 가벼운 연산 정밀도."""
|
||||||
|
if compute_type in ("float16", "float32"):
|
||||||
|
return "int8_float16"
|
||||||
|
return compute_type
|
||||||
|
|
||||||
|
|
||||||
|
class ModelManager:
|
||||||
|
"""현재 선택된 티어의 모델 두 개를 들고 있는다."""
|
||||||
|
|
||||||
|
def __init__(self, config: ModelConfig, low_power: bool = False) -> None:
|
||||||
|
self.config = config
|
||||||
|
#: 저부하(게임) 모드. 연산 정밀도를 낮춰 VRAM과 GPU 점유를 줄인다.
|
||||||
|
self.low_power = low_power
|
||||||
|
self._tier: Tier = get_tier(config.tier)
|
||||||
|
self._asr: SpeechRecognizer | None = None
|
||||||
|
self._mt: Translator | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tier(self) -> Tier:
|
||||||
|
return self._tier
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
return bool(self._asr and self._asr.loaded and self._mt and self._mt.loaded)
|
||||||
|
|
||||||
|
def effective_asr_spec(self):
|
||||||
|
"""저부하 모드가 켜져 있으면 정밀도를 낮춘 사양을 돌려준다."""
|
||||||
|
spec = self._tier.asr
|
||||||
|
if not self.low_power:
|
||||||
|
return spec
|
||||||
|
return replace(
|
||||||
|
spec, compute_type=downgrade_compute(spec.compute_type), beam_size=1
|
||||||
|
)
|
||||||
|
|
||||||
|
def effective_mt_spec(self):
|
||||||
|
spec = self._tier.mt
|
||||||
|
# 양자화 LLM(int4)은 이미 최소 상태라 더 낮출 수 없다.
|
||||||
|
if not self.low_power or spec.compute_type == "int4":
|
||||||
|
return spec
|
||||||
|
return replace(spec, compute_type=downgrade_compute(spec.compute_type))
|
||||||
|
|
||||||
|
def recognizer(self) -> SpeechRecognizer:
|
||||||
|
with self._lock:
|
||||||
|
if self._asr is None:
|
||||||
|
self._asr = SpeechRecognizer(
|
||||||
|
self.effective_asr_spec(), self.config.device, self.config.device_index
|
||||||
|
)
|
||||||
|
return self._asr
|
||||||
|
|
||||||
|
def translator(self) -> Translator:
|
||||||
|
with self._lock:
|
||||||
|
if self._mt is None:
|
||||||
|
self._mt = create_translator(
|
||||||
|
self.effective_mt_spec(),
|
||||||
|
self.config.device,
|
||||||
|
self.config.device_index,
|
||||||
|
self.config.lora_adapter_path,
|
||||||
|
)
|
||||||
|
return self._mt
|
||||||
|
|
||||||
|
def preload(self, progress=None) -> None:
|
||||||
|
"""두 모델을 미리 올린다. progress(str, float) 콜백으로 상태를 보고."""
|
||||||
|
def report(msg: str, value: float) -> None:
|
||||||
|
if progress:
|
||||||
|
progress(msg, value)
|
||||||
|
|
||||||
|
report(f"'{self._tier.name}' 음성인식 모델 준비 중…", 0.1)
|
||||||
|
self.recognizer().load()
|
||||||
|
report(f"'{self._tier.name}' 번역 모델 준비 중…", 0.6)
|
||||||
|
self.translator().load()
|
||||||
|
report("준비 완료", 1.0)
|
||||||
|
|
||||||
|
def switch_tier(self, tier_key: str) -> Tier:
|
||||||
|
"""티어 변경. 기존 모델은 내려서 VRAM을 즉시 돌려준다."""
|
||||||
|
if tier_key == self._tier.key:
|
||||||
|
return self._tier
|
||||||
|
self.unload()
|
||||||
|
self.config.tier = tier_key
|
||||||
|
self._tier = get_tier(tier_key)
|
||||||
|
log.info("모델 티어 변경: %s", self._tier.name)
|
||||||
|
return self._tier
|
||||||
|
|
||||||
|
def unload(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._asr is not None:
|
||||||
|
self._asr.unload()
|
||||||
|
self._asr = None
|
||||||
|
if self._mt is not None:
|
||||||
|
self._mt.unload()
|
||||||
|
self._mt = None
|
||||||
152
src/livesub/models/packs.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""기본 제공 게임 용어집 팩.
|
||||||
|
|
||||||
|
`resources/glossaries/*.json` 에 게임별로 묶어둔 용어를 읽어, 사용자가 켠 팩만
|
||||||
|
사용자 용어집과 합쳐 하나의 `Glossary` 로 만든다.
|
||||||
|
|
||||||
|
합칠 때 **사용자가 직접 등록한 항목이 항상 이긴다.** 기본 팩의 역어가 마음에
|
||||||
|
안 들면 용어집 화면에서 같은 단어를 등록하면 그쪽이 적용된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .glossary import Glossary, GlossaryEntry
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PACK_DIR = Path(__file__).resolve().parent.parent / "resources" / "glossaries"
|
||||||
|
|
||||||
|
|
||||||
|
#: 여러 팩을 동시에 켰을 때 같은 단어의 역어가 갈리는 것을 막기 위한 계층.
|
||||||
|
#: 숫자가 클수록 나중에 적용되어 이긴다.
|
||||||
|
SCOPE_PRIORITY = {"generic": 0, "game": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GlossaryPack:
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
entries: tuple[GlossaryEntry, ...]
|
||||||
|
#: "generic" = 장르 공통, "game" = 특정 게임 전용
|
||||||
|
scope: str = "game"
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.entries)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def priority(self) -> int:
|
||||||
|
return SCOPE_PRIORITY.get(self.scope, 1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_generic(self) -> bool:
|
||||||
|
return self.scope == "generic"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def available_packs() -> tuple[GlossaryPack, ...]:
|
||||||
|
"""번들된 팩 목록. 파일이 깨져 있으면 그 팩만 건너뛴다."""
|
||||||
|
if not PACK_DIR.is_dir():
|
||||||
|
return ()
|
||||||
|
packs: list[GlossaryPack] = []
|
||||||
|
for path in sorted(PACK_DIR.glob("*.json")):
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
log.warning("용어집 팩을 읽지 못했습니다 (%s): %s", path.name, exc)
|
||||||
|
continue
|
||||||
|
entries = tuple(
|
||||||
|
GlossaryEntry(
|
||||||
|
source=item["source"],
|
||||||
|
targets=dict(item.get("targets", {})),
|
||||||
|
note=raw.get("name", path.stem),
|
||||||
|
whole_only=bool(item.get("whole_only", False)),
|
||||||
|
)
|
||||||
|
for item in raw.get("entries", [])
|
||||||
|
if item.get("source")
|
||||||
|
)
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
packs.append(
|
||||||
|
GlossaryPack(
|
||||||
|
key=raw.get("key", path.stem),
|
||||||
|
name=raw.get("name", path.stem),
|
||||||
|
description=raw.get("description", ""),
|
||||||
|
entries=entries,
|
||||||
|
scope=raw.get("scope", "game"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(packs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pack(key: str) -> GlossaryPack | None:
|
||||||
|
for pack in available_packs():
|
||||||
|
if pack.key == key:
|
||||||
|
return pack
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_packs(enabled_packs: list[str]) -> list[GlossaryPack]:
|
||||||
|
"""켜둔 팩을 적용 순서대로 돌려준다 (범용 먼저, 게임 전용 나중).
|
||||||
|
|
||||||
|
`knocked` 는 FPS 공통에서 "기절", 배그에서 "눕혔다"다. 어느 쪽이 맞는지는
|
||||||
|
지금 무슨 게임을 하느냐로 갈리므로, **게임 전용 팩이 항상 범용을 이겨야**
|
||||||
|
한다. 그렇지 않으면 체크박스를 누른 순서에 따라 결과가 달라진다.
|
||||||
|
"""
|
||||||
|
packs: list[GlossaryPack] = []
|
||||||
|
for key in enabled_packs:
|
||||||
|
pack = get_pack(key)
|
||||||
|
if pack is None:
|
||||||
|
log.warning("알 수 없는 용어집 팩입니다: %s", key)
|
||||||
|
continue
|
||||||
|
packs.append(pack)
|
||||||
|
# 같은 계층 안에서는 사용자가 켠 순서를 유지한다 (stable sort).
|
||||||
|
return sorted(packs, key=lambda p: p.priority)
|
||||||
|
|
||||||
|
|
||||||
|
def find_conflicts(enabled_packs: list[str], lang: str = "ko") -> dict[str, list[tuple[str, str]]]:
|
||||||
|
"""켜둔 게임 전용 팩끼리 역어가 갈리는 단어를 찾는다.
|
||||||
|
|
||||||
|
범용 팩은 게임 팩에 확실히 지므로 충돌로 보지 않는다. 게임 팩 둘 이상이
|
||||||
|
같은 단어를 다르게 번역하면 그건 사용자가 판단해야 할 문제라 UI로 알린다.
|
||||||
|
"""
|
||||||
|
seen: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
for pack in resolve_packs(enabled_packs):
|
||||||
|
if pack.is_generic:
|
||||||
|
continue
|
||||||
|
for entry in pack.entries:
|
||||||
|
target = entry.target_for(lang)
|
||||||
|
if not target:
|
||||||
|
continue
|
||||||
|
seen.setdefault(entry.source.casefold(), []).append((pack.name, target))
|
||||||
|
return {
|
||||||
|
source: owners
|
||||||
|
for source, owners in seen.items()
|
||||||
|
if len({t for _, t in owners}) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_glossary(
|
||||||
|
user_glossary: Glossary,
|
||||||
|
enabled_packs: list[str],
|
||||||
|
case_sensitive: bool = False,
|
||||||
|
) -> Glossary:
|
||||||
|
"""켜둔 팩 + 사용자 용어집을 하나로 합친다.
|
||||||
|
|
||||||
|
적용 순서: 범용 팩 → 게임 전용 팩 → 사용자 항목.
|
||||||
|
뒤에 오는 쪽이 이기므로 사용자가 직접 등록한 것이 항상 최우선이다.
|
||||||
|
"""
|
||||||
|
merged: list[GlossaryEntry] = []
|
||||||
|
for pack in resolve_packs(enabled_packs):
|
||||||
|
merged.extend(pack.entries)
|
||||||
|
merged.extend(user_glossary.entries)
|
||||||
|
|
||||||
|
combined = Glossary(case_sensitive=case_sensitive)
|
||||||
|
for entry in merged:
|
||||||
|
combined.add(entry) # add() 가 같은 원문을 교체해준다
|
||||||
|
return combined
|
||||||
310
src/livesub/models/speech_level.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
"""말투(존댓말/반말) 처리.
|
||||||
|
|
||||||
|
규칙은 두 줄로 요약된다.
|
||||||
|
|
||||||
|
1. 원문이 **실제로 존댓말**이면 반말 모드여도 존댓말을 지킨다.
|
||||||
|
상대가 정중하게 말했는데 자막이 반말로 나오면 뉘앙스가 통째로 뒤집힌다.
|
||||||
|
2. 원문에 높임이 **없거나 알 수 없으면** 사용자가 고른 모드를 따른다.
|
||||||
|
영어·중국어는 문법적 높임이 없으므로 항상 여기에 해당한다.
|
||||||
|
|
||||||
|
한국어로 번역할 때만 동작한다. 영어·중국어는 대상 언어일 때 적용할 문법이
|
||||||
|
없고, 일본어 경어 변환은 동사 활용이 필요해 규칙만으로는 위험하다.
|
||||||
|
|
||||||
|
변환 방향도 한쪽만 한다. 번역 모델의 한국어 출력은 거의 항상 격식체
|
||||||
|
(합니다체/해요체)이므로 **존댓말 모드는 손대지 않고**, 반말 모드일 때만
|
||||||
|
낮춤 변환을 건다. 반대 방향(반말→존댓말)은 어간 정보가 없으면 훨씬 자주
|
||||||
|
틀리므로 시도하지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechLevel(str, Enum):
|
||||||
|
"""사용자가 고르는 출력 말투."""
|
||||||
|
|
||||||
|
POLITE = "polite" # 존댓말 (기본)
|
||||||
|
CASUAL = "casual" # 반말
|
||||||
|
|
||||||
|
|
||||||
|
class Politeness(str, Enum):
|
||||||
|
"""원문에서 감지한 높임 정도."""
|
||||||
|
|
||||||
|
UNKNOWN = "unknown" # 높임이 없는 언어이거나 판단 불가
|
||||||
|
POLITE = "polite" # 확실히 존댓말
|
||||||
|
CASUAL = "casual" # 확실히 반말
|
||||||
|
|
||||||
|
|
||||||
|
#: 문법적 높임 체계가 있는 언어. 나머지는 항상 UNKNOWN 이다.
|
||||||
|
HONORIFIC_LANGUAGES = frozenset({"ko", "ja"})
|
||||||
|
|
||||||
|
# --- 원문 높임 감지 ---------------------------------------------------------
|
||||||
|
|
||||||
|
_KO_POLITE = re.compile(
|
||||||
|
r"(습니다|습니까|ㅂ니다|ㅂ니까|십시오|십시요|세요|셔요|시죠"
|
||||||
|
r"|어요|아요|에요|예요|해요|네요|지요|죠|드려|드립|주세|감사합)"
|
||||||
|
)
|
||||||
|
_JA_POLITE = re.compile(
|
||||||
|
r"(です|ます|ません|でした|ました|ましょう|ください|下さい"
|
||||||
|
r"|ございま|でしょう|いたし|申し上げ|なさい)"
|
||||||
|
)
|
||||||
|
|
||||||
|
_HAS_HANGUL = re.compile(r"[가-힣]")
|
||||||
|
_HAS_JAPANESE = re.compile(r"[-ヿ一-鿿]")
|
||||||
|
|
||||||
|
|
||||||
|
def detect_politeness(text: str, lang: str) -> Politeness:
|
||||||
|
"""원문의 높임 정도를 판단한다.
|
||||||
|
|
||||||
|
높임 체계가 없는 언어(영어·중국어)는 항상 UNKNOWN 이다. "please" 같은
|
||||||
|
공손 표현은 문법적 높임이 아니라 어조라, 이걸로 존댓말을 확정하면
|
||||||
|
오탐이 너무 많아진다.
|
||||||
|
|
||||||
|
한국어·일본어는 존댓말 표지가 있으면 POLITE, 없으면 CASUAL 로 본다.
|
||||||
|
두 언어 모두 문장은 둘 중 하나로 끝나므로 "중립"이라는 선택지가 사실상
|
||||||
|
없고, 어미를 일일이 열거하는 것보다 이 편이 덜 틀린다.
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text or lang not in HONORIFIC_LANGUAGES:
|
||||||
|
return Politeness.UNKNOWN
|
||||||
|
|
||||||
|
polite, script = (
|
||||||
|
(_KO_POLITE, _HAS_HANGUL) if lang == "ko" else (_JA_POLITE, _HAS_JAPANESE)
|
||||||
|
)
|
||||||
|
if polite.search(text):
|
||||||
|
return Politeness.POLITE
|
||||||
|
if script.search(text):
|
||||||
|
return Politeness.CASUAL
|
||||||
|
return Politeness.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
# --- 한국어 낮춤 변환 -------------------------------------------------------
|
||||||
|
|
||||||
|
_HANGUL_BASE = 0xAC00
|
||||||
|
_JONGSUNG_COUNT = 28
|
||||||
|
_JUNGSUNG_COUNT = 21
|
||||||
|
_HANGUL_COUNT = 11172
|
||||||
|
|
||||||
|
# 중성 인덱스
|
||||||
|
_A, _AE, _EO, _E, _YEO, _O, _WA, _U, _WO, _EU, _I = 0, 1, 4, 5, 6, 8, 9, 13, 14, 18, 20
|
||||||
|
#: '아'로 이어지는 밝은 모음 (모음조화)
|
||||||
|
_BRIGHT = frozenset({_A, _O, 2, _WA})
|
||||||
|
#: 받침 없는 어간 + 어/아 의 축약. 오+아 -> 와, 지+어 -> 져
|
||||||
|
_CONTRACTION = {_O: _WA, _U: _WO, _I: _YEO, _EU: _EO}
|
||||||
|
#: 붙여도 모양이 그대로인 어간 모음. 가+아 -> 가, 서+어 -> 서
|
||||||
|
_ABSORBING = frozenset({_A, _EO, _AE, _E, _YEO, _WA, _WO})
|
||||||
|
_RIEUL = 8 # 종성 ㄹ
|
||||||
|
_SSANG_SIOT = 20 # 종성 ㅆ — 과거형 '았/었' 판별용
|
||||||
|
|
||||||
|
|
||||||
|
def _decompose(char: str) -> tuple[int, int, int] | None:
|
||||||
|
code = ord(char) - _HANGUL_BASE
|
||||||
|
if not (0 <= code < _HANGUL_COUNT):
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
code // (_JUNGSUNG_COUNT * _JONGSUNG_COUNT),
|
||||||
|
(code // _JONGSUNG_COUNT) % _JUNGSUNG_COUNT,
|
||||||
|
code % _JONGSUNG_COUNT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compose(cho: int, jung: int, jong: int) -> str:
|
||||||
|
return chr(
|
||||||
|
_HANGUL_BASE + (cho * _JUNGSUNG_COUNT + jung) * _JONGSUNG_COUNT + jong
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_eo(stem: str) -> str:
|
||||||
|
"""어간에 어/아 를 붙인다 (해체 활용).
|
||||||
|
|
||||||
|
먹->먹어, 좋->좋아, 하->해, 오->와, 주->줘, 빠지->빠져, 가->가.
|
||||||
|
한국어 활용의 실제 규칙(모음조화 + 축약)을 그대로 구현한 것이라
|
||||||
|
어미 목록을 나열하는 방식보다 훨씬 넓게 맞는다.
|
||||||
|
"""
|
||||||
|
if not stem:
|
||||||
|
return stem
|
||||||
|
parts = _decompose(stem[-1])
|
||||||
|
if parts is None:
|
||||||
|
return stem + "어"
|
||||||
|
cho, jung, jong = parts
|
||||||
|
|
||||||
|
if jong == _SSANG_SIOT:
|
||||||
|
# 과거형 '았/었/였' 뒤에는 모음조화와 무관하게 항상 '어'가 온다.
|
||||||
|
# (남았아 X -> 남았어 O)
|
||||||
|
return stem + "어"
|
||||||
|
if jong: # 받침이 있으면 그냥 붙인다
|
||||||
|
return stem + ("아" if jung in _BRIGHT else "어")
|
||||||
|
if cho == 18 and jung == _A: # 하 -> 해
|
||||||
|
return stem[:-1] + _compose(cho, _AE, 0)
|
||||||
|
if jung in _CONTRACTION: # 오+아 -> 와, 지+어 -> 져
|
||||||
|
return stem[:-1] + _compose(cho, _CONTRACTION[jung], 0)
|
||||||
|
if jung in _ABSORBING: # 가+아 -> 가
|
||||||
|
return stem
|
||||||
|
return stem + ("아" if jung in _BRIGHT else "어")
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_lge(stem: str) -> str:
|
||||||
|
"""'~겠습니다' -> '~ㄹ게'. 치->칠게, 가->갈게, 먹->먹을게."""
|
||||||
|
if not stem:
|
||||||
|
return stem
|
||||||
|
parts = _decompose(stem[-1])
|
||||||
|
if parts is None:
|
||||||
|
return stem + "을게"
|
||||||
|
cho, jung, jong = parts
|
||||||
|
if jong:
|
||||||
|
return stem + "을게"
|
||||||
|
return stem[:-1] + _compose(cho, jung, _RIEUL) + "게"
|
||||||
|
|
||||||
|
|
||||||
|
#: 받침 없는 어간 + ㅂ니다 축약형. 규칙으로 풀기 어려운 것만 표로 둔다.
|
||||||
|
_CONTRACTED = {
|
||||||
|
"입니다": "야",
|
||||||
|
"됩니다": "돼",
|
||||||
|
"드립니다": "줄게",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: (패턴, 치환) — 긴 어미부터 적용해야 짧은 어미가 먼저 먹지 않는다.
|
||||||
|
_CASUAL_RULES: list[tuple[re.Pattern[str], str]] = [
|
||||||
|
(re.compile(r"있습니다"), "있어"),
|
||||||
|
(re.compile(r"없습니다"), "없어"),
|
||||||
|
(re.compile(r"이에요"), "이야"),
|
||||||
|
(re.compile(r"예요"), "야"),
|
||||||
|
(re.compile(r"네요"), "네"),
|
||||||
|
(re.compile(r"지요"), "지"),
|
||||||
|
(re.compile(r"죠"), "지"),
|
||||||
|
]
|
||||||
|
|
||||||
|
#: 어간을 활용해야 하는 어미들. 앞 글자를 잡아 _attach_* 로 넘긴다.
|
||||||
|
_GESSEUMNIDA = re.compile(r"([가-힣])겠습니다")
|
||||||
|
_SEUMNIDA = re.compile(r"([가-힣])습니다")
|
||||||
|
_BNIDA = re.compile(r"([가-힣])ᆸ니다|([가-힣])ㅂ니다")
|
||||||
|
_SEYO = re.compile(r"([가-힣])세요")
|
||||||
|
_SEUMNIKKA = re.compile(r"([가-힣])습니까")
|
||||||
|
#: 명령형 존대 '~십시오' 도 어간을 활용해야 한다. 조심하십시오 -> 조심해
|
||||||
|
_SIPSIO = re.compile(r"([가-힣])십시[오요]")
|
||||||
|
|
||||||
|
#: 문장 끝의 '요'만 떼어낸다. 문장 중간의 '요'(중요, 필요)는 건드리면 안 된다.
|
||||||
|
_TRAILING_YO = re.compile(r"([가-힣])요(\s*[.!?…,~]*)\s*$")
|
||||||
|
_TRAILING_YO_MID = re.compile(r"([가-힣])요(\s*[.!?…,~]+\s+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_bnida(text: str) -> str:
|
||||||
|
"""'갑니다' 처럼 어간에 ㅂ 이 합쳐진 형태를 푼다. 갑니다 -> 가 + 어."""
|
||||||
|
|
||||||
|
def _sub(match: re.Match[str]) -> str:
|
||||||
|
syllable = match.group(1)
|
||||||
|
parts = _decompose(syllable)
|
||||||
|
if parts is None:
|
||||||
|
return match.group(0)
|
||||||
|
cho, jung, jong = parts
|
||||||
|
if jong != 17: # ㅂ 받침이 아니면 대상이 아니다
|
||||||
|
return match.group(0)
|
||||||
|
return _attach_eo(_compose(cho, jung, 0))
|
||||||
|
|
||||||
|
return re.sub(r"([가-힣])니다", _sub, text)
|
||||||
|
|
||||||
|
|
||||||
|
def to_casual(text: str) -> str:
|
||||||
|
"""한국어 존댓말 문장을 반말로 낮춘다.
|
||||||
|
|
||||||
|
번역 모델이 내놓는 격식체 어미를 대상으로 한 규칙 기반 변환이라
|
||||||
|
100%는 아니다. 다만 게임 자막에서 실제로 나오는 어미는 종류가
|
||||||
|
많지 않고, 활용은 실제 문법 규칙으로 처리하므로 대부분을 덮는다.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
out = text
|
||||||
|
for source, target in _CONTRACTED.items():
|
||||||
|
out = out.replace(source, target)
|
||||||
|
|
||||||
|
out = _GESSEUMNIDA.sub(lambda m: _attach_lge(m.group(1)), out)
|
||||||
|
out = _SIPSIO.sub(lambda m: _attach_eo(m.group(1)), out)
|
||||||
|
for pattern, target in _CASUAL_RULES:
|
||||||
|
out = pattern.sub(target, out)
|
||||||
|
out = _SEUMNIKKA.sub(lambda m: _attach_eo(m.group(1)) + "?", out)
|
||||||
|
out = _SEUMNIDA.sub(lambda m: _attach_eo(m.group(1)), out)
|
||||||
|
out = _SEYO.sub(lambda m: _attach_eo(m.group(1)), out)
|
||||||
|
out = _split_bnida(out)
|
||||||
|
|
||||||
|
# 문장 끝 '요' 떼기 — 문장 중간의 '중요/필요'가 다치면 안 된다.
|
||||||
|
out = _TRAILING_YO_MID.sub(r"\1\2", out)
|
||||||
|
out = _TRAILING_YO.sub(r"\1\2", out)
|
||||||
|
return out.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_speech_level(
|
||||||
|
translated: str,
|
||||||
|
target_lang: str,
|
||||||
|
level: SpeechLevel,
|
||||||
|
source_politeness: Politeness = Politeness.UNKNOWN,
|
||||||
|
) -> str:
|
||||||
|
"""번역 결과에 말투 설정을 적용한다.
|
||||||
|
|
||||||
|
존댓말 모드는 아무것도 하지 않는다. 번역 모델의 한국어 출력이 이미
|
||||||
|
격식체이고, 한국어에서 존댓말은 어떤 상황에서도 틀린 선택이 아니다.
|
||||||
|
"""
|
||||||
|
if target_lang != "ko" or level is not SpeechLevel.CASUAL:
|
||||||
|
return translated
|
||||||
|
# 사용자 규칙: 원문이 진짜 존댓말이면 반말 모드여도 존댓말을 지킨다.
|
||||||
|
if source_politeness is Politeness.POLITE:
|
||||||
|
return translated
|
||||||
|
return to_casual(translated)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_instruction(level: SpeechLevel, source_politeness: Politeness) -> str:
|
||||||
|
"""지시문을 이해하는 LLM(Qwen3 등)에 넣을 말투 지시.
|
||||||
|
|
||||||
|
Seed-X 처럼 지시문을 못 알아듣는 번역 전용 모델에는 쓰지 않는다.
|
||||||
|
그쪽은 `apply_speech_level` 후처리로만 말투를 맞춘다.
|
||||||
|
"""
|
||||||
|
if source_politeness is Politeness.POLITE:
|
||||||
|
return "The speaker is being polite, so use Korean polite speech (존댓말)."
|
||||||
|
if level is SpeechLevel.CASUAL:
|
||||||
|
return "Use casual Korean speech (반말), the way friends talk while gaming."
|
||||||
|
return "Use polite Korean speech (존댓말)."
|
||||||
|
|
||||||
|
|
||||||
|
# --- 조사 교정 --------------------------------------------------------------
|
||||||
|
|
||||||
|
#: 앞말의 받침 유무로 갈리는 조사 쌍 (받침 있음, 받침 없음)
|
||||||
|
_PARTICLE_PAIRS = [("을", "를"), ("이", "가"), ("은", "는"), ("과", "와"), ("으로", "로")]
|
||||||
|
_PARTICLE_RE = re.compile(
|
||||||
|
r"([가-힣])(" + "|".join(sorted(
|
||||||
|
{p for pair in _PARTICLE_PAIRS for p in pair}, key=len, reverse=True
|
||||||
|
)) + r")(?=\s|$|[.!?,…~])"
|
||||||
|
)
|
||||||
|
_PARTICLE_LOOKUP = {}
|
||||||
|
for _with, _without in _PARTICLE_PAIRS:
|
||||||
|
_PARTICLE_LOOKUP[_with] = (_with, _without)
|
||||||
|
_PARTICLE_LOOKUP[_without] = (_with, _without)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_particles(text: str) -> str:
|
||||||
|
"""앞말 받침에 맞게 조사를 고친다. 자기장를 -> 자기장을.
|
||||||
|
|
||||||
|
용어집이 단어를 통째로 바꿔치기하기 때문에 생기는 문제다. 번역 모델은
|
||||||
|
자리표시자를 기준으로 조사를 골랐는데, 복원된 역어의 받침이 그와 다르면
|
||||||
|
"자기장를" 같은 어긋남이 남는다. 받침은 한글 코드에서 바로 읽을 수 있어
|
||||||
|
규칙만으로 정확히 고칠 수 있다.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _sub(match: re.Match[str]) -> str:
|
||||||
|
head, particle = match.group(1), match.group(2)
|
||||||
|
pair = _PARTICLE_LOOKUP.get(particle)
|
||||||
|
if pair is None:
|
||||||
|
return match.group(0)
|
||||||
|
parts = _decompose(head)
|
||||||
|
if parts is None:
|
||||||
|
return match.group(0)
|
||||||
|
has_batchim = parts[2] != 0
|
||||||
|
# '으로/로' 는 ㄹ 받침이면 '로'를 쓴다 (서울로, 칼로).
|
||||||
|
if pair == ("으로", "로") and parts[2] == _RIEUL:
|
||||||
|
return head + "로"
|
||||||
|
return head + (pair[0] if has_batchim else pair[1])
|
||||||
|
|
||||||
|
return _PARTICLE_RE.sub(_sub, text)
|
||||||
209
src/livesub/models/tiers.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""모델 품질 티어 정의.
|
||||||
|
|
||||||
|
사용자에게는 실제 모델 이름 대신 5단계 티어(속도우선 → 품질우선)로 노출한다.
|
||||||
|
각 티어는 ASR(음성인식) 1개 + MT(번역) 1개의 조합이다.
|
||||||
|
|
||||||
|
선정 근거는 docs/MODELS.md 참고.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from ..constants import DEFAULT_TIER
|
||||||
|
|
||||||
|
|
||||||
|
class MTBackend(str, Enum):
|
||||||
|
"""번역 모델 실행 백엔드."""
|
||||||
|
|
||||||
|
CTRANSLATE2 = "ctranslate2" # NLLB 계열 seq2seq
|
||||||
|
TRANSFORMERS = "transformers" # LLM 계열 (Seed-X / Qwen3)
|
||||||
|
|
||||||
|
|
||||||
|
class PromptStyle(str, Enum):
|
||||||
|
"""LLM 백엔드의 프롬프트 형식. 모델 계열마다 요구사항이 다르다."""
|
||||||
|
|
||||||
|
#: 해당 없음 (seq2seq)
|
||||||
|
NONE = "none"
|
||||||
|
#: Seed-X 전용. chat template 이 없는 번역 전용 completion 모델이라
|
||||||
|
#: 모델 카드에 적힌 고정 형식 + 끝의 `<언어코드>` 태그를 그대로 지켜야 한다.
|
||||||
|
SEEDX = "seedx"
|
||||||
|
#: Qwen3 등 일반 instruct 모델. chat template + 자유 지시문 사용 가능.
|
||||||
|
INSTRUCT = "instruct"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AsrSpec:
|
||||||
|
repo: str
|
||||||
|
compute_type: str
|
||||||
|
beam_size: int = 1
|
||||||
|
vram_mb: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MTSpec:
|
||||||
|
repo: str
|
||||||
|
backend: MTBackend
|
||||||
|
compute_type: str = "float16"
|
||||||
|
vram_mb: int = 0
|
||||||
|
prompt_style: PromptStyle = PromptStyle.NONE
|
||||||
|
#: LoRA 추가학습 난이도 1(쉬움) ~ 3(까다로움)
|
||||||
|
finetune_ease: int = 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_prompt_glossary(self) -> bool:
|
||||||
|
"""프롬프트로 용어집을 지시할 수 있는가.
|
||||||
|
|
||||||
|
지시문을 이해하는 instruct 모델만 가능하다. Seed-X 는 번역만 하도록
|
||||||
|
학습된 completion 모델이라 "이 용어는 이렇게 옮겨라" 같은 문장을
|
||||||
|
넣으면 학습 분포를 벗어나 오히려 번역이 망가진다. seq2seq 도 마찬가지다.
|
||||||
|
이 둘은 플레이스홀더 치환 방식으로 용어를 보호한다.
|
||||||
|
"""
|
||||||
|
return self.prompt_style is PromptStyle.INSTRUCT
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Tier:
|
||||||
|
key: str
|
||||||
|
order: int
|
||||||
|
name: str
|
||||||
|
tagline: str
|
||||||
|
asr: AsrSpec
|
||||||
|
mt: MTSpec
|
||||||
|
approx_latency_s: float
|
||||||
|
min_vram_gb: float
|
||||||
|
notes: str = ""
|
||||||
|
recommended: bool = False
|
||||||
|
best_after_finetune: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_vram_mb(self) -> int:
|
||||||
|
return self.asr.vram_mb + self.mt.vram_mb
|
||||||
|
|
||||||
|
@property
|
||||||
|
def download_gb(self) -> float:
|
||||||
|
return round(self.total_vram_mb / 1024 * 1.25, 1)
|
||||||
|
|
||||||
|
|
||||||
|
TIERS: dict[str, Tier] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _register(tier: Tier) -> Tier:
|
||||||
|
TIERS[tier.key] = tier
|
||||||
|
return tier
|
||||||
|
|
||||||
|
|
||||||
|
LIGHTNING = _register(
|
||||||
|
Tier(
|
||||||
|
key="lightning",
|
||||||
|
order=1,
|
||||||
|
name="번개",
|
||||||
|
tagline="속도 최우선 · 저사양 GPU",
|
||||||
|
asr=AsrSpec("Systran/faster-whisper-small", "int8_float16", 1, 700),
|
||||||
|
mt=MTSpec(
|
||||||
|
"entai2965/nllb-200-distilled-600M-ctranslate2",
|
||||||
|
MTBackend.CTRANSLATE2,
|
||||||
|
"int8_float16",
|
||||||
|
800,
|
||||||
|
finetune_ease=1,
|
||||||
|
),
|
||||||
|
approx_latency_s=0.6,
|
||||||
|
min_vram_gb=2,
|
||||||
|
notes="짧은 대사·게임 음성에 충분. 긴 문장이나 전문용어는 품질이 떨어질 수 있습니다.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
SWIFT = _register(
|
||||||
|
Tier(
|
||||||
|
key="swift",
|
||||||
|
order=2,
|
||||||
|
name="신속",
|
||||||
|
tagline="빠르면서 인식률은 최상급",
|
||||||
|
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "int8_float16", 1, 1600),
|
||||||
|
mt=MTSpec(
|
||||||
|
"entai2965/nllb-200-distilled-600M-ctranslate2",
|
||||||
|
MTBackend.CTRANSLATE2,
|
||||||
|
"float16",
|
||||||
|
1400,
|
||||||
|
finetune_ease=1,
|
||||||
|
),
|
||||||
|
approx_latency_s=0.9,
|
||||||
|
min_vram_gb=4,
|
||||||
|
notes="음성인식을 최상급으로 올리고 번역만 경량으로 유지한 구성. 가성비가 가장 좋습니다.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
BALANCE = _register(
|
||||||
|
Tier(
|
||||||
|
key="balance",
|
||||||
|
order=3,
|
||||||
|
name="균형",
|
||||||
|
tagline="기본값 · 속도와 품질의 중간",
|
||||||
|
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "float16", 2, 2400),
|
||||||
|
mt=MTSpec(
|
||||||
|
"entai2965/nllb-200-distilled-1.3B-ctranslate2",
|
||||||
|
MTBackend.CTRANSLATE2,
|
||||||
|
"float16",
|
||||||
|
2900,
|
||||||
|
finetune_ease=2,
|
||||||
|
),
|
||||||
|
approx_latency_s=1.2,
|
||||||
|
min_vram_gb=6,
|
||||||
|
notes="8GB VRAM 환경에서 가장 무난한 선택.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
PRECISION = _register(
|
||||||
|
Tier(
|
||||||
|
key="precision",
|
||||||
|
order=4,
|
||||||
|
name="정밀",
|
||||||
|
tagline="추천 · 현 시점 최고 번역 품질",
|
||||||
|
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "float16", 2, 2400),
|
||||||
|
mt=MTSpec(
|
||||||
|
"ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4",
|
||||||
|
MTBackend.TRANSFORMERS,
|
||||||
|
"int4",
|
||||||
|
5600,
|
||||||
|
prompt_style=PromptStyle.SEEDX,
|
||||||
|
finetune_ease=3,
|
||||||
|
),
|
||||||
|
approx_latency_s=2.0,
|
||||||
|
min_vram_gb=10,
|
||||||
|
notes="번역 전용으로 학습된 7B 모델. 한/영/일/중 구어체와 문맥 처리가 확연히 좋습니다.",
|
||||||
|
recommended=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ULTIMATE = _register(
|
||||||
|
Tier(
|
||||||
|
key="ultimate",
|
||||||
|
order=5,
|
||||||
|
name="극한",
|
||||||
|
tagline="품질 최우선 · 추가학습(LoRA)에 최적",
|
||||||
|
asr=AsrSpec("Systran/faster-whisper-large-v3", "float16", 5, 3100),
|
||||||
|
mt=MTSpec(
|
||||||
|
"Qwen/Qwen3-8B",
|
||||||
|
MTBackend.TRANSFORMERS,
|
||||||
|
"float16",
|
||||||
|
11000,
|
||||||
|
prompt_style=PromptStyle.INSTRUCT,
|
||||||
|
finetune_ease=1,
|
||||||
|
),
|
||||||
|
approx_latency_s=3.0,
|
||||||
|
min_vram_gb=16,
|
||||||
|
notes="게임·방송 용어를 LoRA로 추가학습해 붙일 때 가장 잘 먹히는 구성입니다.",
|
||||||
|
best_after_finetune=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert DEFAULT_TIER in TIERS, f"constants.DEFAULT_TIER({DEFAULT_TIER}) 가 티어 목록에 없습니다"
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_tiers() -> list[Tier]:
|
||||||
|
return sorted(TIERS.values(), key=lambda t: t.order)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tier(key: str) -> Tier:
|
||||||
|
return TIERS.get(key, TIERS[DEFAULT_TIER])
|
||||||
376
src/livesub/models/translator.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
"""번역(MT) 래퍼.
|
||||||
|
|
||||||
|
두 백엔드를 지원한다.
|
||||||
|
|
||||||
|
* CTranslate2 — NLLB 계열 seq2seq. 가볍고 빠르며 추가학습이 쉽다.
|
||||||
|
* Transformers — Seed-X / Qwen3 같은 LLM. 품질이 좋고 LoRA 어댑터를 얹을 수 있다.
|
||||||
|
|
||||||
|
LLM 백엔드는 다시 두 가지 프롬프트 형식으로 갈린다. 같은 "LLM"이라도 요구사항이
|
||||||
|
정반대라 하나로 합치면 한쪽이 반드시 망가진다.
|
||||||
|
|
||||||
|
* PromptStyle.SEEDX — Seed-X 는 chat template 이 없는 **번역 전용 completion 모델**이다.
|
||||||
|
모델 카드가 정한 고정 문장과 끝의 `<언어코드>` 태그를 그대로 지켜야 하고,
|
||||||
|
그 밖의 지시문을 끼워 넣으면 학습 분포를 벗어난다. 따라서 용어집은 프롬프트가
|
||||||
|
아니라 **플레이스홀더 치환**으로 넣는다.
|
||||||
|
* PromptStyle.INSTRUCT — Qwen3 등. chat template 을 쓰고 용어집을 지시문으로 넣는다.
|
||||||
|
|
||||||
|
세 경우 모두 `Translator` 인터페이스를 따르므로 상위 레이어는 구분하지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from ..constants import LANGUAGES, models_dir
|
||||||
|
from .glossary import Glossary
|
||||||
|
from .speech_level import Politeness, SpeechLevel, fix_particles, prompt_instruction
|
||||||
|
from .tiers import MTBackend, MTSpec, PromptStyle
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: 문장 끝에 LLM이 붙이곤 하는 군더더기를 걷어내기 위한 패턴
|
||||||
|
_TRAILING_NOISE = re.compile(r"^\s*(?:번역[::]|Translation[::])\s*", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_or_retranslate(out: str, replacements: list[str], retry) -> str:
|
||||||
|
"""자리표시자를 역어로 되돌린다. 하나라도 사라졌으면 용어집 없이 다시 번역.
|
||||||
|
|
||||||
|
번역 모델이 문장 일부를 통째로 누락하면 그 안의 자리표시자도 같이
|
||||||
|
사라진다. 그대로 복원하면 용어가 자막에서 증발하는데, 그건 용어집을
|
||||||
|
아예 안 쓴 것보다 나쁘다. 그래서 그럴 때는 보호 없이 한 번 더 번역해
|
||||||
|
원문 내용이 살아 있는 결과를 쓴다.
|
||||||
|
"""
|
||||||
|
missing = Glossary.missing_placeholders(out, replacements)
|
||||||
|
if not missing:
|
||||||
|
return Glossary.restore(out, replacements)
|
||||||
|
log.debug("자리표시자 %s 소실 — 용어집 없이 재번역", missing)
|
||||||
|
return retry()
|
||||||
|
|
||||||
|
|
||||||
|
def build_seedx_prompt(text: str, source_lang: str, target_lang: str) -> str:
|
||||||
|
"""Seed-X 모델 카드가 요구하는 정확한 프롬프트.
|
||||||
|
|
||||||
|
Translate the following English sentence into Chinese:
|
||||||
|
May the force be with you <zh>
|
||||||
|
|
||||||
|
끝의 `<언어코드>` 태그는 PPO 학습에 쓰인 것이라 **필수**다. 빠지면 번역
|
||||||
|
품질이 크게 흔들린다. 형식을 바꾸면 안 되므로 지시문이나 용어집 설명을
|
||||||
|
여기 끼워 넣지 말 것.
|
||||||
|
|
||||||
|
출처: https://huggingface.co/ByteDance-Seed/Seed-X-PPO-7B
|
||||||
|
"""
|
||||||
|
src = LANGUAGES[source_lang]["english"]
|
||||||
|
tgt = LANGUAGES[target_lang]["english"]
|
||||||
|
tag = LANGUAGES[target_lang]["seedx"]
|
||||||
|
return f"Translate the following {src} sentence into {tgt}:\n{text} <{tag}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Translator(abc.ABC):
|
||||||
|
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0) -> None:
|
||||||
|
self.spec = spec
|
||||||
|
self.device = device
|
||||||
|
self.device_index = device_index
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def loaded(self) -> bool: ...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def load(self) -> None: ...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def unload(self) -> None: ...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def _translate(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
source_lang: str,
|
||||||
|
target_lang: str,
|
||||||
|
glossary: Glossary | None,
|
||||||
|
speech_level: SpeechLevel,
|
||||||
|
source_politeness: Politeness,
|
||||||
|
) -> str: ...
|
||||||
|
|
||||||
|
def translate(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
source_lang: str,
|
||||||
|
target_lang: str,
|
||||||
|
glossary: Glossary | None = None,
|
||||||
|
speech_level: SpeechLevel = SpeechLevel.POLITE,
|
||||||
|
source_politeness: Politeness = Politeness.UNKNOWN,
|
||||||
|
) -> str:
|
||||||
|
"""말투(speech_level)는 지시문을 이해하는 모델에만 프롬프트로 전달된다.
|
||||||
|
|
||||||
|
나머지 백엔드는 상위에서 `apply_speech_level` 후처리로 맞춘다.
|
||||||
|
"""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if source_lang == target_lang:
|
||||||
|
return text
|
||||||
|
out = self._translate(
|
||||||
|
text, source_lang, target_lang, glossary, speech_level, source_politeness
|
||||||
|
)
|
||||||
|
out = _TRAILING_NOISE.sub("", out).strip()
|
||||||
|
if target_lang == "ko":
|
||||||
|
# 용어집이 단어를 바꿔치기하면 앞말 받침이 달라져 조사가 어긋난다.
|
||||||
|
# (자기장를 -> 자기장을)
|
||||||
|
out = fix_particles(out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class CTranslate2Translator(Translator):
|
||||||
|
"""NLLB 계열. 용어집은 플레이스홀더 보호 방식으로 적용한다."""
|
||||||
|
|
||||||
|
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0) -> None:
|
||||||
|
super().__init__(spec, device, device_index)
|
||||||
|
self._model = None
|
||||||
|
self._tokenizer = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loaded(self) -> bool:
|
||||||
|
return self._model is not None
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
if self._model is not None:
|
||||||
|
return
|
||||||
|
import ctranslate2
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
from transformers import AutoTokenizer
|
||||||
|
|
||||||
|
local = snapshot_download(self.spec.repo, cache_dir=str(models_dir()))
|
||||||
|
compute = self.spec.compute_type
|
||||||
|
if self.device == "cpu" and "float16" in compute:
|
||||||
|
compute = "int8"
|
||||||
|
log.info("번역 모델 로드: %s (%s/%s)", self.spec.repo, self.device, compute)
|
||||||
|
self._model = ctranslate2.Translator(
|
||||||
|
local,
|
||||||
|
device=self.device,
|
||||||
|
device_index=self.device_index,
|
||||||
|
compute_type=compute,
|
||||||
|
)
|
||||||
|
self._tokenizer = AutoTokenizer.from_pretrained(local, cache_dir=str(models_dir()))
|
||||||
|
|
||||||
|
def unload(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._model = None
|
||||||
|
self._tokenizer = None
|
||||||
|
|
||||||
|
def _run_model(self, text: str, source_lang: str, target_lang: str) -> str:
|
||||||
|
"""용어집 처리를 뺀 순수 번역 한 번."""
|
||||||
|
src_code = LANGUAGES[source_lang]["nllb"]
|
||||||
|
tgt_code = LANGUAGES[target_lang]["nllb"]
|
||||||
|
with self._lock:
|
||||||
|
self._tokenizer.src_lang = src_code
|
||||||
|
tokens = self._tokenizer.convert_ids_to_tokens(
|
||||||
|
self._tokenizer.encode(text, truncation=True, max_length=512)
|
||||||
|
)
|
||||||
|
results = self._model.translate_batch(
|
||||||
|
[tokens],
|
||||||
|
target_prefix=[[tgt_code]],
|
||||||
|
beam_size=2,
|
||||||
|
max_decoding_length=512,
|
||||||
|
repetition_penalty=1.1,
|
||||||
|
)
|
||||||
|
hyp = results[0].hypotheses[0]
|
||||||
|
if hyp and hyp[0] == tgt_code:
|
||||||
|
hyp = hyp[1:]
|
||||||
|
return self._tokenizer.decode(
|
||||||
|
self._tokenizer.convert_tokens_to_ids(hyp), skip_special_tokens=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _translate(self, text, source_lang, target_lang, glossary,
|
||||||
|
speech_level=SpeechLevel.POLITE, source_politeness=Politeness.UNKNOWN):
|
||||||
|
# seq2seq 는 프롬프트가 없어 말투를 지시할 수 없다. 후처리로 맞춘다.
|
||||||
|
if self._model is None:
|
||||||
|
self.load()
|
||||||
|
assert self._model is not None and self._tokenizer is not None
|
||||||
|
|
||||||
|
if glossary is None or not len(glossary):
|
||||||
|
return self._run_model(text, source_lang, target_lang)
|
||||||
|
|
||||||
|
protected, replacements = glossary.protect(text, target_lang)
|
||||||
|
if not replacements:
|
||||||
|
return self._run_model(text, source_lang, target_lang)
|
||||||
|
|
||||||
|
out = self._run_model(protected, source_lang, target_lang)
|
||||||
|
return _restore_or_retranslate(
|
||||||
|
out, replacements,
|
||||||
|
lambda: self._run_model(text, source_lang, target_lang),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMTranslator(Translator):
|
||||||
|
"""Seed-X / Qwen3 등 LLM 백엔드. 용어집은 프롬프트로 직접 지시한다."""
|
||||||
|
|
||||||
|
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0,
|
||||||
|
lora_path: str = "") -> None:
|
||||||
|
super().__init__(spec, device, device_index)
|
||||||
|
self.lora_path = lora_path
|
||||||
|
self._model = None
|
||||||
|
self._tokenizer = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loaded(self) -> bool:
|
||||||
|
return self._model is not None
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
if self._model is not None:
|
||||||
|
return
|
||||||
|
import torch
|
||||||
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||||
|
|
||||||
|
dtype = torch.float16 if self.device == "cuda" else torch.float32
|
||||||
|
log.info("번역 LLM 로드: %s (%s)", self.spec.repo, self.device)
|
||||||
|
self._tokenizer = AutoTokenizer.from_pretrained(
|
||||||
|
self.spec.repo, cache_dir=str(models_dir())
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._model = AutoModelForCausalLM.from_pretrained(
|
||||||
|
self.spec.repo,
|
||||||
|
cache_dir=str(models_dir()),
|
||||||
|
torch_dtype=dtype,
|
||||||
|
device_map={"": self.device_index} if self.device == "cuda" else "cpu",
|
||||||
|
)
|
||||||
|
except (ImportError, RuntimeError, ValueError) as exc:
|
||||||
|
if self.spec.compute_type == "int4":
|
||||||
|
# AWQ 가중치는 전용 커널이 있어야 읽힌다. 메시지가 모호해서
|
||||||
|
# 사용자가 원인을 못 찾는 일이 잦으므로 해결 방법을 같이 알려준다.
|
||||||
|
raise RuntimeError(
|
||||||
|
f"양자화 번역 모델({self.spec.repo})을 불러오지 못했습니다.\n"
|
||||||
|
"AWQ 커널이 필요합니다: pip install autoawq\n"
|
||||||
|
"설치가 어려우면 '모델' 화면에서 다른 티어를 선택하세요.\n"
|
||||||
|
f"원인: {exc}"
|
||||||
|
) from exc
|
||||||
|
raise
|
||||||
|
if self.lora_path:
|
||||||
|
from peft import PeftModel
|
||||||
|
|
||||||
|
log.info("LoRA 어댑터 적용: %s", self.lora_path)
|
||||||
|
self._model = PeftModel.from_pretrained(self._model, self.lora_path)
|
||||||
|
self._model.eval()
|
||||||
|
|
||||||
|
def unload(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._model = None
|
||||||
|
self._tokenizer = None
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _build_prompt(self, text, source_lang, target_lang, glossary,
|
||||||
|
speech_level=SpeechLevel.POLITE,
|
||||||
|
source_politeness=Politeness.UNKNOWN) -> str:
|
||||||
|
if self.spec.prompt_style is PromptStyle.SEEDX:
|
||||||
|
# Seed-X 는 지시문을 못 알아듣는다. 말투는 후처리로만 맞춘다.
|
||||||
|
return build_seedx_prompt(text, source_lang, target_lang)
|
||||||
|
return self._build_instruct_prompt(
|
||||||
|
text, source_lang, target_lang, glossary, speech_level, source_politeness
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_instruct_prompt(self, text, source_lang, target_lang, glossary,
|
||||||
|
speech_level=SpeechLevel.POLITE,
|
||||||
|
source_politeness=Politeness.UNKNOWN) -> str:
|
||||||
|
"""지시문을 이해하는 모델용. 가능하면 chat template 을 태운다."""
|
||||||
|
src = LANGUAGES[source_lang]["english"]
|
||||||
|
tgt = LANGUAGES[target_lang]["english"]
|
||||||
|
hint = glossary.prompt_hint(text, target_lang) if glossary else ""
|
||||||
|
parts = [
|
||||||
|
f"Translate the following {src} text into {tgt}. "
|
||||||
|
"It is a live spoken line from a game or broadcast, so keep the tone "
|
||||||
|
"casual and natural. Output only the translation."
|
||||||
|
]
|
||||||
|
if target_lang == "ko":
|
||||||
|
parts.append(prompt_instruction(speech_level, source_politeness))
|
||||||
|
if hint:
|
||||||
|
parts.append(hint)
|
||||||
|
parts.append(f"{src}: {text}\n{tgt}:")
|
||||||
|
instruction = "\n\n".join(parts)
|
||||||
|
|
||||||
|
template = getattr(self._tokenizer, "chat_template", None)
|
||||||
|
if not template:
|
||||||
|
return instruction
|
||||||
|
# Qwen3 는 기본적으로 thinking 모드가 켜져 실시간 자막에는 너무 느리다.
|
||||||
|
try:
|
||||||
|
return self._tokenizer.apply_chat_template(
|
||||||
|
[{"role": "user", "content": instruction}],
|
||||||
|
tokenize=False,
|
||||||
|
add_generation_prompt=True,
|
||||||
|
enable_thinking=False,
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
# enable_thinking 을 모르는 템플릿
|
||||||
|
return self._tokenizer.apply_chat_template(
|
||||||
|
[{"role": "user", "content": instruction}],
|
||||||
|
tokenize=False,
|
||||||
|
add_generation_prompt=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate(self, prompt: str) -> str:
|
||||||
|
"""프롬프트 하나를 돌려 첫 줄만 돌려준다."""
|
||||||
|
import torch
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
|
||||||
|
with torch.inference_mode():
|
||||||
|
generated = self._model.generate(
|
||||||
|
**inputs,
|
||||||
|
max_new_tokens=256,
|
||||||
|
do_sample=False,
|
||||||
|
num_beams=1,
|
||||||
|
repetition_penalty=1.05,
|
||||||
|
pad_token_id=self._tokenizer.eos_token_id,
|
||||||
|
)
|
||||||
|
new_tokens = generated[0][inputs["input_ids"].shape[-1] :]
|
||||||
|
out = self._tokenizer.decode(new_tokens, skip_special_tokens=True)
|
||||||
|
return out.strip().split("\n")[0]
|
||||||
|
|
||||||
|
def _translate(self, text, source_lang, target_lang, glossary,
|
||||||
|
speech_level=SpeechLevel.POLITE, source_politeness=Politeness.UNKNOWN):
|
||||||
|
if self._model is None:
|
||||||
|
self.load()
|
||||||
|
assert self._model is not None and self._tokenizer is not None
|
||||||
|
|
||||||
|
def run(source: str, gloss) -> str:
|
||||||
|
return self._generate(
|
||||||
|
self._build_prompt(
|
||||||
|
source, source_lang, target_lang, gloss,
|
||||||
|
speech_level, source_politeness,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed-X 는 지시문을 못 알아들으므로 용어집을 자리표시자로 보호한다.
|
||||||
|
# 지시문을 이해하는 모델은 프롬프트로 넣으므로 보호가 필요 없다.
|
||||||
|
needs_placeholder = (
|
||||||
|
self.spec.prompt_style is PromptStyle.SEEDX
|
||||||
|
and glossary is not None
|
||||||
|
and len(glossary)
|
||||||
|
)
|
||||||
|
if not needs_placeholder:
|
||||||
|
return run(text, glossary)
|
||||||
|
|
||||||
|
protected, replacements = glossary.protect(text, target_lang)
|
||||||
|
if not replacements:
|
||||||
|
return run(text, glossary)
|
||||||
|
|
||||||
|
out = run(protected, None)
|
||||||
|
return _restore_or_retranslate(
|
||||||
|
out, replacements, lambda: run(text, None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_translator(
|
||||||
|
spec: MTSpec, device: str = "cuda", device_index: int = 0, lora_path: str = ""
|
||||||
|
) -> Translator:
|
||||||
|
if spec.backend is MTBackend.CTRANSLATE2:
|
||||||
|
return CTranslate2Translator(spec, device, device_index)
|
||||||
|
return LLMTranslator(spec, device, device_index, lora_path)
|
||||||
374
src/livesub/resources/glossaries/apex.json
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
{
|
||||||
|
"key": "apex",
|
||||||
|
"name": "에이펙스 레전드",
|
||||||
|
"description": "링·배너·부활 등 스쿼드 콜. 배틀로얄 FPS를 같이 하신다면 켜두세요.",
|
||||||
|
"scope": "game",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"source": "legend",
|
||||||
|
"targets": {
|
||||||
|
"ko": "레전드",
|
||||||
|
"ja": "レジェンド",
|
||||||
|
"zh": "传奇"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ring",
|
||||||
|
"targets": {
|
||||||
|
"ko": "링",
|
||||||
|
"ja": "リング",
|
||||||
|
"zh": "圆圈"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ring is closing",
|
||||||
|
"targets": {
|
||||||
|
"ko": "링 좁혀진다",
|
||||||
|
"ja": "リングが縮む",
|
||||||
|
"zh": "圈要缩了"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "jumpmaster",
|
||||||
|
"targets": {
|
||||||
|
"ko": "점프마스터",
|
||||||
|
"ja": "ジャンプマスター",
|
||||||
|
"zh": "跳伞指挥"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "hot zone",
|
||||||
|
"targets": {
|
||||||
|
"ko": "핫존",
|
||||||
|
"ja": "ホットゾーン",
|
||||||
|
"zh": "热区"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "care package",
|
||||||
|
"targets": {
|
||||||
|
"ko": "보급",
|
||||||
|
"ja": "ケアパッケージ",
|
||||||
|
"zh": "空投"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "banner",
|
||||||
|
"targets": {
|
||||||
|
"ko": "배너",
|
||||||
|
"ja": "バナー",
|
||||||
|
"zh": "旗帜"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "grab my banner",
|
||||||
|
"targets": {
|
||||||
|
"ko": "내 배너 챙겨",
|
||||||
|
"ja": "バナー取って",
|
||||||
|
"zh": "捡我旗帜"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "respawn beacon",
|
||||||
|
"targets": {
|
||||||
|
"ko": "리스폰 비컨",
|
||||||
|
"ja": "リスポーンビーコン",
|
||||||
|
"zh": "重生信标"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "survey beacon",
|
||||||
|
"targets": {
|
||||||
|
"ko": "조사 비컨",
|
||||||
|
"ja": "調査ビーコン",
|
||||||
|
"zh": "勘探信标"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "knocked",
|
||||||
|
"targets": {
|
||||||
|
"ko": "눕혔다",
|
||||||
|
"ja": "ダウンさせた",
|
||||||
|
"zh": "打倒了"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "thirsting",
|
||||||
|
"targets": {
|
||||||
|
"ko": "마무리",
|
||||||
|
"ja": "追い打ち",
|
||||||
|
"zh": "补刀"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "finisher",
|
||||||
|
"targets": {
|
||||||
|
"ko": "피니셔",
|
||||||
|
"ja": "フィニッシャー",
|
||||||
|
"zh": "处决"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "knockdown shield",
|
||||||
|
"targets": {
|
||||||
|
"ko": "녹다운 실드",
|
||||||
|
"ja": "ノックダウンシールド",
|
||||||
|
"zh": "击倒护盾"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "evo shield",
|
||||||
|
"targets": {
|
||||||
|
"ko": "진화 실드",
|
||||||
|
"ja": "EVOシールド",
|
||||||
|
"zh": "进化护盾"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "shield cell",
|
||||||
|
"targets": {
|
||||||
|
"ko": "실드 셀",
|
||||||
|
"ja": "シールドセル",
|
||||||
|
"zh": "小蓝"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "shield battery",
|
||||||
|
"targets": {
|
||||||
|
"ko": "실드 배터리",
|
||||||
|
"ja": "シールドバッテリー",
|
||||||
|
"zh": "大蓝"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "med kit",
|
||||||
|
"targets": {
|
||||||
|
"ko": "의료 키트",
|
||||||
|
"ja": "医療キット",
|
||||||
|
"zh": "大血包"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "syringe",
|
||||||
|
"targets": {
|
||||||
|
"ko": "주사기",
|
||||||
|
"ja": "注射器",
|
||||||
|
"zh": "小血包"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "phoenix kit",
|
||||||
|
"targets": {
|
||||||
|
"ko": "피닉스 키트",
|
||||||
|
"ja": "フェニックスキット",
|
||||||
|
"zh": "凤凰治疗包"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "heat shield",
|
||||||
|
"targets": {
|
||||||
|
"ko": "히트 실드",
|
||||||
|
"ja": "ヒートシールド",
|
||||||
|
"zh": "隔热罩"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ult",
|
||||||
|
"targets": {
|
||||||
|
"ko": "궁",
|
||||||
|
"ja": "アルト",
|
||||||
|
"zh": "大招"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ultimate accelerant",
|
||||||
|
"targets": {
|
||||||
|
"ko": "궁 가속제",
|
||||||
|
"ja": "アルト促進剤",
|
||||||
|
"zh": "大招加速器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tactical",
|
||||||
|
"targets": {
|
||||||
|
"ko": "전술 능력",
|
||||||
|
"ja": "戦術アビリティ",
|
||||||
|
"zh": "战术技能"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "passive",
|
||||||
|
"targets": {
|
||||||
|
"ko": "패시브",
|
||||||
|
"ja": "パッシブ",
|
||||||
|
"zh": "被动"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "zipline",
|
||||||
|
"targets": {
|
||||||
|
"ko": "집라인",
|
||||||
|
"ja": "ジップライン",
|
||||||
|
"zh": "索道"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "portal",
|
||||||
|
"targets": {
|
||||||
|
"ko": "포탈",
|
||||||
|
"ja": "ポータル",
|
||||||
|
"zh": "传送门"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "dome",
|
||||||
|
"targets": {
|
||||||
|
"ko": "돔",
|
||||||
|
"ja": "ドーム",
|
||||||
|
"zh": "护盾罩"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "gas",
|
||||||
|
"targets": {
|
||||||
|
"ko": "가스",
|
||||||
|
"ja": "ガス",
|
||||||
|
"zh": "毒气"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "wall",
|
||||||
|
"targets": {
|
||||||
|
"ko": "벽",
|
||||||
|
"ja": "ウォール",
|
||||||
|
"zh": "墙"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "attachment",
|
||||||
|
"targets": {
|
||||||
|
"ko": "부착물",
|
||||||
|
"ja": "アタッチメント",
|
||||||
|
"zh": "配件"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "purple",
|
||||||
|
"targets": {
|
||||||
|
"ko": "보라",
|
||||||
|
"ja": "紫",
|
||||||
|
"zh": "紫色"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "gold",
|
||||||
|
"targets": {
|
||||||
|
"ko": "금",
|
||||||
|
"ja": "金",
|
||||||
|
"zh": "金色"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "loot bin",
|
||||||
|
"targets": {
|
||||||
|
"ko": "보급 상자",
|
||||||
|
"ja": "ルートビン",
|
||||||
|
"zh": "物资箱"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "death box",
|
||||||
|
"targets": {
|
||||||
|
"ko": "데스박스",
|
||||||
|
"ja": "デスボックス",
|
||||||
|
"zh": "死亡盒"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "third party",
|
||||||
|
"targets": {
|
||||||
|
"ko": "어부",
|
||||||
|
"ja": "漁夫",
|
||||||
|
"zh": "第三方"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rotate",
|
||||||
|
"targets": {
|
||||||
|
"ko": "로테",
|
||||||
|
"ja": "ローテ",
|
||||||
|
"zh": "转点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "push them",
|
||||||
|
"targets": {
|
||||||
|
"ko": "밀어",
|
||||||
|
"ja": "詰めよう",
|
||||||
|
"zh": "推他们"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "back off",
|
||||||
|
"targets": {
|
||||||
|
"ko": "빠져",
|
||||||
|
"ja": "引こう",
|
||||||
|
"zh": "撤"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "high ground",
|
||||||
|
"targets": {
|
||||||
|
"ko": "고지",
|
||||||
|
"ja": "高所",
|
||||||
|
"zh": "高地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ping",
|
||||||
|
"targets": {
|
||||||
|
"ko": "핑",
|
||||||
|
"ja": "ピン",
|
||||||
|
"zh": "标记"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ping it",
|
||||||
|
"targets": {
|
||||||
|
"ko": "핑 찍어",
|
||||||
|
"ja": "ピンして",
|
||||||
|
"zh": "标记一下"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "squad wiped",
|
||||||
|
"targets": {
|
||||||
|
"ko": "스쿼드 전멸",
|
||||||
|
"ja": "スクワッド全滅",
|
||||||
|
"zh": "小队团灭"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "champion",
|
||||||
|
"targets": {
|
||||||
|
"ko": "챔피언",
|
||||||
|
"ja": "チャンピオン",
|
||||||
|
"zh": "冠军"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "kill leader",
|
||||||
|
"targets": {
|
||||||
|
"ko": "킬 리더",
|
||||||
|
"ja": "キルリーダー",
|
||||||
|
"zh": "击杀王"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
385
src/livesub/resources/glossaries/fps-common.json
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
{
|
||||||
|
"key": "fps-common",
|
||||||
|
"name": "FPS 공통",
|
||||||
|
"description": "장르 전반에서 두루 쓰는 총기·교전·파티 콜. 게임별 팩과 같이 켜세요.",
|
||||||
|
"scope": "generic",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"source": "enemy",
|
||||||
|
"targets": {
|
||||||
|
"ko": "적",
|
||||||
|
"ja": "敵",
|
||||||
|
"zh": "敌人"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "enemy spotted",
|
||||||
|
"targets": {
|
||||||
|
"ko": "적 발견",
|
||||||
|
"ja": "敵発見",
|
||||||
|
"zh": "发现敌人"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "contact",
|
||||||
|
"targets": {
|
||||||
|
"ko": "교전",
|
||||||
|
"ja": "接敵",
|
||||||
|
"zh": "接敌"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "reloading",
|
||||||
|
"targets": {
|
||||||
|
"ko": "재장전 중",
|
||||||
|
"ja": "リロード中",
|
||||||
|
"zh": "换弹中"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "cover me",
|
||||||
|
"targets": {
|
||||||
|
"ko": "엄호해줘",
|
||||||
|
"ja": "援護して",
|
||||||
|
"zh": "掩护我"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "covering",
|
||||||
|
"targets": {
|
||||||
|
"ko": "엄호 중",
|
||||||
|
"ja": "援護中",
|
||||||
|
"zh": "掩护中"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "push",
|
||||||
|
"targets": {
|
||||||
|
"ko": "밀자",
|
||||||
|
"ja": "押そう",
|
||||||
|
"zh": "推进"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "fall back",
|
||||||
|
"targets": {
|
||||||
|
"ko": "후퇴",
|
||||||
|
"ja": "後退",
|
||||||
|
"zh": "撤退"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "hold",
|
||||||
|
"targets": {
|
||||||
|
"ko": "대기",
|
||||||
|
"ja": "ホールド",
|
||||||
|
"zh": "守住"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "knocked",
|
||||||
|
"targets": {
|
||||||
|
"ko": "기절",
|
||||||
|
"ja": "ダウン",
|
||||||
|
"zh": "被击倒"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "down",
|
||||||
|
"targets": {
|
||||||
|
"ko": "다운",
|
||||||
|
"ja": "ダウン",
|
||||||
|
"zh": "倒地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "revive",
|
||||||
|
"targets": {
|
||||||
|
"ko": "부활",
|
||||||
|
"ja": "リヴァイヴ",
|
||||||
|
"zh": "复活"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "res",
|
||||||
|
"targets": {
|
||||||
|
"ko": "살려줘",
|
||||||
|
"ja": "蘇生して",
|
||||||
|
"zh": "救我"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "heal up",
|
||||||
|
"targets": {
|
||||||
|
"ko": "회복하자",
|
||||||
|
"ja": "回復しよう",
|
||||||
|
"zh": "回血"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "loot",
|
||||||
|
"targets": {
|
||||||
|
"ko": "파밍",
|
||||||
|
"ja": "ルート",
|
||||||
|
"zh": "搜物资"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "looting",
|
||||||
|
"targets": {
|
||||||
|
"ko": "파밍 중",
|
||||||
|
"ja": "ルート中",
|
||||||
|
"zh": "搜物资中"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "third party",
|
||||||
|
"targets": {
|
||||||
|
"ko": "어부",
|
||||||
|
"ja": "漁夫",
|
||||||
|
"zh": "第三方"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "zone",
|
||||||
|
"targets": {
|
||||||
|
"ko": "자기장",
|
||||||
|
"ja": "安全地帯",
|
||||||
|
"zh": "毒圈"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "circle",
|
||||||
|
"targets": {
|
||||||
|
"ko": "자기장",
|
||||||
|
"ja": "サークル",
|
||||||
|
"zh": "圈"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "storm",
|
||||||
|
"targets": {
|
||||||
|
"ko": "폭풍",
|
||||||
|
"ja": "ストーム",
|
||||||
|
"zh": "风暴"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "high ground",
|
||||||
|
"targets": {
|
||||||
|
"ko": "고지",
|
||||||
|
"ja": "高所",
|
||||||
|
"zh": "高地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "flank",
|
||||||
|
"targets": {
|
||||||
|
"ko": "측면",
|
||||||
|
"ja": "側面",
|
||||||
|
"zh": "侧翼"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "behind us",
|
||||||
|
"targets": {
|
||||||
|
"ko": "뒤에 있다",
|
||||||
|
"ja": "後ろにいる",
|
||||||
|
"zh": "背后有人"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "on me",
|
||||||
|
"targets": {
|
||||||
|
"ko": "나한테 붙어",
|
||||||
|
"ja": "ついてきて",
|
||||||
|
"zh": "跟着我"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "grenade",
|
||||||
|
"targets": {
|
||||||
|
"ko": "수류탄",
|
||||||
|
"ja": "グレネード",
|
||||||
|
"zh": "手雷"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "nade",
|
||||||
|
"targets": {
|
||||||
|
"ko": "수류탄",
|
||||||
|
"ja": "グレネード",
|
||||||
|
"zh": "手雷"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "frag",
|
||||||
|
"targets": {
|
||||||
|
"ko": "파편탄",
|
||||||
|
"ja": "フラグ",
|
||||||
|
"zh": "破片雷"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "smoke",
|
||||||
|
"targets": {
|
||||||
|
"ko": "연막",
|
||||||
|
"ja": "スモーク",
|
||||||
|
"zh": "烟雾弹"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "flashbang",
|
||||||
|
"targets": {
|
||||||
|
"ko": "섬광탄",
|
||||||
|
"ja": "フラッシュ",
|
||||||
|
"zh": "闪光弹"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "scope",
|
||||||
|
"targets": {
|
||||||
|
"ko": "조준경",
|
||||||
|
"ja": "スコープ",
|
||||||
|
"zh": "瞄准镜"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ADS",
|
||||||
|
"targets": {
|
||||||
|
"ko": "정조준",
|
||||||
|
"ja": "ADS",
|
||||||
|
"zh": "开镜"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "recoil",
|
||||||
|
"targets": {
|
||||||
|
"ko": "반동",
|
||||||
|
"ja": "リコイル",
|
||||||
|
"zh": "后坐力"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "spray control",
|
||||||
|
"targets": {
|
||||||
|
"ko": "반동 제어",
|
||||||
|
"ja": "リコイル制御",
|
||||||
|
"zh": "压枪"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "aim",
|
||||||
|
"targets": {
|
||||||
|
"ko": "에임",
|
||||||
|
"ja": "エイム",
|
||||||
|
"zh": "枪法"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "headshot",
|
||||||
|
"targets": {
|
||||||
|
"ko": "헤드샷",
|
||||||
|
"ja": "ヘッドショット",
|
||||||
|
"zh": "爆头"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "wall bang",
|
||||||
|
"targets": {
|
||||||
|
"ko": "관통샷",
|
||||||
|
"ja": "ウォールバン",
|
||||||
|
"zh": "穿墙"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "prefire",
|
||||||
|
"targets": {
|
||||||
|
"ko": "선샷",
|
||||||
|
"ja": "プリファイア",
|
||||||
|
"zh": "预瞄"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "camping",
|
||||||
|
"targets": {
|
||||||
|
"ko": "캠핑",
|
||||||
|
"ja": "キャンプ",
|
||||||
|
"zh": "蹲点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "respawn",
|
||||||
|
"targets": {
|
||||||
|
"ko": "리스폰",
|
||||||
|
"ja": "リスポーン",
|
||||||
|
"zh": "重生"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "kill streak",
|
||||||
|
"targets": {
|
||||||
|
"ko": "연속 킬",
|
||||||
|
"ja": "キルストリーク",
|
||||||
|
"zh": "连杀"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ammo",
|
||||||
|
"targets": {
|
||||||
|
"ko": "탄약",
|
||||||
|
"ja": "弾薬",
|
||||||
|
"zh": "子弹"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "out of ammo",
|
||||||
|
"targets": {
|
||||||
|
"ko": "탄 없음",
|
||||||
|
"ja": "弾切れ",
|
||||||
|
"zh": "没子弹"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "backup",
|
||||||
|
"targets": {
|
||||||
|
"ko": "지원",
|
||||||
|
"ja": "バックアップ",
|
||||||
|
"zh": "支援"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "good game",
|
||||||
|
"targets": {
|
||||||
|
"ko": "잘 했다",
|
||||||
|
"ja": "GG",
|
||||||
|
"zh": "打得好"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "nice shot",
|
||||||
|
"targets": {
|
||||||
|
"ko": "잘 쐈다",
|
||||||
|
"ja": "ナイスショット",
|
||||||
|
"zh": "好枪"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "my bad",
|
||||||
|
"targets": {
|
||||||
|
"ko": "내 실수",
|
||||||
|
"ja": "ごめん",
|
||||||
|
"zh": "我的错"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
600
src/livesub/resources/glossaries/lol.json
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
{
|
||||||
|
"key": "lol",
|
||||||
|
"name": "리그 오브 레전드",
|
||||||
|
"description": "라인·오브젝트·교전 콜, 한국 서버에서 실제로 쓰는 말로 맞췄습니다.",
|
||||||
|
"scope": "game",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"source": "Nexus",
|
||||||
|
"targets": {
|
||||||
|
"ko": "넥서스",
|
||||||
|
"ja": "ネクサス",
|
||||||
|
"zh": "基地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "inhibitor",
|
||||||
|
"targets": {
|
||||||
|
"ko": "억제기",
|
||||||
|
"ja": "抑制器",
|
||||||
|
"zh": "水晶"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "inhib",
|
||||||
|
"targets": {
|
||||||
|
"ko": "억제기",
|
||||||
|
"ja": "抑制器",
|
||||||
|
"zh": "水晶"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "turret",
|
||||||
|
"targets": {
|
||||||
|
"ko": "포탑",
|
||||||
|
"ja": "タワー",
|
||||||
|
"zh": "防御塔"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tower",
|
||||||
|
"targets": {
|
||||||
|
"ko": "포탑",
|
||||||
|
"ja": "タワー",
|
||||||
|
"zh": "防御塔"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "baron",
|
||||||
|
"targets": {
|
||||||
|
"ko": "바론",
|
||||||
|
"ja": "バロン",
|
||||||
|
"zh": "大龙"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Baron Nashor",
|
||||||
|
"targets": {
|
||||||
|
"ko": "내셔 남작",
|
||||||
|
"ja": "バロン・ナッシャー",
|
||||||
|
"zh": "纳什男爵"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "dragon",
|
||||||
|
"targets": {
|
||||||
|
"ko": "드래곤",
|
||||||
|
"ja": "ドラゴン",
|
||||||
|
"zh": "小龙"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "drake",
|
||||||
|
"targets": {
|
||||||
|
"ko": "드래곤",
|
||||||
|
"ja": "ドラゴン",
|
||||||
|
"zh": "小龙"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "elder",
|
||||||
|
"targets": {
|
||||||
|
"ko": "장로 드래곤",
|
||||||
|
"ja": "エルダードラゴン",
|
||||||
|
"zh": "远古龙"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "herald",
|
||||||
|
"targets": {
|
||||||
|
"ko": "전령",
|
||||||
|
"ja": "ヘラルド",
|
||||||
|
"zh": "峡谷先锋"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Rift Herald",
|
||||||
|
"targets": {
|
||||||
|
"ko": "협곡의 전령",
|
||||||
|
"ja": "リフトヘラルド",
|
||||||
|
"zh": "峡谷先锋"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "grubs",
|
||||||
|
"targets": {
|
||||||
|
"ko": "유충",
|
||||||
|
"ja": "虫",
|
||||||
|
"zh": "虚空幼虫"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "gank",
|
||||||
|
"targets": {
|
||||||
|
"ko": "갱",
|
||||||
|
"ja": "ガンク",
|
||||||
|
"zh": "抓人"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ganking",
|
||||||
|
"targets": {
|
||||||
|
"ko": "갱킹",
|
||||||
|
"ja": "ガンク",
|
||||||
|
"zh": "抓人"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "counter gank",
|
||||||
|
"targets": {
|
||||||
|
"ko": "역갱",
|
||||||
|
"ja": "カウンターガンク",
|
||||||
|
"zh": "反蹲"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "roam",
|
||||||
|
"targets": {
|
||||||
|
"ko": "로밍",
|
||||||
|
"ja": "ローム",
|
||||||
|
"zh": "游走"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "jungle",
|
||||||
|
"targets": {
|
||||||
|
"ko": "정글",
|
||||||
|
"ja": "ジャングル",
|
||||||
|
"zh": "打野"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "jungler",
|
||||||
|
"targets": {
|
||||||
|
"ko": "정글러",
|
||||||
|
"ja": "ジャングラー",
|
||||||
|
"zh": "打野"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "top lane",
|
||||||
|
"targets": {
|
||||||
|
"ko": "탑",
|
||||||
|
"ja": "トップ",
|
||||||
|
"zh": "上路"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "mid lane",
|
||||||
|
"targets": {
|
||||||
|
"ko": "미드",
|
||||||
|
"ja": "ミッド",
|
||||||
|
"zh": "中路"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "bot lane",
|
||||||
|
"targets": {
|
||||||
|
"ko": "바텀",
|
||||||
|
"ja": "ボット",
|
||||||
|
"zh": "下路"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "support",
|
||||||
|
"targets": {
|
||||||
|
"ko": "서폿",
|
||||||
|
"ja": "サポート",
|
||||||
|
"zh": "辅助"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ADC",
|
||||||
|
"targets": {
|
||||||
|
"ko": "원딜",
|
||||||
|
"ja": "ADC",
|
||||||
|
"zh": "ADC"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "minion",
|
||||||
|
"targets": {
|
||||||
|
"ko": "미니언",
|
||||||
|
"ja": "ミニオン",
|
||||||
|
"zh": "小兵"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "wave",
|
||||||
|
"targets": {
|
||||||
|
"ko": "웨이브",
|
||||||
|
"ja": "ウェーブ",
|
||||||
|
"zh": "兵线"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "creep score",
|
||||||
|
"targets": {
|
||||||
|
"ko": "CS",
|
||||||
|
"ja": "CS",
|
||||||
|
"zh": "补刀"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "last hit",
|
||||||
|
"targets": {
|
||||||
|
"ko": "막타",
|
||||||
|
"ja": "ラストヒット",
|
||||||
|
"zh": "补刀"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "freeze",
|
||||||
|
"targets": {
|
||||||
|
"ko": "프리징",
|
||||||
|
"ja": "フリーズ",
|
||||||
|
"zh": "控线"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "push",
|
||||||
|
"targets": {
|
||||||
|
"ko": "푸시",
|
||||||
|
"ja": "プッシュ",
|
||||||
|
"zh": "推线"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "split push",
|
||||||
|
"targets": {
|
||||||
|
"ko": "스플릿",
|
||||||
|
"ja": "スプリットプッシュ",
|
||||||
|
"zh": "分推"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "back",
|
||||||
|
"targets": {
|
||||||
|
"ko": "귀환",
|
||||||
|
"ja": "リコール",
|
||||||
|
"zh": "回城"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "recall",
|
||||||
|
"targets": {
|
||||||
|
"ko": "귀환",
|
||||||
|
"ja": "リコール",
|
||||||
|
"zh": "回城"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ward",
|
||||||
|
"targets": {
|
||||||
|
"ko": "와드",
|
||||||
|
"ja": "ワード",
|
||||||
|
"zh": "眼"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "vision",
|
||||||
|
"targets": {
|
||||||
|
"ko": "시야",
|
||||||
|
"ja": "視界",
|
||||||
|
"zh": "视野"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "flash",
|
||||||
|
"targets": {
|
||||||
|
"ko": "점멸",
|
||||||
|
"ja": "フラッシュ",
|
||||||
|
"zh": "闪现"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ignite",
|
||||||
|
"targets": {
|
||||||
|
"ko": "점화",
|
||||||
|
"ja": "イグナイト",
|
||||||
|
"zh": "点燃"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "teleport",
|
||||||
|
"targets": {
|
||||||
|
"ko": "순간이동",
|
||||||
|
"ja": "テレポート",
|
||||||
|
"zh": "传送"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "smite",
|
||||||
|
"targets": {
|
||||||
|
"ko": "강타",
|
||||||
|
"ja": "スマイト",
|
||||||
|
"zh": "惩戒"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ult",
|
||||||
|
"targets": {
|
||||||
|
"ko": "궁",
|
||||||
|
"ja": "アルト",
|
||||||
|
"zh": "大招"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ultimate",
|
||||||
|
"targets": {
|
||||||
|
"ko": "궁극기",
|
||||||
|
"ja": "アルティメット",
|
||||||
|
"zh": "大招"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "cooldown",
|
||||||
|
"targets": {
|
||||||
|
"ko": "쿨",
|
||||||
|
"ja": "クールダウン",
|
||||||
|
"zh": "冷却"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "on cooldown",
|
||||||
|
"targets": {
|
||||||
|
"ko": "쿨 도는 중",
|
||||||
|
"ja": "クール中",
|
||||||
|
"zh": "冷却中"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "engage",
|
||||||
|
"targets": {
|
||||||
|
"ko": "이니시",
|
||||||
|
"ja": "エンゲージ",
|
||||||
|
"zh": "开团"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "disengage",
|
||||||
|
"targets": {
|
||||||
|
"ko": "빠지기",
|
||||||
|
"ja": "ディスエンゲージ",
|
||||||
|
"zh": "撤退"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "team fight",
|
||||||
|
"targets": {
|
||||||
|
"ko": "한타",
|
||||||
|
"ja": "集団戦",
|
||||||
|
"zh": "团战"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "teamfight",
|
||||||
|
"targets": {
|
||||||
|
"ko": "한타",
|
||||||
|
"ja": "集団戦",
|
||||||
|
"zh": "团战"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "peel",
|
||||||
|
"targets": {
|
||||||
|
"ko": "피어싱 보호",
|
||||||
|
"ja": "ピール",
|
||||||
|
"zh": "保护"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "kite",
|
||||||
|
"targets": {
|
||||||
|
"ko": "카이팅",
|
||||||
|
"ja": "カイト",
|
||||||
|
"zh": "风筝"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "poke",
|
||||||
|
"targets": {
|
||||||
|
"ko": "견제",
|
||||||
|
"ja": "ポーク",
|
||||||
|
"zh": "消耗"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "dive",
|
||||||
|
"targets": {
|
||||||
|
"ko": "다이브",
|
||||||
|
"ja": "ダイブ",
|
||||||
|
"zh": "越塔"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tower dive",
|
||||||
|
"targets": {
|
||||||
|
"ko": "타워 다이브",
|
||||||
|
"ja": "タワーダイブ",
|
||||||
|
"zh": "越塔强杀"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ace",
|
||||||
|
"targets": {
|
||||||
|
"ko": "에이스",
|
||||||
|
"ja": "エース",
|
||||||
|
"zh": "团灭"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "first blood",
|
||||||
|
"targets": {
|
||||||
|
"ko": "퍼블",
|
||||||
|
"ja": "ファーストブラッド",
|
||||||
|
"zh": "一血"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "shutdown",
|
||||||
|
"targets": {
|
||||||
|
"ko": "현상금",
|
||||||
|
"ja": "シャットダウン",
|
||||||
|
"zh": "赏金"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "respawn",
|
||||||
|
"targets": {
|
||||||
|
"ko": "부활",
|
||||||
|
"ja": "リスポーン",
|
||||||
|
"zh": "复活"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "missing",
|
||||||
|
"targets": {
|
||||||
|
"ko": "실종",
|
||||||
|
"ja": "ミア",
|
||||||
|
"zh": "消失"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "MIA",
|
||||||
|
"targets": {
|
||||||
|
"ko": "실종",
|
||||||
|
"ja": "ミア",
|
||||||
|
"zh": "消失"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "SS",
|
||||||
|
"targets": {
|
||||||
|
"ko": "실종",
|
||||||
|
"ja": "ミア",
|
||||||
|
"zh": "消失"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "fed",
|
||||||
|
"targets": {
|
||||||
|
"ko": "잘 큰",
|
||||||
|
"ja": "育った",
|
||||||
|
"zh": "养肥"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "scaling",
|
||||||
|
"targets": {
|
||||||
|
"ko": "성장력",
|
||||||
|
"ja": "スケーリング",
|
||||||
|
"zh": "后期"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "objective",
|
||||||
|
"targets": {
|
||||||
|
"ko": "오브젝트",
|
||||||
|
"ja": "オブジェクト",
|
||||||
|
"zh": "资源"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "backdoor",
|
||||||
|
"targets": {
|
||||||
|
"ko": "백도어",
|
||||||
|
"ja": "バックドア",
|
||||||
|
"zh": "偷家"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "surrender",
|
||||||
|
"targets": {
|
||||||
|
"ko": "항복",
|
||||||
|
"ja": "サレンダー",
|
||||||
|
"zh": "投降"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "buff",
|
||||||
|
"targets": {
|
||||||
|
"ko": "버프",
|
||||||
|
"ja": "バフ",
|
||||||
|
"zh": "增益"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "nerf",
|
||||||
|
"targets": {
|
||||||
|
"ko": "너프",
|
||||||
|
"ja": "ナーフ",
|
||||||
|
"zh": "削弱"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "CC",
|
||||||
|
"targets": {
|
||||||
|
"ko": "군중제어",
|
||||||
|
"ja": "CC",
|
||||||
|
"zh": "控制"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "stun",
|
||||||
|
"targets": {
|
||||||
|
"ko": "기절",
|
||||||
|
"ja": "スタン",
|
||||||
|
"zh": "眩晕"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "slow",
|
||||||
|
"targets": {
|
||||||
|
"ko": "둔화",
|
||||||
|
"ja": "スロウ",
|
||||||
|
"zh": "减速"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "shield",
|
||||||
|
"targets": {
|
||||||
|
"ko": "실드",
|
||||||
|
"ja": "シールド",
|
||||||
|
"zh": "护盾"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "heal",
|
||||||
|
"targets": {
|
||||||
|
"ko": "회복",
|
||||||
|
"ja": "ヒール",
|
||||||
|
"zh": "治疗"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "mana",
|
||||||
|
"targets": {
|
||||||
|
"ko": "마나",
|
||||||
|
"ja": "マナ",
|
||||||
|
"zh": "蓝"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "low HP",
|
||||||
|
"targets": {
|
||||||
|
"ko": "체력 낮음",
|
||||||
|
"ja": "体力低い",
|
||||||
|
"zh": "残血"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "full build",
|
||||||
|
"targets": {
|
||||||
|
"ko": "풀템",
|
||||||
|
"ja": "フルビルド",
|
||||||
|
"zh": "满装备"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
312
src/livesub/resources/glossaries/minecraft.json
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
{
|
||||||
|
"key": "minecraft",
|
||||||
|
"name": "마인크래프트",
|
||||||
|
"description": "블록·몹·아이템 이름. 한국어판 공식 번역어를 따랐습니다.",
|
||||||
|
"scope": "game",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"source": "creeper",
|
||||||
|
"targets": {
|
||||||
|
"ko": "크리퍼",
|
||||||
|
"ja": "クリーパー",
|
||||||
|
"zh": "苦力怕"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "enderman",
|
||||||
|
"targets": {
|
||||||
|
"ko": "엔더맨",
|
||||||
|
"ja": "エンダーマン",
|
||||||
|
"zh": "末影人"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ender dragon",
|
||||||
|
"targets": {
|
||||||
|
"ko": "엔더 드래곤",
|
||||||
|
"ja": "エンダードラゴン",
|
||||||
|
"zh": "末影龙"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "wither",
|
||||||
|
"targets": {
|
||||||
|
"ko": "위더",
|
||||||
|
"ja": "ウィザー",
|
||||||
|
"zh": "凋灵"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "skeleton",
|
||||||
|
"targets": {
|
||||||
|
"ko": "스켈레톤",
|
||||||
|
"ja": "スケルトン",
|
||||||
|
"zh": "骷髅"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "zombie",
|
||||||
|
"targets": {
|
||||||
|
"ko": "좀비",
|
||||||
|
"ja": "ゾンビ",
|
||||||
|
"zh": "僵尸"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "piglin",
|
||||||
|
"targets": {
|
||||||
|
"ko": "피글린",
|
||||||
|
"ja": "ピグリン",
|
||||||
|
"zh": "猪灵"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "villager",
|
||||||
|
"targets": {
|
||||||
|
"ko": "주민",
|
||||||
|
"ja": "村人",
|
||||||
|
"zh": "村民"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "the Nether",
|
||||||
|
"targets": {
|
||||||
|
"ko": "네더",
|
||||||
|
"ja": "ネザー",
|
||||||
|
"zh": "下界"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "the End",
|
||||||
|
"targets": {
|
||||||
|
"ko": "엔드",
|
||||||
|
"ja": "ジ・エンド",
|
||||||
|
"zh": "末地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Overworld",
|
||||||
|
"targets": {
|
||||||
|
"ko": "오버월드",
|
||||||
|
"ja": "オーバーワールド",
|
||||||
|
"zh": "主世界"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "nether portal",
|
||||||
|
"targets": {
|
||||||
|
"ko": "네더 차원문",
|
||||||
|
"ja": "ネザーゲート",
|
||||||
|
"zh": "下界传送门"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "stronghold",
|
||||||
|
"targets": {
|
||||||
|
"ko": "요새",
|
||||||
|
"ja": "要塞",
|
||||||
|
"zh": "要塞"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "bastion",
|
||||||
|
"targets": {
|
||||||
|
"ko": "잔해 성채",
|
||||||
|
"ja": "砦の残骸",
|
||||||
|
"zh": "堡垒遗迹"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "diamond",
|
||||||
|
"targets": {
|
||||||
|
"ko": "다이아몬드",
|
||||||
|
"ja": "ダイヤ",
|
||||||
|
"zh": "钻石"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "netherite",
|
||||||
|
"targets": {
|
||||||
|
"ko": "네더라이트",
|
||||||
|
"ja": "ネザライト",
|
||||||
|
"zh": "下界合金"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "obsidian",
|
||||||
|
"targets": {
|
||||||
|
"ko": "흑요석",
|
||||||
|
"ja": "黒曜石",
|
||||||
|
"zh": "黑曜石"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "redstone",
|
||||||
|
"targets": {
|
||||||
|
"ko": "레드스톤",
|
||||||
|
"ja": "レッドストーン",
|
||||||
|
"zh": "红石"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ender pearl",
|
||||||
|
"targets": {
|
||||||
|
"ko": "엔더 진주",
|
||||||
|
"ja": "エンダーパール",
|
||||||
|
"zh": "末影珍珠"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "blaze rod",
|
||||||
|
"targets": {
|
||||||
|
"ko": "블레이즈 막대",
|
||||||
|
"ja": "ブレイズロッド",
|
||||||
|
"zh": "烈焰棒"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "elytra",
|
||||||
|
"targets": {
|
||||||
|
"ko": "겉날개",
|
||||||
|
"ja": "エリトラ",
|
||||||
|
"zh": "鞘翅"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "totem",
|
||||||
|
"targets": {
|
||||||
|
"ko": "불사의 토템",
|
||||||
|
"ja": "不死のトーテム",
|
||||||
|
"zh": "不死图腾"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "enchant",
|
||||||
|
"targets": {
|
||||||
|
"ko": "인챈트",
|
||||||
|
"ja": "エンチャント",
|
||||||
|
"zh": "附魔"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "anvil",
|
||||||
|
"targets": {
|
||||||
|
"ko": "모루",
|
||||||
|
"ja": "金床",
|
||||||
|
"zh": "铁砧"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "crafting table",
|
||||||
|
"targets": {
|
||||||
|
"ko": "작업대",
|
||||||
|
"ja": "作業台",
|
||||||
|
"zh": "工作台"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "furnace",
|
||||||
|
"targets": {
|
||||||
|
"ko": "화로",
|
||||||
|
"ja": "かまど",
|
||||||
|
"zh": "熔炉"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "chest",
|
||||||
|
"targets": {
|
||||||
|
"ko": "상자",
|
||||||
|
"ja": "チェスト",
|
||||||
|
"zh": "箱子"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "spawn point",
|
||||||
|
"targets": {
|
||||||
|
"ko": "스폰 지점",
|
||||||
|
"ja": "スポーン地点",
|
||||||
|
"zh": "出生点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "respawn",
|
||||||
|
"targets": {
|
||||||
|
"ko": "리스폰",
|
||||||
|
"ja": "リスポーン",
|
||||||
|
"zh": "重生"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "biome",
|
||||||
|
"targets": {
|
||||||
|
"ko": "생물 군계",
|
||||||
|
"ja": "バイオーム",
|
||||||
|
"zh": "生物群系"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "seed",
|
||||||
|
"targets": {
|
||||||
|
"ko": "시드",
|
||||||
|
"ja": "シード",
|
||||||
|
"zh": "种子"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "mob",
|
||||||
|
"targets": {
|
||||||
|
"ko": "몹",
|
||||||
|
"ja": "モブ",
|
||||||
|
"zh": "生物"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "hardcore",
|
||||||
|
"targets": {
|
||||||
|
"ko": "하드코어",
|
||||||
|
"ja": "ハードコア",
|
||||||
|
"zh": "极限模式"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "speedrun",
|
||||||
|
"targets": {
|
||||||
|
"ko": "스피드런",
|
||||||
|
"ja": "RTA",
|
||||||
|
"zh": "速通"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "mine",
|
||||||
|
"targets": {
|
||||||
|
"ko": "채굴",
|
||||||
|
"ja": "採掘",
|
||||||
|
"zh": "挖矿"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "craft",
|
||||||
|
"targets": {
|
||||||
|
"ko": "제작",
|
||||||
|
"ja": "クラフト",
|
||||||
|
"zh": "合成"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "build",
|
||||||
|
"targets": {
|
||||||
|
"ko": "건축",
|
||||||
|
"ja": "建築",
|
||||||
|
"zh": "建造"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "farm",
|
||||||
|
"targets": {
|
||||||
|
"ko": "농장",
|
||||||
|
"ja": "トラップ",
|
||||||
|
"zh": "农场"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
405
src/livesub/resources/glossaries/overwatch.json
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
{
|
||||||
|
"key": "overwatch",
|
||||||
|
"name": "오버워치 2",
|
||||||
|
"description": "역할·궁·거점 콜. 한국 공식 번역어를 우선했습니다.",
|
||||||
|
"scope": "game",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"source": "tank",
|
||||||
|
"targets": {
|
||||||
|
"ko": "탱커",
|
||||||
|
"ja": "タンク",
|
||||||
|
"zh": "重装"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "DPS",
|
||||||
|
"targets": {
|
||||||
|
"ko": "딜러",
|
||||||
|
"ja": "ダメージ",
|
||||||
|
"zh": "输出"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "damage",
|
||||||
|
"targets": {
|
||||||
|
"ko": "딜",
|
||||||
|
"ja": "ダメージ",
|
||||||
|
"zh": "伤害"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "support",
|
||||||
|
"targets": {
|
||||||
|
"ko": "지원가",
|
||||||
|
"ja": "サポート",
|
||||||
|
"zh": "辅助"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "healer",
|
||||||
|
"targets": {
|
||||||
|
"ko": "힐러",
|
||||||
|
"ja": "ヒーラー",
|
||||||
|
"zh": "奶"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ult",
|
||||||
|
"targets": {
|
||||||
|
"ko": "궁",
|
||||||
|
"ja": "アルト",
|
||||||
|
"zh": "大招"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ultimate",
|
||||||
|
"targets": {
|
||||||
|
"ko": "궁극기",
|
||||||
|
"ja": "アルティメット",
|
||||||
|
"zh": "终极技能"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ult charge",
|
||||||
|
"targets": {
|
||||||
|
"ko": "궁 게이지",
|
||||||
|
"ja": "アルトゲージ",
|
||||||
|
"zh": "大招能量"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ult economy",
|
||||||
|
"targets": {
|
||||||
|
"ko": "궁 경제",
|
||||||
|
"ja": "アルト回し",
|
||||||
|
"zh": "大招经济"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "combo",
|
||||||
|
"targets": {
|
||||||
|
"ko": "연계",
|
||||||
|
"ja": "コンボ",
|
||||||
|
"zh": "连招"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "dive",
|
||||||
|
"targets": {
|
||||||
|
"ko": "다이브",
|
||||||
|
"ja": "ダイブ",
|
||||||
|
"zh": "突进"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "poke",
|
||||||
|
"targets": {
|
||||||
|
"ko": "견제",
|
||||||
|
"ja": "ポーク",
|
||||||
|
"zh": "消耗"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "brawl",
|
||||||
|
"targets": {
|
||||||
|
"ko": "난전",
|
||||||
|
"ja": "ブロール",
|
||||||
|
"zh": "近战团"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "payload",
|
||||||
|
"targets": {
|
||||||
|
"ko": "화물",
|
||||||
|
"ja": "ペイロード",
|
||||||
|
"zh": "运载目标"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "point",
|
||||||
|
"targets": {
|
||||||
|
"ko": "거점",
|
||||||
|
"ja": "拠点",
|
||||||
|
"zh": "点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "capture",
|
||||||
|
"targets": {
|
||||||
|
"ko": "점령",
|
||||||
|
"ja": "占領",
|
||||||
|
"zh": "占点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "contest",
|
||||||
|
"targets": {
|
||||||
|
"ko": "저지",
|
||||||
|
"ja": "コンテスト",
|
||||||
|
"zh": "拖点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "overtime",
|
||||||
|
"targets": {
|
||||||
|
"ko": "연장",
|
||||||
|
"ja": "オーバータイム",
|
||||||
|
"zh": "加时"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "checkpoint",
|
||||||
|
"targets": {
|
||||||
|
"ko": "관문",
|
||||||
|
"ja": "チェックポイント",
|
||||||
|
"zh": "检查点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "spawn",
|
||||||
|
"targets": {
|
||||||
|
"ko": "리스폰",
|
||||||
|
"ja": "リスポーン",
|
||||||
|
"zh": "重生点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "regroup",
|
||||||
|
"targets": {
|
||||||
|
"ko": "모이자",
|
||||||
|
"ja": "集合",
|
||||||
|
"zh": "集合"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "group up",
|
||||||
|
"targets": {
|
||||||
|
"ko": "모이자",
|
||||||
|
"ja": "集合",
|
||||||
|
"zh": "集合"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "stagger",
|
||||||
|
"targets": {
|
||||||
|
"ko": "스태거",
|
||||||
|
"ja": "スタッガー",
|
||||||
|
"zh": "送人头"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "flank",
|
||||||
|
"targets": {
|
||||||
|
"ko": "측면",
|
||||||
|
"ja": "フランク",
|
||||||
|
"zh": "绕后"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "high ground",
|
||||||
|
"targets": {
|
||||||
|
"ko": "고지",
|
||||||
|
"ja": "高台",
|
||||||
|
"zh": "高地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "cooldown",
|
||||||
|
"targets": {
|
||||||
|
"ko": "쿨",
|
||||||
|
"ja": "クールダウン",
|
||||||
|
"zh": "冷却"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "barrier",
|
||||||
|
"targets": {
|
||||||
|
"ko": "방벽",
|
||||||
|
"ja": "バリア",
|
||||||
|
"zh": "护盾"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "shield",
|
||||||
|
"targets": {
|
||||||
|
"ko": "보호막",
|
||||||
|
"ja": "シールド",
|
||||||
|
"zh": "护盾"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "armor",
|
||||||
|
"targets": {
|
||||||
|
"ko": "방어력",
|
||||||
|
"ja": "アーマー",
|
||||||
|
"zh": "护甲"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "burst",
|
||||||
|
"targets": {
|
||||||
|
"ko": "순간 딜",
|
||||||
|
"ja": "バースト",
|
||||||
|
"zh": "爆发"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "focus",
|
||||||
|
"targets": {
|
||||||
|
"ko": "집중 공격",
|
||||||
|
"ja": "フォーカス",
|
||||||
|
"zh": "集火"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "peel",
|
||||||
|
"targets": {
|
||||||
|
"ko": "보호",
|
||||||
|
"ja": "ピール",
|
||||||
|
"zh": "保护"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "nano",
|
||||||
|
"targets": {
|
||||||
|
"ko": "나노",
|
||||||
|
"ja": "ナノ",
|
||||||
|
"zh": "纳米"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "beat",
|
||||||
|
"targets": {
|
||||||
|
"ko": "비트",
|
||||||
|
"ja": "ビート",
|
||||||
|
"zh": "音障"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "sound barrier",
|
||||||
|
"targets": {
|
||||||
|
"ko": "음파 방벽",
|
||||||
|
"ja": "サウンドバリア",
|
||||||
|
"zh": "音障"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rez",
|
||||||
|
"targets": {
|
||||||
|
"ko": "부활",
|
||||||
|
"ja": "リザ",
|
||||||
|
"zh": "复活"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "resurrect",
|
||||||
|
"targets": {
|
||||||
|
"ko": "부활",
|
||||||
|
"ja": "リザレクト",
|
||||||
|
"zh": "复活"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "discord",
|
||||||
|
"targets": {
|
||||||
|
"ko": "불화의 구슬",
|
||||||
|
"ja": "ディスコード",
|
||||||
|
"zh": "不和之球"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "hack",
|
||||||
|
"targets": {
|
||||||
|
"ko": "해킹",
|
||||||
|
"ja": "ハック",
|
||||||
|
"zh": "黑入"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "EMP",
|
||||||
|
"targets": {
|
||||||
|
"ko": "EMP",
|
||||||
|
"ja": "EMP",
|
||||||
|
"zh": "EMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "team wipe",
|
||||||
|
"targets": {
|
||||||
|
"ko": "전멸",
|
||||||
|
"ja": "全滅",
|
||||||
|
"zh": "团灭"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "team fight",
|
||||||
|
"targets": {
|
||||||
|
"ko": "한타",
|
||||||
|
"ja": "チームファイト",
|
||||||
|
"zh": "团战"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "counter pick",
|
||||||
|
"targets": {
|
||||||
|
"ko": "카운터 픽",
|
||||||
|
"ja": "カウンターピック",
|
||||||
|
"zh": "针对英雄"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "swap",
|
||||||
|
"targets": {
|
||||||
|
"ko": "영웅 교체",
|
||||||
|
"ja": "ヒーロー変更",
|
||||||
|
"zh": "换英雄"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "health pack",
|
||||||
|
"targets": {
|
||||||
|
"ko": "회복팩",
|
||||||
|
"ja": "回復パック",
|
||||||
|
"zh": "血包"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "critical",
|
||||||
|
"targets": {
|
||||||
|
"ko": "치명타",
|
||||||
|
"ja": "クリティカル",
|
||||||
|
"zh": "暴击"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "behind you",
|
||||||
|
"targets": {
|
||||||
|
"ko": "뒤에 있다",
|
||||||
|
"ja": "後ろだ",
|
||||||
|
"zh": "背后有人"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "falling back",
|
||||||
|
"targets": {
|
||||||
|
"ko": "후퇴한다",
|
||||||
|
"ja": "後退する",
|
||||||
|
"zh": "撤退"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "push",
|
||||||
|
"targets": {
|
||||||
|
"ko": "밀자",
|
||||||
|
"ja": "押そう",
|
||||||
|
"zh": "推进"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
538
src/livesub/resources/glossaries/pubg.json
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
{
|
||||||
|
"key": "pubg",
|
||||||
|
"name": "배틀그라운드 (PUBG)",
|
||||||
|
"description": "자기장·파밍·스쿼드 콜. 배틀로얄 공통 용어도 같이 들어 있습니다.",
|
||||||
|
"scope": "game",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"source": "chicken dinner",
|
||||||
|
"targets": {
|
||||||
|
"ko": "치킨",
|
||||||
|
"ja": "ドン勝",
|
||||||
|
"zh": "吃鸡"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "winner winner chicken dinner",
|
||||||
|
"targets": {
|
||||||
|
"ko": "이겼닭! 오늘 저녁은 치킨이닭!",
|
||||||
|
"ja": "ドン勝つ!",
|
||||||
|
"zh": "大吉大利,今晚吃鸡"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "blue zone",
|
||||||
|
"targets": {
|
||||||
|
"ko": "자기장",
|
||||||
|
"ja": "ブルーゾーン",
|
||||||
|
"zh": "蓝圈"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "circle",
|
||||||
|
"targets": {
|
||||||
|
"ko": "자기장",
|
||||||
|
"ja": "サークル",
|
||||||
|
"zh": "圈"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "red zone",
|
||||||
|
"targets": {
|
||||||
|
"ko": "폭격 지역",
|
||||||
|
"ja": "レッドゾーン",
|
||||||
|
"zh": "红区"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "safe zone",
|
||||||
|
"targets": {
|
||||||
|
"ko": "안전 구역",
|
||||||
|
"ja": "セーフゾーン",
|
||||||
|
"zh": "安全区"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "zone is closing",
|
||||||
|
"targets": {
|
||||||
|
"ko": "자기장 좁혀진다",
|
||||||
|
"ja": "ゾーンが縮まる",
|
||||||
|
"zh": "圈要缩了"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "final circle",
|
||||||
|
"targets": {
|
||||||
|
"ko": "막자",
|
||||||
|
"ja": "最終サークル",
|
||||||
|
"zh": "决赛圈"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "drop",
|
||||||
|
"targets": {
|
||||||
|
"ko": "낙하",
|
||||||
|
"ja": "降下",
|
||||||
|
"zh": "跳伞"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "hot drop",
|
||||||
|
"targets": {
|
||||||
|
"ko": "핫드랍",
|
||||||
|
"ja": "ホットドロップ",
|
||||||
|
"zh": "热门点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "landing",
|
||||||
|
"targets": {
|
||||||
|
"ko": "착지",
|
||||||
|
"ja": "着地",
|
||||||
|
"zh": "落地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "where are we dropping",
|
||||||
|
"targets": {
|
||||||
|
"ko": "어디 내릴까",
|
||||||
|
"ja": "どこ降りる",
|
||||||
|
"zh": "跳哪里"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "loot",
|
||||||
|
"targets": {
|
||||||
|
"ko": "파밍",
|
||||||
|
"ja": "物資",
|
||||||
|
"zh": "物资"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "looting",
|
||||||
|
"targets": {
|
||||||
|
"ko": "파밍 중",
|
||||||
|
"ja": "漁り中",
|
||||||
|
"zh": "搜物资"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "care package",
|
||||||
|
"targets": {
|
||||||
|
"ko": "보급",
|
||||||
|
"ja": "補給物資",
|
||||||
|
"zh": "空投"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "airdrop",
|
||||||
|
"targets": {
|
||||||
|
"ko": "보급 상자",
|
||||||
|
"ja": "エアドロップ",
|
||||||
|
"zh": "空投"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "third party",
|
||||||
|
"targets": {
|
||||||
|
"ko": "어부",
|
||||||
|
"ja": "漁夫",
|
||||||
|
"zh": "第三方"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "third partied",
|
||||||
|
"targets": {
|
||||||
|
"ko": "어부 당했다",
|
||||||
|
"ja": "漁夫られた",
|
||||||
|
"zh": "被第三方了"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "squad",
|
||||||
|
"targets": {
|
||||||
|
"ko": "스쿼드",
|
||||||
|
"ja": "スクワッド",
|
||||||
|
"zh": "四排"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "duo",
|
||||||
|
"targets": {
|
||||||
|
"ko": "듀오",
|
||||||
|
"ja": "デュオ",
|
||||||
|
"zh": "双排"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "solo",
|
||||||
|
"targets": {
|
||||||
|
"ko": "솔로",
|
||||||
|
"ja": "ソロ",
|
||||||
|
"zh": "单排"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "knocked",
|
||||||
|
"targets": {
|
||||||
|
"ko": "눕혔다",
|
||||||
|
"ja": "ダウンさせた",
|
||||||
|
"zh": "打倒了"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "knocked down",
|
||||||
|
"targets": {
|
||||||
|
"ko": "기절",
|
||||||
|
"ja": "ダウン",
|
||||||
|
"zh": "倒地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "finish him",
|
||||||
|
"targets": {
|
||||||
|
"ko": "마무리해",
|
||||||
|
"ja": "とどめ刺して",
|
||||||
|
"zh": "补掉他"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "revive",
|
||||||
|
"targets": {
|
||||||
|
"ko": "살리기",
|
||||||
|
"ja": "蘇生",
|
||||||
|
"zh": "救人"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "reviving",
|
||||||
|
"targets": {
|
||||||
|
"ko": "살리는 중",
|
||||||
|
"ja": "蘇生中",
|
||||||
|
"zh": "救人中"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "res me",
|
||||||
|
"targets": {
|
||||||
|
"ko": "살려줘",
|
||||||
|
"ja": "起こして",
|
||||||
|
"zh": "救我"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "med kit",
|
||||||
|
"targets": {
|
||||||
|
"ko": "구급상자",
|
||||||
|
"ja": "救急箱",
|
||||||
|
"zh": "医疗箱"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "first aid",
|
||||||
|
"targets": {
|
||||||
|
"ko": "구급상자",
|
||||||
|
"ja": "応急手当",
|
||||||
|
"zh": "急救包"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "bandage",
|
||||||
|
"targets": {
|
||||||
|
"ko": "붕대",
|
||||||
|
"ja": "包帯",
|
||||||
|
"zh": "绷带"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "energy drink",
|
||||||
|
"targets": {
|
||||||
|
"ko": "에너지 드링크",
|
||||||
|
"ja": "エナドリ",
|
||||||
|
"zh": "饮料"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "painkiller",
|
||||||
|
"targets": {
|
||||||
|
"ko": "진통제",
|
||||||
|
"ja": "鎮痛剤",
|
||||||
|
"zh": "止痛药"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "boost",
|
||||||
|
"targets": {
|
||||||
|
"ko": "부스트",
|
||||||
|
"ja": "ブースト",
|
||||||
|
"zh": "能量"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "level 3 helmet",
|
||||||
|
"targets": {
|
||||||
|
"ko": "3렙 헬멧",
|
||||||
|
"ja": "レベル3ヘルメット",
|
||||||
|
"zh": "三级头"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "level 3 vest",
|
||||||
|
"targets": {
|
||||||
|
"ko": "3렙 조끼",
|
||||||
|
"ja": "レベル3アーマー",
|
||||||
|
"zh": "三级甲"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "backpack",
|
||||||
|
"targets": {
|
||||||
|
"ko": "가방",
|
||||||
|
"ja": "バックパック",
|
||||||
|
"zh": "背包"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "scope",
|
||||||
|
"targets": {
|
||||||
|
"ko": "배율",
|
||||||
|
"ja": "スコープ",
|
||||||
|
"zh": "倍镜"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "8x",
|
||||||
|
"targets": {
|
||||||
|
"ko": "8배율",
|
||||||
|
"ja": "8倍",
|
||||||
|
"zh": "八倍镜"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "4x",
|
||||||
|
"targets": {
|
||||||
|
"ko": "4배율",
|
||||||
|
"ja": "4倍",
|
||||||
|
"zh": "四倍镜"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "red dot",
|
||||||
|
"targets": {
|
||||||
|
"ko": "레드닷",
|
||||||
|
"ja": "レッドドット",
|
||||||
|
"zh": "红点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "suppressor",
|
||||||
|
"targets": {
|
||||||
|
"ko": "소음기",
|
||||||
|
"ja": "サプレッサー",
|
||||||
|
"zh": "消音器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "compensator",
|
||||||
|
"targets": {
|
||||||
|
"ko": "보정기",
|
||||||
|
"ja": "コンペンセイター",
|
||||||
|
"zh": "补偿器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "grip",
|
||||||
|
"targets": {
|
||||||
|
"ko": "손잡이",
|
||||||
|
"ja": "グリップ",
|
||||||
|
"zh": "握把"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "extended mag",
|
||||||
|
"targets": {
|
||||||
|
"ko": "확장 탄창",
|
||||||
|
"ja": "拡張マガジン",
|
||||||
|
"zh": "扩容弹匣"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ammo",
|
||||||
|
"targets": {
|
||||||
|
"ko": "탄약",
|
||||||
|
"ja": "弾薬",
|
||||||
|
"zh": "子弹"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "need ammo",
|
||||||
|
"targets": {
|
||||||
|
"ko": "탄 필요해",
|
||||||
|
"ja": "弾ちょうだい",
|
||||||
|
"zh": "要子弹"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "frag grenade",
|
||||||
|
"targets": {
|
||||||
|
"ko": "수류탄",
|
||||||
|
"ja": "フラグ",
|
||||||
|
"zh": "手雷"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "molotov",
|
||||||
|
"targets": {
|
||||||
|
"ko": "화염병",
|
||||||
|
"ja": "モロトフ",
|
||||||
|
"zh": "燃烧瓶"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "smoke",
|
||||||
|
"targets": {
|
||||||
|
"ko": "연막",
|
||||||
|
"ja": "スモーク",
|
||||||
|
"zh": "烟雾弹"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "stun",
|
||||||
|
"targets": {
|
||||||
|
"ko": "섬광",
|
||||||
|
"ja": "スタン",
|
||||||
|
"zh": "闪光"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "prone",
|
||||||
|
"targets": {
|
||||||
|
"ko": "엎드려",
|
||||||
|
"ja": "伏せろ",
|
||||||
|
"zh": "趴下"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "take cover",
|
||||||
|
"targets": {
|
||||||
|
"ko": "엄폐해",
|
||||||
|
"ja": "隠れて",
|
||||||
|
"zh": "找掩体"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "cover",
|
||||||
|
"targets": {
|
||||||
|
"ko": "엄폐물",
|
||||||
|
"ja": "遮蔽物",
|
||||||
|
"zh": "掩体"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "compound",
|
||||||
|
"targets": {
|
||||||
|
"ko": "건물 단지",
|
||||||
|
"ja": "建物群",
|
||||||
|
"zh": "房区"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ridge",
|
||||||
|
"targets": {
|
||||||
|
"ko": "능선",
|
||||||
|
"ja": "尾根",
|
||||||
|
"zh": "山脊"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "high ground",
|
||||||
|
"targets": {
|
||||||
|
"ko": "고지",
|
||||||
|
"ja": "高所",
|
||||||
|
"zh": "高地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "vehicle",
|
||||||
|
"targets": {
|
||||||
|
"ko": "차량",
|
||||||
|
"ja": "車両",
|
||||||
|
"zh": "载具"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "get in the car",
|
||||||
|
"targets": {
|
||||||
|
"ko": "차 타",
|
||||||
|
"ja": "車乗って",
|
||||||
|
"zh": "上车"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "fuel",
|
||||||
|
"targets": {
|
||||||
|
"ko": "연료",
|
||||||
|
"ja": "燃料",
|
||||||
|
"zh": "油"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "footsteps",
|
||||||
|
"targets": {
|
||||||
|
"ko": "발소리",
|
||||||
|
"ja": "足音",
|
||||||
|
"zh": "脚步声"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "I hear footsteps",
|
||||||
|
"targets": {
|
||||||
|
"ko": "발소리 들린다",
|
||||||
|
"ja": "足音する",
|
||||||
|
"zh": "听到脚步"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "sniper",
|
||||||
|
"targets": {
|
||||||
|
"ko": "저격수",
|
||||||
|
"ja": "スナイパー",
|
||||||
|
"zh": "狙击手"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "kar98",
|
||||||
|
"targets": {
|
||||||
|
"ko": "카구팔",
|
||||||
|
"ja": "Kar98k",
|
||||||
|
"zh": "98K"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "spectate",
|
||||||
|
"targets": {
|
||||||
|
"ko": "관전",
|
||||||
|
"ja": "観戦",
|
||||||
|
"zh": "观战"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rank push",
|
||||||
|
"targets": {
|
||||||
|
"ko": "티어 올리기",
|
||||||
|
"ja": "ランク上げ",
|
||||||
|
"zh": "上分"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
551
src/livesub/resources/glossaries/r6siege.json
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
{
|
||||||
|
"key": "r6siege",
|
||||||
|
"name": "레인보우 식스 시즈",
|
||||||
|
"description": "역할·보강·파밸·콜아웃. 한국 서버에서 실제로 쓰는 말로 맞췄습니다.",
|
||||||
|
"scope": "game",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"source": "operator",
|
||||||
|
"targets": {
|
||||||
|
"ko": "오퍼레이터",
|
||||||
|
"ja": "オペレーター",
|
||||||
|
"zh": "干员"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "op",
|
||||||
|
"targets": {
|
||||||
|
"ko": "오퍼",
|
||||||
|
"ja": "オペ",
|
||||||
|
"zh": "干员"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "attacker",
|
||||||
|
"targets": {
|
||||||
|
"ko": "공격팀",
|
||||||
|
"ja": "攻撃側",
|
||||||
|
"zh": "进攻方"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "defender",
|
||||||
|
"targets": {
|
||||||
|
"ko": "수비팀",
|
||||||
|
"ja": "防衛側",
|
||||||
|
"zh": "防守方"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "drone",
|
||||||
|
"targets": {
|
||||||
|
"ko": "드론",
|
||||||
|
"ja": "ドローン",
|
||||||
|
"zh": "无人机"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "droning",
|
||||||
|
"targets": {
|
||||||
|
"ko": "드론 정찰",
|
||||||
|
"ja": "ドローン偵察",
|
||||||
|
"zh": "无人机侦察"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "prep phase",
|
||||||
|
"targets": {
|
||||||
|
"ko": "준비 시간",
|
||||||
|
"ja": "準備フェーズ",
|
||||||
|
"zh": "准备阶段"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "roam",
|
||||||
|
"targets": {
|
||||||
|
"ko": "로밍",
|
||||||
|
"ja": "ローミング",
|
||||||
|
"zh": "游走"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "roamer",
|
||||||
|
"targets": {
|
||||||
|
"ko": "로머",
|
||||||
|
"ja": "ローマー",
|
||||||
|
"zh": "游走位"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "anchor",
|
||||||
|
"targets": {
|
||||||
|
"ko": "앵커",
|
||||||
|
"ja": "アンカー",
|
||||||
|
"zh": "守点位"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "anchoring",
|
||||||
|
"targets": {
|
||||||
|
"ko": "앵커링",
|
||||||
|
"ja": "アンカリング",
|
||||||
|
"zh": "守点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "site",
|
||||||
|
"targets": {
|
||||||
|
"ko": "거점",
|
||||||
|
"ja": "サイト",
|
||||||
|
"zh": "据点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "bomb site",
|
||||||
|
"targets": {
|
||||||
|
"ko": "폭탄 거점",
|
||||||
|
"ja": "爆弾サイト",
|
||||||
|
"zh": "炸弹点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "defuser",
|
||||||
|
"targets": {
|
||||||
|
"ko": "디퓨저",
|
||||||
|
"ja": "ディフューザー",
|
||||||
|
"zh": "拆弹器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plant",
|
||||||
|
"targets": {
|
||||||
|
"ko": "설치",
|
||||||
|
"ja": "設置",
|
||||||
|
"zh": "安装"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "planted",
|
||||||
|
"targets": {
|
||||||
|
"ko": "설치됐다",
|
||||||
|
"ja": "設置された",
|
||||||
|
"zh": "已安装"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "defuse",
|
||||||
|
"targets": {
|
||||||
|
"ko": "해체",
|
||||||
|
"ja": "解除",
|
||||||
|
"zh": "拆除"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "reinforcement",
|
||||||
|
"targets": {
|
||||||
|
"ko": "보강",
|
||||||
|
"ja": "補強",
|
||||||
|
"zh": "加固"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "reinforce",
|
||||||
|
"targets": {
|
||||||
|
"ko": "보강하다",
|
||||||
|
"ja": "補強する",
|
||||||
|
"zh": "加固"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "hard breach",
|
||||||
|
"targets": {
|
||||||
|
"ko": "하드 브리치",
|
||||||
|
"ja": "ハードブリーチ",
|
||||||
|
"zh": "硬破"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "hard breacher",
|
||||||
|
"targets": {
|
||||||
|
"ko": "하드 브리처",
|
||||||
|
"ja": "ハードブリーチャー",
|
||||||
|
"zh": "硬破手"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "soft breach",
|
||||||
|
"targets": {
|
||||||
|
"ko": "소프트 브리치",
|
||||||
|
"ja": "ソフトブリーチ",
|
||||||
|
"zh": "软破"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "breach charge",
|
||||||
|
"targets": {
|
||||||
|
"ko": "브리치 차지",
|
||||||
|
"ja": "ブリーチチャージ",
|
||||||
|
"zh": "破门炸药"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rotate hole",
|
||||||
|
"targets": {
|
||||||
|
"ko": "로테 구멍",
|
||||||
|
"ja": "ローテ穴",
|
||||||
|
"zh": "转点洞"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "murder hole",
|
||||||
|
"targets": {
|
||||||
|
"ko": "총구멍",
|
||||||
|
"ja": "マーダーホール",
|
||||||
|
"zh": "射击孔"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "hatch",
|
||||||
|
"targets": {
|
||||||
|
"ko": "해치",
|
||||||
|
"ja": "ハッチ",
|
||||||
|
"zh": "天花板洞"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rappel",
|
||||||
|
"targets": {
|
||||||
|
"ko": "레펠",
|
||||||
|
"ja": "ラペル",
|
||||||
|
"zh": "速降"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "vault",
|
||||||
|
"targets": {
|
||||||
|
"ko": "창넘기",
|
||||||
|
"ja": "乗り越え",
|
||||||
|
"zh": "翻窗"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "run out",
|
||||||
|
"targets": {
|
||||||
|
"ko": "런아웃",
|
||||||
|
"ja": "ランアウト",
|
||||||
|
"zh": "冲出"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "spawn peek",
|
||||||
|
"targets": {
|
||||||
|
"ko": "스폰킬",
|
||||||
|
"ja": "スポーンピーク",
|
||||||
|
"zh": "出生点偷袭"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "gadget",
|
||||||
|
"targets": {
|
||||||
|
"ko": "장비",
|
||||||
|
"ja": "ガジェット",
|
||||||
|
"zh": "道具"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "utility",
|
||||||
|
"targets": {
|
||||||
|
"ko": "유틸",
|
||||||
|
"ja": "ユーティリティ",
|
||||||
|
"zh": "道具"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "trap",
|
||||||
|
"targets": {
|
||||||
|
"ko": "함정",
|
||||||
|
"ja": "トラップ",
|
||||||
|
"zh": "陷阱"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "nitro cell",
|
||||||
|
"targets": {
|
||||||
|
"ko": "니트로",
|
||||||
|
"ja": "ニトロセル",
|
||||||
|
"zh": "C4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "impact",
|
||||||
|
"targets": {
|
||||||
|
"ko": "임팩트",
|
||||||
|
"ja": "インパクト",
|
||||||
|
"zh": "碰撞雷"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "flashbang",
|
||||||
|
"targets": {
|
||||||
|
"ko": "섬광탄",
|
||||||
|
"ja": "フラッシュ",
|
||||||
|
"zh": "闪光弹"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "smoke grenade",
|
||||||
|
"targets": {
|
||||||
|
"ko": "연막탄",
|
||||||
|
"ja": "スモーク",
|
||||||
|
"zh": "烟雾弹"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "claymore",
|
||||||
|
"targets": {
|
||||||
|
"ko": "클레이모어",
|
||||||
|
"ja": "クレイモア",
|
||||||
|
"zh": "阔剑雷"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "barbed wire",
|
||||||
|
"targets": {
|
||||||
|
"ko": "철조망",
|
||||||
|
"ja": "鉄条網",
|
||||||
|
"zh": "铁丝网"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "deployable shield",
|
||||||
|
"targets": {
|
||||||
|
"ko": "방패",
|
||||||
|
"ja": "展開シールド",
|
||||||
|
"zh": "可部署盾"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "bulletproof camera",
|
||||||
|
"targets": {
|
||||||
|
"ko": "방탄 카메라",
|
||||||
|
"ja": "防弾カメラ",
|
||||||
|
"zh": "防弹摄像头"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "cam",
|
||||||
|
"targets": {
|
||||||
|
"ko": "카메라",
|
||||||
|
"ja": "カメラ",
|
||||||
|
"zh": "摄像头"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "destroy the cam",
|
||||||
|
"targets": {
|
||||||
|
"ko": "카메라 깨줘",
|
||||||
|
"ja": "カメラ壊して",
|
||||||
|
"zh": "打掉摄像头"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "prefire",
|
||||||
|
"targets": {
|
||||||
|
"ko": "선샷",
|
||||||
|
"ja": "プリファイア",
|
||||||
|
"zh": "预瞄"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "peek",
|
||||||
|
"targets": {
|
||||||
|
"ko": "픽",
|
||||||
|
"ja": "ピーク",
|
||||||
|
"zh": "架点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "lean",
|
||||||
|
"targets": {
|
||||||
|
"ko": "린",
|
||||||
|
"ja": "リーン",
|
||||||
|
"zh": "侧身"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "crouch",
|
||||||
|
"targets": {
|
||||||
|
"ko": "앉기",
|
||||||
|
"ja": "しゃがみ",
|
||||||
|
"zh": "蹲"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "prone",
|
||||||
|
"targets": {
|
||||||
|
"ko": "엎드리기",
|
||||||
|
"ja": "伏せ",
|
||||||
|
"zh": "趴下"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "trade",
|
||||||
|
"targets": {
|
||||||
|
"ko": "트레이드",
|
||||||
|
"ja": "トレード",
|
||||||
|
"zh": "换人"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "entry frag",
|
||||||
|
"targets": {
|
||||||
|
"ko": "선취점",
|
||||||
|
"ja": "エントリーキル",
|
||||||
|
"zh": "首杀"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "clutch",
|
||||||
|
"targets": {
|
||||||
|
"ko": "클러치",
|
||||||
|
"ja": "クラッチ",
|
||||||
|
"zh": "残局"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ace",
|
||||||
|
"targets": {
|
||||||
|
"ko": "에이스",
|
||||||
|
"ja": "エース",
|
||||||
|
"zh": "一穿五"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "down",
|
||||||
|
"targets": {
|
||||||
|
"ko": "다운",
|
||||||
|
"ja": "ダウン",
|
||||||
|
"zh": "倒地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "dbno",
|
||||||
|
"targets": {
|
||||||
|
"ko": "기절",
|
||||||
|
"ja": "ダウン状態",
|
||||||
|
"zh": "濒死"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "revive",
|
||||||
|
"targets": {
|
||||||
|
"ko": "살리기",
|
||||||
|
"ja": "蘇生",
|
||||||
|
"zh": "扶起"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "callout",
|
||||||
|
"targets": {
|
||||||
|
"ko": "콜",
|
||||||
|
"ja": "コール",
|
||||||
|
"zh": "报点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rotate",
|
||||||
|
"targets": {
|
||||||
|
"ko": "로테",
|
||||||
|
"ja": "ローテ",
|
||||||
|
"zh": "转点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "flank",
|
||||||
|
"targets": {
|
||||||
|
"ko": "측면",
|
||||||
|
"ja": "フランク",
|
||||||
|
"zh": "绕后"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "watch flank",
|
||||||
|
"targets": {
|
||||||
|
"ko": "측면 봐줘",
|
||||||
|
"ja": "フランク見て",
|
||||||
|
"zh": "看侧面"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "above you",
|
||||||
|
"targets": {
|
||||||
|
"ko": "위에 있다",
|
||||||
|
"ja": "上にいる",
|
||||||
|
"zh": "在你上面"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "below you",
|
||||||
|
"targets": {
|
||||||
|
"ko": "아래에 있다",
|
||||||
|
"ja": "下にいる",
|
||||||
|
"zh": "在你下面"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "basement",
|
||||||
|
"targets": {
|
||||||
|
"ko": "지하",
|
||||||
|
"ja": "地下",
|
||||||
|
"zh": "地下室"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "top floor",
|
||||||
|
"targets": {
|
||||||
|
"ko": "최상층",
|
||||||
|
"ja": "最上階",
|
||||||
|
"zh": "顶层"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "outside",
|
||||||
|
"targets": {
|
||||||
|
"ko": "바깥",
|
||||||
|
"ja": "外",
|
||||||
|
"zh": "外面"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ranked",
|
||||||
|
"targets": {
|
||||||
|
"ko": "랭크",
|
||||||
|
"ja": "ランク",
|
||||||
|
"zh": "排位"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "overtime",
|
||||||
|
"targets": {
|
||||||
|
"ko": "연장",
|
||||||
|
"ja": "オーバータイム",
|
||||||
|
"zh": "加时"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ban",
|
||||||
|
"targets": {
|
||||||
|
"ko": "밴",
|
||||||
|
"ja": "バン",
|
||||||
|
"zh": "禁用"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
322
src/livesub/resources/glossaries/streaming.json
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
{
|
||||||
|
"key": "streaming",
|
||||||
|
"name": "방송·스트리밍",
|
||||||
|
"description": "해외 방송을 볼 때 자주 나오는 말. 게임 용어집과 같이 켜면 좋습니다.",
|
||||||
|
"scope": "generic",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"source": "stream",
|
||||||
|
"targets": {
|
||||||
|
"ko": "방송",
|
||||||
|
"ja": "配信",
|
||||||
|
"zh": "直播"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "streamer",
|
||||||
|
"targets": {
|
||||||
|
"ko": "스트리머",
|
||||||
|
"ja": "配信者",
|
||||||
|
"zh": "主播"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "chat",
|
||||||
|
"targets": {
|
||||||
|
"ko": "채팅",
|
||||||
|
"ja": "チャット",
|
||||||
|
"zh": "弹幕"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "donation",
|
||||||
|
"targets": {
|
||||||
|
"ko": "후원",
|
||||||
|
"ja": "投げ銭",
|
||||||
|
"zh": "打赏"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "donate",
|
||||||
|
"targets": {
|
||||||
|
"ko": "후원하다",
|
||||||
|
"ja": "投げ銭する",
|
||||||
|
"zh": "打赏"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "sub",
|
||||||
|
"targets": {
|
||||||
|
"ko": "구독",
|
||||||
|
"ja": "サブスク",
|
||||||
|
"zh": "订阅"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "subscriber",
|
||||||
|
"targets": {
|
||||||
|
"ko": "구독자",
|
||||||
|
"ja": "登録者",
|
||||||
|
"zh": "订阅者"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "follow",
|
||||||
|
"targets": {
|
||||||
|
"ko": "팔로우",
|
||||||
|
"ja": "フォロー",
|
||||||
|
"zh": "关注"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "raid",
|
||||||
|
"targets": {
|
||||||
|
"ko": "합방 몰아주기",
|
||||||
|
"ja": "レイド",
|
||||||
|
"zh": "转播"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "mod",
|
||||||
|
"targets": {
|
||||||
|
"ko": "매니저",
|
||||||
|
"ja": "モデレーター",
|
||||||
|
"zh": "房管"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "clip",
|
||||||
|
"targets": {
|
||||||
|
"ko": "클립",
|
||||||
|
"ja": "クリップ",
|
||||||
|
"zh": "切片"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "highlight",
|
||||||
|
"targets": {
|
||||||
|
"ko": "하이라이트",
|
||||||
|
"ja": "ハイライト",
|
||||||
|
"zh": "高光"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "VOD",
|
||||||
|
"targets": {
|
||||||
|
"ko": "다시보기",
|
||||||
|
"ja": "アーカイブ",
|
||||||
|
"zh": "录播"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "lag",
|
||||||
|
"targets": {
|
||||||
|
"ko": "렉",
|
||||||
|
"ja": "ラグ",
|
||||||
|
"zh": "卡"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ping",
|
||||||
|
"targets": {
|
||||||
|
"ko": "핑",
|
||||||
|
"ja": "ピン",
|
||||||
|
"zh": "延迟"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "frame drop",
|
||||||
|
"targets": {
|
||||||
|
"ko": "프레임 드랍",
|
||||||
|
"ja": "フレーム落ち",
|
||||||
|
"zh": "掉帧"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "patch",
|
||||||
|
"targets": {
|
||||||
|
"ko": "패치",
|
||||||
|
"ja": "パッチ",
|
||||||
|
"zh": "补丁"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "meta",
|
||||||
|
"targets": {
|
||||||
|
"ko": "메타",
|
||||||
|
"ja": "メタ",
|
||||||
|
"zh": "版本环境"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "nerf",
|
||||||
|
"targets": {
|
||||||
|
"ko": "너프",
|
||||||
|
"ja": "ナーフ",
|
||||||
|
"zh": "削弱"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "buff",
|
||||||
|
"targets": {
|
||||||
|
"ko": "버프",
|
||||||
|
"ja": "バフ",
|
||||||
|
"zh": "加强"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ranked",
|
||||||
|
"targets": {
|
||||||
|
"ko": "랭크",
|
||||||
|
"ja": "ランク",
|
||||||
|
"zh": "排位"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "casual",
|
||||||
|
"targets": {
|
||||||
|
"ko": "일반전",
|
||||||
|
"ja": "カジュアル",
|
||||||
|
"zh": "休闲"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "smurf",
|
||||||
|
"targets": {
|
||||||
|
"ko": "부계정",
|
||||||
|
"ja": "サブ垢",
|
||||||
|
"zh": "小号"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "grind",
|
||||||
|
"targets": {
|
||||||
|
"ko": "노가다",
|
||||||
|
"ja": "周回",
|
||||||
|
"zh": "刷"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "carry",
|
||||||
|
"targets": {
|
||||||
|
"ko": "캐리",
|
||||||
|
"ja": "キャリー",
|
||||||
|
"zh": "带飞"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "throw",
|
||||||
|
"targets": {
|
||||||
|
"ko": "던지다",
|
||||||
|
"ja": "スローする",
|
||||||
|
"zh": "送"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "griefing",
|
||||||
|
"targets": {
|
||||||
|
"ko": "트롤링",
|
||||||
|
"ja": "荒らし",
|
||||||
|
"zh": "演员"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "toxic",
|
||||||
|
"targets": {
|
||||||
|
"ko": "매너 나쁜",
|
||||||
|
"ja": "民度が低い",
|
||||||
|
"zh": "喷子"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "cracked",
|
||||||
|
"targets": {
|
||||||
|
"ko": "미쳤다",
|
||||||
|
"ja": "ヤバい",
|
||||||
|
"zh": "太强了"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "insane",
|
||||||
|
"targets": {
|
||||||
|
"ko": "대박",
|
||||||
|
"ja": "やばい",
|
||||||
|
"zh": "离谱"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "clutch",
|
||||||
|
"targets": {
|
||||||
|
"ko": "클러치",
|
||||||
|
"ja": "クラッチ",
|
||||||
|
"zh": "关键时刻"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "let's go",
|
||||||
|
"targets": {
|
||||||
|
"ko": "가자",
|
||||||
|
"ja": "行くぞ",
|
||||||
|
"zh": "冲"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "no way",
|
||||||
|
"targets": {
|
||||||
|
"ko": "말도 안 돼",
|
||||||
|
"ja": "ありえない",
|
||||||
|
"zh": "不可能吧"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "give me a second",
|
||||||
|
"targets": {
|
||||||
|
"ko": "잠깐만",
|
||||||
|
"ja": "ちょっと待って",
|
||||||
|
"zh": "等一下"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "hold on",
|
||||||
|
"targets": {
|
||||||
|
"ko": "잠깐",
|
||||||
|
"ja": "待って",
|
||||||
|
"zh": "等等"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "be right back",
|
||||||
|
"targets": {
|
||||||
|
"ko": "금방 올게",
|
||||||
|
"ja": "すぐ戻る",
|
||||||
|
"zh": "马上回来"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "thanks for the sub",
|
||||||
|
"targets": {
|
||||||
|
"ko": "구독 고마워",
|
||||||
|
"ja": "サブありがとう",
|
||||||
|
"zh": "谢谢订阅"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "welcome back",
|
||||||
|
"targets": {
|
||||||
|
"ko": "다시 왔네",
|
||||||
|
"ja": "おかえり",
|
||||||
|
"zh": "欢迎回来"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
482
src/livesub/resources/glossaries/valorant.json
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
{
|
||||||
|
"key": "valorant",
|
||||||
|
"name": "발로란트",
|
||||||
|
"description": "스파이크·유틸·이코노미 콜. 아시아 서버 영어 콜아웃에 맞췄습니다.",
|
||||||
|
"scope": "game",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"source": "spike",
|
||||||
|
"targets": {
|
||||||
|
"ko": "스파이크",
|
||||||
|
"ja": "スパイク",
|
||||||
|
"zh": "斩杀者"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plant",
|
||||||
|
"targets": {
|
||||||
|
"ko": "설치",
|
||||||
|
"ja": "設置",
|
||||||
|
"zh": "安装"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "planted",
|
||||||
|
"targets": {
|
||||||
|
"ko": "설치됐다",
|
||||||
|
"ja": "設置された",
|
||||||
|
"zh": "已安装"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "defuse",
|
||||||
|
"targets": {
|
||||||
|
"ko": "해체",
|
||||||
|
"ja": "解除",
|
||||||
|
"zh": "拆包"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "defusing",
|
||||||
|
"targets": {
|
||||||
|
"ko": "해체 중",
|
||||||
|
"ja": "解除中",
|
||||||
|
"zh": "拆包中"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "fake defuse",
|
||||||
|
"targets": {
|
||||||
|
"ko": "페이크 해체",
|
||||||
|
"ja": "フェイク解除",
|
||||||
|
"zh": "假拆"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "post plant",
|
||||||
|
"targets": {
|
||||||
|
"ko": "설치 후",
|
||||||
|
"ja": "ポストプラント",
|
||||||
|
"zh": "安装后"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "retake",
|
||||||
|
"targets": {
|
||||||
|
"ko": "리테이크",
|
||||||
|
"ja": "リテイク",
|
||||||
|
"zh": "残局夺回"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "site",
|
||||||
|
"targets": {
|
||||||
|
"ko": "사이트",
|
||||||
|
"ja": "サイト",
|
||||||
|
"zh": "包点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "A site",
|
||||||
|
"targets": {
|
||||||
|
"ko": "A사이트",
|
||||||
|
"ja": "Aサイト",
|
||||||
|
"zh": "A点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "B site",
|
||||||
|
"targets": {
|
||||||
|
"ko": "B사이트",
|
||||||
|
"ja": "Bサイト",
|
||||||
|
"zh": "B点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "mid",
|
||||||
|
"targets": {
|
||||||
|
"ko": "미드",
|
||||||
|
"ja": "ミッド",
|
||||||
|
"zh": "中路"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rotate",
|
||||||
|
"targets": {
|
||||||
|
"ko": "로테",
|
||||||
|
"ja": "ローテ",
|
||||||
|
"zh": "换点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rotating",
|
||||||
|
"targets": {
|
||||||
|
"ko": "로테 중",
|
||||||
|
"ja": "ローテ中",
|
||||||
|
"zh": "换点中"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "eco",
|
||||||
|
"targets": {
|
||||||
|
"ko": "이코",
|
||||||
|
"ja": "エコ",
|
||||||
|
"zh": "经济局"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "full eco",
|
||||||
|
"targets": {
|
||||||
|
"ko": "풀이코",
|
||||||
|
"ja": "フルエコ",
|
||||||
|
"zh": "全经济局"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "eco round",
|
||||||
|
"targets": {
|
||||||
|
"ko": "이코 라운드",
|
||||||
|
"ja": "エコラウンド",
|
||||||
|
"zh": "经济局"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "save",
|
||||||
|
"targets": {
|
||||||
|
"ko": "세이브",
|
||||||
|
"ja": "セーブ",
|
||||||
|
"zh": "存钱"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "buy round",
|
||||||
|
"targets": {
|
||||||
|
"ko": "풀 구매",
|
||||||
|
"ja": "フルバイ",
|
||||||
|
"zh": "全起"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "force buy",
|
||||||
|
"targets": {
|
||||||
|
"ko": "강제 구매",
|
||||||
|
"ja": "フォースバイ",
|
||||||
|
"zh": "强起"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "pistol round",
|
||||||
|
"targets": {
|
||||||
|
"ko": "권총 라운드",
|
||||||
|
"ja": "ピストルラウンド",
|
||||||
|
"zh": "手枪局"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ult",
|
||||||
|
"targets": {
|
||||||
|
"ko": "궁",
|
||||||
|
"ja": "アルト",
|
||||||
|
"zh": "大招"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ultimate",
|
||||||
|
"targets": {
|
||||||
|
"ko": "궁극기",
|
||||||
|
"ja": "アルティメット",
|
||||||
|
"zh": "终极技能"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ult orb",
|
||||||
|
"targets": {
|
||||||
|
"ko": "궁 오브",
|
||||||
|
"ja": "アルトオーブ",
|
||||||
|
"zh": "能量球"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "util",
|
||||||
|
"targets": {
|
||||||
|
"ko": "유틸",
|
||||||
|
"ja": "ユーティリティ",
|
||||||
|
"zh": "技能"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "utility",
|
||||||
|
"targets": {
|
||||||
|
"ko": "유틸",
|
||||||
|
"ja": "ユーティリティ",
|
||||||
|
"zh": "技能"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "smoke",
|
||||||
|
"targets": {
|
||||||
|
"ko": "연막",
|
||||||
|
"ja": "スモーク",
|
||||||
|
"zh": "烟"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "flash",
|
||||||
|
"targets": {
|
||||||
|
"ko": "섬광",
|
||||||
|
"ja": "フラッシュ",
|
||||||
|
"zh": "闪"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "flashed",
|
||||||
|
"targets": {
|
||||||
|
"ko": "섬광 맞음",
|
||||||
|
"ja": "フラッシュ食らった",
|
||||||
|
"zh": "被闪了"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "molly",
|
||||||
|
"targets": {
|
||||||
|
"ko": "몰리",
|
||||||
|
"ja": "モリー",
|
||||||
|
"zh": "火"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "wall",
|
||||||
|
"targets": {
|
||||||
|
"ko": "벽",
|
||||||
|
"ja": "ウォール",
|
||||||
|
"zh": "墙"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "recon",
|
||||||
|
"targets": {
|
||||||
|
"ko": "정찰",
|
||||||
|
"ja": "リコン",
|
||||||
|
"zh": "侦察"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "one tap",
|
||||||
|
"targets": {
|
||||||
|
"ko": "원탭",
|
||||||
|
"ja": "ワンタップ",
|
||||||
|
"zh": "一枪"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "headshot",
|
||||||
|
"targets": {
|
||||||
|
"ko": "헤드샷",
|
||||||
|
"ja": "ヘッドショット",
|
||||||
|
"zh": "爆头"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "entry",
|
||||||
|
"targets": {
|
||||||
|
"ko": "진입",
|
||||||
|
"ja": "エントリー",
|
||||||
|
"zh": "突破"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "entry frag",
|
||||||
|
"targets": {
|
||||||
|
"ko": "선취점",
|
||||||
|
"ja": "エントリーキル",
|
||||||
|
"zh": "首杀"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "trade",
|
||||||
|
"targets": {
|
||||||
|
"ko": "트레이드",
|
||||||
|
"ja": "トレード",
|
||||||
|
"zh": "换人"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "peek",
|
||||||
|
"targets": {
|
||||||
|
"ko": "픽",
|
||||||
|
"ja": "ピーク",
|
||||||
|
"zh": "架点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "wide peek",
|
||||||
|
"targets": {
|
||||||
|
"ko": "와이드 픽",
|
||||||
|
"ja": "ワイドピーク",
|
||||||
|
"zh": "大幅架点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "clear",
|
||||||
|
"targets": {
|
||||||
|
"ko": "클리어",
|
||||||
|
"ja": "クリア",
|
||||||
|
"zh": "清点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rush",
|
||||||
|
"targets": {
|
||||||
|
"ko": "러쉬",
|
||||||
|
"ja": "ラッシュ",
|
||||||
|
"zh": "强攻"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "stack",
|
||||||
|
"targets": {
|
||||||
|
"ko": "몰빵",
|
||||||
|
"ja": "スタック",
|
||||||
|
"zh": "集火"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "lurk",
|
||||||
|
"targets": {
|
||||||
|
"ko": "럴커",
|
||||||
|
"ja": "ラーク",
|
||||||
|
"zh": "潜伏"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "op",
|
||||||
|
"targets": {
|
||||||
|
"ko": "오페",
|
||||||
|
"ja": "オペ",
|
||||||
|
"zh": "狙"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "operator",
|
||||||
|
"targets": {
|
||||||
|
"ko": "오퍼레이터",
|
||||||
|
"ja": "オペレーター",
|
||||||
|
"zh": "狙击枪"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "clutch",
|
||||||
|
"targets": {
|
||||||
|
"ko": "클러치",
|
||||||
|
"ja": "クラッチ",
|
||||||
|
"zh": "残局"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ace",
|
||||||
|
"targets": {
|
||||||
|
"ko": "에이스",
|
||||||
|
"ja": "エース",
|
||||||
|
"zh": "一穿五"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "spray",
|
||||||
|
"targets": {
|
||||||
|
"ko": "연사",
|
||||||
|
"ja": "スプレー",
|
||||||
|
"zh": "扫射"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "crosshair",
|
||||||
|
"targets": {
|
||||||
|
"ko": "크로스헤어",
|
||||||
|
"ja": "クロスヘア",
|
||||||
|
"zh": "准星"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "callout",
|
||||||
|
"targets": {
|
||||||
|
"ko": "콜",
|
||||||
|
"ja": "コール",
|
||||||
|
"zh": "报点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "spotted",
|
||||||
|
"targets": {
|
||||||
|
"ko": "봤다",
|
||||||
|
"ja": "発見",
|
||||||
|
"zh": "看到了"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "one down",
|
||||||
|
"targets": {
|
||||||
|
"ko": "한 명 잡음",
|
||||||
|
"ja": "一人ダウン",
|
||||||
|
"zh": "倒一个"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "man advantage",
|
||||||
|
"targets": {
|
||||||
|
"ko": "인원 우위",
|
||||||
|
"ja": "人数有利",
|
||||||
|
"zh": "人数优势"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "low",
|
||||||
|
"targets": {
|
||||||
|
"ko": "피 없음",
|
||||||
|
"ja": "体力低い",
|
||||||
|
"zh": "残血"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "armor",
|
||||||
|
"targets": {
|
||||||
|
"ko": "방어구",
|
||||||
|
"ja": "アーマー",
|
||||||
|
"zh": "护甲"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "creds",
|
||||||
|
"targets": {
|
||||||
|
"ko": "크레딧",
|
||||||
|
"ja": "クレジット",
|
||||||
|
"zh": "钱"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "drop",
|
||||||
|
"targets": {
|
||||||
|
"ko": "드랍",
|
||||||
|
"ja": "ドロップ",
|
||||||
|
"zh": "丢枪"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "overtime",
|
||||||
|
"targets": {
|
||||||
|
"ko": "연장",
|
||||||
|
"ja": "オーバータイム",
|
||||||
|
"zh": "加时"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "match point",
|
||||||
|
"targets": {
|
||||||
|
"ko": "매치포인트",
|
||||||
|
"ja": "マッチポイント",
|
||||||
|
"zh": "赛点"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
487
src/livesub/resources/glossaries/watchdogs.json
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
{
|
||||||
|
"key": "watchdogs",
|
||||||
|
"name": "워치독스 (Watch Dogs)",
|
||||||
|
"description": "1·2·리전 공통. 조직명과 해킹 용어는 고유명사라 번역기가 특히 잘 틀립니다.",
|
||||||
|
"scope": "game",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"source": "DedSec",
|
||||||
|
"targets": {
|
||||||
|
"ko": "데드섹",
|
||||||
|
"ja": "デッドセック",
|
||||||
|
"zh": "DedSec"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ctOS",
|
||||||
|
"targets": {
|
||||||
|
"ko": "ctOS",
|
||||||
|
"ja": "ctOS",
|
||||||
|
"zh": "ctOS"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Blume",
|
||||||
|
"targets": {
|
||||||
|
"ko": "블룸",
|
||||||
|
"ja": "ブルーム",
|
||||||
|
"zh": "布鲁姆"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Albion",
|
||||||
|
"targets": {
|
||||||
|
"ko": "알비온",
|
||||||
|
"ja": "アルビオン",
|
||||||
|
"zh": "阿尔比恩"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Clan Kelley",
|
||||||
|
"targets": {
|
||||||
|
"ko": "켈리 클랜",
|
||||||
|
"ja": "ケリー一家",
|
||||||
|
"zh": "凯利帮"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Zero Day",
|
||||||
|
"targets": {
|
||||||
|
"ko": "제로 데이",
|
||||||
|
"ja": "ゼロデイ",
|
||||||
|
"zh": "零日"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Bratva",
|
||||||
|
"targets": {
|
||||||
|
"ko": "브라트바",
|
||||||
|
"ja": "ブラトヴァ",
|
||||||
|
"zh": "兄弟会"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Viceroys",
|
||||||
|
"targets": {
|
||||||
|
"ko": "바이스로이",
|
||||||
|
"ja": "ヴァイスロイ",
|
||||||
|
"zh": "总督帮"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "the Fixer",
|
||||||
|
"targets": {
|
||||||
|
"ko": "해결사",
|
||||||
|
"ja": "フィクサー",
|
||||||
|
"zh": "中间人"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "fixer contract",
|
||||||
|
"targets": {
|
||||||
|
"ko": "해결사 의뢰",
|
||||||
|
"ja": "フィクサー依頼",
|
||||||
|
"zh": "中间人任务"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "operative",
|
||||||
|
"targets": {
|
||||||
|
"ko": "요원",
|
||||||
|
"ja": "オペレーティブ",
|
||||||
|
"zh": "特工"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "recruit",
|
||||||
|
"targets": {
|
||||||
|
"ko": "영입",
|
||||||
|
"ja": "勧誘",
|
||||||
|
"zh": "招募"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "permadeath",
|
||||||
|
"targets": {
|
||||||
|
"ko": "영구 사망",
|
||||||
|
"ja": "パーマデス",
|
||||||
|
"zh": "永久死亡"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "hack",
|
||||||
|
"targets": {
|
||||||
|
"ko": "해킹",
|
||||||
|
"ja": "ハッキング",
|
||||||
|
"zh": "黑入"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "hacked",
|
||||||
|
"targets": {
|
||||||
|
"ko": "해킹됐다",
|
||||||
|
"ja": "ハックされた",
|
||||||
|
"zh": "被黑了"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "remote hack",
|
||||||
|
"targets": {
|
||||||
|
"ko": "원격 해킹",
|
||||||
|
"ja": "リモートハック",
|
||||||
|
"zh": "远程黑入"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "backdoor",
|
||||||
|
"targets": {
|
||||||
|
"ko": "백도어",
|
||||||
|
"ja": "バックドア",
|
||||||
|
"zh": "后门"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "firewall",
|
||||||
|
"targets": {
|
||||||
|
"ko": "방화벽",
|
||||||
|
"ja": "ファイアウォール",
|
||||||
|
"zh": "防火墙"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "exploit",
|
||||||
|
"targets": {
|
||||||
|
"ko": "익스플로잇",
|
||||||
|
"ja": "エクスプロイト",
|
||||||
|
"zh": "漏洞利用"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "payload",
|
||||||
|
"targets": {
|
||||||
|
"ko": "페이로드",
|
||||||
|
"ja": "ペイロード",
|
||||||
|
"zh": "载荷"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "botnet",
|
||||||
|
"targets": {
|
||||||
|
"ko": "봇넷",
|
||||||
|
"ja": "ボットネット",
|
||||||
|
"zh": "僵尸网络"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "server farm",
|
||||||
|
"targets": {
|
||||||
|
"ko": "서버 팜",
|
||||||
|
"ja": "サーバーファーム",
|
||||||
|
"zh": "服务器农场"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "data breach",
|
||||||
|
"targets": {
|
||||||
|
"ko": "정보 유출",
|
||||||
|
"ja": "情報漏洩",
|
||||||
|
"zh": "数据泄露"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "encryption",
|
||||||
|
"targets": {
|
||||||
|
"ko": "암호화",
|
||||||
|
"ja": "暗号化",
|
||||||
|
"zh": "加密"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "profiler",
|
||||||
|
"targets": {
|
||||||
|
"ko": "프로파일러",
|
||||||
|
"ja": "プロファイラー",
|
||||||
|
"zh": "分析器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "profiling",
|
||||||
|
"targets": {
|
||||||
|
"ko": "신원 조회",
|
||||||
|
"ja": "プロファイリング",
|
||||||
|
"zh": "身份分析"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "surveillance",
|
||||||
|
"targets": {
|
||||||
|
"ko": "감시",
|
||||||
|
"ja": "監視",
|
||||||
|
"zh": "监控"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "security camera",
|
||||||
|
"targets": {
|
||||||
|
"ko": "감시 카메라",
|
||||||
|
"ja": "防犯カメラ",
|
||||||
|
"zh": "监控摄像头"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "jump to camera",
|
||||||
|
"targets": {
|
||||||
|
"ko": "카메라 이동",
|
||||||
|
"ja": "カメラに移動",
|
||||||
|
"zh": "切换摄像头"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NetHack",
|
||||||
|
"targets": {
|
||||||
|
"ko": "넷핵",
|
||||||
|
"ja": "ネットハック",
|
||||||
|
"zh": "网络视野"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "spiderbot",
|
||||||
|
"targets": {
|
||||||
|
"ko": "스파이더봇",
|
||||||
|
"ja": "スパイダーボット",
|
||||||
|
"zh": "蜘蛛机器人"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "quadcopter",
|
||||||
|
"targets": {
|
||||||
|
"ko": "쿼드콥터",
|
||||||
|
"ja": "クアッドコプター",
|
||||||
|
"zh": "四轴飞行器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "jumper",
|
||||||
|
"targets": {
|
||||||
|
"ko": "점퍼",
|
||||||
|
"ja": "ジャンパー",
|
||||||
|
"zh": "跳跳车"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "RC car",
|
||||||
|
"targets": {
|
||||||
|
"ko": "RC카",
|
||||||
|
"ja": "ラジコン",
|
||||||
|
"zh": "遥控车"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "cargo drone",
|
||||||
|
"targets": {
|
||||||
|
"ko": "화물 드론",
|
||||||
|
"ja": "カーゴドローン",
|
||||||
|
"zh": "货运无人机"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "turret",
|
||||||
|
"targets": {
|
||||||
|
"ko": "터렛",
|
||||||
|
"ja": "タレット",
|
||||||
|
"zh": "炮塔"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "guard",
|
||||||
|
"targets": {
|
||||||
|
"ko": "경비원",
|
||||||
|
"ja": "警備員",
|
||||||
|
"zh": "守卫"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "restricted area",
|
||||||
|
"targets": {
|
||||||
|
"ko": "제한 구역",
|
||||||
|
"ja": "立ち入り禁止区域",
|
||||||
|
"zh": "禁区"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "detected",
|
||||||
|
"targets": {
|
||||||
|
"ko": "발각됐다",
|
||||||
|
"ja": "見つかった",
|
||||||
|
"zh": "被发现了"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "stay hidden",
|
||||||
|
"targets": {
|
||||||
|
"ko": "숨어 있어",
|
||||||
|
"ja": "隠れてろ",
|
||||||
|
"zh": "别暴露"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "take down",
|
||||||
|
"targets": {
|
||||||
|
"ko": "제압",
|
||||||
|
"ja": "テイクダウン",
|
||||||
|
"zh": "制服"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "non lethal",
|
||||||
|
"targets": {
|
||||||
|
"ko": "비살상",
|
||||||
|
"ja": "非殺傷",
|
||||||
|
"zh": "非致命"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "lethal",
|
||||||
|
"targets": {
|
||||||
|
"ko": "살상",
|
||||||
|
"ja": "殺傷",
|
||||||
|
"zh": "致命"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "heat level",
|
||||||
|
"targets": {
|
||||||
|
"ko": "수배 단계",
|
||||||
|
"ja": "指名手配レベル",
|
||||||
|
"zh": "通缉等级"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "wanted",
|
||||||
|
"targets": {
|
||||||
|
"ko": "수배 중",
|
||||||
|
"ja": "指名手配",
|
||||||
|
"zh": "被通缉"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "lose the cops",
|
||||||
|
"targets": {
|
||||||
|
"ko": "경찰 따돌려",
|
||||||
|
"ja": "警察撒いて",
|
||||||
|
"zh": "甩掉警察"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "traffic light",
|
||||||
|
"targets": {
|
||||||
|
"ko": "신호등",
|
||||||
|
"ja": "信号機",
|
||||||
|
"zh": "红绿灯"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "steam pipe",
|
||||||
|
"targets": {
|
||||||
|
"ko": "증기관",
|
||||||
|
"ja": "蒸気管",
|
||||||
|
"zh": "蒸汽管道"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "blocker",
|
||||||
|
"targets": {
|
||||||
|
"ko": "차단봉",
|
||||||
|
"ja": "ブロッカー",
|
||||||
|
"zh": "路障"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "escape",
|
||||||
|
"targets": {
|
||||||
|
"ko": "도주",
|
||||||
|
"ja": "逃走",
|
||||||
|
"zh": "逃脱"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "mission",
|
||||||
|
"targets": {
|
||||||
|
"ko": "임무",
|
||||||
|
"ja": "ミッション",
|
||||||
|
"zh": "任务"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "side mission",
|
||||||
|
"targets": {
|
||||||
|
"ko": "서브 임무",
|
||||||
|
"ja": "サブミッション",
|
||||||
|
"zh": "支线任务"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "checkpoint",
|
||||||
|
"targets": {
|
||||||
|
"ko": "체크포인트",
|
||||||
|
"ja": "チェックポイント",
|
||||||
|
"zh": "检查点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "borough",
|
||||||
|
"targets": {
|
||||||
|
"ko": "자치구",
|
||||||
|
"ja": "区",
|
||||||
|
"zh": "行政区"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "uprising",
|
||||||
|
"targets": {
|
||||||
|
"ko": "봉기",
|
||||||
|
"ja": "蜂起",
|
||||||
|
"zh": "起义"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "resistance",
|
||||||
|
"targets": {
|
||||||
|
"ko": "저항군",
|
||||||
|
"ja": "レジスタンス",
|
||||||
|
"zh": "抵抗组织"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "whistleblower",
|
||||||
|
"targets": {
|
||||||
|
"ko": "내부 고발자",
|
||||||
|
"ja": "内部告発者",
|
||||||
|
"zh": "举报人"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "privacy",
|
||||||
|
"targets": {
|
||||||
|
"ko": "사생활",
|
||||||
|
"ja": "プライバシー",
|
||||||
|
"zh": "隐私"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "the city is watching",
|
||||||
|
"targets": {
|
||||||
|
"ko": "도시가 지켜보고 있다",
|
||||||
|
"ja": "街が見ている",
|
||||||
|
"zh": "城市在注视"
|
||||||
|
},
|
||||||
|
"whole_only": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
src/livesub/resources/icon.ico
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
src/livesub/resources/icon.png
Normal file
|
After Width: | Height: | Size: 209 KiB |
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
|
||||||
6
src/livesub/ui/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Qt UI 레이어."""
|
||||||
|
|
||||||
|
from .main_window import MainWindow
|
||||||
|
from .overlay import SubtitleOverlay, SubtitlePreview
|
||||||
|
|
||||||
|
__all__ = ["MainWindow", "SubtitleOverlay", "SubtitlePreview"]
|
||||||
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()
|
||||||
173
src/livesub/ui/hotkeys.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""전역 단축키.
|
||||||
|
|
||||||
|
Qt 의 QShortcut 은 우리 창이 포커스를 가졌을 때만 동작한다. 게임을 하는 중에
|
||||||
|
자막을 껐다 켜야 하므로 OS 레벨 단축키가 필요하다. Windows 에서는
|
||||||
|
`RegisterHotKey` + 네이티브 이벤트 필터로 구현하고, 다른 OS 에서는 조용히
|
||||||
|
아무 것도 하지 않는다(창이 떠 있을 때만 쓰는 QShortcut 은 상위에서 따로 건다).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from PySide6.QtCore import QAbstractNativeEventFilter, QObject
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WM_HOTKEY = 0x0312
|
||||||
|
|
||||||
|
MOD_ALT = 0x0001
|
||||||
|
MOD_CONTROL = 0x0002
|
||||||
|
MOD_SHIFT = 0x0004
|
||||||
|
MOD_WIN = 0x0008
|
||||||
|
MOD_NOREPEAT = 0x4000
|
||||||
|
|
||||||
|
_MODIFIERS = {
|
||||||
|
"ctrl": MOD_CONTROL,
|
||||||
|
"control": MOD_CONTROL,
|
||||||
|
"alt": MOD_ALT,
|
||||||
|
"shift": MOD_SHIFT,
|
||||||
|
"win": MOD_WIN,
|
||||||
|
"meta": MOD_WIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 문자/숫자가 아닌 키의 가상 키 코드
|
||||||
|
_NAMED_KEYS = {
|
||||||
|
"f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73, "f5": 0x74, "f6": 0x75,
|
||||||
|
"f7": 0x76, "f8": 0x77, "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
|
||||||
|
"space": 0x20, "enter": 0x0D, "return": 0x0D, "tab": 0x09, "esc": 0x1B,
|
||||||
|
"escape": 0x1B, "insert": 0x2D, "delete": 0x2E, "home": 0x24, "end": 0x23,
|
||||||
|
"pageup": 0x21, "pagedown": 0x22, "up": 0x26, "down": 0x28,
|
||||||
|
"left": 0x25, "right": 0x27, "`": 0xC0, "-": 0xBD, "=": 0xBB,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HotkeyError(RuntimeError):
|
||||||
|
"""단축키를 등록할 수 없을 때 (대개 다른 프로그램이 선점)."""
|
||||||
|
|
||||||
|
|
||||||
|
def parse(sequence: str) -> tuple[int, int]:
|
||||||
|
""""Ctrl+Alt+S" → (수정자 비트마스크, 가상 키 코드)."""
|
||||||
|
parts = [p.strip().lower() for p in sequence.split("+") if p.strip()]
|
||||||
|
if not parts:
|
||||||
|
raise HotkeyError(f"빈 단축키입니다: {sequence!r}")
|
||||||
|
|
||||||
|
mods = 0
|
||||||
|
key_part = ""
|
||||||
|
for part in parts:
|
||||||
|
if part in _MODIFIERS:
|
||||||
|
mods |= _MODIFIERS[part]
|
||||||
|
else:
|
||||||
|
key_part = part
|
||||||
|
|
||||||
|
if not key_part:
|
||||||
|
raise HotkeyError(f"조합키만 있고 실제 키가 없습니다: {sequence!r}")
|
||||||
|
if not mods:
|
||||||
|
# 수정자 없는 전역 단축키는 게임 입력을 통째로 가로채므로 막는다.
|
||||||
|
raise HotkeyError(f"Ctrl/Alt/Shift 중 하나는 있어야 합니다: {sequence!r}")
|
||||||
|
|
||||||
|
if key_part in _NAMED_KEYS:
|
||||||
|
vk = _NAMED_KEYS[key_part]
|
||||||
|
elif len(key_part) == 1 and key_part.isalnum():
|
||||||
|
vk = ord(key_part.upper())
|
||||||
|
else:
|
||||||
|
raise HotkeyError(f"알 수 없는 키입니다: {key_part!r}")
|
||||||
|
|
||||||
|
return mods | MOD_NOREPEAT, vk
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalHotkeys(QObject, QAbstractNativeEventFilter):
|
||||||
|
"""전역 단축키 묶음. Windows 전용이며 그 외에서는 no-op."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
|
QObject.__init__(self, parent)
|
||||||
|
QAbstractNativeEventFilter.__init__(self)
|
||||||
|
self._callbacks: dict[int, Callable[[], None]] = {}
|
||||||
|
self._next_id = 1
|
||||||
|
self._installed = False
|
||||||
|
self._last_error = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported(self) -> bool:
|
||||||
|
return sys.platform == "win32"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str:
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
|
def register(self, sequence: str, callback: Callable[[], None]) -> bool:
|
||||||
|
"""단축키 하나를 등록한다. 성공하면 True."""
|
||||||
|
if not self.supported:
|
||||||
|
self._last_error = "전역 단축키는 Windows에서만 지원합니다."
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mods, vk = parse(sequence)
|
||||||
|
except HotkeyError as exc:
|
||||||
|
self._last_error = str(exc)
|
||||||
|
log.warning("단축키 해석 실패: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
hotkey_id = self._next_id
|
||||||
|
if not ctypes.windll.user32.RegisterHotKey(None, hotkey_id, mods, vk):
|
||||||
|
self._last_error = (
|
||||||
|
f"'{sequence}' 를 등록하지 못했습니다. 다른 프로그램이 쓰고 있을 수 있습니다."
|
||||||
|
)
|
||||||
|
log.warning(self._last_error)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._callbacks[hotkey_id] = callback
|
||||||
|
self._next_id += 1
|
||||||
|
self._ensure_filter()
|
||||||
|
log.info("전역 단축키 등록: %s", sequence)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unregister_all(self) -> None:
|
||||||
|
if not self.supported or not self._callbacks:
|
||||||
|
return
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
for hotkey_id in self._callbacks:
|
||||||
|
ctypes.windll.user32.UnregisterHotKey(None, hotkey_id)
|
||||||
|
self._callbacks.clear()
|
||||||
|
|
||||||
|
if self._installed:
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is not None:
|
||||||
|
app.removeNativeEventFilter(self)
|
||||||
|
self._installed = False
|
||||||
|
|
||||||
|
def _ensure_filter(self) -> None:
|
||||||
|
if self._installed:
|
||||||
|
return
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is not None:
|
||||||
|
app.installNativeEventFilter(self)
|
||||||
|
self._installed = True
|
||||||
|
|
||||||
|
def nativeEventFilter(self, event_type, message): # noqa: N802 - Qt 규약
|
||||||
|
if not self._callbacks or event_type not in (b"windows_generic_MSG",
|
||||||
|
b"windows_dispatcher_MSG"):
|
||||||
|
return False, 0
|
||||||
|
import ctypes
|
||||||
|
import ctypes.wintypes
|
||||||
|
|
||||||
|
msg = ctypes.cast(int(message), ctypes.POINTER(ctypes.wintypes.MSG)).contents
|
||||||
|
if msg.message != WM_HOTKEY:
|
||||||
|
return False, 0
|
||||||
|
|
||||||
|
callback = self._callbacks.get(int(msg.wParam))
|
||||||
|
if callback is None:
|
||||||
|
return False, 0
|
||||||
|
try:
|
||||||
|
callback()
|
||||||
|
except Exception: # noqa: BLE001 - 콜백 오류로 이벤트 루프가 죽으면 안 된다
|
||||||
|
log.exception("단축키 처리 실패")
|
||||||
|
return True, 0
|
||||||
329
src/livesub/ui/main_window.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
"""메인 컨트롤 창 — 사이드바 + 페이지 스택.
|
||||||
|
|
||||||
|
디자인 의도: 자막은 게임 위 오버레이가 전담하고, 이 창은 '설정과 상태'만
|
||||||
|
담당한다. 그래서 두 창이 서로 방해하지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QTimer, Signal
|
||||||
|
from PySide6.QtGui import QCloseEvent
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QButtonGroup,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QMainWindow,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QStackedWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..config import AppConfig
|
||||||
|
from ..constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
|
||||||
|
from ..core import EngineState, EngineStatus, TranslationEngine, TranslationLine
|
||||||
|
from .hotkeys import GlobalHotkeys
|
||||||
|
from .overlay import SubtitleOverlay
|
||||||
|
from .pages import GlossaryPage, HomePage, ModelsPage, SettingsPage, SubtitlePage
|
||||||
|
from .theme import SPACING, palette, stylesheet
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NAV = [
|
||||||
|
("홈", "실시간 번역"),
|
||||||
|
("모델", "품질 티어"),
|
||||||
|
("자막", "표시 설정"),
|
||||||
|
("용어집", "고유명사 고정"),
|
||||||
|
("설정", "세부 조정"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
#: 엔진 콜백은 워커 스레드에서 오므로 시그널로 GUI 스레드에 넘긴다.
|
||||||
|
line_received = Signal(object)
|
||||||
|
status_received = Signal(object)
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.config = config
|
||||||
|
self.setWindowTitle(f"{APP_NAME} · {APP_SLOGAN}")
|
||||||
|
self.resize(1060, 820)
|
||||||
|
|
||||||
|
self.engine = TranslationEngine(
|
||||||
|
config,
|
||||||
|
on_line=self.line_received.emit,
|
||||||
|
on_status=self.status_received.emit,
|
||||||
|
)
|
||||||
|
self.overlay = SubtitleOverlay(config.subtitle, config.overlay)
|
||||||
|
self.hotkeys = GlobalHotkeys(self)
|
||||||
|
self._transcript_file = None
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._connect()
|
||||||
|
self._install_hotkeys()
|
||||||
|
|
||||||
|
self._save_timer = QTimer(self)
|
||||||
|
self._save_timer.setSingleShot(True)
|
||||||
|
self._save_timer.setInterval(800) # 입력 중 계속 쓰지 않도록 묶어서 저장
|
||||||
|
self._save_timer.timeout.connect(lambda: self.config.save())
|
||||||
|
|
||||||
|
# --- UI -------------------------------------------------------------
|
||||||
|
def _build_ui(self) -> None:
|
||||||
|
self.setStyleSheet(stylesheet(self.config.theme))
|
||||||
|
|
||||||
|
central = QWidget()
|
||||||
|
layout = QHBoxLayout(central)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
layout.addWidget(self._build_sidebar())
|
||||||
|
|
||||||
|
self.stack = QStackedWidget()
|
||||||
|
self.home_page = HomePage(self.config)
|
||||||
|
self.models_page = ModelsPage(self.config)
|
||||||
|
self.subtitle_page = SubtitlePage(self.config)
|
||||||
|
self.glossary_page = GlossaryPage(self.config)
|
||||||
|
self.settings_page = SettingsPage(self.config)
|
||||||
|
for page in (
|
||||||
|
self.home_page,
|
||||||
|
self.models_page,
|
||||||
|
self.subtitle_page,
|
||||||
|
self.glossary_page,
|
||||||
|
self.settings_page,
|
||||||
|
):
|
||||||
|
self.stack.addWidget(_scrollable(page))
|
||||||
|
layout.addWidget(self.stack, 1)
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
|
||||||
|
def _build_sidebar(self) -> QFrame:
|
||||||
|
p = palette(self.config.theme)
|
||||||
|
bar = QFrame()
|
||||||
|
bar.setObjectName("Sidebar")
|
||||||
|
bar.setFixedWidth(212)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(bar)
|
||||||
|
layout.setContentsMargins(14, 22, 14, 18)
|
||||||
|
layout.setSpacing(6)
|
||||||
|
|
||||||
|
brand = QLabel(APP_NAME)
|
||||||
|
brand.setStyleSheet(
|
||||||
|
f"font-size: 24px; font-weight: 800; color: {p.accent}; padding-left: 6px;"
|
||||||
|
)
|
||||||
|
layout.addWidget(brand)
|
||||||
|
tag = QLabel(APP_SLOGAN)
|
||||||
|
tag.setObjectName("Hint")
|
||||||
|
tag.setStyleSheet(f"color: {p.text_dim}; padding-left: 7px;")
|
||||||
|
layout.addWidget(tag)
|
||||||
|
layout.addSpacing(SPACING + 8)
|
||||||
|
|
||||||
|
self._nav_group = QButtonGroup(self)
|
||||||
|
self._nav_group.setExclusive(True)
|
||||||
|
for index, (title, hint) in enumerate(NAV):
|
||||||
|
button = QPushButton(title)
|
||||||
|
button.setObjectName("NavButton")
|
||||||
|
button.setCheckable(True)
|
||||||
|
button.setToolTip(hint)
|
||||||
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self._nav_group.addButton(button, index)
|
||||||
|
layout.addWidget(button)
|
||||||
|
self._nav_group.button(0).setChecked(True)
|
||||||
|
self._nav_group.idClicked.connect(self._goto_page)
|
||||||
|
|
||||||
|
layout.addStretch(1)
|
||||||
|
version = QLabel(f"v{APP_VERSION}")
|
||||||
|
version.setObjectName("Hint")
|
||||||
|
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
layout.addWidget(version)
|
||||||
|
return bar
|
||||||
|
|
||||||
|
def _connect(self) -> None:
|
||||||
|
self.line_received.connect(self._on_line)
|
||||||
|
self.status_received.connect(self._on_status)
|
||||||
|
|
||||||
|
self.home_page.start_requested.connect(self.start_engine)
|
||||||
|
self.home_page.stop_requested.connect(self.stop_engine)
|
||||||
|
self.home_page.config_changed.connect(self._schedule_save)
|
||||||
|
|
||||||
|
self.models_page.tier_changed.connect(self._on_tier_changed)
|
||||||
|
self.models_page.config_changed.connect(self._schedule_save)
|
||||||
|
|
||||||
|
self.subtitle_page.style_changed.connect(self._on_style_changed)
|
||||||
|
self.subtitle_page.overlay_toggled.connect(self._on_overlay_toggled)
|
||||||
|
self.subtitle_page.placement_changed.connect(self._on_placement_changed)
|
||||||
|
|
||||||
|
self.glossary_page.glossary_changed.connect(self._on_glossary_changed)
|
||||||
|
|
||||||
|
self.settings_page.config_changed.connect(self._schedule_save)
|
||||||
|
self.settings_page.restart_needed.connect(self._on_restart_needed)
|
||||||
|
self.settings_page.hotkeys_changed.connect(self._install_hotkeys)
|
||||||
|
|
||||||
|
self.overlay.geometry_changed.connect(self._on_overlay_moved)
|
||||||
|
self.overlay.visibility_changed.connect(self.subtitle_page.overlay_check.setChecked)
|
||||||
|
self.overlay.closed.connect(
|
||||||
|
lambda: self.subtitle_page.overlay_check.setChecked(False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 전역 단축키 ------------------------------------------------------
|
||||||
|
def _install_hotkeys(self) -> None:
|
||||||
|
"""설정에 적힌 단축키를 OS에 등록한다. 변경될 때마다 다시 등록한다."""
|
||||||
|
self.hotkeys.unregister_all()
|
||||||
|
keys = self.config.hotkeys
|
||||||
|
if not keys.enabled:
|
||||||
|
self.settings_page.set_hotkey_status("전역 단축키를 끈 상태입니다.")
|
||||||
|
return
|
||||||
|
if not self.hotkeys.supported:
|
||||||
|
self.settings_page.set_hotkey_status(
|
||||||
|
"전역 단축키는 Windows에서만 동작합니다. "
|
||||||
|
"이 창이 떠 있을 때는 자막 화면에서 직접 끄고 켤 수 있습니다.",
|
||||||
|
ok=False,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
registered, failed = [], []
|
||||||
|
for label, sequence, callback in (
|
||||||
|
("자막", keys.toggle_subtitle, self.toggle_subtitle),
|
||||||
|
("번역", keys.toggle_engine, self.toggle_engine),
|
||||||
|
):
|
||||||
|
if not sequence:
|
||||||
|
continue
|
||||||
|
if self.hotkeys.register(sequence, callback):
|
||||||
|
registered.append(f"{label} {sequence}")
|
||||||
|
else:
|
||||||
|
failed.append(f"{label} {sequence}")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
self.settings_page.set_hotkey_status(
|
||||||
|
f"등록 실패: {', '.join(failed)} — {self.hotkeys.last_error}", ok=False
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.settings_page.set_hotkey_status(
|
||||||
|
f"등록됨: {' · '.join(registered)}" if registered else "등록된 단축키가 없습니다."
|
||||||
|
)
|
||||||
|
|
||||||
|
def toggle_subtitle(self) -> None:
|
||||||
|
"""자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다."""
|
||||||
|
visible = self.overlay.toggle_subtitle()
|
||||||
|
self._schedule_save()
|
||||||
|
log.info("자막 %s", "켬" if visible else "끔")
|
||||||
|
|
||||||
|
def toggle_engine(self) -> None:
|
||||||
|
if self.engine.running:
|
||||||
|
self.stop_engine()
|
||||||
|
else:
|
||||||
|
self.start_engine()
|
||||||
|
|
||||||
|
# --- 엔진 -----------------------------------------------------------
|
||||||
|
def start_engine(self) -> None:
|
||||||
|
if self.config.overlay.visible:
|
||||||
|
self.overlay.show()
|
||||||
|
self.config.save()
|
||||||
|
self.engine.start()
|
||||||
|
|
||||||
|
def stop_engine(self) -> None:
|
||||||
|
self.engine.stop()
|
||||||
|
self.overlay.clear()
|
||||||
|
self._close_transcript()
|
||||||
|
|
||||||
|
def _on_tier_changed(self, tier_key: str) -> None:
|
||||||
|
was_running = self.engine.running
|
||||||
|
if was_running:
|
||||||
|
self.engine.stop()
|
||||||
|
self.engine.models.switch_tier(tier_key)
|
||||||
|
if was_running:
|
||||||
|
self.engine.start()
|
||||||
|
|
||||||
|
def _on_restart_needed(self) -> None:
|
||||||
|
# 정지 상태에서도 반영해 둬야 저부하 모드를 켠 즉시 VRAM이 반납된다.
|
||||||
|
self.engine.sync_performance()
|
||||||
|
if self.engine.running:
|
||||||
|
self.engine.stop()
|
||||||
|
self.engine.start()
|
||||||
|
|
||||||
|
# --- 이벤트 ---------------------------------------------------------
|
||||||
|
def _on_line(self, line: TranslationLine) -> None:
|
||||||
|
self.overlay.set_line(line.translated_text, line.source_text, line.is_final)
|
||||||
|
self.home_page.on_line(line)
|
||||||
|
if line.is_final and self.config.log_transcripts:
|
||||||
|
self._write_transcript(line)
|
||||||
|
|
||||||
|
def _on_status(self, status: EngineStatus) -> None:
|
||||||
|
self.home_page.on_status(status)
|
||||||
|
if status.state is EngineState.ERROR:
|
||||||
|
QMessageBox.warning(self, "오류", status.message)
|
||||||
|
|
||||||
|
def _on_style_changed(self) -> None:
|
||||||
|
self.overlay.update()
|
||||||
|
self._schedule_save()
|
||||||
|
|
||||||
|
def _on_overlay_toggled(self, visible: bool) -> None:
|
||||||
|
if visible == self.config.overlay.visible:
|
||||||
|
return # 단축키로 바뀐 것을 체크박스가 되돌려 보내는 루프 차단
|
||||||
|
self.overlay.set_subtitle_visible(visible)
|
||||||
|
self._schedule_save()
|
||||||
|
|
||||||
|
def _on_placement_changed(self) -> None:
|
||||||
|
"""모니터·9분할 위치·여백·클릭통과가 바뀌면 자막 창에 바로 반영."""
|
||||||
|
self.overlay.apply_config()
|
||||||
|
self.overlay.snap_to_anchor()
|
||||||
|
self.subtitle_page.sync_from_config()
|
||||||
|
self._schedule_save()
|
||||||
|
|
||||||
|
def _on_overlay_moved(self, *_) -> None:
|
||||||
|
"""드래그로 옮기면 '자유 배치'가 되므로 설정 화면 표시를 맞춘다."""
|
||||||
|
self.subtitle_page.sync_from_config()
|
||||||
|
self._schedule_save()
|
||||||
|
|
||||||
|
def _on_glossary_changed(self) -> None:
|
||||||
|
self.engine.reload_glossary()
|
||||||
|
self._schedule_save()
|
||||||
|
|
||||||
|
def _goto_page(self, index: int) -> None:
|
||||||
|
self.stack.setCurrentIndex(index)
|
||||||
|
|
||||||
|
def _schedule_save(self) -> None:
|
||||||
|
self._save_timer.start()
|
||||||
|
|
||||||
|
# --- 기록 -----------------------------------------------------------
|
||||||
|
def _write_transcript(self, line: TranslationLine) -> None:
|
||||||
|
if self._transcript_file is None:
|
||||||
|
folder = user_data_dir() / "transcripts"
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
name = datetime.now().strftime("%Y%m%d-%H%M%S.txt")
|
||||||
|
# 세션 내내 열어두는 핸들이다. _close_transcript() 에서 닫는다.
|
||||||
|
self._transcript_file = open(folder / name, "a", encoding="utf-8") # noqa: SIM115
|
||||||
|
stamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
self._transcript_file.write(
|
||||||
|
f"[{stamp}] {line.source_text}\n {line.translated_text}\n"
|
||||||
|
)
|
||||||
|
self._transcript_file.flush()
|
||||||
|
|
||||||
|
def _close_transcript(self) -> None:
|
||||||
|
if self._transcript_file is not None:
|
||||||
|
self._transcript_file.close()
|
||||||
|
self._transcript_file = None
|
||||||
|
|
||||||
|
# --- 종료 -----------------------------------------------------------
|
||||||
|
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||||
|
self.config.save()
|
||||||
|
self.hotkeys.unregister_all() # OS에 잡아둔 키를 반드시 풀어준다
|
||||||
|
self.engine.shutdown()
|
||||||
|
self._close_transcript()
|
||||||
|
self.overlay.close()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
def _scrollable(widget: QWidget) -> QScrollArea:
|
||||||
|
area = QScrollArea()
|
||||||
|
area.setWidgetResizable(True)
|
||||||
|
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
holder = QWidget()
|
||||||
|
layout = QVBoxLayout(holder)
|
||||||
|
layout.setContentsMargins(26, 24, 26, 24)
|
||||||
|
layout.addWidget(widget)
|
||||||
|
area.setWidget(holder)
|
||||||
|
return area
|
||||||
385
src/livesub/ui/overlay.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
"""자막 오버레이 창.
|
||||||
|
|
||||||
|
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
|
||||||
|
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
|
||||||
|
|
||||||
|
조작:
|
||||||
|
* 드래그 — 위치 이동
|
||||||
|
* 우하단 모서리 — 크기 조절
|
||||||
|
* 휠 — 글자 크기 조절
|
||||||
|
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPoint, QRect, Qt, QTimer, Signal
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QAction,
|
||||||
|
QColor,
|
||||||
|
QFont,
|
||||||
|
QFontMetrics,
|
||||||
|
QPainter,
|
||||||
|
QPainterPath,
|
||||||
|
QPen,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import QMenu, QWidget
|
||||||
|
|
||||||
|
from ..config import OverlayConfig, SubtitleStyle
|
||||||
|
from .placement import ANCHOR_CUSTOM, list_screens, pick_screen, resolve_rect, screen_for_point
|
||||||
|
|
||||||
|
GRIP = 18 # 우하단 리사이즈 핸들 크기
|
||||||
|
|
||||||
|
|
||||||
|
def paint_subtitle(
|
||||||
|
painter: QPainter,
|
||||||
|
rect: QRect,
|
||||||
|
st: SubtitleStyle,
|
||||||
|
translated: str,
|
||||||
|
source: str = "",
|
||||||
|
partial: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""자막 한 화면을 그린다. 오버레이와 설정 미리보기가 이 함수를 공유한다."""
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
||||||
|
|
||||||
|
if st.background_opacity > 0:
|
||||||
|
bg = QColor(st.background_color)
|
||||||
|
bg.setAlpha(int(255 * st.background_opacity / 100))
|
||||||
|
painter.setBrush(bg)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.drawRoundedRect(rect, 12, 12)
|
||||||
|
|
||||||
|
rows: list[tuple[str, QColor, int]] = []
|
||||||
|
if st.show_source and source and translated:
|
||||||
|
rows.append((source, QColor(st.source_text_color), int(st.font_size * 0.62)))
|
||||||
|
main = translated or partial
|
||||||
|
if main:
|
||||||
|
color = QColor(st.text_color)
|
||||||
|
if not translated: # 아직 확정 전 — 흐리게
|
||||||
|
color.setAlpha(170)
|
||||||
|
rows.append((main, color, st.font_size))
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
margin = 18
|
||||||
|
available = rect.width() - margin * 2
|
||||||
|
blocks: list[tuple[list[str], QColor, QFont]] = []
|
||||||
|
total_h = 0
|
||||||
|
for text, color, size in rows:
|
||||||
|
font = QFont(st.font_family, size)
|
||||||
|
font.setBold(st.bold)
|
||||||
|
lines = _wrap(text, QFontMetrics(font), available, st.max_lines)
|
||||||
|
blocks.append((lines, color, font))
|
||||||
|
total_h += len(lines) * (QFontMetrics(font).height() + st.line_spacing)
|
||||||
|
|
||||||
|
y = rect.y() + max(margin, (rect.height() - total_h) // 2)
|
||||||
|
for lines, color, font in blocks:
|
||||||
|
painter.setFont(font)
|
||||||
|
fm = QFontMetrics(font)
|
||||||
|
for line in lines:
|
||||||
|
width = fm.horizontalAdvance(line)
|
||||||
|
if st.align == "left":
|
||||||
|
x = rect.x() + margin
|
||||||
|
elif st.align == "right":
|
||||||
|
x = rect.right() - margin - width
|
||||||
|
else:
|
||||||
|
x = rect.x() + (rect.width() - width) // 2
|
||||||
|
_draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st)
|
||||||
|
y += fm.height() + st.line_spacing
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None:
|
||||||
|
"""외곽선 있는 글자.
|
||||||
|
|
||||||
|
스트로크는 글자 윤곽선 '가운데'에 그려지므로 절반이 글자 안쪽을 파먹는다.
|
||||||
|
한글처럼 획이 얇은 글꼴은 그대로 두면 글자가 통째로 외곽선 색이 된다.
|
||||||
|
그래서 스트로크를 먼저 깔고 그 위에 글자를 채우는 2패스로 그린다.
|
||||||
|
"""
|
||||||
|
if st.outline_width <= 0:
|
||||||
|
painter.setPen(color)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
painter.drawText(x, y, text)
|
||||||
|
return
|
||||||
|
|
||||||
|
path = QPainterPath()
|
||||||
|
path.addText(x, y, font, text)
|
||||||
|
|
||||||
|
pen = QPen(QColor(st.outline_color))
|
||||||
|
pen.setWidth(st.outline_width * 2) # 절반이 안쪽으로 먹히므로 2배
|
||||||
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
painter.drawPath(path)
|
||||||
|
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(color)
|
||||||
|
painter.drawPath(path)
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitlePreview(QWidget):
|
||||||
|
"""설정 화면에서 쓰는 미리보기. 실제 자막과 같은 코드로 그린다."""
|
||||||
|
|
||||||
|
def __init__(self, style: SubtitleStyle, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.style_cfg = style
|
||||||
|
self.translated = ""
|
||||||
|
self.source = ""
|
||||||
|
self.setMinimumHeight(140)
|
||||||
|
|
||||||
|
def set_sample(self, translated: str, source: str) -> None:
|
||||||
|
self.translated = translated
|
||||||
|
self.source = source
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None: # noqa: N802
|
||||||
|
painter = QPainter(self)
|
||||||
|
paint_subtitle(
|
||||||
|
painter, self.rect(), self.style_cfg, self.translated, self.source
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleOverlay(QWidget):
|
||||||
|
"""항상 위에 떠 있는 자막 창."""
|
||||||
|
|
||||||
|
geometry_changed = Signal(int, int, int, int)
|
||||||
|
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
|
||||||
|
visibility_changed = Signal(bool)
|
||||||
|
closed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
|
||||||
|
super().__init__(None)
|
||||||
|
self.style_cfg = style
|
||||||
|
self.overlay_cfg = overlay
|
||||||
|
|
||||||
|
self._final_text = ""
|
||||||
|
self._partial_text = ""
|
||||||
|
self._source_text = ""
|
||||||
|
self._drag_from: QPoint | None = None
|
||||||
|
self._resize_from: QRect | None = None
|
||||||
|
|
||||||
|
self._fade = QTimer(self)
|
||||||
|
self._fade.setSingleShot(True)
|
||||||
|
self._fade.timeout.connect(self._clear_text)
|
||||||
|
|
||||||
|
self.setWindowFlags(
|
||||||
|
Qt.WindowType.FramelessWindowHint
|
||||||
|
| Qt.WindowType.Tool
|
||||||
|
| Qt.WindowType.WindowStaysOnTopHint
|
||||||
|
)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||||
|
self.setMinimumSize(320, 90)
|
||||||
|
self.apply_config()
|
||||||
|
|
||||||
|
# --- 외부 API -------------------------------------------------------
|
||||||
|
def apply_config(self) -> None:
|
||||||
|
cfg = self.overlay_cfg
|
||||||
|
flags = self.windowFlags()
|
||||||
|
flags = (
|
||||||
|
flags | Qt.WindowType.WindowStaysOnTopHint
|
||||||
|
if cfg.always_on_top
|
||||||
|
else flags & ~Qt.WindowType.WindowStaysOnTopHint
|
||||||
|
)
|
||||||
|
self.setWindowFlags(flags)
|
||||||
|
self.setAttribute(
|
||||||
|
Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through
|
||||||
|
)
|
||||||
|
|
||||||
|
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
|
||||||
|
if cfg.anchor != ANCHOR_CUSTOM or cfg.x < 0 or cfg.y < 0:
|
||||||
|
self.snap_to_anchor(save_only=True)
|
||||||
|
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
|
||||||
|
self.setVisible(cfg.visible)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def snap_to_anchor(self, anchor: str | None = None, save_only: bool = False) -> None:
|
||||||
|
"""선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식)."""
|
||||||
|
cfg = self.overlay_cfg
|
||||||
|
if anchor is not None:
|
||||||
|
cfg.anchor = anchor
|
||||||
|
if cfg.anchor == ANCHOR_CUSTOM:
|
||||||
|
return
|
||||||
|
|
||||||
|
screens = list_screens()
|
||||||
|
screen = pick_screen(screens, cfg.screen_index)
|
||||||
|
if screen is None:
|
||||||
|
return
|
||||||
|
cfg.screen_index = screen.index
|
||||||
|
cfg.x, cfg.y, cfg.width, cfg.height = resolve_rect(
|
||||||
|
screen, cfg.anchor, cfg.width, cfg.height, cfg.margin
|
||||||
|
)
|
||||||
|
if not save_only:
|
||||||
|
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
|
||||||
|
self.geometry_changed.emit(cfg.x, cfg.y, cfg.width, cfg.height)
|
||||||
|
|
||||||
|
def set_subtitle_visible(self, visible: bool) -> None:
|
||||||
|
"""자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다."""
|
||||||
|
self.overlay_cfg.visible = visible
|
||||||
|
self.setVisible(visible)
|
||||||
|
if not visible:
|
||||||
|
self._clear_text()
|
||||||
|
self.visibility_changed.emit(visible)
|
||||||
|
|
||||||
|
def toggle_subtitle(self) -> bool:
|
||||||
|
self.set_subtitle_visible(not self.overlay_cfg.visible)
|
||||||
|
return self.overlay_cfg.visible
|
||||||
|
|
||||||
|
def set_line(self, translated: str, source: str = "", is_final: bool = True) -> None:
|
||||||
|
if is_final:
|
||||||
|
self._final_text = translated or source
|
||||||
|
self._partial_text = ""
|
||||||
|
self._source_text = source
|
||||||
|
else:
|
||||||
|
self._partial_text = source
|
||||||
|
self._restart_fade()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._clear_text()
|
||||||
|
|
||||||
|
# --- 그리기 ---------------------------------------------------------
|
||||||
|
def paintEvent(self, event) -> None: # noqa: N802 - Qt 규약
|
||||||
|
painter = QPainter(self)
|
||||||
|
paint_subtitle(
|
||||||
|
painter,
|
||||||
|
self.rect(),
|
||||||
|
self.style_cfg,
|
||||||
|
self._final_text,
|
||||||
|
self._source_text,
|
||||||
|
self._partial_text,
|
||||||
|
)
|
||||||
|
if not (self._final_text or self._partial_text) and not self.overlay_cfg.locked:
|
||||||
|
self._draw_placeholder(painter)
|
||||||
|
self._draw_grip(painter)
|
||||||
|
|
||||||
|
def _draw_placeholder(self, painter) -> None:
|
||||||
|
painter.setPen(QColor(255, 255, 255, 90))
|
||||||
|
font = QFont(self.style_cfg.font_family, 13)
|
||||||
|
painter.setFont(font)
|
||||||
|
painter.drawText(
|
||||||
|
self.rect(),
|
||||||
|
Qt.AlignmentFlag.AlignCenter,
|
||||||
|
"자막이 여기에 표시됩니다\n드래그로 이동 · 휠로 크기 · 우클릭으로 메뉴",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_grip(self, painter) -> None:
|
||||||
|
if self.overlay_cfg.locked or self.overlay_cfg.click_through:
|
||||||
|
return
|
||||||
|
painter.setPen(QPen(QColor(255, 255, 255, 110), 2))
|
||||||
|
w, h = self.width(), self.height()
|
||||||
|
for offset in (5, 10, 15):
|
||||||
|
painter.drawLine(w - offset, h - 4, w - 4, h - offset)
|
||||||
|
|
||||||
|
# --- 마우스 ---------------------------------------------------------
|
||||||
|
def mousePressEvent(self, event) -> None: # noqa: N802
|
||||||
|
if self.overlay_cfg.locked or event.button() != Qt.MouseButton.LeftButton:
|
||||||
|
return
|
||||||
|
pos = event.position().toPoint()
|
||||||
|
if pos.x() > self.width() - GRIP and pos.y() > self.height() - GRIP:
|
||||||
|
self._resize_from = QRect(event.globalPosition().toPoint(), self.size())
|
||||||
|
else:
|
||||||
|
self._drag_from = event.globalPosition().toPoint() - self.pos()
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event) -> None: # noqa: N802
|
||||||
|
if self._resize_from is not None:
|
||||||
|
delta = event.globalPosition().toPoint() - self._resize_from.topLeft()
|
||||||
|
self.resize(
|
||||||
|
max(self.minimumWidth(), self._resize_from.width() + delta.x()),
|
||||||
|
max(self.minimumHeight(), self._resize_from.height() + delta.y()),
|
||||||
|
)
|
||||||
|
elif self._drag_from is not None:
|
||||||
|
self.move(event.globalPosition().toPoint() - self._drag_from)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event) -> None: # noqa: N802
|
||||||
|
if self._drag_from is None and self._resize_from is None:
|
||||||
|
return
|
||||||
|
self._drag_from = None
|
||||||
|
self._resize_from = None
|
||||||
|
|
||||||
|
cfg = self.overlay_cfg
|
||||||
|
g = self.geometry()
|
||||||
|
cfg.x, cfg.y = g.x(), g.y()
|
||||||
|
cfg.width, cfg.height = g.width(), g.height()
|
||||||
|
# 손으로 옮긴 순간부터는 앵커가 아니라 그 자리를 그대로 지킨다.
|
||||||
|
cfg.anchor = ANCHOR_CUSTOM
|
||||||
|
# 다른 모니터로 끌고 갔을 수 있으므로 소속을 다시 찾는다.
|
||||||
|
owner = screen_for_point(list_screens(), g.center().x(), g.center().y())
|
||||||
|
if owner is not None:
|
||||||
|
cfg.screen_index = owner.index
|
||||||
|
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
|
||||||
|
|
||||||
|
def wheelEvent(self, event) -> None: # noqa: N802
|
||||||
|
if self.overlay_cfg.locked:
|
||||||
|
return
|
||||||
|
step = 2 if event.angleDelta().y() > 0 else -2
|
||||||
|
self.style_cfg.font_size = max(12, min(120, self.style_cfg.font_size + step))
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def contextMenuEvent(self, event) -> None: # noqa: N802
|
||||||
|
menu = QMenu(self)
|
||||||
|
cfg = self.overlay_cfg
|
||||||
|
|
||||||
|
lock = QAction("위치 잠금", self, checkable=True, checked=cfg.locked)
|
||||||
|
lock.toggled.connect(self._set_locked)
|
||||||
|
menu.addAction(lock)
|
||||||
|
|
||||||
|
through = QAction("클릭 통과", self, checkable=True, checked=cfg.click_through)
|
||||||
|
through.toggled.connect(self._set_click_through)
|
||||||
|
menu.addAction(through)
|
||||||
|
|
||||||
|
top = QAction("항상 위에", self, checkable=True, checked=cfg.always_on_top)
|
||||||
|
top.toggled.connect(self._set_always_on_top)
|
||||||
|
menu.addAction(top)
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
menu.addAction("자막 지우기", self.clear)
|
||||||
|
menu.addAction("자막 창 숨기기", self.hide_overlay)
|
||||||
|
menu.exec(event.globalPos())
|
||||||
|
|
||||||
|
# --- 메뉴 동작 ------------------------------------------------------
|
||||||
|
def _set_locked(self, value: bool) -> None:
|
||||||
|
self.overlay_cfg.locked = value
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _set_click_through(self, value: bool) -> None:
|
||||||
|
self.overlay_cfg.click_through = value
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, value)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _set_always_on_top(self, value: bool) -> None:
|
||||||
|
self.overlay_cfg.always_on_top = value
|
||||||
|
self.apply_config()
|
||||||
|
|
||||||
|
def hide_overlay(self) -> None:
|
||||||
|
self.set_subtitle_visible(False)
|
||||||
|
self.closed.emit()
|
||||||
|
|
||||||
|
def _restart_fade(self) -> None:
|
||||||
|
self._fade.stop()
|
||||||
|
if self.style_cfg.fade_out_ms > 0:
|
||||||
|
self._fade.start(self.style_cfg.fade_out_ms)
|
||||||
|
|
||||||
|
def _clear_text(self) -> None:
|
||||||
|
self._final_text = ""
|
||||||
|
self._partial_text = ""
|
||||||
|
self._source_text = ""
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(text: str, fm: QFontMetrics, width: int, max_lines: int) -> list[str]:
|
||||||
|
"""단어 단위 줄바꿈. 마지막 줄이 넘치면 앞부분을 버리고 최신 내용을 남긴다."""
|
||||||
|
if width <= 0 or not text:
|
||||||
|
return [text] if text else []
|
||||||
|
lines: list[str] = []
|
||||||
|
current = ""
|
||||||
|
for word in text.split():
|
||||||
|
candidate = f"{current} {word}".strip()
|
||||||
|
if fm.horizontalAdvance(candidate) <= width or not current:
|
||||||
|
current = candidate
|
||||||
|
else:
|
||||||
|
lines.append(current)
|
||||||
|
current = word
|
||||||
|
if current:
|
||||||
|
lines.append(current)
|
||||||
|
# 실시간 자막에서는 앞이 아니라 뒤(최신)를 보여주는 게 맞다.
|
||||||
|
return lines[-max_lines:] if max_lines > 0 else lines
|
||||||
7
src/livesub/ui/pages/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from .glossary_page import GlossaryPage
|
||||||
|
from .home import HomePage
|
||||||
|
from .models_page import ModelsPage
|
||||||
|
from .settings_page import SettingsPage
|
||||||
|
from .subtitle_page import SubtitlePage
|
||||||
|
|
||||||
|
__all__ = ["GlossaryPage", "HomePage", "ModelsPage", "SettingsPage", "SubtitlePage"]
|
||||||
336
src/livesub/ui/pages/glossary_page.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""용어집 — 게임·방송 고유명사를 원하는 번역으로 고정한다."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QCheckBox,
|
||||||
|
QFileDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ...config import AppConfig
|
||||||
|
from ...constants import LANGUAGE_CODES, LANGUAGES, user_data_dir
|
||||||
|
from ...models import Glossary, GlossaryEntry
|
||||||
|
from ...models.packs import available_packs, find_conflicts
|
||||||
|
from ..theme import SPACING, palette
|
||||||
|
from ..widgets import Card, PageHeader
|
||||||
|
|
||||||
|
COLUMNS = ["원문 용어", *[f"{LANGUAGES[c]['label']} 역어" for c in LANGUAGE_CODES], "메모"]
|
||||||
|
|
||||||
|
|
||||||
|
class GlossaryPage(QWidget):
|
||||||
|
glossary_changed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.config = config
|
||||||
|
self.path = Path(config.glossary.path or user_data_dir() / "glossary.json")
|
||||||
|
self.glossary = Glossary.load(self.path, config.glossary.case_sensitive)
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
|
root.setSpacing(SPACING + 4)
|
||||||
|
root.addWidget(
|
||||||
|
PageHeader(
|
||||||
|
"용어집",
|
||||||
|
"여기 등록한 단어는 번역 모델이 마음대로 바꾸지 못하고 지정한 역어로 고정됩니다. "
|
||||||
|
"모델을 추가로 학습시키지 않아도 바로 적용됩니다.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
root.addWidget(self._build_packs_card())
|
||||||
|
root.addWidget(self._build_options_card())
|
||||||
|
root.addWidget(self._build_table_card(), 1)
|
||||||
|
self._reload_table()
|
||||||
|
|
||||||
|
# --- 기본 제공 팩 ----------------------------------------------------
|
||||||
|
def _build_packs_card(self) -> Card:
|
||||||
|
card = Card(
|
||||||
|
"게임별 기본 용어집",
|
||||||
|
"체크만 하면 바로 적용됩니다. 역어가 마음에 안 들면 아래 표에 같은 "
|
||||||
|
"단어를 등록하세요 — 직접 등록한 쪽이 항상 이깁니다.",
|
||||||
|
)
|
||||||
|
self._pack_checks: dict[str, QCheckBox] = {}
|
||||||
|
enabled = set(self.config.glossary.enabled_packs)
|
||||||
|
|
||||||
|
packs = available_packs()
|
||||||
|
if not packs:
|
||||||
|
card.add(_hint("기본 제공 용어집을 찾지 못했습니다."))
|
||||||
|
return card
|
||||||
|
|
||||||
|
# 범용 팩을 위에 두어 "게임 전용이 범용을 이긴다"는 규칙이 눈에 보이게 한다.
|
||||||
|
for pack in sorted(packs, key=lambda p: (not p.is_generic, p.name)):
|
||||||
|
suffix = " · 장르 공통" if pack.is_generic else ""
|
||||||
|
check = QCheckBox(f"{pack.name} ({len(pack)}개){suffix}")
|
||||||
|
check.setChecked(pack.key in enabled)
|
||||||
|
check.setToolTip(pack.description)
|
||||||
|
check.toggled.connect(self._on_packs_changed)
|
||||||
|
self._pack_checks[pack.key] = check
|
||||||
|
card.add(check)
|
||||||
|
|
||||||
|
self.conflict_label = card.add(_warning(""))
|
||||||
|
self.pack_total = card.add(_hint(""))
|
||||||
|
self._update_pack_total()
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _on_packs_changed(self, *_) -> None:
|
||||||
|
self.config.glossary.enabled_packs = [
|
||||||
|
key for key, check in self._pack_checks.items() if check.isChecked()
|
||||||
|
]
|
||||||
|
self._update_pack_total()
|
||||||
|
self.glossary_changed.emit()
|
||||||
|
|
||||||
|
def _update_pack_total(self) -> None:
|
||||||
|
enabled = set(self.config.glossary.enabled_packs)
|
||||||
|
total = sum(len(p) for p in available_packs() if p.key in enabled)
|
||||||
|
self.pack_total.setText(f"켜둔 기본 용어 {total}개")
|
||||||
|
self._update_conflicts()
|
||||||
|
|
||||||
|
def _update_conflicts(self) -> None:
|
||||||
|
"""게임 전용 팩끼리 역어가 갈리는 단어를 알려준다.
|
||||||
|
|
||||||
|
예를 들어 오버워치와 워치독스를 같이 켜면 payload 가 '화물'과
|
||||||
|
'페이로드'로 갈린다. 조용히 아무거나 고르면 사용자는 영문도 모른 채
|
||||||
|
이상한 자막을 보게 되므로, 어떤 단어가 문제인지 그대로 보여준다.
|
||||||
|
"""
|
||||||
|
conflicts = find_conflicts(self.config.glossary.enabled_packs)
|
||||||
|
if not conflicts:
|
||||||
|
self.conflict_label.setText("")
|
||||||
|
self.conflict_label.setVisible(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
shown = sorted(conflicts.items())[:4]
|
||||||
|
parts = [
|
||||||
|
f"{source} ({' / '.join(t for _, t in owners)})" for source, owners in shown
|
||||||
|
]
|
||||||
|
more = f" 외 {len(conflicts) - len(shown)}개" if len(conflicts) > len(shown) else ""
|
||||||
|
self.conflict_label.setText(
|
||||||
|
"게임 팩끼리 역어가 갈리는 단어가 있습니다 — "
|
||||||
|
+ ", ".join(parts)
|
||||||
|
+ more
|
||||||
|
+ ". 안 하는 게임은 꺼주세요. (직접 등록하면 그쪽이 이깁니다)"
|
||||||
|
)
|
||||||
|
self.conflict_label.setVisible(True)
|
||||||
|
|
||||||
|
# --- 구성 -----------------------------------------------------------
|
||||||
|
def _build_options_card(self) -> Card:
|
||||||
|
card = Card()
|
||||||
|
row = QWidget()
|
||||||
|
layout = QHBoxLayout(row)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
|
||||||
|
self.enabled_check = QCheckBox("용어집 사용")
|
||||||
|
self.enabled_check.setChecked(self.config.glossary.enabled)
|
||||||
|
self.enabled_check.toggled.connect(self._on_enabled)
|
||||||
|
layout.addWidget(self.enabled_check)
|
||||||
|
|
||||||
|
self.case_check = QCheckBox("대소문자 구분")
|
||||||
|
self.case_check.setChecked(self.config.glossary.case_sensitive)
|
||||||
|
self.case_check.toggled.connect(self._on_case)
|
||||||
|
layout.addWidget(self.case_check)
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
for label, slot in (
|
||||||
|
("CSV 가져오기", self._import_csv),
|
||||||
|
("CSV 내보내기", self._export_csv),
|
||||||
|
):
|
||||||
|
button = QPushButton(label)
|
||||||
|
button.clicked.connect(slot)
|
||||||
|
layout.addWidget(button)
|
||||||
|
|
||||||
|
card.add(row)
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _build_table_card(self) -> Card:
|
||||||
|
card = Card(
|
||||||
|
"등록된 용어",
|
||||||
|
"역어를 비워두면 그 언어에서는 규칙을 적용하지 않습니다. "
|
||||||
|
"CSV 형식: 원문,한국어,English,日本語,中文,메모",
|
||||||
|
)
|
||||||
|
|
||||||
|
search_row = QWidget()
|
||||||
|
search_layout = QHBoxLayout(search_row)
|
||||||
|
search_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
search_layout.setSpacing(10)
|
||||||
|
self.search = QLineEdit()
|
||||||
|
self.search.setPlaceholderText("용어 검색…")
|
||||||
|
self.search.textChanged.connect(self._filter)
|
||||||
|
search_layout.addWidget(self.search, 1)
|
||||||
|
for label, slot in (("행 추가", self._add_row), ("선택 삭제", self._delete_rows)):
|
||||||
|
button = QPushButton(label)
|
||||||
|
button.clicked.connect(slot)
|
||||||
|
search_layout.addWidget(button)
|
||||||
|
card.add(search_row)
|
||||||
|
|
||||||
|
self.table = QTableWidget(0, len(COLUMNS))
|
||||||
|
self.table.setHorizontalHeaderLabels(COLUMNS)
|
||||||
|
self.table.verticalHeader().setVisible(False)
|
||||||
|
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(
|
||||||
|
QHeaderView.ResizeMode.Stretch
|
||||||
|
)
|
||||||
|
self.table.itemChanged.connect(self._on_item_changed)
|
||||||
|
card.add(self.table)
|
||||||
|
|
||||||
|
self.count_label = card.add(
|
||||||
|
_hint(f"{len(self.glossary)}개 등록됨")
|
||||||
|
)
|
||||||
|
return card
|
||||||
|
|
||||||
|
# --- 데이터 ---------------------------------------------------------
|
||||||
|
def _reload_table(self) -> None:
|
||||||
|
self.table.blockSignals(True)
|
||||||
|
self.table.setRowCount(0)
|
||||||
|
for entry in self.glossary.entries:
|
||||||
|
self._append_row(entry)
|
||||||
|
self.table.blockSignals(False)
|
||||||
|
self.count_label.setText(f"{len(self.glossary)}개 등록됨")
|
||||||
|
|
||||||
|
def _append_row(self, entry: GlossaryEntry) -> None:
|
||||||
|
row = self.table.rowCount()
|
||||||
|
self.table.insertRow(row)
|
||||||
|
values = [entry.source, *[entry.target_for(c) for c in LANGUAGE_CODES], entry.note]
|
||||||
|
for col, value in enumerate(values):
|
||||||
|
self.table.setItem(row, col, QTableWidgetItem(value))
|
||||||
|
|
||||||
|
def _collect(self) -> list[GlossaryEntry]:
|
||||||
|
entries: list[GlossaryEntry] = []
|
||||||
|
for row in range(self.table.rowCount()):
|
||||||
|
source_item = self.table.item(row, 0)
|
||||||
|
source = source_item.text().strip() if source_item else ""
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
targets: dict[str, str] = {}
|
||||||
|
for i, code in enumerate(LANGUAGE_CODES, start=1):
|
||||||
|
item = self.table.item(row, i)
|
||||||
|
text = item.text().strip() if item else ""
|
||||||
|
if text:
|
||||||
|
targets[code] = text
|
||||||
|
note_item = self.table.item(row, len(COLUMNS) - 1)
|
||||||
|
entries.append(
|
||||||
|
GlossaryEntry(
|
||||||
|
source=source,
|
||||||
|
targets=targets,
|
||||||
|
note=note_item.text().strip() if note_item else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
self.glossary = Glossary(self._collect(), self.config.glossary.case_sensitive)
|
||||||
|
self.glossary.save(self.path)
|
||||||
|
self.count_label.setText(f"{len(self.glossary)}개 등록됨")
|
||||||
|
self.glossary_changed.emit()
|
||||||
|
|
||||||
|
# --- 동작 -----------------------------------------------------------
|
||||||
|
def _on_item_changed(self, *_) -> None:
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _on_enabled(self, value: bool) -> None:
|
||||||
|
self.config.glossary.enabled = value
|
||||||
|
self.glossary_changed.emit()
|
||||||
|
|
||||||
|
def _on_case(self, value: bool) -> None:
|
||||||
|
self.config.glossary.case_sensitive = value
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _add_row(self) -> None:
|
||||||
|
self.table.blockSignals(True)
|
||||||
|
self._append_row(GlossaryEntry(source=""))
|
||||||
|
self.table.blockSignals(False)
|
||||||
|
self.table.editItem(self.table.item(self.table.rowCount() - 1, 0))
|
||||||
|
|
||||||
|
def _delete_rows(self) -> None:
|
||||||
|
rows = sorted({i.row() for i in self.table.selectedIndexes()}, reverse=True)
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
self.table.blockSignals(True)
|
||||||
|
for row in rows:
|
||||||
|
self.table.removeRow(row)
|
||||||
|
self.table.blockSignals(False)
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _filter(self, text: str) -> None:
|
||||||
|
needle = text.strip().casefold()
|
||||||
|
for row in range(self.table.rowCount()):
|
||||||
|
haystack = " ".join(
|
||||||
|
(self.table.item(row, c).text() if self.table.item(row, c) else "")
|
||||||
|
for c in range(self.table.columnCount())
|
||||||
|
).casefold()
|
||||||
|
self.table.setRowHidden(row, bool(needle) and needle not in haystack)
|
||||||
|
|
||||||
|
def _import_csv(self) -> None:
|
||||||
|
path, _ = QFileDialog.getOpenFileName(self, "CSV 가져오기", "", "CSV (*.csv)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8-sig", newline="") as fh:
|
||||||
|
rows = [r for r in csv.reader(fh) if r and r[0].strip()]
|
||||||
|
except OSError as exc:
|
||||||
|
QMessageBox.warning(self, "가져오기 실패", str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
# 첫 줄이 헤더처럼 보이면 건너뛴다.
|
||||||
|
if rows and rows[0][0].strip() in ("원문", "원문 용어", "source"):
|
||||||
|
rows = rows[1:]
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
for row in rows:
|
||||||
|
cells = [c.strip() for c in row] + [""] * (len(COLUMNS) - len(row))
|
||||||
|
targets = {
|
||||||
|
code: cells[i]
|
||||||
|
for i, code in enumerate(LANGUAGE_CODES, start=1)
|
||||||
|
if cells[i]
|
||||||
|
}
|
||||||
|
self.glossary.add(
|
||||||
|
GlossaryEntry(source=cells[0], targets=targets, note=cells[len(COLUMNS) - 1])
|
||||||
|
)
|
||||||
|
added += 1
|
||||||
|
self.glossary.save(self.path)
|
||||||
|
self._reload_table()
|
||||||
|
self.glossary_changed.emit()
|
||||||
|
QMessageBox.information(self, "가져오기 완료", f"{added}개 용어를 반영했습니다.")
|
||||||
|
|
||||||
|
def _export_csv(self) -> None:
|
||||||
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self, "CSV 내보내기", "glossary.csv", "CSV (*.csv)"
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
with open(path, "w", encoding="utf-8-sig", newline="") as fh:
|
||||||
|
writer = csv.writer(fh)
|
||||||
|
writer.writerow(COLUMNS)
|
||||||
|
for entry in self.glossary.entries:
|
||||||
|
writer.writerow(
|
||||||
|
[entry.source, *[entry.target_for(c) for c in LANGUAGE_CODES], entry.note]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hint(text: str) -> QLabel:
|
||||||
|
label = QLabel(text)
|
||||||
|
label.setObjectName("Hint")
|
||||||
|
label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
def _warning(text: str) -> QLabel:
|
||||||
|
label = QLabel(text)
|
||||||
|
label.setObjectName("Hint")
|
||||||
|
label.setWordWrap(True)
|
||||||
|
label.setStyleSheet(f"color: {palette('dark').warning};")
|
||||||
|
label.setVisible(bool(text))
|
||||||
|
return label
|
||||||
248
src/livesub/ui/pages/home.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"""홈 — 대상 선택, 언어, 시작/정지, 실시간 로그."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ...audio import capture_capabilities, list_sources
|
||||||
|
from ...config import AppConfig
|
||||||
|
from ...constants import LANGUAGES
|
||||||
|
from ...core.events import EngineState, EngineStatus, TranslationLine
|
||||||
|
from ..theme import SPACING, palette
|
||||||
|
from ..widgets import Card, LevelMeter, PageHeader, StatusPill
|
||||||
|
|
||||||
|
MAX_LOG_ROWS = 300
|
||||||
|
|
||||||
|
|
||||||
|
class HomePage(QWidget):
|
||||||
|
start_requested = Signal()
|
||||||
|
stop_requested = Signal()
|
||||||
|
config_changed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.config = config
|
||||||
|
self._sources: list = []
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
|
root.setSpacing(SPACING + 4)
|
||||||
|
root.addWidget(
|
||||||
|
PageHeader("실시간 번역", "소리를 받아올 프로그램과 번역할 언어를 고르세요.")
|
||||||
|
)
|
||||||
|
|
||||||
|
root.addWidget(self._build_source_card())
|
||||||
|
root.addWidget(self._build_control_card())
|
||||||
|
root.addWidget(self._build_log_card(), 1)
|
||||||
|
|
||||||
|
self.refresh_sources()
|
||||||
|
|
||||||
|
# --- 구성 -----------------------------------------------------------
|
||||||
|
def _build_source_card(self) -> Card:
|
||||||
|
card = Card("소리 받아올 곳", "")
|
||||||
|
self.capability_label = QLabel()
|
||||||
|
self.capability_label.setObjectName("Hint")
|
||||||
|
self.capability_label.setWordWrap(True)
|
||||||
|
card.add(self.capability_label)
|
||||||
|
|
||||||
|
row = QWidget()
|
||||||
|
layout = QHBoxLayout(row)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
|
||||||
|
self.source_combo = QComboBox()
|
||||||
|
self.source_combo.setMinimumWidth(360)
|
||||||
|
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
|
||||||
|
layout.addWidget(self.source_combo, 1)
|
||||||
|
|
||||||
|
refresh = QPushButton("새로고침")
|
||||||
|
refresh.clicked.connect(self.refresh_sources)
|
||||||
|
layout.addWidget(refresh)
|
||||||
|
card.add(row)
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _build_control_card(self) -> Card:
|
||||||
|
card = Card()
|
||||||
|
row = QWidget()
|
||||||
|
layout = QHBoxLayout(row)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
self.source_lang = QComboBox()
|
||||||
|
self.source_lang.addItem("자동 감지", "auto")
|
||||||
|
for code, meta in LANGUAGES.items():
|
||||||
|
self.source_lang.addItem(meta["label"], code)
|
||||||
|
self.source_lang.setCurrentIndex(
|
||||||
|
max(0, self.source_lang.findData(self.config.models.source_lang))
|
||||||
|
)
|
||||||
|
self.source_lang.currentIndexChanged.connect(self._on_lang_changed)
|
||||||
|
|
||||||
|
self.target_lang = QComboBox()
|
||||||
|
for code, meta in LANGUAGES.items():
|
||||||
|
self.target_lang.addItem(meta["label"], code)
|
||||||
|
self.target_lang.setCurrentIndex(
|
||||||
|
max(0, self.target_lang.findData(self.config.models.target_lang))
|
||||||
|
)
|
||||||
|
self.target_lang.currentIndexChanged.connect(self._on_lang_changed)
|
||||||
|
|
||||||
|
layout.addWidget(QLabel("원본"))
|
||||||
|
layout.addWidget(self.source_lang)
|
||||||
|
arrow = QLabel("→")
|
||||||
|
arrow.setStyleSheet(f"color: {palette('dark').accent}; font-size: 18px;")
|
||||||
|
layout.addWidget(arrow)
|
||||||
|
layout.addWidget(QLabel("번역"))
|
||||||
|
layout.addWidget(self.target_lang)
|
||||||
|
|
||||||
|
self.speech_combo = QComboBox()
|
||||||
|
self.speech_combo.addItem("존댓말", "polite")
|
||||||
|
self.speech_combo.addItem("반말", "casual")
|
||||||
|
self.speech_combo.setCurrentIndex(
|
||||||
|
max(0, self.speech_combo.findData(self.config.models.speech_level))
|
||||||
|
)
|
||||||
|
self.speech_combo.setToolTip(
|
||||||
|
"한국어 자막의 말투입니다.\n"
|
||||||
|
"반말을 골라도 원문이 실제로 존댓말이면 존댓말로 나옵니다.\n"
|
||||||
|
"영어·중국어처럼 높임이 없는 언어는 이 설정을 그대로 따릅니다."
|
||||||
|
)
|
||||||
|
self.speech_combo.currentIndexChanged.connect(self._on_lang_changed)
|
||||||
|
layout.addWidget(QLabel("말투"))
|
||||||
|
layout.addWidget(self.speech_combo)
|
||||||
|
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
self.status_pill = StatusPill()
|
||||||
|
layout.addWidget(self.status_pill)
|
||||||
|
|
||||||
|
self.toggle_button = QPushButton("번역 시작")
|
||||||
|
self.toggle_button.setObjectName("Primary")
|
||||||
|
self.toggle_button.setMinimumWidth(140)
|
||||||
|
self.toggle_button.clicked.connect(self._on_toggle)
|
||||||
|
layout.addWidget(self.toggle_button)
|
||||||
|
|
||||||
|
card.add(row)
|
||||||
|
self.level_meter = LevelMeter()
|
||||||
|
card.add(self.level_meter)
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _build_log_card(self) -> Card:
|
||||||
|
card = Card("최근 자막", "확정된 문장이 위로 쌓입니다. 지연 시간도 함께 보여줍니다.")
|
||||||
|
self.log_list = QListWidget()
|
||||||
|
self.log_list.setWordWrap(True)
|
||||||
|
self.log_list.setAlternatingRowColors(False)
|
||||||
|
card.add(self.log_list)
|
||||||
|
return card
|
||||||
|
|
||||||
|
# --- 동작 -----------------------------------------------------------
|
||||||
|
def refresh_sources(self) -> None:
|
||||||
|
caps = capture_capabilities()
|
||||||
|
self._sources = list_sources()
|
||||||
|
|
||||||
|
self.source_combo.blockSignals(True)
|
||||||
|
self.source_combo.clear()
|
||||||
|
for src in self._sources:
|
||||||
|
prefix = "🎮" if src.kind == "process" else "🔊"
|
||||||
|
self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src)
|
||||||
|
if not self._sources:
|
||||||
|
self.source_combo.addItem("사용 가능한 오디오 대상이 없습니다", None)
|
||||||
|
self._select_saved_source()
|
||||||
|
self.source_combo.blockSignals(False)
|
||||||
|
self._on_source_changed()
|
||||||
|
|
||||||
|
if caps["process"]:
|
||||||
|
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
|
||||||
|
elif caps["device"]:
|
||||||
|
msg = (
|
||||||
|
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
|
||||||
|
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
|
||||||
|
"프로그램 단위로 분리됩니다."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = "이 환경에서는 오디오 캡처를 할 수 없습니다. Windows에서 실행하세요."
|
||||||
|
self.capability_label.setText(msg)
|
||||||
|
|
||||||
|
def _select_saved_source(self) -> None:
|
||||||
|
cfg = self.config.audio
|
||||||
|
for i, src in enumerate(self._sources):
|
||||||
|
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:
|
||||||
|
self.source_combo.setCurrentIndex(i)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _on_source_changed(self, *_) -> None:
|
||||||
|
src = self.source_combo.currentData()
|
||||||
|
cfg = self.config.audio
|
||||||
|
if src is None:
|
||||||
|
return
|
||||||
|
if src.kind == "process":
|
||||||
|
cfg.backend = "process"
|
||||||
|
cfg.target_pid = src.pid
|
||||||
|
cfg.target_process_name = src.label
|
||||||
|
else:
|
||||||
|
cfg.backend = "device"
|
||||||
|
cfg.device_index = src.device_index
|
||||||
|
cfg.target_pid = 0
|
||||||
|
self.config_changed.emit()
|
||||||
|
|
||||||
|
def _on_lang_changed(self, *_) -> None:
|
||||||
|
self.config.models.source_lang = self.source_lang.currentData()
|
||||||
|
self.config.models.target_lang = self.target_lang.currentData()
|
||||||
|
self.config.models.speech_level = self.speech_combo.currentData()
|
||||||
|
# 한국어가 아니면 말투 규칙을 적용할 데가 없다.
|
||||||
|
self.speech_combo.setEnabled(self.config.models.target_lang == "ko")
|
||||||
|
self.config_changed.emit()
|
||||||
|
|
||||||
|
def _on_toggle(self) -> None:
|
||||||
|
if self.toggle_button.property("running"):
|
||||||
|
self.stop_requested.emit()
|
||||||
|
else:
|
||||||
|
self.start_requested.emit()
|
||||||
|
|
||||||
|
# --- 엔진 연동 ------------------------------------------------------
|
||||||
|
def on_status(self, status: EngineStatus) -> None:
|
||||||
|
running = status.state in (EngineState.RUNNING, EngineState.LOADING)
|
||||||
|
self.toggle_button.setProperty("running", running)
|
||||||
|
self.toggle_button.setText("번역 정지" if running else "번역 시작")
|
||||||
|
self.toggle_button.setObjectName("Danger" if running else "Primary")
|
||||||
|
self.toggle_button.style().polish(self.toggle_button)
|
||||||
|
|
||||||
|
kind = {
|
||||||
|
EngineState.RUNNING: "ok",
|
||||||
|
EngineState.LOADING: "busy",
|
||||||
|
EngineState.STOPPING: "busy",
|
||||||
|
EngineState.ERROR: "error",
|
||||||
|
}.get(status.state, "idle")
|
||||||
|
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):
|
||||||
|
widget.setEnabled(not running)
|
||||||
|
self.speech_combo.setEnabled(
|
||||||
|
not running and self.config.models.target_lang == "ko"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_line(self, line: TranslationLine) -> None:
|
||||||
|
if not line.is_final:
|
||||||
|
return
|
||||||
|
item = QListWidgetItem(
|
||||||
|
f"{line.translated_text}\n"
|
||||||
|
f" {line.source_text} · {line.latency_s:.1f}초 "
|
||||||
|
f"(인식 {line.asr_ms}ms · 번역 {line.mt_ms}ms)"
|
||||||
|
)
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
item.setToolTip(line.source_text)
|
||||||
|
self.log_list.insertItem(0, item)
|
||||||
|
while self.log_list.count() > MAX_LOG_ROWS:
|
||||||
|
self.log_list.takeItem(self.log_list.count() - 1)
|
||||||
|
self.log_list.scrollToTop()
|
||||||
238
src/livesub/ui/pages/models_page.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""모델 — 5단계 품질 티어 선택과 GPU 상태."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QButtonGroup,
|
||||||
|
QComboBox,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QRadioButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ...config import AppConfig
|
||||||
|
from ...models import Tier, detect_gpu, ordered_tiers
|
||||||
|
from ...models.manager import GpuInfo, tier_availability
|
||||||
|
from ..theme import SPACING, palette
|
||||||
|
from ..widgets import Card, PageHeader
|
||||||
|
|
||||||
|
|
||||||
|
class TierCard(QFrame):
|
||||||
|
"""티어 하나를 라디오 버튼 카드로."""
|
||||||
|
|
||||||
|
def __init__(self, tier: Tier, gpu: GpuInfo, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.tier = tier
|
||||||
|
gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
|
||||||
|
self.available, self.unavailable_reason = tier_availability(tier, gpu)
|
||||||
|
self.setObjectName("Card")
|
||||||
|
p = palette("dark")
|
||||||
|
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(16, 14, 16, 14)
|
||||||
|
layout.setSpacing(14)
|
||||||
|
|
||||||
|
self.radio = QRadioButton()
|
||||||
|
layout.addWidget(self.radio, 0, Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
text = QVBoxLayout()
|
||||||
|
text.setSpacing(3)
|
||||||
|
|
||||||
|
head = QHBoxLayout()
|
||||||
|
head.setSpacing(8)
|
||||||
|
name = QLabel(f"{tier.order}. {tier.name}")
|
||||||
|
name.setStyleSheet("font-size: 16px; font-weight: 700;")
|
||||||
|
head.addWidget(name)
|
||||||
|
if tier.recommended:
|
||||||
|
head.addWidget(_badge("추천", p.accent))
|
||||||
|
if tier.best_after_finetune:
|
||||||
|
head.addWidget(_badge("추가학습 최적", p.success))
|
||||||
|
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
|
||||||
|
head.addWidget(_badge(f"VRAM {tier.min_vram_gb:g}GB 필요", p.warning))
|
||||||
|
if not self.available:
|
||||||
|
head.addWidget(_badge("사용 불가", p.danger))
|
||||||
|
head.addStretch(1)
|
||||||
|
text.addLayout(head)
|
||||||
|
|
||||||
|
tagline = QLabel(tier.tagline)
|
||||||
|
tagline.setObjectName("Subtitle")
|
||||||
|
text.addWidget(tagline)
|
||||||
|
|
||||||
|
spec = QLabel(
|
||||||
|
f"예상 지연 {tier.approx_latency_s:g}초 · 권장 VRAM {tier.min_vram_gb:g}GB "
|
||||||
|
f"· 내려받기 약 {tier.download_gb:g}GB"
|
||||||
|
)
|
||||||
|
spec.setObjectName("Hint")
|
||||||
|
text.addWidget(spec)
|
||||||
|
|
||||||
|
if tier.notes:
|
||||||
|
note = QLabel(tier.notes)
|
||||||
|
note.setObjectName("Hint")
|
||||||
|
note.setWordWrap(True)
|
||||||
|
text.addWidget(note)
|
||||||
|
|
||||||
|
if not self.available:
|
||||||
|
# 포터블 exe 에는 torch 가 없어 LLM 티어를 못 쓴다. 눌러도 안 되는
|
||||||
|
# 이유를 카드에 적어둬야 사용자가 헤매지 않는다.
|
||||||
|
blocked = QLabel(self.unavailable_reason)
|
||||||
|
blocked.setStyleSheet(f"color: {p.warning};")
|
||||||
|
blocked.setWordWrap(True)
|
||||||
|
text.addWidget(blocked)
|
||||||
|
self.radio.setEnabled(False)
|
||||||
|
self.setEnabled(True)
|
||||||
|
|
||||||
|
layout.addLayout(text, 1)
|
||||||
|
|
||||||
|
def set_selected(self, selected: bool) -> None:
|
||||||
|
self.setObjectName("CardSelected" if selected else "Card")
|
||||||
|
self.style().unpolish(self)
|
||||||
|
self.style().polish(self)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelsPage(QWidget):
|
||||||
|
tier_changed = Signal(str)
|
||||||
|
config_changed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.config = config
|
||||||
|
self._cards: list[TierCard] = []
|
||||||
|
# 티어 카드마다 조회하면 nvidia-smi 를 다섯 번 부른다. 한 번만 본다.
|
||||||
|
self._gpu = detect_gpu()
|
||||||
|
self._gpu_vram_gb = self._gpu.total_vram_mb / 1024 if self._gpu.available else 0.0
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
|
root.setSpacing(SPACING + 4)
|
||||||
|
root.addWidget(
|
||||||
|
PageHeader(
|
||||||
|
"모델",
|
||||||
|
"속도 우선(1)부터 품질 우선(5)까지 다섯 단계입니다. "
|
||||||
|
"모델은 처음 사용할 때 자동으로 내려받습니다.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
root.addWidget(self._build_gpu_card())
|
||||||
|
|
||||||
|
self._group = QButtonGroup(self)
|
||||||
|
self._group.setExclusive(True)
|
||||||
|
for tier in ordered_tiers():
|
||||||
|
card = TierCard(tier, self._gpu)
|
||||||
|
self._group.addButton(card.radio, tier.order)
|
||||||
|
card.radio.toggled.connect(
|
||||||
|
lambda checked, t=tier: self._on_tier_selected(t) if checked else None
|
||||||
|
)
|
||||||
|
self._cards.append(card)
|
||||||
|
root.addWidget(card)
|
||||||
|
|
||||||
|
root.addWidget(self._build_lora_card())
|
||||||
|
root.addStretch(1)
|
||||||
|
self._apply_selection(config.models.tier)
|
||||||
|
|
||||||
|
# --- 구성 -----------------------------------------------------------
|
||||||
|
def _build_gpu_card(self) -> Card:
|
||||||
|
gpu = self._gpu
|
||||||
|
p = palette("dark")
|
||||||
|
|
||||||
|
card = Card("실행 환경")
|
||||||
|
row = QWidget()
|
||||||
|
layout = QHBoxLayout(row)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
if gpu.available:
|
||||||
|
text = (
|
||||||
|
f"GPU: {gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB "
|
||||||
|
f"(여유 {gpu.free_vram_mb / 1024:.1f}GB)"
|
||||||
|
)
|
||||||
|
color = p.success
|
||||||
|
else:
|
||||||
|
text = f"GPU를 쓸 수 없습니다 — {gpu.reason} (CPU로도 동작하지만 많이 느립니다)"
|
||||||
|
color = p.warning
|
||||||
|
info = QLabel(text)
|
||||||
|
info.setStyleSheet(f"color: {color}; font-weight: 600;")
|
||||||
|
info.setWordWrap(True)
|
||||||
|
layout.addWidget(info, 1)
|
||||||
|
|
||||||
|
self.device_combo = QComboBox()
|
||||||
|
self.device_combo.addItem("GPU (CUDA)", "cuda")
|
||||||
|
self.device_combo.addItem("CPU", "cpu")
|
||||||
|
self.device_combo.setCurrentIndex(
|
||||||
|
max(0, self.device_combo.findData(self.config.models.device))
|
||||||
|
)
|
||||||
|
self.device_combo.setEnabled(gpu.available)
|
||||||
|
self.device_combo.currentIndexChanged.connect(self._on_device_changed)
|
||||||
|
layout.addWidget(self.device_combo)
|
||||||
|
|
||||||
|
card.add(row)
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _build_lora_card(self) -> Card:
|
||||||
|
card = Card(
|
||||||
|
"추가학습 어댑터 (선택)",
|
||||||
|
"게임·방송 용어로 따로 학습시킨 LoRA 어댑터 폴더를 지정하면 번역 모델에 얹습니다. "
|
||||||
|
"scripts/finetune_mt.py 로 만들 수 있습니다.",
|
||||||
|
)
|
||||||
|
row = QWidget()
|
||||||
|
layout = QHBoxLayout(row)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
|
||||||
|
self.lora_edit = QLineEdit(self.config.models.lora_adapter_path)
|
||||||
|
self.lora_edit.setPlaceholderText("비워두면 사용하지 않습니다")
|
||||||
|
self.lora_edit.editingFinished.connect(self._on_lora_changed)
|
||||||
|
layout.addWidget(self.lora_edit, 1)
|
||||||
|
|
||||||
|
browse = QPushButton("폴더 선택")
|
||||||
|
browse.clicked.connect(self._browse_lora)
|
||||||
|
layout.addWidget(browse)
|
||||||
|
|
||||||
|
card.add(row)
|
||||||
|
return card
|
||||||
|
|
||||||
|
# --- 동작 -----------------------------------------------------------
|
||||||
|
def _apply_selection(self, tier_key: str) -> None:
|
||||||
|
for card in self._cards:
|
||||||
|
selected = card.tier.key == tier_key
|
||||||
|
card.radio.setChecked(selected)
|
||||||
|
card.set_selected(selected)
|
||||||
|
|
||||||
|
def _on_tier_selected(self, tier: Tier) -> None:
|
||||||
|
if self.config.models.tier == tier.key:
|
||||||
|
return
|
||||||
|
self.config.models.tier = tier.key
|
||||||
|
for card in self._cards:
|
||||||
|
card.set_selected(card.tier.key == tier.key)
|
||||||
|
self.tier_changed.emit(tier.key)
|
||||||
|
self.config_changed.emit()
|
||||||
|
|
||||||
|
def _on_device_changed(self) -> None:
|
||||||
|
self.config.models.device = self.device_combo.currentData()
|
||||||
|
self.config_changed.emit()
|
||||||
|
|
||||||
|
def _on_lora_changed(self) -> None:
|
||||||
|
self.config.models.lora_adapter_path = self.lora_edit.text().strip()
|
||||||
|
self.config_changed.emit()
|
||||||
|
|
||||||
|
def _browse_lora(self) -> None:
|
||||||
|
from PySide6.QtWidgets import QFileDialog
|
||||||
|
|
||||||
|
path = QFileDialog.getExistingDirectory(self, "LoRA 어댑터 폴더 선택")
|
||||||
|
if path:
|
||||||
|
self.lora_edit.setText(path)
|
||||||
|
self._on_lora_changed()
|
||||||
|
|
||||||
|
|
||||||
|
def _badge(text: str, color: str) -> QLabel:
|
||||||
|
label = QLabel(text)
|
||||||
|
label.setStyleSheet(
|
||||||
|
f"color: {color}; border: 1px solid {color}; border-radius: 9px;"
|
||||||
|
f"padding: 1px 8px; font-size: 11px; font-weight: 700;"
|
||||||
|
)
|
||||||
|
return label
|
||||||
368
src/livesub/ui/pages/settings_page.py
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
"""설정 — 인식 민감도, 응답 속도, 기타 동작."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QSlider,
|
||||||
|
QSpinBox,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ...config import AppConfig
|
||||||
|
from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
|
||||||
|
from ...models import detect_gpu
|
||||||
|
from ...storage import free_space, human_size
|
||||||
|
from ..theme import SPACING, palette
|
||||||
|
from ..widgets import Card, PageHeader
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsPage(QWidget):
|
||||||
|
config_changed = Signal()
|
||||||
|
#: 오디오 파이프라인 설정이 바뀌어 엔진 재시작이 필요할 때
|
||||||
|
restart_needed = Signal()
|
||||||
|
#: 전역 단축키를 다시 등록해야 할 때
|
||||||
|
hotkeys_changed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
|
root.setSpacing(SPACING + 4)
|
||||||
|
root.addWidget(
|
||||||
|
PageHeader("설정", "게임과 같이 돌릴 때의 GPU 부담, 단축키, 인식 민감도를 조절합니다.")
|
||||||
|
)
|
||||||
|
|
||||||
|
root.addWidget(self._build_performance_card())
|
||||||
|
root.addWidget(self._build_hotkey_card())
|
||||||
|
root.addWidget(self._build_audio_card())
|
||||||
|
root.addWidget(self._build_behavior_card())
|
||||||
|
root.addWidget(self._build_storage_card())
|
||||||
|
root.addWidget(self._build_about_card())
|
||||||
|
root.addStretch(1)
|
||||||
|
|
||||||
|
# --- GPU 부담 --------------------------------------------------------
|
||||||
|
def _build_performance_card(self) -> Card:
|
||||||
|
perf = self.config.performance
|
||||||
|
card = Card(
|
||||||
|
"게임과 같이 쓰기 (GPU 절약)",
|
||||||
|
"게임이 먼저입니다. 이 모드를 켜면 자막 품질을 조금 내주는 대신 "
|
||||||
|
"게임 프레임과 VRAM을 지킵니다.",
|
||||||
|
)
|
||||||
|
form = card.add_form()
|
||||||
|
|
||||||
|
self.game_mode_check = QCheckBox("저부하 모드 (권장)")
|
||||||
|
self.game_mode_check.setChecked(perf.game_mode)
|
||||||
|
self.game_mode_check.setToolTip(
|
||||||
|
"연산 정밀도를 int8로 낮추고, 추론 사이에 GPU를 잠깐 쉬게 하고, "
|
||||||
|
"조용할 때 모델을 내립니다."
|
||||||
|
)
|
||||||
|
self.game_mode_check.toggled.connect(self._on_performance_changed)
|
||||||
|
form.addRow("", self.game_mode_check)
|
||||||
|
|
||||||
|
self.vram_slider, vram_row = _slider_row(
|
||||||
|
int(perf.vram_limit_ratio * 100), 0, 90, "%", self._on_performance_changed
|
||||||
|
)
|
||||||
|
self.vram_slider.setToolTip(
|
||||||
|
"이 프로그램이 쓸 수 있는 VRAM 상한입니다. 0%면 제한하지 않습니다.\n"
|
||||||
|
"게임이 VRAM 부족으로 버벅이면 낮추세요."
|
||||||
|
)
|
||||||
|
form.addRow("VRAM 상한", vram_row)
|
||||||
|
|
||||||
|
self.yield_spin = _spin(perf.yield_ms, 0, 500, 10, " ms", self._on_performance_changed)
|
||||||
|
self.yield_spin.setToolTip(
|
||||||
|
"한 문장을 처리하고 나서 GPU를 놓아주는 시간입니다.\n"
|
||||||
|
"길수록 게임이 부드럽고 자막은 조금 늦게 뜹니다."
|
||||||
|
)
|
||||||
|
form.addRow("GPU 양보 시간", self.yield_spin)
|
||||||
|
|
||||||
|
self.idle_spin = _spin(
|
||||||
|
perf.idle_unload_s, 0, 3600, 30, " 초", self._on_performance_changed
|
||||||
|
)
|
||||||
|
self.idle_spin.setToolTip(
|
||||||
|
"이 시간 동안 말소리가 없으면 모델을 내려 VRAM을 통째로 반납합니다.\n"
|
||||||
|
"다음 대사가 나오면 자동으로 다시 올라갑니다. 0이면 끕니다."
|
||||||
|
)
|
||||||
|
form.addRow("유휴 시 모델 해제", self.idle_spin)
|
||||||
|
|
||||||
|
self.perf_summary = QLabel()
|
||||||
|
self.perf_summary.setObjectName("Hint")
|
||||||
|
self.perf_summary.setWordWrap(True)
|
||||||
|
card.add(self.perf_summary)
|
||||||
|
self._update_perf_summary()
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _update_perf_summary(self) -> None:
|
||||||
|
perf = self.config.performance
|
||||||
|
gpu = detect_gpu()
|
||||||
|
if not gpu.available or not gpu.total_vram_mb:
|
||||||
|
self.perf_summary.setText("GPU 정보를 읽지 못해 상한을 계산할 수 없습니다.")
|
||||||
|
return
|
||||||
|
if perf.vram_limit_ratio <= 0:
|
||||||
|
self.perf_summary.setText(
|
||||||
|
f"{gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB — 상한 없음 "
|
||||||
|
"(게임이 VRAM 부족으로 버벅일 수 있습니다)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
budget = gpu.total_vram_mb * perf.vram_limit_ratio / 1024
|
||||||
|
left = gpu.total_vram_mb / 1024 - budget
|
||||||
|
self.perf_summary.setText(
|
||||||
|
f"{gpu.name} · 자막이 최대 {budget:.1f}GB 사용, 게임 몫 약 {left:.1f}GB"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 단축키 ----------------------------------------------------------
|
||||||
|
def _build_hotkey_card(self) -> Card:
|
||||||
|
keys = self.config.hotkeys
|
||||||
|
card = Card(
|
||||||
|
"전역 단축키",
|
||||||
|
"게임 창이 떠 있어도 동작합니다. Ctrl / Alt / Shift 중 하나는 반드시 넣으세요.",
|
||||||
|
)
|
||||||
|
form = card.add_form()
|
||||||
|
|
||||||
|
self.hotkey_enabled = QCheckBox("전역 단축키 사용")
|
||||||
|
self.hotkey_enabled.setChecked(keys.enabled)
|
||||||
|
self.hotkey_enabled.toggled.connect(self._on_hotkey_changed)
|
||||||
|
form.addRow("", self.hotkey_enabled)
|
||||||
|
|
||||||
|
self.subtitle_hotkey = QLineEdit(keys.toggle_subtitle)
|
||||||
|
self.subtitle_hotkey.setPlaceholderText("예: Ctrl+Alt+S")
|
||||||
|
self.subtitle_hotkey.editingFinished.connect(self._on_hotkey_changed)
|
||||||
|
form.addRow("자막 켜기/끄기", self.subtitle_hotkey)
|
||||||
|
|
||||||
|
self.engine_hotkey = QLineEdit(keys.toggle_engine)
|
||||||
|
self.engine_hotkey.setPlaceholderText("예: Ctrl+Alt+D")
|
||||||
|
self.engine_hotkey.editingFinished.connect(self._on_hotkey_changed)
|
||||||
|
form.addRow("번역 시작/정지", self.engine_hotkey)
|
||||||
|
|
||||||
|
self.hotkey_status = QLabel()
|
||||||
|
self.hotkey_status.setObjectName("Hint")
|
||||||
|
self.hotkey_status.setWordWrap(True)
|
||||||
|
card.add(self.hotkey_status)
|
||||||
|
return card
|
||||||
|
|
||||||
|
def set_hotkey_status(self, message: str, ok: bool = True) -> None:
|
||||||
|
p = palette("dark")
|
||||||
|
self.hotkey_status.setText(message)
|
||||||
|
self.hotkey_status.setStyleSheet(f"color: {p.text_dim if ok else p.warning};")
|
||||||
|
|
||||||
|
def _on_performance_changed(self, *_) -> None:
|
||||||
|
perf = self.config.performance
|
||||||
|
perf.game_mode = self.game_mode_check.isChecked()
|
||||||
|
perf.vram_limit_ratio = self.vram_slider.value() / 100
|
||||||
|
perf.yield_ms = self.yield_spin.value()
|
||||||
|
perf.idle_unload_s = self.idle_spin.value()
|
||||||
|
for widget in (self.vram_slider, self.yield_spin, self.idle_spin):
|
||||||
|
widget.setEnabled(perf.game_mode)
|
||||||
|
self._update_perf_summary()
|
||||||
|
if hasattr(self, "partial_spin"): # 오디오 카드가 아직 안 만들어졌을 수 있다
|
||||||
|
self._sync_partial_enabled()
|
||||||
|
self.config_changed.emit()
|
||||||
|
self.restart_needed.emit()
|
||||||
|
|
||||||
|
def _on_hotkey_changed(self, *_) -> None:
|
||||||
|
keys = self.config.hotkeys
|
||||||
|
keys.enabled = self.hotkey_enabled.isChecked()
|
||||||
|
keys.toggle_subtitle = self.subtitle_hotkey.text().strip()
|
||||||
|
keys.toggle_engine = self.engine_hotkey.text().strip()
|
||||||
|
for widget in (self.subtitle_hotkey, self.engine_hotkey):
|
||||||
|
widget.setEnabled(keys.enabled)
|
||||||
|
self.config_changed.emit()
|
||||||
|
self.hotkeys_changed.emit()
|
||||||
|
|
||||||
|
# --- 구성 -----------------------------------------------------------
|
||||||
|
def _build_audio_card(self) -> Card:
|
||||||
|
cfg = self.config.audio
|
||||||
|
card = Card(
|
||||||
|
"음성 인식",
|
||||||
|
"소리가 작아 인식이 안 되면 입력 증폭을 올리고, 주변 소음까지 잡히면 "
|
||||||
|
"민감도를 낮추세요. 값을 바꾸면 다음 시작부터 적용됩니다.",
|
||||||
|
)
|
||||||
|
form = card.add_form()
|
||||||
|
|
||||||
|
self.gain_slider, gain_row = _slider_row(
|
||||||
|
int(cfg.input_gain * 100), 25, 400, "%", self._on_audio_changed
|
||||||
|
)
|
||||||
|
form.addRow("입력 증폭", gain_row)
|
||||||
|
|
||||||
|
self.vad_combo = QComboBox()
|
||||||
|
for label, value in (
|
||||||
|
("낮음 — 작은 소리도 잡음", 0),
|
||||||
|
("보통", 1),
|
||||||
|
("높음 (기본)", 2),
|
||||||
|
("매우 높음 — 또렷한 말만", 3),
|
||||||
|
):
|
||||||
|
self.vad_combo.addItem(label, value)
|
||||||
|
self.vad_combo.setCurrentIndex(max(0, self.vad_combo.findData(cfg.vad_aggressiveness)))
|
||||||
|
self.vad_combo.currentIndexChanged.connect(self._on_audio_changed)
|
||||||
|
form.addRow("잡음 억제", self.vad_combo)
|
||||||
|
|
||||||
|
self.silence_spin = _spin(cfg.silence_ms, 200, 2000, 50, " ms", self._on_audio_changed)
|
||||||
|
self.silence_spin.setToolTip(
|
||||||
|
"이 시간만큼 조용하면 한 문장이 끝난 것으로 봅니다. "
|
||||||
|
"짧게 하면 자막이 빨리 뜨지만 문장이 자주 끊깁니다."
|
||||||
|
)
|
||||||
|
form.addRow("문장 끊는 침묵", self.silence_spin)
|
||||||
|
|
||||||
|
self.max_spin = _spin(cfg.max_segment_ms, 3000, 30_000, 1000, " ms", self._on_audio_changed)
|
||||||
|
self.max_spin.setToolTip("말이 계속 이어져도 이 길이에서 강제로 끊습니다.")
|
||||||
|
form.addRow("문장 최대 길이", self.max_spin)
|
||||||
|
|
||||||
|
self.partial_spin = _spin(
|
||||||
|
cfg.partial_interval_ms, 0, 3000, 100, " ms", self._on_audio_changed
|
||||||
|
)
|
||||||
|
self.partial_spin.setToolTip(
|
||||||
|
"말하는 도중에도 인식 중인 내용을 흐리게 미리 보여줍니다.\n"
|
||||||
|
"같은 오디오를 여러 번 인식하므로 GPU 작업이 거의 두 배가 됩니다.\n"
|
||||||
|
"저부하 모드가 켜져 있으면 무시됩니다."
|
||||||
|
)
|
||||||
|
self.partial_row_label = QLabel("중간 결과 갱신")
|
||||||
|
form.addRow(self.partial_row_label, self.partial_spin)
|
||||||
|
self._sync_partial_enabled()
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _sync_partial_enabled(self) -> None:
|
||||||
|
"""저부하 모드에서는 중간 결과를 쓰지 않으므로 입력을 잠근다."""
|
||||||
|
on = not self.config.performance.game_mode
|
||||||
|
self.partial_spin.setEnabled(on)
|
||||||
|
self.partial_row_label.setText(
|
||||||
|
"중간 결과 갱신" if on else "중간 결과 갱신 (저부하 모드에서 꺼짐)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_behavior_card(self) -> Card:
|
||||||
|
card = Card("동작")
|
||||||
|
form = card.add_form()
|
||||||
|
|
||||||
|
self.theme_combo = QComboBox()
|
||||||
|
self.theme_combo.addItem("어두운 테마", "dark")
|
||||||
|
self.theme_combo.addItem("밝은 테마", "light")
|
||||||
|
self.theme_combo.setCurrentIndex(max(0, self.theme_combo.findData(self.config.theme)))
|
||||||
|
self.theme_combo.currentIndexChanged.connect(self._on_behavior_changed)
|
||||||
|
form.addRow("테마", self.theme_combo)
|
||||||
|
|
||||||
|
self.preload_check = QCheckBox("시작할 때 모델을 미리 올려두기")
|
||||||
|
self.preload_check.setChecked(self.config.models.preload_on_start)
|
||||||
|
self.preload_check.setToolTip(
|
||||||
|
"끄면 프로그램이 빨리 뜨지만 첫 문장 번역이 오래 걸립니다."
|
||||||
|
)
|
||||||
|
self.preload_check.toggled.connect(self._on_behavior_changed)
|
||||||
|
form.addRow("", self.preload_check)
|
||||||
|
|
||||||
|
self.log_check = QCheckBox("번역 기록을 파일로 남기기")
|
||||||
|
self.log_check.setChecked(self.config.log_transcripts)
|
||||||
|
self.log_check.toggled.connect(self._on_behavior_changed)
|
||||||
|
form.addRow("", self.log_check)
|
||||||
|
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:
|
||||||
|
return Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN)
|
||||||
|
|
||||||
|
# --- 동작 -----------------------------------------------------------
|
||||||
|
def _on_audio_changed(self, *_) -> None:
|
||||||
|
cfg = self.config.audio
|
||||||
|
cfg.input_gain = self.gain_slider.value() / 100
|
||||||
|
cfg.vad_aggressiveness = self.vad_combo.currentData()
|
||||||
|
cfg.silence_ms = self.silence_spin.value()
|
||||||
|
cfg.max_segment_ms = self.max_spin.value()
|
||||||
|
cfg.partial_interval_ms = self.partial_spin.value()
|
||||||
|
self.config_changed.emit()
|
||||||
|
self.restart_needed.emit()
|
||||||
|
|
||||||
|
def _on_behavior_changed(self, *_) -> None:
|
||||||
|
self.config.theme = self.theme_combo.currentData()
|
||||||
|
self.config.models.preload_on_start = self.preload_check.isChecked()
|
||||||
|
self.config.log_transcripts = self.log_check.isChecked()
|
||||||
|
self.config_changed.emit()
|
||||||
|
|
||||||
|
def _open_folder(self) -> None:
|
||||||
|
from PySide6.QtCore import QUrl
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
|
||||||
|
QDesktopServices.openUrl(QUrl.fromLocalFile(str(user_data_dir())))
|
||||||
|
|
||||||
|
|
||||||
|
def _spin(value, low, high, step, suffix, slot) -> QSpinBox:
|
||||||
|
spin = QSpinBox()
|
||||||
|
spin.setRange(low, high)
|
||||||
|
spin.setSingleStep(step)
|
||||||
|
spin.setSuffix(suffix)
|
||||||
|
spin.setValue(value)
|
||||||
|
spin.valueChanged.connect(slot)
|
||||||
|
return spin
|
||||||
|
|
||||||
|
|
||||||
|
def _slider_row(value, low, high, suffix, slot) -> tuple[QSlider, QWidget]:
|
||||||
|
row = QWidget()
|
||||||
|
row.setObjectName("Row")
|
||||||
|
layout = QHBoxLayout(row)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
slider = QSlider(Qt.Orientation.Horizontal)
|
||||||
|
slider.setRange(low, high)
|
||||||
|
slider.setValue(value)
|
||||||
|
label = QLabel(f"{value}{suffix}")
|
||||||
|
label.setMinimumWidth(48)
|
||||||
|
slider.valueChanged.connect(lambda v: label.setText(f"{v}{suffix}"))
|
||||||
|
slider.valueChanged.connect(slot)
|
||||||
|
layout.addWidget(slider, 1)
|
||||||
|
layout.addWidget(label)
|
||||||
|
return slider, row
|
||||||
360
src/livesub/ui/pages/subtitle_page.py
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
"""자막 — 글꼴·색·크기 등 표시 설정과 실시간 미리보기."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtGui import QColor, QFont, QFontDatabase
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QColorDialog,
|
||||||
|
QComboBox,
|
||||||
|
QFontComboBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSlider,
|
||||||
|
QSpinBox,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ...config import AppConfig
|
||||||
|
from ..overlay import SubtitlePreview
|
||||||
|
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, list_screens
|
||||||
|
from ..theme import SPACING
|
||||||
|
from ..widgets import AnchorGrid, Card, PageHeader
|
||||||
|
|
||||||
|
PREVIEW_TEXT = "적이 왼쪽에서 들어온다, 지금 바로 빠져!"
|
||||||
|
PREVIEW_SOURCE = "Enemy coming from the left, fall back now!"
|
||||||
|
|
||||||
|
|
||||||
|
class ColorButton(QPushButton):
|
||||||
|
color_picked = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, color: str, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setFixedSize(52, 30)
|
||||||
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.set_color(color)
|
||||||
|
self.clicked.connect(self._pick)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def color(self) -> str:
|
||||||
|
return self._color
|
||||||
|
|
||||||
|
def set_color(self, color: str) -> None:
|
||||||
|
self._color = color
|
||||||
|
self.setStyleSheet(
|
||||||
|
f"background: {color}; border: 1px solid #3A4152; border-radius: 6px;"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _pick(self) -> None:
|
||||||
|
chosen = QColorDialog.getColor(QColor(self._color), self, "색 선택")
|
||||||
|
if chosen.isValid():
|
||||||
|
self.set_color(chosen.name())
|
||||||
|
self.color_picked.emit(chosen.name())
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitlePage(QWidget):
|
||||||
|
style_changed = Signal()
|
||||||
|
#: 자막 표시 on/off
|
||||||
|
overlay_toggled = Signal(bool)
|
||||||
|
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜
|
||||||
|
placement_changed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
|
root.setSpacing(SPACING + 4)
|
||||||
|
root.addWidget(
|
||||||
|
PageHeader("자막", "자막을 켜고 끄고, 어느 모니터 어디에 띄울지 정합니다.")
|
||||||
|
)
|
||||||
|
|
||||||
|
root.addWidget(self._build_display_card())
|
||||||
|
root.addWidget(self._build_preview_card())
|
||||||
|
root.addWidget(self._build_text_card())
|
||||||
|
root.addWidget(self._build_layout_card())
|
||||||
|
root.addStretch(1)
|
||||||
|
|
||||||
|
self._sync_preview()
|
||||||
|
|
||||||
|
# --- 구성 -----------------------------------------------------------
|
||||||
|
def _build_display_card(self) -> Card:
|
||||||
|
"""자막 on/off + 모니터 선택 + 디스코드식 위치 선택."""
|
||||||
|
cfg = self.config.overlay
|
||||||
|
card = Card(
|
||||||
|
"표시 위치",
|
||||||
|
"모니터를 고르고 네모칸을 누르면 그 자리에 자막이 붙습니다. "
|
||||||
|
"직접 드래그해서 옮기면 그 위치를 그대로 기억합니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- on/off + 단축키 안내 ---
|
||||||
|
toggle_row = QWidget()
|
||||||
|
toggle_layout = QHBoxLayout(toggle_row)
|
||||||
|
toggle_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
toggle_layout.setSpacing(10)
|
||||||
|
|
||||||
|
self.overlay_check = QCheckBox("자막 켜기")
|
||||||
|
self.overlay_check.setChecked(cfg.visible)
|
||||||
|
self.overlay_check.setStyleSheet("font-weight: 700;")
|
||||||
|
self.overlay_check.toggled.connect(self.overlay_toggled.emit)
|
||||||
|
toggle_layout.addWidget(self.overlay_check)
|
||||||
|
|
||||||
|
hotkey_hint = QLabel(f"게임 중에도 {self.config.hotkeys.toggle_subtitle} 로 켜고 끌 수 있습니다")
|
||||||
|
hotkey_hint.setObjectName("Hint")
|
||||||
|
toggle_layout.addWidget(hotkey_hint)
|
||||||
|
toggle_layout.addStretch(1)
|
||||||
|
|
||||||
|
self.click_through_check = QCheckBox("클릭 통과")
|
||||||
|
self.click_through_check.setChecked(cfg.click_through)
|
||||||
|
self.click_through_check.setToolTip(
|
||||||
|
"켜면 자막 위를 클릭해도 게임으로 전달됩니다. 게임 중에는 켜두세요."
|
||||||
|
)
|
||||||
|
self.click_through_check.toggled.connect(self._on_placement_changed)
|
||||||
|
toggle_layout.addWidget(self.click_through_check)
|
||||||
|
card.add(toggle_row)
|
||||||
|
|
||||||
|
# --- 모니터 선택 ---
|
||||||
|
monitor_row = QWidget()
|
||||||
|
monitor_layout = QHBoxLayout(monitor_row)
|
||||||
|
monitor_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
monitor_layout.setSpacing(10)
|
||||||
|
monitor_layout.addWidget(QLabel("모니터"))
|
||||||
|
|
||||||
|
self.screen_combo = QComboBox()
|
||||||
|
self.screen_combo.currentIndexChanged.connect(self._on_screen_changed)
|
||||||
|
monitor_layout.addWidget(self.screen_combo, 1)
|
||||||
|
|
||||||
|
refresh = QPushButton("새로고침")
|
||||||
|
refresh.setToolTip("모니터를 연결하거나 뺐다면 눌러주세요.")
|
||||||
|
refresh.clicked.connect(self.refresh_screens)
|
||||||
|
monitor_layout.addWidget(refresh)
|
||||||
|
card.add(monitor_row)
|
||||||
|
|
||||||
|
# --- 9분할 위치 ---
|
||||||
|
self.anchor_grid = AnchorGrid()
|
||||||
|
self.anchor_grid.anchor_selected.connect(self._on_anchor_selected)
|
||||||
|
card.add(self.anchor_grid)
|
||||||
|
|
||||||
|
self.anchor_label = QLabel()
|
||||||
|
self.anchor_label.setObjectName("Hint")
|
||||||
|
self.anchor_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
card.add(self.anchor_label)
|
||||||
|
|
||||||
|
margin_row = QWidget()
|
||||||
|
margin_layout = QHBoxLayout(margin_row)
|
||||||
|
margin_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
margin_layout.setSpacing(10)
|
||||||
|
margin_layout.addWidget(QLabel("가장자리 여백"))
|
||||||
|
self.margin_spin = QSpinBox()
|
||||||
|
self.margin_spin.setRange(0, 400)
|
||||||
|
self.margin_spin.setSingleStep(10)
|
||||||
|
self.margin_spin.setSuffix(" px")
|
||||||
|
self.margin_spin.setValue(cfg.margin)
|
||||||
|
self.margin_spin.valueChanged.connect(self._on_placement_changed)
|
||||||
|
margin_layout.addWidget(self.margin_spin)
|
||||||
|
margin_layout.addStretch(1)
|
||||||
|
card.add(margin_row)
|
||||||
|
|
||||||
|
self.refresh_screens()
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _build_preview_card(self) -> Card:
|
||||||
|
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
|
||||||
|
self.preview = SubtitlePreview(self.config.subtitle)
|
||||||
|
self.preview.set_sample(PREVIEW_TEXT, PREVIEW_SOURCE)
|
||||||
|
card.add(self.preview)
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _build_text_card(self) -> Card:
|
||||||
|
st = self.config.subtitle
|
||||||
|
card = Card("글씨")
|
||||||
|
form = card.add_form()
|
||||||
|
|
||||||
|
self.font_combo = QFontComboBox()
|
||||||
|
available = set(QFontDatabase.families())
|
||||||
|
self.font_combo.setCurrentFont(
|
||||||
|
QFont(st.font_family if st.font_family in available else "Segoe UI")
|
||||||
|
)
|
||||||
|
self.font_combo.currentFontChanged.connect(self._on_font_changed)
|
||||||
|
form.addRow("글꼴", self.font_combo)
|
||||||
|
|
||||||
|
self.size_spin = QSpinBox()
|
||||||
|
self.size_spin.setRange(12, 120)
|
||||||
|
self.size_spin.setSuffix(" px")
|
||||||
|
self.size_spin.setValue(st.font_size)
|
||||||
|
self.size_spin.valueChanged.connect(self._on_change)
|
||||||
|
form.addRow("크기", self.size_spin)
|
||||||
|
|
||||||
|
self.bold_check = QCheckBox("굵게")
|
||||||
|
self.bold_check.setChecked(st.bold)
|
||||||
|
self.bold_check.toggled.connect(self._on_change)
|
||||||
|
form.addRow("", self.bold_check)
|
||||||
|
|
||||||
|
self.text_color = ColorButton(st.text_color)
|
||||||
|
self.text_color.color_picked.connect(self._on_change)
|
||||||
|
form.addRow("번역문 색", self.text_color)
|
||||||
|
|
||||||
|
self.source_color = ColorButton(st.source_text_color)
|
||||||
|
self.source_color.color_picked.connect(self._on_change)
|
||||||
|
form.addRow("원문 색", self.source_color)
|
||||||
|
|
||||||
|
self.outline_color = ColorButton(st.outline_color)
|
||||||
|
self.outline_color.color_picked.connect(self._on_change)
|
||||||
|
form.addRow("외곽선 색", self.outline_color)
|
||||||
|
|
||||||
|
self.outline_spin = QSpinBox()
|
||||||
|
self.outline_spin.setRange(0, 8)
|
||||||
|
self.outline_spin.setSuffix(" px")
|
||||||
|
self.outline_spin.setValue(st.outline_width)
|
||||||
|
self.outline_spin.setToolTip("배경이 밝든 어둡든 글씨가 읽히게 해줍니다. 0이면 끕니다.")
|
||||||
|
self.outline_spin.valueChanged.connect(self._on_change)
|
||||||
|
form.addRow("외곽선 두께", self.outline_spin)
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _build_layout_card(self) -> Card:
|
||||||
|
st = self.config.subtitle
|
||||||
|
card = Card("배치와 배경")
|
||||||
|
form = card.add_form()
|
||||||
|
|
||||||
|
self.bg_color = ColorButton(st.background_color)
|
||||||
|
self.bg_color.color_picked.connect(self._on_change)
|
||||||
|
form.addRow("배경 색", self.bg_color)
|
||||||
|
|
||||||
|
opacity_row = QWidget()
|
||||||
|
opacity_row.setObjectName("Row")
|
||||||
|
layout = QHBoxLayout(opacity_row)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
|
||||||
|
self.opacity_slider.setRange(0, 100)
|
||||||
|
self.opacity_slider.setValue(st.background_opacity)
|
||||||
|
self.opacity_slider.valueChanged.connect(self._on_change)
|
||||||
|
self.opacity_value = QLabel(f"{st.background_opacity}%")
|
||||||
|
self.opacity_value.setMinimumWidth(42)
|
||||||
|
layout.addWidget(self.opacity_slider, 1)
|
||||||
|
layout.addWidget(self.opacity_value)
|
||||||
|
form.addRow("배경 투명도", opacity_row)
|
||||||
|
|
||||||
|
self.align_combo = QComboBox()
|
||||||
|
for label, value in (("가운데", "center"), ("왼쪽", "left"), ("오른쪽", "right")):
|
||||||
|
self.align_combo.addItem(label, value)
|
||||||
|
self.align_combo.setCurrentIndex(max(0, self.align_combo.findData(st.align)))
|
||||||
|
self.align_combo.currentIndexChanged.connect(self._on_change)
|
||||||
|
form.addRow("정렬", self.align_combo)
|
||||||
|
|
||||||
|
self.lines_spin = QSpinBox()
|
||||||
|
self.lines_spin.setRange(1, 5)
|
||||||
|
self.lines_spin.setSuffix(" 줄")
|
||||||
|
self.lines_spin.setValue(st.max_lines)
|
||||||
|
self.lines_spin.valueChanged.connect(self._on_change)
|
||||||
|
form.addRow("최대 줄 수", self.lines_spin)
|
||||||
|
|
||||||
|
self.fade_spin = QSpinBox()
|
||||||
|
self.fade_spin.setRange(0, 30_000)
|
||||||
|
self.fade_spin.setSingleStep(500)
|
||||||
|
self.fade_spin.setSuffix(" ms")
|
||||||
|
self.fade_spin.setValue(st.fade_out_ms)
|
||||||
|
self.fade_spin.setToolTip("이 시간이 지나면 자막을 지웁니다. 0이면 계속 남겨둡니다.")
|
||||||
|
self.fade_spin.valueChanged.connect(self._on_change)
|
||||||
|
form.addRow("자동 지우기", self.fade_spin)
|
||||||
|
|
||||||
|
self.show_source_check = QCheckBox("원문도 같이 표시")
|
||||||
|
self.show_source_check.setChecked(st.show_source)
|
||||||
|
self.show_source_check.toggled.connect(self._on_change)
|
||||||
|
form.addRow("", self.show_source_check)
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
# --- 표시 위치 -------------------------------------------------------
|
||||||
|
def refresh_screens(self) -> None:
|
||||||
|
"""연결된 모니터를 다시 읽어 콤보를 채운다."""
|
||||||
|
cfg = self.config.overlay
|
||||||
|
screens = list_screens()
|
||||||
|
|
||||||
|
self.screen_combo.blockSignals(True)
|
||||||
|
self.screen_combo.clear()
|
||||||
|
for screen in screens:
|
||||||
|
self.screen_combo.addItem(screen.label, screen.index)
|
||||||
|
if not screens:
|
||||||
|
self.screen_combo.addItem("모니터를 찾지 못했습니다", -1)
|
||||||
|
found = self.screen_combo.findData(cfg.screen_index)
|
||||||
|
self.screen_combo.setCurrentIndex(found if found >= 0 else 0)
|
||||||
|
self.screen_combo.blockSignals(False)
|
||||||
|
|
||||||
|
self.anchor_grid.set_anchor(cfg.anchor)
|
||||||
|
self._update_anchor_label()
|
||||||
|
|
||||||
|
def sync_from_config(self) -> None:
|
||||||
|
"""드래그 등으로 설정이 바뀐 뒤 화면 표시를 맞춘다."""
|
||||||
|
cfg = self.config.overlay
|
||||||
|
self.overlay_check.blockSignals(True)
|
||||||
|
self.overlay_check.setChecked(cfg.visible)
|
||||||
|
self.overlay_check.blockSignals(False)
|
||||||
|
self.anchor_grid.set_anchor(cfg.anchor)
|
||||||
|
self._update_anchor_label()
|
||||||
|
|
||||||
|
def _update_anchor_label(self) -> None:
|
||||||
|
anchor = self.config.overlay.anchor
|
||||||
|
if anchor == ANCHOR_CUSTOM:
|
||||||
|
self.anchor_label.setText(
|
||||||
|
"자유 배치 — 직접 옮긴 자리를 쓰고 있습니다. 칸을 누르면 다시 맞춰집니다."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.anchor_label.setText(f"현재 위치: {ANCHOR_LABELS.get(anchor, anchor)}")
|
||||||
|
|
||||||
|
def _on_screen_changed(self) -> None:
|
||||||
|
index = self.screen_combo.currentData()
|
||||||
|
if index is None or index < 0:
|
||||||
|
return
|
||||||
|
self.config.overlay.screen_index = index
|
||||||
|
# 모니터를 바꾸면 그 모니터 기준으로 다시 배치해야 한다. 자유 배치
|
||||||
|
# 상태였다면 하단 중앙으로 되돌린다 — 이전 좌표는 다른 모니터 것이라
|
||||||
|
# 그대로 쓰면 화면 밖으로 나간다.
|
||||||
|
if self.config.overlay.anchor == ANCHOR_CUSTOM:
|
||||||
|
self.config.overlay.anchor = "bottom-center"
|
||||||
|
self.anchor_grid.set_anchor("bottom-center")
|
||||||
|
self._update_anchor_label()
|
||||||
|
self.placement_changed.emit()
|
||||||
|
|
||||||
|
def _on_anchor_selected(self, anchor: str) -> None:
|
||||||
|
self.config.overlay.anchor = anchor
|
||||||
|
self._update_anchor_label()
|
||||||
|
self.placement_changed.emit()
|
||||||
|
|
||||||
|
def _on_placement_changed(self, *_) -> None:
|
||||||
|
cfg = self.config.overlay
|
||||||
|
cfg.margin = self.margin_spin.value()
|
||||||
|
cfg.click_through = self.click_through_check.isChecked()
|
||||||
|
self.placement_changed.emit()
|
||||||
|
|
||||||
|
# --- 동작 -----------------------------------------------------------
|
||||||
|
def _on_font_changed(self, font: QFont) -> None:
|
||||||
|
self.config.subtitle.font_family = font.family()
|
||||||
|
self._on_change()
|
||||||
|
|
||||||
|
def _on_change(self, *_) -> None:
|
||||||
|
st = self.config.subtitle
|
||||||
|
st.font_size = self.size_spin.value()
|
||||||
|
st.bold = self.bold_check.isChecked()
|
||||||
|
st.text_color = self.text_color.color
|
||||||
|
st.source_text_color = self.source_color.color
|
||||||
|
st.outline_color = self.outline_color.color
|
||||||
|
st.outline_width = self.outline_spin.value()
|
||||||
|
st.background_color = self.bg_color.color
|
||||||
|
st.background_opacity = self.opacity_slider.value()
|
||||||
|
st.align = self.align_combo.currentData()
|
||||||
|
st.max_lines = self.lines_spin.value()
|
||||||
|
st.fade_out_ms = self.fade_spin.value()
|
||||||
|
st.show_source = self.show_source_check.isChecked()
|
||||||
|
|
||||||
|
self.opacity_value.setText(f"{st.background_opacity}%")
|
||||||
|
self._sync_preview()
|
||||||
|
self.style_changed.emit()
|
||||||
|
|
||||||
|
def _sync_preview(self) -> None:
|
||||||
|
# 미리보기는 설정 객체를 그대로 참조하므로 다시 그리기만 하면 된다.
|
||||||
|
self.preview.setMinimumHeight(max(140, self.config.subtitle.font_size * 4))
|
||||||
|
self.preview.update()
|
||||||
141
src/livesub/ui/placement.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""자막 창 배치 계산.
|
||||||
|
|
||||||
|
디스코드 오버레이와 같은 방식이다. 모니터를 고르고 9개 구역 중 하나를 누르면
|
||||||
|
그 자리에 딱 붙는다. 세밀하게 옮기고 싶으면 그 다음에 드래그하면 된다.
|
||||||
|
|
||||||
|
Qt 에 의존하지 않는 순수 계산부(`resolve_rect`)와 Qt 화면 조회부를 나눠놨다.
|
||||||
|
덕분에 모니터가 없는 환경에서도 배치 로직을 그대로 테스트할 수 있다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
#: 디스코드 오버레이와 같은 3x3 그리드. 값은 (가로 정렬, 세로 정렬).
|
||||||
|
ANCHORS: dict[str, tuple[str, str]] = {
|
||||||
|
"top-left": ("left", "top"),
|
||||||
|
"top-center": ("center", "top"),
|
||||||
|
"top-right": ("right", "top"),
|
||||||
|
"middle-left": ("left", "middle"),
|
||||||
|
"middle-center": ("center", "middle"),
|
||||||
|
"middle-right": ("right", "middle"),
|
||||||
|
"bottom-left": ("left", "bottom"),
|
||||||
|
"bottom-center": ("center", "bottom"),
|
||||||
|
"bottom-right": ("right", "bottom"),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: UI 표시용 이름 (그리드 순서와 동일)
|
||||||
|
ANCHOR_LABELS: dict[str, str] = {
|
||||||
|
"top-left": "좌상단",
|
||||||
|
"top-center": "상단 중앙",
|
||||||
|
"top-right": "우상단",
|
||||||
|
"middle-left": "좌측 중앙",
|
||||||
|
"middle-center": "정중앙",
|
||||||
|
"middle-right": "우측 중앙",
|
||||||
|
"bottom-left": "좌하단",
|
||||||
|
"bottom-center": "하단 중앙",
|
||||||
|
"bottom-right": "우하단",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 자유 배치 — 사용자가 드래그로 직접 옮긴 상태
|
||||||
|
ANCHOR_CUSTOM = "custom"
|
||||||
|
|
||||||
|
DEFAULT_ANCHOR = "bottom-center"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScreenInfo:
|
||||||
|
"""모니터 하나. Qt 의 QScreen 에서 뽑아낸 값만 담는다."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
name: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
is_primary: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
tag = " (주 모니터)" if self.is_primary else ""
|
||||||
|
return f"{self.index + 1}. {self.name} · {self.width}×{self.height}{tag}"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_rect(
|
||||||
|
screen: ScreenInfo,
|
||||||
|
anchor: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
margin: int = 60,
|
||||||
|
) -> tuple[int, int, int, int]:
|
||||||
|
"""앵커와 크기로 최종 창 위치를 계산한다.
|
||||||
|
|
||||||
|
창이 모니터보다 크면 모니터 안에 들어가도록 줄인다. 그러지 않으면
|
||||||
|
자막이 화면 밖으로 나가 영영 안 보이는 상태가 될 수 있다.
|
||||||
|
"""
|
||||||
|
width = max(200, min(width, screen.width - margin * 2))
|
||||||
|
height = max(80, min(height, screen.height - margin * 2))
|
||||||
|
|
||||||
|
horizontal, vertical = ANCHORS.get(anchor, ANCHORS[DEFAULT_ANCHOR])
|
||||||
|
|
||||||
|
if horizontal == "left":
|
||||||
|
x = screen.x + margin
|
||||||
|
elif horizontal == "right":
|
||||||
|
x = screen.x + screen.width - width - margin
|
||||||
|
else:
|
||||||
|
x = screen.x + (screen.width - width) // 2
|
||||||
|
|
||||||
|
if vertical == "top":
|
||||||
|
y = screen.y + margin
|
||||||
|
elif vertical == "bottom":
|
||||||
|
y = screen.y + screen.height - height - margin
|
||||||
|
else:
|
||||||
|
y = screen.y + (screen.height - height) // 2
|
||||||
|
|
||||||
|
return x, y, width, height
|
||||||
|
|
||||||
|
|
||||||
|
def screen_for_point(screens: list[ScreenInfo], x: int, y: int) -> ScreenInfo | None:
|
||||||
|
"""해당 좌표를 품고 있는 모니터. 드래그로 옮긴 뒤 소속을 다시 찾을 때 쓴다."""
|
||||||
|
for screen in screens:
|
||||||
|
if screen.x <= x < screen.x + screen.width and screen.y <= y < screen.y + screen.height:
|
||||||
|
return screen
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pick_screen(screens: list[ScreenInfo], index: int) -> ScreenInfo | None:
|
||||||
|
"""저장된 인덱스로 모니터를 찾는다. 모니터 구성이 바뀌었으면 주 모니터로."""
|
||||||
|
if not screens:
|
||||||
|
return None
|
||||||
|
for screen in screens:
|
||||||
|
if screen.index == index:
|
||||||
|
return screen
|
||||||
|
for screen in screens:
|
||||||
|
if screen.is_primary:
|
||||||
|
return screen
|
||||||
|
return screens[0]
|
||||||
|
|
||||||
|
|
||||||
|
def list_screens() -> list[ScreenInfo]:
|
||||||
|
"""현재 연결된 모니터 목록 (Qt 필요)."""
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
return []
|
||||||
|
primary = app.primaryScreen()
|
||||||
|
out: list[ScreenInfo] = []
|
||||||
|
for index, screen in enumerate(app.screens()):
|
||||||
|
geo = screen.geometry()
|
||||||
|
out.append(
|
||||||
|
ScreenInfo(
|
||||||
|
index=index,
|
||||||
|
name=screen.name() or f"모니터 {index + 1}",
|
||||||
|
x=geo.x(),
|
||||||
|
y=geo.y(),
|
||||||
|
width=geo.width(),
|
||||||
|
height=geo.height(),
|
||||||
|
is_primary=screen is primary,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
210
src/livesub/ui/theme.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""디자인 토큰과 QSS 스타일시트.
|
||||||
|
|
||||||
|
의도적으로 외부 테마 패키지를 쓰지 않는다. 자막 오버레이가 반투명·무테두리라
|
||||||
|
플랫폼 기본 테마와 섞이면 오히려 지저분해지고, 색/간격을 한곳에서 관리하는 편이
|
||||||
|
설정 화면의 '미리보기'와 실제 자막을 일치시키기에도 좋다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Palette:
|
||||||
|
bg: str
|
||||||
|
surface: str
|
||||||
|
surface_alt: str
|
||||||
|
border: str
|
||||||
|
text: str
|
||||||
|
text_dim: str
|
||||||
|
accent: str
|
||||||
|
accent_hover: str
|
||||||
|
accent_text: str
|
||||||
|
success: str
|
||||||
|
warning: str
|
||||||
|
danger: str
|
||||||
|
|
||||||
|
|
||||||
|
DARK = Palette(
|
||||||
|
bg="#0F1117",
|
||||||
|
surface="#171A22",
|
||||||
|
surface_alt="#1E222C",
|
||||||
|
border="#2A2F3C",
|
||||||
|
text="#E7EAF0",
|
||||||
|
text_dim="#8B93A5",
|
||||||
|
accent="#5B8CFF",
|
||||||
|
accent_hover="#7AA2FF",
|
||||||
|
accent_text="#0B0E14",
|
||||||
|
success="#3DD68C",
|
||||||
|
warning="#F5B84E",
|
||||||
|
danger="#F2555A",
|
||||||
|
)
|
||||||
|
|
||||||
|
LIGHT = Palette(
|
||||||
|
bg="#F4F6FA",
|
||||||
|
surface="#FFFFFF",
|
||||||
|
surface_alt="#EDF0F6",
|
||||||
|
border="#D9DEE9",
|
||||||
|
text="#131722",
|
||||||
|
text_dim="#5C6578",
|
||||||
|
accent="#3B6EF0",
|
||||||
|
accent_hover="#2A5AD6",
|
||||||
|
accent_text="#FFFFFF",
|
||||||
|
success="#17A46A",
|
||||||
|
warning="#C9821B",
|
||||||
|
danger="#D63A3F",
|
||||||
|
)
|
||||||
|
|
||||||
|
RADIUS = 10
|
||||||
|
SPACING = 12
|
||||||
|
|
||||||
|
|
||||||
|
def palette(theme: str) -> Palette:
|
||||||
|
return LIGHT if theme == "light" else DARK
|
||||||
|
|
||||||
|
|
||||||
|
def stylesheet(theme: str = "dark") -> str:
|
||||||
|
p = palette(theme)
|
||||||
|
return f"""
|
||||||
|
QWidget {{
|
||||||
|
background: {p.bg};
|
||||||
|
color: {p.text};
|
||||||
|
font-family: "Pretendard", "Segoe UI Variable", "Segoe UI", "Malgun Gothic", sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}}
|
||||||
|
/* 라벨류는 부모(카드/사이드바) 배경이 비쳐야 한다. 지정하지 않으면
|
||||||
|
QWidget 규칙의 창 배경색을 상속해 카드 위에 검은 박스가 생긴다. */
|
||||||
|
QLabel, QCheckBox, QRadioButton, QScrollArea > QWidget > QWidget,
|
||||||
|
QWidget#Row {{
|
||||||
|
background: transparent;
|
||||||
|
}}
|
||||||
|
QLabel#Title {{ font-size: 22px; font-weight: 700; }}
|
||||||
|
QLabel#Subtitle {{ font-size: 13px; color: {p.text_dim}; }}
|
||||||
|
QLabel#SectionLabel {{ font-size: 12px; font-weight: 700; color: {p.text_dim};
|
||||||
|
letter-spacing: 1px; }}
|
||||||
|
QLabel#Hint {{ font-size: 12px; color: {p.text_dim}; }}
|
||||||
|
|
||||||
|
/* --- 사이드바 --- */
|
||||||
|
QFrame#Sidebar {{
|
||||||
|
background: {p.surface};
|
||||||
|
border-right: 1px solid {p.border};
|
||||||
|
}}
|
||||||
|
QPushButton#NavButton {{
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: {RADIUS}px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
text-align: left;
|
||||||
|
color: {p.text_dim};
|
||||||
|
font-size: 14px;
|
||||||
|
}}
|
||||||
|
QPushButton#NavButton:hover {{ background: {p.surface_alt}; color: {p.text}; }}
|
||||||
|
QPushButton#NavButton:checked {{
|
||||||
|
background: {p.accent};
|
||||||
|
color: {p.accent_text};
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* --- 카드 --- */
|
||||||
|
QFrame#Card {{
|
||||||
|
background: {p.surface};
|
||||||
|
border: 1px solid {p.border};
|
||||||
|
border-radius: {RADIUS}px;
|
||||||
|
}}
|
||||||
|
QFrame#CardSelected {{
|
||||||
|
background: {p.surface};
|
||||||
|
border: 2px solid {p.accent};
|
||||||
|
border-radius: {RADIUS}px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* --- 입력 위젯 --- */
|
||||||
|
QComboBox, QLineEdit, QSpinBox, QPlainTextEdit, QTextEdit, QListWidget, QTableWidget {{
|
||||||
|
background: {p.surface_alt};
|
||||||
|
border: 1px solid {p.border};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
selection-background-color: {p.accent};
|
||||||
|
selection-color: {p.accent_text};
|
||||||
|
}}
|
||||||
|
QComboBox:focus, QLineEdit:focus, QSpinBox:focus, QPlainTextEdit:focus {{
|
||||||
|
border: 1px solid {p.accent};
|
||||||
|
}}
|
||||||
|
QComboBox::drop-down {{ border: none; width: 22px; }}
|
||||||
|
QComboBox QAbstractItemView {{
|
||||||
|
background: {p.surface_alt};
|
||||||
|
border: 1px solid {p.border};
|
||||||
|
selection-background-color: {p.accent};
|
||||||
|
outline: none;
|
||||||
|
}}
|
||||||
|
QHeaderView::section {{
|
||||||
|
background: {p.surface};
|
||||||
|
color: {p.text_dim};
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid {p.border};
|
||||||
|
padding: 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
QTableWidget {{ gridline-color: {p.border}; }}
|
||||||
|
|
||||||
|
/* --- 버튼 --- */
|
||||||
|
QPushButton {{
|
||||||
|
background: {p.surface_alt};
|
||||||
|
border: 1px solid {p.border};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: {p.text};
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ border-color: {p.accent}; }}
|
||||||
|
QPushButton:disabled {{ color: {p.text_dim}; border-color: {p.border}; }}
|
||||||
|
QPushButton#Primary {{
|
||||||
|
background: {p.accent};
|
||||||
|
color: {p.accent_text};
|
||||||
|
border: none;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 11px 22px;
|
||||||
|
}}
|
||||||
|
QPushButton#Primary:hover {{ background: {p.accent_hover}; }}
|
||||||
|
QPushButton#Primary:disabled {{ background: {p.border}; color: {p.text_dim}; }}
|
||||||
|
QPushButton#Danger {{
|
||||||
|
background: {p.danger};
|
||||||
|
color: #FFFFFF;
|
||||||
|
border: none;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 11px 22px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* --- 기타 --- */
|
||||||
|
QSlider::groove:horizontal {{
|
||||||
|
height: 4px; background: {p.border}; border-radius: 2px;
|
||||||
|
}}
|
||||||
|
QSlider::handle:horizontal {{
|
||||||
|
width: 16px; height: 16px; margin: -6px 0;
|
||||||
|
background: {p.accent}; border-radius: 8px;
|
||||||
|
}}
|
||||||
|
QSlider::sub-page:horizontal {{ background: {p.accent}; border-radius: 2px; }}
|
||||||
|
QProgressBar {{
|
||||||
|
background: {p.surface_alt}; border: none; border-radius: 4px;
|
||||||
|
height: 6px; text-align: center; color: transparent;
|
||||||
|
}}
|
||||||
|
QProgressBar::chunk {{ background: {p.accent}; border-radius: 4px; }}
|
||||||
|
QCheckBox::indicator, QRadioButton::indicator {{
|
||||||
|
width: 17px; height: 17px;
|
||||||
|
border: 1px solid {p.border}; border-radius: 4px;
|
||||||
|
background: {p.surface_alt};
|
||||||
|
}}
|
||||||
|
QCheckBox::indicator:checked, QRadioButton::indicator:checked {{
|
||||||
|
background: {p.accent}; border-color: {p.accent};
|
||||||
|
}}
|
||||||
|
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 0; }}
|
||||||
|
QScrollBar::handle:vertical {{
|
||||||
|
background: {p.border}; border-radius: 5px; min-height: 30px;
|
||||||
|
}}
|
||||||
|
QScrollBar::handle:vertical:hover {{ background: {p.text_dim}; }}
|
||||||
|
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
|
||||||
|
QScrollArea {{ border: none; }}
|
||||||
|
QToolTip {{
|
||||||
|
background: {p.surface_alt}; color: {p.text};
|
||||||
|
border: 1px solid {p.border}; padding: 6px; border-radius: 6px;
|
||||||
|
}}
|
||||||
|
"""
|
||||||
4
src/livesub/ui/widgets/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .anchor_grid import AnchorGrid
|
||||||
|
from .common import Card, LevelMeter, PageHeader, StatusPill, labelled_row
|
||||||
|
|
||||||
|
__all__ = ["AnchorGrid", "Card", "LevelMeter", "PageHeader", "StatusPill", "labelled_row"]
|
||||||
69
src/livesub/ui/widgets/anchor_grid.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""디스코드 오버레이식 9분할 위치 선택기.
|
||||||
|
|
||||||
|
모니터 모양의 사각형 안에 3x3 버튼을 놓고, 누르면 자막이 그 자리로 붙는다.
|
||||||
|
좌표를 숫자로 입력하게 하는 것보다 훨씬 직관적이다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import QButtonGroup, QFrame, QGridLayout, QPushButton, QWidget
|
||||||
|
|
||||||
|
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, ANCHORS
|
||||||
|
from ..theme import palette
|
||||||
|
|
||||||
|
GRID_ORDER = [
|
||||||
|
["top-left", "top-center", "top-right"],
|
||||||
|
["middle-left", "middle-center", "middle-right"],
|
||||||
|
["bottom-left", "bottom-center", "bottom-right"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AnchorGrid(QFrame):
|
||||||
|
"""3x3 위치 버튼. 선택된 칸이 강조된다."""
|
||||||
|
|
||||||
|
anchor_selected = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("Card")
|
||||||
|
self.setFixedHeight(190)
|
||||||
|
|
||||||
|
p = palette("dark")
|
||||||
|
layout = QGridLayout(self)
|
||||||
|
layout.setContentsMargins(14, 14, 14, 14)
|
||||||
|
layout.setSpacing(6)
|
||||||
|
|
||||||
|
self._buttons: dict[str, QPushButton] = {}
|
||||||
|
self._group = QButtonGroup(self)
|
||||||
|
self._group.setExclusive(True)
|
||||||
|
|
||||||
|
for row, keys in enumerate(GRID_ORDER):
|
||||||
|
for col, key in enumerate(keys):
|
||||||
|
button = QPushButton()
|
||||||
|
button.setCheckable(True)
|
||||||
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
button.setToolTip(ANCHOR_LABELS[key])
|
||||||
|
button.setMinimumHeight(42)
|
||||||
|
button.setStyleSheet(
|
||||||
|
f"QPushButton {{ background: {p.surface_alt};"
|
||||||
|
f"border: 1px solid {p.border}; border-radius: 6px; }}"
|
||||||
|
f"QPushButton:hover {{ border-color: {p.accent}; }}"
|
||||||
|
f"QPushButton:checked {{ background: {p.accent};"
|
||||||
|
f"border-color: {p.accent}; }}"
|
||||||
|
)
|
||||||
|
button.clicked.connect(lambda _, k=key: self.anchor_selected.emit(k))
|
||||||
|
self._group.addButton(button)
|
||||||
|
self._buttons[key] = button
|
||||||
|
layout.addWidget(button, row, col)
|
||||||
|
|
||||||
|
def set_anchor(self, anchor: str) -> None:
|
||||||
|
"""현재 선택 표시. 자유 배치(custom)면 아무 칸도 켜지 않는다."""
|
||||||
|
self._group.setExclusive(False)
|
||||||
|
for key, button in self._buttons.items():
|
||||||
|
button.setChecked(anchor != ANCHOR_CUSTOM and key == anchor)
|
||||||
|
self._group.setExclusive(True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_valid(anchor: str) -> bool:
|
||||||
|
return anchor in ANCHORS
|
||||||
141
src/livesub/ui/widgets/common.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""여러 화면에서 재사용하는 작은 위젯들."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QColor, QPainter
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFormLayout,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QSizePolicy,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..theme import SPACING, palette
|
||||||
|
|
||||||
|
|
||||||
|
class Card(QFrame):
|
||||||
|
"""제목 + 본문 레이아웃을 가진 카드 컨테이너."""
|
||||||
|
|
||||||
|
def __init__(self, title: str = "", subtitle: str = "", parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("Card")
|
||||||
|
outer = QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(SPACING + 4, SPACING + 4, SPACING + 4, SPACING + 4)
|
||||||
|
outer.setSpacing(SPACING)
|
||||||
|
|
||||||
|
if title:
|
||||||
|
label = QLabel(title)
|
||||||
|
label.setStyleSheet("font-size: 15px; font-weight: 700;")
|
||||||
|
outer.addWidget(label)
|
||||||
|
if subtitle:
|
||||||
|
hint = QLabel(subtitle)
|
||||||
|
hint.setObjectName("Hint")
|
||||||
|
hint.setWordWrap(True)
|
||||||
|
outer.addWidget(hint)
|
||||||
|
|
||||||
|
self.body = QVBoxLayout()
|
||||||
|
self.body.setSpacing(SPACING)
|
||||||
|
outer.addLayout(self.body)
|
||||||
|
|
||||||
|
def add(self, widget: QWidget) -> QWidget:
|
||||||
|
# 레이아웃용 맨 QWidget 은 창 배경색을 상속해 카드 위에 검은 막대가 된다.
|
||||||
|
# 이름을 달아주면 QSS 가 투명하게 처리한다 (theme.py 의 QWidget#Row).
|
||||||
|
if type(widget) is QWidget:
|
||||||
|
widget.setObjectName("Row")
|
||||||
|
self.body.addWidget(widget)
|
||||||
|
return widget
|
||||||
|
|
||||||
|
def add_form(self) -> QFormLayout:
|
||||||
|
form = QFormLayout()
|
||||||
|
form.setSpacing(10)
|
||||||
|
form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
|
||||||
|
self.body.addLayout(form)
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
class PageHeader(QWidget):
|
||||||
|
def __init__(self, title: str, subtitle: str = "", parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(4)
|
||||||
|
head = QLabel(title)
|
||||||
|
head.setObjectName("Title")
|
||||||
|
layout.addWidget(head)
|
||||||
|
if subtitle:
|
||||||
|
sub = QLabel(subtitle)
|
||||||
|
sub.setObjectName("Subtitle")
|
||||||
|
sub.setWordWrap(True)
|
||||||
|
layout.addWidget(sub)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusPill(QLabel):
|
||||||
|
"""상태를 색 점 + 문구로 보여주는 배지."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._color = "#8B93A5"
|
||||||
|
self.setContentsMargins(10, 6, 12, 6)
|
||||||
|
self.set_status("대기 중", "idle")
|
||||||
|
|
||||||
|
def set_status(self, text: str, kind: str = "idle") -> None:
|
||||||
|
p = palette("dark")
|
||||||
|
self._color = {
|
||||||
|
"idle": p.text_dim,
|
||||||
|
"busy": p.warning,
|
||||||
|
"ok": p.success,
|
||||||
|
"error": p.danger,
|
||||||
|
}.get(kind, p.text_dim)
|
||||||
|
self.setText(f"● {text}")
|
||||||
|
self.setStyleSheet(
|
||||||
|
f"color: {self._color}; background: {p.surface_alt};"
|
||||||
|
f"border-radius: 13px; padding: 6px 14px; font-weight: 600;"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LevelMeter(QWidget):
|
||||||
|
"""입력 오디오 레벨 바. 소리가 잡히는지 한눈에 확인하는 용도."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._level = 0.0
|
||||||
|
self._peak = 0.0
|
||||||
|
self.setFixedHeight(8)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
|
|
||||||
|
def set_level(self, value: float) -> None:
|
||||||
|
# RMS를 그대로 그리면 거의 안 움직여서 제곱근으로 펴준다.
|
||||||
|
self._level = min(1.0, max(0.0, value) ** 0.5 * 2.2)
|
||||||
|
self._peak = max(self._level, self._peak * 0.92)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None: # noqa: N802
|
||||||
|
p = palette("dark")
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(p.surface_alt))
|
||||||
|
painter.drawRoundedRect(self.rect(), 4, 4)
|
||||||
|
if self._level > 0.01:
|
||||||
|
color = QColor(p.danger if self._level > 0.92 else p.success)
|
||||||
|
rect = self.rect().adjusted(0, 0, -int(self.width() * (1 - self._level)), 0)
|
||||||
|
painter.setBrush(color)
|
||||||
|
painter.drawRoundedRect(rect, 4, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def labelled_row(label: str, widget: QWidget) -> QWidget:
|
||||||
|
row = QWidget()
|
||||||
|
row.setObjectName("Row")
|
||||||
|
layout = QHBoxLayout(row)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
text = QLabel(label)
|
||||||
|
text.setMinimumWidth(110)
|
||||||
|
layout.addWidget(text)
|
||||||
|
layout.addWidget(widget, 1)
|
||||||
|
return row
|
||||||
BIN
tests/fixtures/en_callout_16k.wav
vendored
Normal file
58
tests/test_config.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from livesub.config import AppConfig
|
||||||
|
from livesub.models.tiers import DEFAULT_TIER
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_are_sane():
|
||||||
|
cfg = AppConfig()
|
||||||
|
assert cfg.models.tier == DEFAULT_TIER
|
||||||
|
assert cfg.models.target_lang == "ko"
|
||||||
|
assert cfg.subtitle.font_size > 0
|
||||||
|
assert cfg.audio.silence_ms > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_load_roundtrip(tmp_path):
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
cfg = AppConfig()
|
||||||
|
cfg.subtitle.font_size = 52
|
||||||
|
cfg.subtitle.text_color = "#FF00AA"
|
||||||
|
cfg.models.tier = "precision"
|
||||||
|
cfg.audio.target_pid = 4242
|
||||||
|
cfg.save(path)
|
||||||
|
|
||||||
|
loaded = AppConfig.load(path)
|
||||||
|
assert loaded.subtitle.font_size == 52
|
||||||
|
assert loaded.subtitle.text_color == "#FF00AA"
|
||||||
|
assert loaded.models.tier == "precision"
|
||||||
|
assert loaded.audio.target_pid == 4242
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_file_gives_defaults(tmp_path):
|
||||||
|
assert AppConfig.load(tmp_path / "nope.json").models.tier == DEFAULT_TIER
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_file_gives_defaults(tmp_path):
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
path.write_text("{ this is not json", encoding="utf-8")
|
||||||
|
assert AppConfig.load(path).models.tier == DEFAULT_TIER
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_keys_are_ignored_and_missing_keys_defaulted(tmp_path):
|
||||||
|
"""스키마가 바뀌어도 사용자의 기존 설정이 깨지면 안 된다."""
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"theme": "light",
|
||||||
|
"removed_option": True,
|
||||||
|
"subtitle": {"font_size": 40, "gone_field": 1},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
cfg = AppConfig.load(path)
|
||||||
|
assert cfg.theme == "light"
|
||||||
|
assert cfg.subtitle.font_size == 40
|
||||||
|
assert cfg.subtitle.bold is True # 기본값 유지
|
||||||
|
assert not hasattr(cfg, "removed_option")
|
||||||
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()
|
||||||