19 Commits

Author SHA1 Message Date
EJClaw
1dde1b2aaa fix: 2~4티어가 존재하지 않는 음성인식 모델 저장소를 가리키던 것
Some checks failed
Windows / verify (push) Has been cancelled
Systran/faster-whisper-large-v3-turbo 는 Hugging Face 에 없는 저장소라
번역 시작 시 401 Repository Not Found 로 바로 실패했다.
faster-whisper 가 'turbo' 로 공식 매핑하는 저장소
(mobiuslabsgmbh → 현재 dropbox-dash/faster-whisper-large-v3-turbo)로 바꾼다.

티어의 모든 ASR/MT 저장소가 로그인 없이 공개돼 있고 가중치가 있는지
확인하는 테스트를 추가했다 (오프라인이면 건너뜀).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:42:25 +09:00
EJClaw
0201c7b913 feat: 자막 배경 여백 · 위치 조정 모드 · CPU 추천 · 마이크 입력
Some checks failed
Windows / verify (push) Has been cancelled
요청 네 가지를 넣는다.

1) 자막 배경 여백(padding)
   배경 상자를 창 전체가 아니라 **글자를 감싸는 크기**로 그리도록 바꿨다.
   창 전체를 칠하던 동안에는 여백을 키워도 글자만 안으로 밀릴 뿐 배경
   크기는 그대로여서, 설정 항목을 만들어도 의미가 없었다.

2) 평소 잠금 + '자막 위치 조정'
   locked 기본값을 True 로 바꾼다. 게임 중에 자막을 실수로 끌고 다니는
   사고를 막는 쪽이 기본이어야 한다. 자막 화면의 버튼으로 조정 모드를
   열면 그때만 이동·네 모서리 크기 조절·휠이 열린다. 조정 중에는 클릭
   통과를 강제로 끈다 — 켜둔 채로는 마우스가 창을 지나가 잡을 수 없다.
   크기를 바꾸면 글자 크기가 높이 비율을 그대로 따라간다. 상자만 커지고
   글자가 그대로면 여백만 늘어나 '자막을 키우는' 조작이 되지 않는다.
   조정 모드는 저장하지 않는다. 다음 실행은 다시 잠긴 상태로 시작한다.

3) GPU 가 없을 때 CPU 추천
   티어 표의 recommended 는 GPU 가 넉넉할 때를 전제한 값이라, 그대로
   쓰면 GPU 없는 사람에게 10GB 짜리를 권하게 된다. 환경을 보고 고르는
   recommended_tier() 를 만들고 배지도 그 결과로 붙인다. GPU 가 없으면
   CTranslate2 계열만 후보에 두고 가장 가벼운 쪽을 권한다 — LLM 계열은
   CPU 에서 한 문장에 수십 초가 걸려 실시간 자막이 되지 않는다.
   '추천대로 맞추기' 버튼과, 실제로는 CPU 로 도는데 화면은 GPU 라고
   표시하던 것도 같이 고친다. nvidia-smi 실패 문구가 명령줄을 통째로
   토해내던 것도 사람이 읽을 문장으로 바꿨다.

4) 마이크 등 입력 장치 선택
   InputDeviceCapture 를 추가하고 홈 화면에 목록 전환 버튼을 둔다.
   두 목록을 한 번에 섞지 않는다 — 다 합치면 정작 찾으려던 게임이
   라인입력들 사이에 파묻힌다. 버튼 글자는 '지금 상태'가 아니라
   '누르면 가는 곳'을 적는다. 고른 쪽은 설정에 기억한다.
2026-09-29 13:27:13 +09:00
EJClaw
bec5e285bd fix: exe 점검이 본 화면 대신 자막 오버레이를 보고 통과하던 것
Some checks failed
Windows / verify (push) Has been cancelled
"아무 창이나 뜨면 통과" 로 둔 탓에, 자막 오버레이 창(제목이 앱 이름뿐이다)이
잡혀서 본 화면이 안 떠도 통과할 수 있었다. 지난 두 번의 오보가 전부 같은
형태였다 — 느슨한 통과 조건. 어떤 창을 기다리는지 호출하는 쪽이 지정하게
바꾼다. 첫 실행은 저장 위치 화면, 두 번째 실행은 본 화면 제목에만 있는
표어를 기다린다. 찾은 창 목록을 전부 기록해 나중에 되짚을 수 있게 한다.
2026-09-28 18:17:46 +09:00
EJClaw
7f678fba7b fix: exe 점검이 자식 프로세스를 안 죽여 임시폴더 정리에서 실패하던 것
Some checks failed
Windows / verify (push) Has been cancelled
onefile 의 부모는 압축을 푸는 부트로더일 뿐이고 창과 로그 파일을 쥐고 있는
것은 자식이다. 부모만 terminate 하니 자식이 livesub.log 를 계속 잡고 있어
임시 APPDATA 를 지울 때 PermissionError 가 났다. exe 의 문제가 아니라
점검이 뒷정리를 못 한 것인데 exe 기동 실패로 보고됐다.

- 프로세스 트리를 통째로 정리한다
- 임시폴더 삭제는 실패해도 점검을 실패시키지 않는다
- 두 번째 실행에서 저장 위치 화면이 또 뜨면 실패로 본다. "기억한다"는
  약속이 깨진 것이므로 본 화면이 아닌 창은 통과가 아니다
2026-09-28 18:12:47 +09:00
EJClaw
628280b227 fix: exe 가 켜지자마자 ImportError 로 죽던 것
Some checks failed
Windows / verify (push) Has been cancelled
포터블 exe 를 실행하면 창 대신 오류창만 떴다.

  File "__main__.py", line 1, in <module>
  ImportError: attempted relative import with no known parent package

PyInstaller 는 진입 스크립트를 **패키지 없이** 최상위로 실행한다
(__package__ 가 비어 있다). 그래서 src/livesub/__main__.py 첫 줄의
`from .app import main` 이 그 자리에서 죽는다. 절대 임포트로 바꾼다.
`python -m livesub` 는 둘 다 동작하므로 잃는 것이 없다.

왜 못 잡았나 — exe 점검이 "8초 뒤에도 프로세스가 살아 있으면 통과" 였다.
PyInstaller 오류창은 프로세스를 그대로 띄워둔 채 서 있으므로 통과로
보고됐다. 살아 있는 것은 떴다는 뜻이 아니다. 고친다:

- 프로세스 생존이 아니라 **실제 창**을 본다. onefile 은 자식 프로세스가
  창을 띄우므로 프로세스 트리 전체를 훑는다.
- "Unhandled exception" / "Failed to execute script" 오류창을 보면
  그 자리에서 실패로 판정한다.
- APPDATA 를 빈 폴더로 돌려 첫 실행(저장 위치 화면)과 두 번째 실행
  (본 화면)을 각각 확인한다. 둘 다 실제 창 제목으로 확인한다.
- 같은 회귀를 리눅스에서도 잡도록 runpy 로 패키지 없는 실행을 재현하는
  테스트를 추가한다. 되돌려보면 실제로 실패하는 것을 확인했다.
2026-09-28 18:06:09 +09:00
EJClaw
88effe0e58 docs: 저장 위치 선택 안내와 실제 Windows 화면
Some checks failed
Windows / verify (push) Has been cancelled
그림은 win-ci VM 의 Windows 11 에서 windows_smoke 가 직접 찍은 것이다.
경로 표기(C:\Users\...)와 디스크 여유 공간이 실제 값이어야 의미가 있다.
2026-09-26 01:01:13 +09:00
EJClaw
81e0271b69 feat: 저장 위치를 첫 실행 때 고르고 기억한다
Some checks failed
Windows / verify (push) Has been cancelled
모델이 티어에 따라 2~17GB 를 차지하는데 %APPDATA% 에 박혀 있어서 C 드라이브가
좁은 사람은 손쓸 방법이 없었다. 처음 켤 때 한 번 묻고, 그 선택을 기억하고,
설정에서 바꿀 수 있게 한다.

닭과 달걀 문제가 하나 있다 — "어디에 둘지" 는 사용자가 정하는데 그 선택 자체는
고정된 곳에 적어둬야 다음 실행에 찾을 수 있다. 그래서 OS 표준 폴더에는 위치를
가리키는 쪽지(location.json) 한 장만 두고, 내용물은 전부 고른 폴더에 둔다.

- storage.py 신설: 쪽지 읽기/쓰기, 경로 검증, 여유 공간, 데이터 이동
- config.py 의 CONFIG_PATH 를 config_path() 로 바꾼다. import 시점 상수라
  위치를 바꿔도 옛 경로를 계속 가리켰다
- 위치가 정해지기 전에는 파일 로그를 열지 않는다. 열면 고르지도 않은 기본
  폴더가 만들어지고 거기에 로그가 남는다
- 이전 버전 사용자에게는 묻지 않는다. 기본 위치에 쓰던 흔적이 있으면 그대로
  쓴다 — 같은 자리를 다시 고르는 헛수고를 시킬 이유가 없다
- 고른 디스크가 빠져도 죽지 않고 기본 위치로 물러난다
- 위치를 바꿀 때 받아둔 모델을 옮길지 물어본다. 수 GB 라 창이 얼어붙지
  않도록 별도 스레드에서 옮긴다
- windows_smoke 에 이 화면 렌더링 점검을 추가하고, 스크린샷을 호스트로
  회수한다. 경로 표기와 디스크 조회는 OS 마다 다르게 도는 부분이다
2026-09-26 00:57:04 +09:00
EJClaw
f79293d893 docs: win-ci 운영 기록 — MSVC 가 필요한 이유와 거짓 PASS 함정
Some checks failed
Windows / verify (push) Has been cancelled
- 검증 통과한 포터블 exe 가 results/LiveSub.exe 로 회수된다는 것
- 산출물의 존재를 성공 근거로 쓰면 안 되는 이유 (옛 exe 가 남아 PASS)
- MSVC 없이는 캡처 도우미가 조용히 빠지고, 그래서 검증 순서가 중요하다는 것
2026-09-26 00:43:13 +09:00
EJClaw
2028b0a651 fix: 프로그램별 캡처 도우미가 애초에 컴파일되지 않던 것
Some checks failed
Windows / verify (push) Has been cancelled
native\process_loopback\main.cpp 이 MSVC 에서 C2275 로 죽는다. WRL 의
FtmBase 는 플래그가 아니라 섞어 넣는 클래스인데 RuntimeClassFlags 안에
`ClassicCom | FtmBase` 로 넣어 타입을 값 자리에 썼다. 인터페이스 목록
쪽으로 옮긴다 (ApplicationLoopback 샘플과 같은 형태).

이 코드는 지금까지 한 번도 빌드된 적이 없다. 리눅스에서는 컴파일할
방법이 없었고, CI 의 네이티브 빌드 단계는 continue-on-error 였다.
win-ci VM 에 MSVC 를 깔고 나서야 드러났다.
2026-09-26 00:38:56 +09:00
EJClaw
4fd8fabcb8 build: 포터블 exe 에서 빠져 있던 프로그램별 캡처 도우미를 복구
Some checks failed
Windows / verify (push) Has been cancelled
livesub_capture.exe 가 없으면 포터블 빌드는 그대로 성공하되 프로그램별
캡처가 장치 전체 캡처로 조용히 폴백된다. 핵심 기능이 빠진 exe 가 나오는데
빌드는 PASS 로 보이는 조합이라 그대로 릴리스될 뻔했다.

- win-ci VM 에 VS 2022 Build Tools(C++)를 깐다. WASAPI 프로세스 루프백
  헤더와 WRL 은 Windows SDK + MSVC 에만 있어서 cmake 만으로는 못 만든다.
- 검증 순서에 native_capture 단계를 windows_smoke 앞에 넣는다. 뒤에 두면
  smoke 와 spec 이 둘 다 "없음"으로 통과해 버린다.
- pyinstaller 단계가 거짓 PASS 를 내던 것을 고친다. 앞 회차의 exe 가
  실행 중이면 PermissionError 로 빌드가 죽는데, 남아 있는 옛 exe 때문에
  Test-Path 검사는 통과했다. 빌드 전에 프로세스를 죽이고 build/dist 를
  지우며 PyInstaller 종료코드도 확인한다.
- 통과한 exe 를 호스트로 회수한다. serve-host.py 는 32MB 상한이라 exe 를
  413 으로 튕겼다. 상한을 올리고 조각으로 받아 디스크에 바로 쓰며,
  .part 로 받아 완료 후 옮겨 반쪽 파일이 결과물로 남지 않게 한다.
- .gitignore 의 네이티브 산출물 경로가 예전 이름(hearo)이라 빌드한
  exe 가 무시되지 않던 것을 livesub 로 고친다.
2026-09-26 00:24:31 +09:00
EJClaw
ca096a69c3 ci: 회수한 증거 파일의 한글이 깨지던 것 수정 + 문서화
Some checks failed
Windows / verify (push) Has been cancelled
[Console]::OutputEncoding 을 UTF-8 로 두지 않으면 PowerShell 이 자식
프로세스 stdout 을 콘솔 코드페이지(LTSC en-US = 437)로 읽는다. python 쪽은
PYTHONUTF8=1 로 UTF-8 을 내보내니 한글이 전부 깨지고, 회수한 증거 파일의
실패 메시지를 읽을 수 없었다(실제로 첫 수집본의 한글 트레이스백이 그랬다).

docs/WINDOWS-TESTING.md 에 증거 채널(POST /artifact -> results/)과
sync.sh <sha> 재검증 절차를 적었다.
2026-09-25 23:44:00 +09:00
EJClaw
0a1389a1d9 ci: 검증 대상 리비전을 명시하고 그것이 재실행 트리거가 되게 한다
Some checks failed
Windows / verify (push) Has been cancelled
게스트 agent 는 stage2.ps1 의 해시가 바뀔 때만 다시 돈다. 그래서 커밋을
푸시해도 재검증이 안 걸렸고, 지금까지는 주석에 "rev2" 같은 문구를 넣어
해시를 억지로 바꿨다. 무엇을 검증한 결과인지도 모호했다.

stage2.ps1 에 $TargetRev 를 두고 `./sync.sh <sha>` 가 그 값을 박아서
배포한다. 검증 요청(= 리비전 지정)이 그대로 재실행 트리거가 되고,
움직이는 origin/main 대신 지정한 커밋을 reset --hard 로 고정해 본다.
2026-09-25 23:39:45 +09:00
EJClaw
a66eedee8b ci: win-ci 검증 출력을 호스트 파일로 회수한다 (증거 기준선)
Some checks failed
Windows / verify (push) Has been cancelled
VM 을 만든 이유가 "리눅스에서 통과한 게 Windows 에서 깨지는 것을 재현
가능하게 본다" 인데, 정작 실패 트레이스백이 호스트에 한 줄도 안 남았다.
로그 채널은 마지막 몇 줄만 흘렸고(Select-Object -Last 30), serial.log 는
VM 이 뜰 때마다 truncate 되고, guest.log 는 stage2 가 다시 돌 때만 채워진다.
그래서 실패 내용을 화면에서 읽어 옮겨 적는 상태였고 — 고친 뒤에 고쳐졌다는
것을 증명할 기준선이 없었다.

- serve-host.py: POST /artifact/<name> 추가. results/<name> 에 파일로 저장
  (경로는 basename 으로만 취하고 32MB 상한)
- stage2.ps1: Step 이 각 단계의 출력 전문을 StreamWriter(AutoFlush)로 받아
  호스트로 업로드한다. 중간에 예외로 죽어도 거기까지는 남는다.
  출력 자르기를 없애고 pytest 는 --tb=long, 검증 리비전을 박은
  summary.log 도 함께 올린다
- sync.sh: 스크립트만 /home/claude/win-ci 로 반영한다. build.sh 를 다시
  돌리면 지워둔 Windows ISO 4.8GB 를 다시 내려받는다

첫 수집 결과(rev 162540b)로 실제 원인 두 개를 특정했다.
2026-09-25 23:38:48 +09:00
EJClaw
dbe1370425 style: models 패키지 ruff 위반 정리 (import 정렬, 불필요한 따옴표 타입)
.gitignore 가 src/livesub/models 를 통째로 무시하던 동안 이 8개 파일
1,674줄은 커밋에서만 빠진 게 아니라 ruff 검사 범위에서도 빠져 있었다
(ruff 는 기본적으로 .gitignore 를 존중한다). 추적 대상이 되면서 처음
검사에 들어와 나온 2건이다.

같은 파일들에 대해 확인한 것: 프로젝트 룰셋 통과, 전체 룰(--select ALL)
감사에서 정확성 계열(F/E9/PLE) 위반 없음 — 남는 건 프로젝트가 켜지 않은
문서화/스타일 룰뿐, 7개 모듈 import 스모크 전부 통과, tests 10개 파일이
이미 models 를 임포트해 실행 범위에는 들어 있었다.
2026-09-25 23:38:34 +09:00
EJClaw
03a2ca50c0 fix: PyInstaller 가 webrtcvad 훅에서 죽어 포터블 exe 가 안 나오던 문제
Windows 포터블 빌드가 dist\LiveSub.exe 를 만들지 못했다. 원인은
pyinstaller-hooks-contrib 의 hook-webrtcvad.py 가 copy_metadata('webrtcvad')
를 부르는 것이다. 우리가 설치하는 배포 이름은 webrtcvad-wheels 라
(원본 webrtcvad 는 Windows 휠이 없어 MSVC 컴파일이 필요하다)
PackageNotFoundError 가 나고, PyInstaller 는 훅 import 실패를
ImportErrorWhenRunningHook 으로 올려 빌드를 통째로 중단한다.

packaging/pyinstaller-hooks/hook-webrtcvad.py 로 같은 이름의 훅을 덮어써
실제 배포 이름으로 메타데이터를 수집한다. spec 의 hookspath 에 등록했으므로
build-portable.ps1, .gitea 워크플로, VM 자체 검증이 모두 같은 경로를 탄다.

리눅스에서 최소 재현으로 확인: 훅 없이는 같은 ImportErrorWhenRunningHook,
훅을 넣으면 "Processing standard module hook ... from packaging/pyinstaller-hooks"
로 우리 훅이 선택되고 빌드 성공, 만들어진 바이너리에서 webrtcvad.Vad(2) 생성까지 통과.
2026-09-25 23:38:34 +09:00
EJClaw
e234ad4401 fix: webrtcvad 가 깔린 환경에서만 분할기 테스트 5개가 깨지던 원인 제거
Windows CI 에서 test_segmenter.py 5개가 실패하고 로컬에서는 통과했다.
플랫폼 차이가 아니라 선택 의존성 차이였다.

테스트 입력은 220Hz 순음이다. `_is_speech` 는 에너지 판정과 webrtcvad 를
AND 로 묶는데, webrtcvad 는 사람 목소리로 학습돼 있어 순음을 발화로 보지
않는다. 포터블 exe 의 requirements 에 webrtcvad-wheels 가 들어 있으니
Windows CI 는 게이트가 켜진 환경이고, 개발 venv 에는 없어서 안 드러났다.
(개발 venv 에 webrtcvad-wheels 를 넣으면 리눅스에서도 5개가 똑같이,
같은 수치로 깨지는 것을 확인했다.)

- 이 파일이 검증하려는 건 분할 상태 기계이므로 autouse fixture 로
  선택 의존성을 끊어 환경과 무관하게 같은 경로를 시험한다
- 게이트 동작 자체는 test_webrtcvad_gate_filters_pure_tone 이 따로 본다
  (webrtcvad 없으면 skip)
- dev extras 에 webrtcvad-wheels 를 넣어 같은 환경 드리프트가 재발하지
  않게 한다
2026-09-25 23:38:17 +09:00
EJClaw
162540b025 docs: win-ci 운영 기준 명시 — 영구 유닛 확인법과 디스크 회수 기준
Some checks failed
Windows / verify (push) Has been cancelled
리뷰에서 나온 두 가지 혼선을 문서로 못 박는다.

1) systemd-run 트랜지언트로 띄우면 /run 에만 남아 재부팅에 사라진다.
   is-enabled 가 'transient' 로 보이면 영구 유닛이 아니라는 뜻이므로,
   FragmentPath 가 ~/.config/... 를 가리키는지 확인하는 절차를 넣었다.
2) 설치 후 무엇을 지우는지 기준을 표로 박았다. 판단 기준은 'VM 이 도는 데
   계속 필요한가' 하나다. Windows ISO(4.8GB)만 지우고, VM 에 물려 있는
   virtio-win.iso 와 unattend.iso 는 남긴다.

같은 ISO 를 버전별로 여러 개 남기지 말라는 주의도 넣었다. 어느 것이 물려
있는지 헷갈리면 고친 줄 알았던 스크립트가 반영되지 않는다.
2026-09-25 23:18:34 +09:00
EJClaw
f37da0cbe2 ci: 설치 미디어 없이도 win-ci VM 이 뜨도록
Some checks failed
Windows / verify (push) Has been cancelled
설치가 끝나면 Windows ISO(4.8GB)는 쓸모가 없어 지운다. 그런데 없는 파일을
-drive 로 넘기면 qemu 가 기동 자체에 실패해서, 재부팅 후 러너 호스트가
조용히 사라진다. cdrom 은 있을 때만 붙인다.
2026-09-25 23:04:28 +09:00
EJClaw
89b0ee0cf7 ci: win-ci VM 을 재부팅 후에도 살아나게 + VM 안 자체 검증 절차
Some checks failed
Windows / verify (push) Has been cancelled
트랜지언트 유닛은 재부팅에 안 남는다. 러너 호스트가 재부팅마다 사라지면
CI 가 조용히 대기 상태가 되므로 영구 user 유닛으로 박는다 (linger=yes 라
로그인 없이도 올라온다). cgroup/CPU 상한은 트랜지언트 때와 동일하다.

stage2 에는 러너 등록 토큰 없이도 워크플로와 같은 단계를 VM 안에서 직접
돌리는 검증을 넣었다. 첫 실행에서 실제로 결함 하나를 잡았다.
2026-09-25 23:03:40 +09:00
44 changed files with 2304 additions and 188 deletions

7
.gitignore vendored
View File

@@ -10,9 +10,12 @@ venv/
.ruff_cache/ .ruff_cache/
# 네이티브 빌드 산출물 # 네이티브 빌드 산출물
#
# ⚠️ 패키지 이름은 livesub 다. 예전 이름(hearo)이 그대로 남아 있어서 빌드한
# livesub_capture.exe 가 무시 대상에서 빠져 있었다.
native/process_loopback/build/ native/process_loopback/build/
src/hearo/resources/bin/*.exe src/livesub/resources/bin/*.exe
src/hearo/resources/bin/*.pdb src/livesub/resources/bin/*.pdb
# 모델 캐시 / 학습 산출물 / 사용자 데이터 # 모델 캐시 / 학습 산출물 / 사용자 데이터
# #

View File

@@ -25,12 +25,14 @@
## 주요 기능 ## 주요 기능
- **프로그램 단위 소리 캡처** — 게임 소리만 받고 디스코드 음성은 안 받습니다 - **프로그램 단위 소리 캡처** — 게임 소리만 받고 디스코드 음성은 안 받습니다
- **마이크 입력도 가능** — 홈 화면에서 목록을 입력 장치로 바꾸면 통화 상대나 내 목소리를 번역합니다
- **게임과 같이 써도 안 무겁게** — GPU를 얼마나 양보할지 직접 정합니다 (아래 참고) - **게임과 같이 써도 안 무겁게** — GPU를 얼마나 양보할지 직접 정합니다 (아래 참고)
- **디스코드식 자막 배치** — 모니터를 고르고 9칸 중 하나를 누르면 그 자리에 붙습니다 - **디스코드식 자막 배치** — 모니터를 고르고 9칸 중 하나를 누르면 그 자리에 붙습니다
- **평소엔 잠겨 있는 자막** — 게임 중에 실수로 끌려가지 않습니다. `자막 위치 조정`을 켜면 옮기고 모서리로 크기를 바꿀 수 있고, 크기에 맞춰 글자도 같이 커집니다
- **전역 단축키** — 게임 중에 `Ctrl+Alt+S` 로 자막을 껐다 켭니다 - **전역 단축키** — 게임 중에 `Ctrl+Alt+S` 로 자막을 껐다 켭니다
- **게임별 기본 용어집 내장** — 옵치2·워독스·배그·R6·롤·발로란트·에이펙스 등 **540개** (필수 게임은 기본 켜짐) - **게임별 기본 용어집 내장** — 옵치2·워독스·배그·R6·롤·발로란트·에이펙스 등 **540개** (필수 게임은 기본 켜짐)
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지 - **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지. GPU가 없으면 CPU에서 실제로 돌아가는 티어를 추천합니다
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수 - **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수·배경 여백 (배경은 글자를 감싸는 크기로 그려집니다)
- **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다 - **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다
- **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다 - **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다
- **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고) - **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고)
@@ -113,8 +115,8 @@ Enemy coming from the left 존댓말 → 적이 왼쪽에서 옵니다
powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1 powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1
``` ```
`dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고 첫 실행 때 `dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고, **첫 실행 때
`%APPDATA%\LiveSub\models`로 내려받습니다. 고른 폴더**로 내려받습니다.
--- ---
@@ -169,6 +171,25 @@ livesub
## 처음 쓸 때 ## 처음 쓸 때
### 먼저 저장 위치를 고릅니다
모델이 티어에 따라 2~17GB를 차지합니다. C 드라이브가 좁으면 곤란하니
**처음 켜면 어디에 저장할지 물어봅니다.** 고른 폴더에 모델·설정·번역 기록이
전부 들어갑니다.
![저장 위치 선택](docs/images/first-run.png)
한 번 고르면 기억하므로 다음부터는 바로 뜹니다. Windows 표준 폴더
(`%APPDATA%\LiveSub`)가 기본 제안이고 그대로 두셔도 됩니다 — 거기에는
"어느 폴더를 쓰는지" 적은 쪽지 한 장만 남습니다.
나중에 바꾸려면 `설정 > 저장 위치 > 위치 변경`입니다. 받아둔 모델을 새 위치로
**옮길지 물어보므로** 다시 받을 필요는 없습니다.
![위치 변경](docs/images/change-location.png)
### 그다음
1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다 1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다
2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다 2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다
3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다) 3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다)
@@ -179,7 +200,7 @@ livesub
VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다. VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다.
모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름). 모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름).
저장 위치는 `%APPDATA%\LiveSub\models` 입니다. 저장 위치는 처음 켤 때 고른 그 폴더입니다.
![모델 화면](docs/images/models.png) ![모델 화면](docs/images/models.png)

View File

@@ -70,7 +70,7 @@ gg,잘 싸웠다,gg,gg,打得好,
1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다. 1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다.
2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬. 2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬.
3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면 3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면
`%APPDATA%/LiveSub/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다. 저장 위치 폴더의 `transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
**틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다. **틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다.
4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로. 4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로.

View File

@@ -175,11 +175,91 @@ scripts/win-ci/build.sh # ISO 받기 + 무인설치 ISO 굽기 + 디스
| 방향 | 통로 | 왜 | | 방향 | 통로 | 왜 |
|---|---|---| |---|---|---|
| 호스트 → 게스트 | HTTP `10.0.2.2:8099` (QEMU user networking) | 스크립트·러너 토큰 전달 | | 호스트 → 게스트 | HTTP `10.0.2.2:8099` (QEMU user networking) | 스크립트·러너 토큰 전달 |
| 게스트 → 호스트 | COM1 → `serial.log`, HTTP POST → `guest.log` | 네트워크가 **없는** 드라이버 설치 단계도 봐야 하므로 시리얼이 필수 | | 게스트 → 호스트 | COM1 → `serial.log`, HTTP POST `/log` → `guest.log` | 네트워크가 **없는** 드라이버 설치 단계도 봐야 하므로 시리얼이 필수 |
| 게스트 → 호스트 | HTTP POST `/artifact/<이름>` → `results/<이름>.log` | 검증 출력 **전문**. 바로 아래 참고 |
##### 검증 결과는 `results/` 에 파일로 남습니다 — 그게 기준선입니다
`stage2.ps1` 의 `Step` 이 각 단계 출력을 게스트 `C:\win-ci\out\` 에 한 줄씩
기록하면서 호스트 `/home/claude/win-ci/results/` 로 올립니다. 판정 요약은
`results/summary.log` 이고 **검증한 리비전이 박혀** 있습니다.
처음에는 로그 채널로 마지막 몇 줄만 흘렸는데, `serial.log` 는 VM 이 뜰 때마다
truncate 되고 `guest.log` 는 `stage2` 가 다시 돌 때만 채워집니다. 그래서 pytest
실패 트레이스백이 호스트에 한 줄도 남지 않아, 화면을 읽고 옮겨 적는 수밖에
없었습니다. 고친 뒤에 고쳐졌다고 말할 근거가 없는 상태였습니다.
재검증은 리비전으로 요청합니다.
```bash
cd scripts/win-ci && ./sync.sh <sha> # stage2 의 $TargetRev 를 그 커밋으로 박아 배포
tail -f /home/claude/win-ci/guest.log # 약 10분, 끝나면 results/summary.log 확인
```
게스트 agent 는 `stage2.ps1` **내용 해시가 바뀔 때만** 다시 돕니다. 커밋만
푸시하면 재검증이 걸리지 않으니 sha 를 넘기세요. `build.sh` 를 다시 돌리면
안 됩니다 — 지워둔 Windows ISO 4.8GB 를 다시 내려받습니다.
배포할 포터블 exe 도 같은 통로로 돌아옵니다. 빌드와 기동이 **둘 다** 통과했을
때만 `results/LiveSub.exe` 로 올라오므로, 거기 파일이 있다는 것 자체가
"검증을 통과한 산출물" 이라는 뜻입니다. 그대로 릴리스에 올리면 됩니다.
##### exe 빌드 단계가 거짓 PASS 를 내던 함정
앞 회차의 `exe기동` 점검이 띄운 `LiveSub.exe` 가 파일을 잡고 있으면 PyInstaller
가 `PermissionError` 로 죽습니다. 그런데 **옛 exe 는 그대로 남아 있어서**
`Test-Path dist\LiveSub.exe` 검사는 통과했고, 결국 빌드가 실패한 회차가 PASS 로
보고됐습니다. 지금은 빌드 전에 프로세스를 죽이고 `build`·`dist` 를 지운 뒤
PyInstaller 종료코드까지 확인합니다. **산출물의 존재를 성공의 근거로 쓰면
안 됩니다** — 이전 성공이 남긴 것일 수 있습니다.
##### MSVC 가 왜 VM 에 깔려 있나
`native\process_loopback` 의 `livesub_capture.exe` 는 WASAPI 프로세스 루프백을
씁니다. 그 헤더(`audioclientactivationparams.h`)와 WRL 은 Windows SDK + MSVC
에만 있어서 cmake 만으로는 못 만듭니다. 그래서 `stage2.ps1` 이 VS 2022 Build
Tools(C++ 워크로드)를 설치합니다 — 첫 설치에 약 9분 걸리고 그 뒤로는 건너뜁니다.
이게 없으면 **포터블 빌드는 그대로 성공하되** 프로그램별 캡처가 출력 장치
전체 캡처로 조용히 폴백됩니다. 기능이 빠진 exe 가 PASS 로 나오는 조합이라,
검증 순서에서 `native_capture` 를 `windows_smoke` 와 `pyinstaller` **앞에**
둡니다. 뒤에 두면 둘 다 "없음"을 정상으로 보고 지나갑니다.
실제로 이 경로는 MSVC 를 깔기 전까지 **한 번도 컴파일된 적이 없었고**, 깔자마자
`main.cpp` 가 C2275 로 죽었습니다. CI 의 네이티브 빌드 단계가
`continue-on-error` 였던 탓에 그때까지 드러나지 않았습니다.
접속 정보: 계정 `ci` / 비밀번호는 `~/.config/ejclaw/secrets.json` 의 접속 정보: 계정 `ci` / 비밀번호는 `~/.config/ejclaw/secrets.json` 의
`win-ci.9` 항목. RDP 는 호스트 `127.0.0.1:13389`, VNC 는 `127.0.0.1:5919`. `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 를 - **`Press any key to boot from CD`** — UEFI 는 이걸 5초 안에 안 누르면 CD 를

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
docs/images/first-run.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -49,8 +49,13 @@ BOOL WINAPI ConsoleHandler(DWORD type) {
} }
// ActivateAudioInterfaceAsync 완료 콜백. 결과를 이벤트로 넘겨준다. // ActivateAudioInterfaceAsync 완료 콜백. 결과를 이벤트로 넘겨준다.
//
// FtmBase 는 플래그가 아니라 섞어 넣는 클래스다. RuntimeClassFlags 안에
// `ClassicCom | FtmBase` 로 넣으면 MSVC 가 C2275 로 거부한다(타입을 값 자리에
// 썼으므로). 인터페이스 목록 쪽에 나열해야 한다 — ApplicationLoopback 샘플과
// 같은 형태다.
class ActivationHandler class ActivationHandler
: public RuntimeClass<RuntimeClassFlags<ClassicCom | FtmBase>, : public RuntimeClass<RuntimeClassFlags<ClassicCom>, FtmBase,
IActivateAudioInterfaceCompletionHandler> { IActivateAudioInterfaceCompletionHandler> {
public: public:
explicit ActivationHandler(HANDLE done) : done_(done) {} explicit ActivationHandler(HANDLE done) : done_(done) {}

View File

@@ -72,7 +72,8 @@ a = Analysis( # noqa: F821
binaries=binaries, binaries=binaries,
datas=datas, datas=datas,
hiddenimports=hiddenimports, hiddenimports=hiddenimports,
hookspath=[], # contrib 훅이 빌드를 중단시키는 것을 덮어쓴다 (hook-webrtcvad.py 주석 참고).
hookspath=[str(ROOT / "packaging" / "pyinstaller-hooks")],
runtime_hooks=[], runtime_hooks=[],
excludes=excludes, excludes=excludes,
noarchive=False, noarchive=False,

View 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

View File

@@ -14,9 +14,13 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import contextlib
import json
import os import os
import shutil
import subprocess import subprocess
import sys import sys
import tempfile
import time import time
from pathlib import Path from pathlib import Path
@@ -159,6 +163,39 @@ def run_source_checks() -> None:
win.close() win.close()
return "스크린샷 2장 저장 (artifacts/)" return "스크린샷 2장 저장 (artifacts/)"
@check("저장 위치 선택 화면")
def _data_location():
"""첫 실행 때 뜨는 위치 선택 화면이 실제 Windows 에서 제대로 그려지는지.
경로 표기(`C:\\...`)와 디스크 여유 공간 조회는 OS 마다 다르게 도는
부분이라 리눅스에서 통과해도 여기서 깨질 수 있다.
"""
from livesub.storage import data_dir, default_data_dir, free_space, human_size
from livesub.ui.data_location import DataLocationDialog
from livesub.ui.theme import stylesheet
app = _qt_app()
ARTIFACTS.mkdir(exist_ok=True)
shots = 0
for name, start, first in (
("windows-first-run.png", default_data_dir(), True),
("windows-change-location.png", data_dir(), False),
):
dialog = DataLocationDialog(start, first_run=first)
try:
dialog.setStyleSheet(stylesheet("dark"))
dialog.resize(560, dialog.sizeHint().height())
dialog.show()
app.processEvents()
if not dialog.ok_button.isEnabled():
raise RuntimeError(f"{start} 를 못 쓴다고 판정됨: {dialog.status.text()}")
dialog.grab().save(str(ARTIFACTS / name))
shots += 1
finally:
dialog.close()
dialog.deleteLater()
return f"{default_data_dir()} · 여유 {human_size(free_space(default_data_dir()))} · 스크린샷 {shots}장"
# --- 전 파이프라인 점검 ----------------------------------------------------- # --- 전 파이프라인 점검 -----------------------------------------------------
@@ -360,6 +397,135 @@ def run_gpu_checks() -> None:
# --- 빌드된 exe 점검 -------------------------------------------------------- # --- 빌드된 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: def run_exe_check(exe: Path) -> None:
print(f"\n=== 빌드된 exe 점검: {exe} ===") print(f"\n=== 빌드된 exe 점검: {exe} ===")
@@ -372,21 +538,37 @@ def run_exe_check(exe: Path) -> None:
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인") raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
return f"{mb:.1f} MB" return f"{mb:.1f} MB"
@check("exe 실행 후 8초 생존") @check("exe 첫 실행 — 저장 위치 화면")
def _launch(): def _first_run():
# 창을 띄우고 잠깐 살아 있는지 본다. 바로 죽으면 의존성 누락이다. # APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다.
proc = subprocess.Popen([str(exe)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) with _scratch_appdata() as tmp:
time.sleep(8) titles = _launch_and_wait_for_window(exe, appdata=tmp, require="저장 위치")
alive = proc.poll() is None return f"창 {titles!r}"
if not alive:
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:] @check("exe 두 번째 실행 — 본 화면")
raise RuntimeError(f"즉시 종료 (코드 {proc.returncode}): {err}") def _second_run():
proc.terminate() # 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다.
try: with _scratch_appdata() as tmp:
proc.wait(timeout=10) data = tmp / "data"
except subprocess.TimeoutExpired: data.mkdir()
proc.kill() pointer = tmp / "LiveSub"
return "정상 기동" 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: def main() -> int:

View File

@@ -43,7 +43,10 @@ finetune = [
"accelerate>=0.33", "accelerate>=0.33",
"bitsandbytes>=0.43; platform_system != 'Darwin'", "bitsandbytes>=0.43; platform_system != 'Darwin'",
] ]
dev = ["pytest>=8.0", "ruff>=0.6"] # webrtcvad 는 포터블 exe 의존성이라 CI 환경에는 항상 있다. 개발 venv 에만 없으면
# `_is_speech` 의 webrtcvad 경로가 로컬에서 통째로 안 돌고, 그 결과 같은 테스트가
# 로컬에서는 통과하고 Windows CI 에서만 깨진다 (실제로 그랬다). 환경을 맞춘다.
dev = ["pytest>=8.0", "ruff>=0.6", "webrtcvad-wheels>=2.0.14"]
[project.scripts] [project.scripts]
livesub = "livesub.app:main" livesub = "livesub.app:main"

View File

@@ -18,6 +18,24 @@ fi
rm -f "$D/qmp.sock" 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 \ exec qemu-system-x86_64 \
-name win-ci,process=win-ci \ -name win-ci,process=win-ci \
-machine q35,smm=on,vmport=off \ -machine q35,smm=on,vmport=off \
@@ -43,12 +61,7 @@ exec qemu-system-x86_64 \
-device usb-kbd -device usb-tablet \ -device usb-kbd -device usb-tablet \
-drive "file=$D/win-ci.qcow2,if=none,id=sysdisk,format=qcow2,cache=writeback,discard=unmap,aio=threads" \ -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 \ -device ide-hd,drive=sysdisk,bus=ide.0,bootindex=2 \
-drive "file=$D/iso/win11-ltsc.iso,if=none,id=cdwin,media=cdrom,readonly=on" \ "${CDROMS[@]}" \
-device ide-cd,drive=cdwin,bus=ide.1,bootindex=1 \
-drive "file=$D/iso/virtio-win.iso,if=none,id=cdvirtio,media=cdrom,readonly=on" \
-device ide-cd,drive=cdvirtio,bus=ide.2 \
-drive "file=$D/unattend.iso,if=none,id=cdunattend,media=cdrom,readonly=on" \
-device ide-cd,drive=cdunattend,bus=ide.3 \
-serial "file:$D/serial.log" \ -serial "file:$D/serial.log" \
-qmp "unix:$D/qmp.sock,server=on,wait=off" \ -qmp "unix:$D/qmp.sock,server=on,wait=off" \
-vnc 127.0.0.1:19 \ -vnc 127.0.0.1:19 \

View File

@@ -4,23 +4,35 @@
게스트(Windows)는 QEMU user networking 안에 있어서 호스트를 10.0.2.2 로 본다. 게스트(Windows)는 QEMU user networking 안에 있어서 호스트를 10.0.2.2 로 본다.
이 서버는 그 반대편이다. 이 서버는 그 반대편이다.
GET /ping 게스트가 네트워크 올라왔는지 확인용 GET /ping 게스트가 네트워크 올라왔는지 확인용
GET /stage20.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로) GET /stage2.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로)
GET /<file> serve/ 안의 아무 파일 (러너 등록 토큰 등) GET /<file> serve/ 안의 아무 파일 (러너 등록 토큰 등)
POST /log 게스트 로그 -> guest.log 에 append 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 는 호스트 루프백으로 들어오므로 127.0.0.1 만 바인딩한다. slirp 의 10.0.2.2 는 호스트 루프백으로 들어오므로
외부에 열 필요가 없다. 외부에 열 필요가 없다.
""" """
import http.server import http.server
import os import os
import posixpath
import socketserver import socketserver
import sys import sys
from datetime import datetime from datetime import datetime
ROOT = "/home/claude/win-ci/serve" ROOT = "/home/claude/win-ci/serve"
LOG = "/home/claude/win-ci/guest.log" LOG = "/home/claude/win-ci/guest.log"
RESULTS = "/home/claude/win-ci/results"
PORT = 8099 PORT = 8099
# 로그 전문뿐 아니라 빌드된 포터블 exe(100MB+)도 이 통로로 회수한다.
# 32MB 로 두면 배포할 exe 가 413 으로 튕긴다.
MAX_ARTIFACT = 1024 * 1024 * 1024
CHUNK = 1024 * 1024
class Handler(http.server.SimpleHTTPRequestHandler): class Handler(http.server.SimpleHTTPRequestHandler):
@@ -47,9 +59,15 @@ class Handler(http.server.SimpleHTTPRequestHandler):
super().do_GET() super().do_GET()
def do_POST(self): def do_POST(self):
if self.path.rstrip("/") != "/log": path = self.path.rstrip("/")
if path == "/log":
self._recv_log()
elif path.startswith("/artifact/"):
self._recv_artifact(path[len("/artifact/") :])
else:
self.send_error(404) self.send_error(404)
return
def _recv_log(self):
n = int(self.headers.get("Content-Length") or 0) n = int(self.headers.get("Content-Length") or 0)
data = self.rfile.read(n).decode("utf-8", "replace").strip() data = self.rfile.read(n).decode("utf-8", "replace").strip()
with open(LOG, "a", encoding="utf-8") as f: with open(LOG, "a", encoding="utf-8") as f:
@@ -57,9 +75,44 @@ class Handler(http.server.SimpleHTTPRequestHandler):
self.send_response(204) self.send_response(204)
self.end_headers() 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): def log_message(self, fmt, *args):
# 접근 로그는 stdout 으로만. journald 가 받는다. # 접근 로그는 stdout 으로만. journald 가 받는다.
sys.stdout.write("%s %s\n" % (self.address_string(), fmt % args)) sys.stdout.write(f"{self.address_string()} {fmt % args}\n")
sys.stdout.flush() sys.stdout.flush()
@@ -70,6 +123,7 @@ class Server(socketserver.ThreadingTCPServer):
if __name__ == "__main__": if __name__ == "__main__":
os.makedirs(ROOT, exist_ok=True) os.makedirs(ROOT, exist_ok=True)
os.makedirs(RESULTS, exist_ok=True)
with Server(("127.0.0.1", PORT), Handler) as httpd: 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) print(f"win-ci control server on 127.0.0.1:{PORT}, root={ROOT}", flush=True)
httpd.serve_forever() httpd.serve_forever()

View File

@@ -10,6 +10,14 @@
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [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' $Dl = 'C:\win-ci\dl'
New-Item -ItemType Directory -Force -Path $Dl | Out-Null New-Item -ItemType Directory -Force -Path $Dl | Out-Null
@@ -105,6 +113,41 @@ if (Have 'cmake') {
else { Log "WARN cmake 설치 실패 - 워크플로의 native 빌드 단계는 건너뛰어진다" } 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 # ------------------------------------------------------------------ act_runner
$RunnerDir = 'C:\gitea-runner' $RunnerDir = 'C:\gitea-runner'
$RunnerExe = Join-Path $RunnerDir 'act_runner.exe' $RunnerExe = Join-Path $RunnerDir 'act_runner.exe'
@@ -211,28 +254,80 @@ Log "=== stage2 완료 ==="
# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다. # agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다.
# ================================================================== # ==================================================================
$Repo = 'C:\win-ci\live-app-translator' $Repo = 'C:\win-ci\live-app-translator'
function Step($name, $block) { $OutDir = 'C:\win-ci\out'
Log "--- [$name] 시작"
# 검증 대상 리비전. '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 { try {
& $block $bytes = [IO.File]::ReadAllBytes($path)
Log "--- [$name] 통과" # 백틱 줄바꿈을 쓰지 않는다. LF 로 저장된 스크립트에서 토크나이저가
return $true # 어긋나 스크립트 전체가 무효가 된 전례가 있다 (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 { } catch {
Log "--- [$name] 실패: $($_.Exception.Message)" Log " -> $name 업로드 실패: $($_.Exception.Message)"
return $false
} }
} }
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' $ErrorActionPreference = 'Continue'
try { try {
Sync-Path Sync-Path
if (Test-Path $Repo) { if (-not (Test-Path $Repo)) {
Log "저장소 갱신"
Push-Location $Repo; & git fetch --all -q; & git reset --hard origin/main -q; Pop-Location
} else {
Log "저장소 clone" Log "저장소 clone"
& git clone -q https://git.tkrmagid.kr/tkrmagid/live-app-translator $Repo & 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 Push-Location $Repo
$py = Join-Path $Repo '.venv-ci\Scripts\python.exe' $py = Join-Path $Repo '.venv-ci\Scripts\python.exe'
@@ -247,15 +342,81 @@ try {
Log "포터블 조건(torch 없음) = $($LASTEXITCODE -eq 0)" Log "포터블 조건(torch 없음) = $($LASTEXITCODE -eq 0)"
$env:PYTHONPATH = 'src' $env:PYTHONPATH = 'src'
$r = @{} # Windows 콘솔 기본 인코딩(cp1252)에서는 한글 print 가 UnicodeEncodeError 로
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q 2>&1 | Select-Object -Last 8 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } # 죽는다. 검증 스크립트가 한글을 찍으므로 UTF-8 로 고정한다.
$r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts 2>&1 | Select-Object -Last 5 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } $env:PYTHONIOENCODING = 'utf-8'
$r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } $env:PYTHONUTF8 = '1'
$r['exe빌드'] = Step 'pyinstaller' { & $py -m pip install pyinstaller --quiet; & $py -m PyInstaller packaging\livesub.spec --noconfirm 2>&1 | Select-Object -Last 4 | ForEach-Object { Log " $_" }; if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }; Log " exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB" } # 출력을 자르지 않는다. 전문은 Step 이 호스트 results/<name>.log 로 올린다.
$r['exe기동'] = Step 'exe 실행' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } # 여기서 truncate 하면 실패 원인이 있는 트레이스백 본문이 날아간다.
$r = [ordered]@{}
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q -rf --tb=long; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
$r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
Log "=== Windows 검증 결과 ===" # 캡처 도우미를 먼저 만든다. windows_smoke 가 이 exe 의 존재를 점검하고,
foreach ($k in $r.Keys) { Log ("{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })) } # 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 "=== 검증 끝 ===" Log "=== 검증 끝 ==="
Pop-Location Pop-Location
} catch { } catch {

35
scripts/win-ci/sync.sh Executable file
View 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초 안에 받아간다)"

View 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

View 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

View File

@@ -1,4 +1,14 @@
from .app import main """`python -m livesub` 와 포터블 exe 의 진입점.
**여기서는 상대 임포트를 쓰면 안 된다.** PyInstaller 는 이 파일을 패키지 없이
최상위 스크립트로 실행한다(`__package__` 가 비어 있다). 그래서 `from .app import`
는 그 자리에서 `attempted relative import with no known parent package` 로 죽는다.
exe 가 켜지자마자 오류창만 띄우던 원인이 이것이었다.
`python -m livesub` 는 어느 형태든 동작하므로 둘 다 되는 절대 임포트로 둔다.
"""
from livesub.app import main
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View File

@@ -11,11 +11,19 @@ from .config import AppConfig
from .constants import APP_NAME, ORG_NAME, user_data_dir from .constants import APP_NAME, ORG_NAME, user_data_dir
def setup_logging(verbose: bool = False) -> None: def setup_logging(verbose: bool = False, to_file: bool = True) -> None:
log_file = user_data_dir() / "livesub.log" """로그 설정.
`to_file=False` 는 저장 위치를 아직 고르기 전에 쓴다. 그 시점에 파일
핸들러를 열면 사용자가 고르지도 않은 기본 폴더가 만들어지고 로그가
거기에 남는다.
"""
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)] handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
with contextlib.suppress(OSError): if to_file:
handlers.append(logging.FileHandler(log_file, encoding="utf-8")) with contextlib.suppress(OSError):
handlers.append(
logging.FileHandler(user_data_dir() / "livesub.log", encoding="utf-8")
)
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO, level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
@@ -30,7 +38,7 @@ def setup_logging(verbose: bool = False) -> None:
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv if argv is None else argv) argv = list(sys.argv if argv is None else argv)
verbose = "--verbose" in argv or "-v" in argv verbose = "--verbose" in argv or "-v" in argv
setup_logging(verbose) setup_logging(verbose, to_file=False)
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
@@ -48,6 +56,15 @@ def main(argv: list[str] | None = None) -> int:
if icon_path.is_file(): if icon_path.is_file():
app.setWindowIcon(QIcon(str(icon_path))) 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() config = AppConfig.load()
window = MainWindow(config) window = MainWindow(config)
if not config.start_minimized: if not config.start_minimized:

View File

@@ -7,17 +7,25 @@ import logging
from ..config import AudioConfig from ..config import AudioConfig
from .base import AudioSource, CaptureBackend, CaptureError from .base import AudioSource, CaptureBackend, CaptureError
from .file_source import FileCapture from .file_source import FileCapture
from .input_device import InputDeviceCapture
from .process_loopback import ProcessLoopbackCapture from .process_loopback import ProcessLoopbackCapture
from .segmenter import Segment, Segmenter, SegmenterConfig from .segmenter import Segment, Segmenter, SegmenterConfig
from .wasapi_loopback import WasapiLoopbackCapture from .wasapi_loopback import WasapiLoopbackCapture
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
#: 홈 화면 목록이 무엇을 보여줄지.
SOURCE_MODE_OUTPUT = "output" # 게임 등 프로그램 소리 + 출력 장치
SOURCE_MODE_INPUT = "input" # 마이크 등 입력 장치
__all__ = [ __all__ = [
"SOURCE_MODE_INPUT",
"SOURCE_MODE_OUTPUT",
"AudioSource", "AudioSource",
"CaptureBackend", "CaptureBackend",
"CaptureError", "CaptureError",
"FileCapture", "FileCapture",
"InputDeviceCapture",
"ProcessLoopbackCapture", "ProcessLoopbackCapture",
"Segment", "Segment",
"Segmenter", "Segmenter",
@@ -34,12 +42,20 @@ def capture_capabilities() -> dict[str, bool]:
return { return {
"process": ProcessLoopbackCapture.available(), "process": ProcessLoopbackCapture.available(),
"device": WasapiLoopbackCapture.available(), "device": WasapiLoopbackCapture.available(),
"input": InputDeviceCapture.available(),
"file": True, "file": True,
} }
def list_sources() -> list[AudioSource]: def list_sources(mode: str = SOURCE_MODE_OUTPUT) -> list[AudioSource]:
"""프로그램 + 출력 장치를 한 목록으로.""" """고른 쪽의 캡처 대상 목록.
출력(게임 소리)과 입력(마이크)을 한 목록에 섞으면 수십 줄이 되고, 정작
찾으려던 게임이 마이크·라인입력 사이에 파묻힌다. 그래서 한 번에 한 쪽만
보여주고 홈 화면에서 전환한다.
"""
if mode == SOURCE_MODE_INPUT:
return InputDeviceCapture.list_sources()
sources: list[AudioSource] = [] sources: list[AudioSource] = []
if ProcessLoopbackCapture.available(): if ProcessLoopbackCapture.available():
sources.extend(ProcessLoopbackCapture.list_sources()) sources.extend(ProcessLoopbackCapture.list_sources())
@@ -53,6 +69,13 @@ def create_capture(config: AudioConfig) -> CaptureBackend:
if backend == "file": if backend == "file":
return FileCapture(config.target_process_name) return FileCapture(config.target_process_name)
if backend == "input":
if not InputDeviceCapture.available():
raise CaptureError(
"입력 장치를 열 수 없습니다. Windows에서 PyAudioWPatch 설치 여부를 확인하세요."
)
return InputDeviceCapture(config.device_index)
want_process = backend in ("process", "auto") and config.target_pid > 0 want_process = backend in ("process", "auto") and config.target_pid > 0
if want_process: if want_process:
if ProcessLoopbackCapture.available(): if ProcessLoopbackCapture.available():

View File

@@ -20,7 +20,8 @@ from ..constants import SAMPLE_RATE
class AudioSource: class AudioSource:
"""UI에 노출되는 캡처 대상 한 줄.""" """UI에 노출되는 캡처 대상 한 줄."""
kind: str # "process" | "device" #: "process"=특정 프로그램 소리 · "device"=출력 장치 루프백 · "input"=마이크 등 입력 장치
kind: str
identifier: str # pid 문자열 또는 장치 인덱스 identifier: str # pid 문자열 또는 장치 인덱스
label: str label: str
detail: str = "" detail: str = ""
@@ -32,7 +33,7 @@ class AudioSource:
@property @property
def device_index(self) -> int: def device_index(self) -> int:
return int(self.identifier) if self.kind == "device" else -1 return int(self.identifier) if self.kind in ("device", "input") else -1
class CaptureError(RuntimeError): class CaptureError(RuntimeError):

View File

@@ -0,0 +1,106 @@
"""마이크 등 입력 장치 캡처 (Windows).
루프백(=스피커로 나가는 소리)이 아니라 **들어오는 소리**를 받는다. 통화나
합주처럼 상대 목소리가 내 스피커로 나오지 않는 상황, 또는 내가 말하는 것을
그대로 번역해 보고 싶을 때 쓴다.
WasapiLoopbackCapture 와 스트림을 여는 모양이 비슷하지만 합치지 않았다.
장치 목록을 만드는 방법(루프백 전용 열거 vs 일반 입력 장치)과 기본 장치를
고르는 방법이 서로 달라서, 한 클래스에 넣으면 분기가 코드의 절반이 된다.
"""
from __future__ import annotations
import contextlib
import logging
import numpy as np
from ..constants import FRAME_SAMPLES
from .base import AudioSource, CaptureBackend, CaptureError, to_mono_16k
log = logging.getLogger(__name__)
try: # pragma: no cover - 플랫폼 의존
import pyaudiowpatch as pyaudio
except ImportError: # pragma: no cover
pyaudio = None # type: ignore[assignment]
class InputDeviceCapture(CaptureBackend):
name = "input"
per_process = False
def __init__(self, device_index: int = -1) -> None:
super().__init__()
self.device_index = device_index
@staticmethod
def available() -> bool:
return pyaudio is not None
@staticmethod
def list_sources() -> list[AudioSource]:
if pyaudio is None:
return []
sources: list[AudioSource] = []
pa = pyaudio.PyAudio()
try:
default_index = -1
with contextlib.suppress(OSError, LookupError):
default_index = int(pa.get_default_input_device_info()["index"])
for i in range(pa.get_device_count()):
info = pa.get_device_info_by_index(i)
if int(info.get("maxInputChannels", 0)) <= 0:
continue
# 루프백 장치는 '출력 장치' 목록에서 이미 보여주고 있다.
# 여기서 또 내보내면 같은 것이 두 목록에 걸쳐 나온다.
if info.get("isLoopbackDevice"):
continue
sources.append(
AudioSource(
kind="input",
identifier=str(i),
label=str(info["name"]),
detail="기본 입력 장치" if i == default_index else "입력 장치",
)
)
finally:
pa.terminate()
return sources
def _resolve_device(self, pa) -> dict:
if self.device_index >= 0:
return pa.get_device_info_by_index(self.device_index)
return pa.get_default_input_device_info()
def _run(self) -> None: # pragma: no cover - 실제 오디오 장치 필요
if pyaudio is None:
raise CaptureError("PyAudioWPatch가 설치되지 않았습니다.")
pa = pyaudio.PyAudio()
stream = None
try:
info = self._resolve_device(pa)
if info is None:
raise CaptureError("입력 장치를 찾지 못했습니다.")
rate = int(info["defaultSampleRate"])
channels = max(1, int(info["maxInputChannels"]))
chunk = max(FRAME_SAMPLES, int(rate * 0.02))
stream = pa.open(
format=pyaudio.paFloat32,
channels=channels,
rate=rate,
input=True,
input_device_index=int(info["index"]),
frames_per_buffer=chunk,
)
log.info("입력 장치 캡처 시작: %s (%dHz, %dch)", info["name"], rate, channels)
while not self._stop.is_set():
raw = stream.read(chunk, exception_on_overflow=False)
data = np.frombuffer(raw, dtype=np.float32)
self._emit(to_mono_16k(data, channels, rate))
finally:
if stream is not None:
stream.stop_stream()
stream.close()
pa.terminate()

View File

@@ -12,7 +12,14 @@ from .constants import DEFAULT_TIER, user_data_dir
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
CONFIG_PATH = user_data_dir() / "config.json"
def config_path() -> Path:
"""설정 파일 경로.
import 시점에 상수로 계산하면 안 된다. 그때는 사용자가 저장 위치를 아직
고르지 않았고, 나중에 설정에서 위치를 바꿔도 옛 경로를 계속 가리킨다.
"""
return user_data_dir() / "config.json"
@dataclass @dataclass
@@ -28,6 +35,9 @@ class SubtitleStyle:
outline_width: int = 3 outline_width: int = 3
background_color: str = "#000000" background_color: str = "#000000"
background_opacity: int = 55 # 0-100 background_opacity: int = 55 # 0-100
#: 글자와 배경 상자 가장자리 사이 여백. 배경 상자는 글자를 감싸는 크기로
#: 그려지므로 이 값이 곧 배경 크기를 정한다.
padding: int = 18
line_spacing: int = 6 line_spacing: int = 6
max_lines: int = 2 max_lines: int = 2
show_source: bool = False # 원문 동시 표시 show_source: bool = False # 원문 동시 표시
@@ -46,7 +56,9 @@ class OverlayConfig:
always_on_top: bool = True always_on_top: bool = True
#: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다. #: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다.
click_through: bool = True click_through: bool = True
locked: bool = False #: 기본은 잠금이다. 게임 중에 자막을 실수로 끌고 다니는 사고를 막는다.
#: 옮기거나 크기를 바꿀 때는 자막 화면의 '자막 위치 조정'을 켠다.
locked: bool = True
#: 자막 표시 on/off (단축키로 토글) #: 자막 표시 on/off (단축키로 토글)
visible: bool = True visible: bool = True
#: 자막을 띄울 모니터 인덱스 (0부터) #: 자막을 띄울 모니터 인덱스 (0부터)
@@ -59,7 +71,10 @@ class OverlayConfig:
@dataclass @dataclass
class AudioConfig: class AudioConfig:
backend: str = "auto" # auto | process | device | file backend: str = "auto" # auto | process | device | input | file
#: 홈 화면 목록이 무엇을 보여줄지. "output"=프로그램과 출력 장치(게임 소리),
#: "input"=마이크 같은 입력 장치. 고른 쪽을 기억한다.
source_mode: str = "output"
target_pid: int = 0 target_pid: int = 0
target_process_name: str = "" target_process_name: str = ""
device_index: int = -1 # -1 = 기본 출력 장치 루프백 device_index: int = -1 # -1 = 기본 출력 장치 루프백
@@ -149,7 +164,7 @@ class AppConfig:
return asdict(self) return asdict(self)
def save(self, path: Path | None = None) -> Path: def save(self, path: Path | None = None) -> Path:
target = path or CONFIG_PATH target = path or config_path()
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(".json.tmp") tmp = target.with_suffix(".json.tmp")
tmp.write_text( tmp.write_text(
@@ -160,7 +175,7 @@ class AppConfig:
@classmethod @classmethod
def load(cls, path: Path | None = None) -> AppConfig: def load(cls, path: Path | None = None) -> AppConfig:
target = path or CONFIG_PATH target = path or config_path()
if not target.exists(): if not target.exists():
return cls() return cls()
try: try:

View File

@@ -42,19 +42,15 @@ FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000
def user_data_dir() -> Path: def user_data_dir() -> Path:
"""설정/로그/모델 캐시를 두는 사용자 디렉터리.""" """설정/로그/모델 캐시를 두는 폴더 — 사용자가 첫 실행 때 고른 위치다.
import os
import sys
if sys.platform == "win32": 실제 결정은 storage 모듈이 한다. 모듈 최상단에서 import 하면 순환이 되므로
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) 함수 안에서 가져온다. 그리고 **미리 계산해 상수로 박아두면 안 된다** —
elif sys.platform == "darwin": 설정에서 위치를 바꿨을 때 따라오지 않는다.
base = Path.home() / "Library" / "Application Support" """
else: from .storage import data_dir
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
path = base / APP_NAME return data_dir()
path.mkdir(parents=True, exist_ok=True)
return path
def models_dir() -> Path: def models_dir() -> Path:

View File

@@ -2,7 +2,14 @@
from .asr import SpeechRecognizer, Transcript from .asr import SpeechRecognizer, Transcript
from .glossary import Glossary, GlossaryEntry from .glossary import Glossary, GlossaryEntry
from .manager import GpuInfo, ModelManager, detect_gpu from .manager import GpuInfo, ModelManager, detect_gpu, recommended_tier
from .speech_level import (
Politeness,
SpeechLevel,
apply_speech_level,
detect_politeness,
to_casual,
)
from .tiers import ( from .tiers import (
DEFAULT_TIER, DEFAULT_TIER,
TIERS, TIERS,
@@ -12,13 +19,6 @@ from .tiers import (
get_tier, get_tier,
ordered_tiers, ordered_tiers,
) )
from .speech_level import (
Politeness,
SpeechLevel,
apply_speech_level,
detect_politeness,
to_casual,
)
from .translator import Translator, build_seedx_prompt, create_translator from .translator import Translator, build_seedx_prompt, create_translator
__all__ = [ __all__ = [
@@ -43,5 +43,6 @@ __all__ = [
"detect_gpu", "detect_gpu",
"get_tier", "get_tier",
"ordered_tiers", "ordered_tiers",
"recommended_tier",
"to_casual", "to_casual",
] ]

View File

@@ -64,7 +64,7 @@ class Glossary:
# --- 영속화 -------------------------------------------------------- # --- 영속화 --------------------------------------------------------
@classmethod @classmethod
def load(cls, path: str | Path, case_sensitive: bool = False) -> "Glossary": def load(cls, path: str | Path, case_sensitive: bool = False) -> Glossary:
p = Path(path) p = Path(path)
if not p.is_file(): if not p.is_file():
return cls(case_sensitive=case_sensitive) return cls(case_sensitive=case_sensitive)

View File

@@ -8,7 +8,7 @@ from dataclasses import dataclass, replace
from ..config import ModelConfig from ..config import ModelConfig
from .asr import SpeechRecognizer from .asr import SpeechRecognizer
from .tiers import TIERS, MTBackend, Tier, get_tier from .tiers import DEFAULT_TIER, TIERS, MTBackend, Tier, get_tier
from .translator import Translator, create_translator from .translator import Translator, create_translator
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -57,7 +57,10 @@ def detect_gpu() -> GpuInfo:
return GpuInfo(True, name, int(total), int(free), return GpuInfo(True, name, int(total), int(free),
reason="PyTorch 미설치 — 정보만 조회했습니다.") reason="PyTorch 미설치 — 정보만 조회했습니다.")
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return GpuInfo(False, reason=f"GPU 조회 실패: {exc}") # 예외 문자열에는 실행한 명령줄이 통째로 들어 있다. 그대로 화면에
# 띄우면 안내가 아니라 로그 덤프가 된다. 자세한 건 로그로 보낸다.
log.warning("nvidia-smi 조회 실패: %s", exc)
return GpuInfo(False, reason="nvidia-smi 로 GPU 정보를 읽지 못했습니다.")
def apply_vram_limit(ratio: float, device_index: int = 0) -> bool: def apply_vram_limit(ratio: float, device_index: int = 0) -> bool:
@@ -136,6 +139,32 @@ def tier_availability(tier: Tier, gpu: GpuInfo | None = None) -> tuple[bool, str
return True, "" return True, ""
def recommended_tier(gpu: GpuInfo | None = None) -> str:
"""이 환경에서 실제로 권할 만한 티어.
티어 표에 박아둔 `recommended` 는 "GPU 가 넉넉할 때" 를 전제로 한 값이라,
그대로 쓰면 GPU 가 없는 사람에게 10GB 짜리를 추천하게 된다. 그래서 환경을
보고 정한다.
GPU 가 없으면 CTranslate2 계열(1~3티어)만 후보에 둔다. LLM 계열은 CPU 에서
한 문장에 수십 초가 걸려 실시간 자막으로 쓸 수 없다. 그중에서도 가장
가벼운 쪽을 고른다 — CPU 에서는 연산량이 그대로 지연으로 돌아온다.
"""
info = detect_gpu() if gpu is None else gpu
usable = [t for t in TIERS.values() if tier_availability(t, info)[0]]
if not usable:
return DEFAULT_TIER
if not info.available:
light = [t for t in usable if t.mt.backend is MTBackend.CTRANSLATE2]
return min(light or usable, key=lambda t: t.order).key
preferred = [t for t in usable if t.recommended]
if preferred:
return max(preferred, key=lambda t: t.order).key
return max(usable, key=lambda t: t.order).key
def first_available_tier(preferred: str) -> str: def first_available_tier(preferred: str) -> str:
"""선호 티어가 불가능하면 쓸 수 있는 가장 좋은 티어로 내린다.""" """선호 티어가 불가능하면 쓸 수 있는 가장 좋은 티어로 내린다."""
tier = get_tier(preferred) tier = get_tier(preferred)

View File

@@ -120,7 +120,7 @@ SWIFT = _register(
order=2, order=2,
name="신속", name="신속",
tagline="빠르면서 인식률은 최상급", tagline="빠르면서 인식률은 최상급",
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "int8_float16", 1, 1600), asr=AsrSpec("dropbox-dash/faster-whisper-large-v3-turbo", "int8_float16", 1, 1600),
mt=MTSpec( mt=MTSpec(
"entai2965/nllb-200-distilled-600M-ctranslate2", "entai2965/nllb-200-distilled-600M-ctranslate2",
MTBackend.CTRANSLATE2, MTBackend.CTRANSLATE2,
@@ -140,7 +140,7 @@ BALANCE = _register(
order=3, order=3,
name="균형", name="균형",
tagline="기본값 · 속도와 품질의 중간", tagline="기본값 · 속도와 품질의 중간",
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "float16", 2, 2400), asr=AsrSpec("dropbox-dash/faster-whisper-large-v3-turbo", "float16", 2, 2400),
mt=MTSpec( mt=MTSpec(
"entai2965/nllb-200-distilled-1.3B-ctranslate2", "entai2965/nllb-200-distilled-1.3B-ctranslate2",
MTBackend.CTRANSLATE2, MTBackend.CTRANSLATE2,
@@ -160,7 +160,7 @@ PRECISION = _register(
order=4, order=4,
name="정밀", name="정밀",
tagline="추천 · 현 시점 최고 번역 품질", tagline="추천 · 현 시점 최고 번역 품질",
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "float16", 2, 2400), asr=AsrSpec("dropbox-dash/faster-whisper-large-v3-turbo", "float16", 2, 2400),
mt=MTSpec( mt=MTSpec(
"ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4", "ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4",
MTBackend.TRANSFORMERS, MTBackend.TRANSFORMERS,

213
src/livesub/storage.py Normal file
View 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

View 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()

View File

@@ -162,8 +162,12 @@ class MainWindow(QMainWindow):
self.settings_page.restart_needed.connect(self._on_restart_needed) self.settings_page.restart_needed.connect(self._on_restart_needed)
self.settings_page.hotkeys_changed.connect(self._install_hotkeys) self.settings_page.hotkeys_changed.connect(self._install_hotkeys)
self.subtitle_page.edit_mode_requested.connect(self.overlay.set_edit_mode)
self.overlay.geometry_changed.connect(self._on_overlay_moved) self.overlay.geometry_changed.connect(self._on_overlay_moved)
self.overlay.visibility_changed.connect(self.subtitle_page.overlay_check.setChecked) self.overlay.visibility_changed.connect(self.subtitle_page.overlay_check.setChecked)
self.overlay.edit_mode_changed.connect(self.subtitle_page.set_edit_mode)
self.overlay.font_size_changed.connect(self._on_overlay_font_resized)
self.overlay.closed.connect( self.overlay.closed.connect(
lambda: self.subtitle_page.overlay_check.setChecked(False) lambda: self.subtitle_page.overlay_check.setChecked(False)
) )
@@ -278,6 +282,11 @@ class MainWindow(QMainWindow):
self.subtitle_page.sync_from_config() self.subtitle_page.sync_from_config()
self._schedule_save() self._schedule_save()
def _on_overlay_font_resized(self, size: int) -> None:
"""모서리를 잡아 크기를 바꾸면 글자 크기도 같이 바뀐다."""
self.subtitle_page.set_font_size(size)
self._schedule_save()
def _on_glossary_changed(self) -> None: def _on_glossary_changed(self) -> None:
self.engine.reload_glossary() self.engine.reload_glossary()
self._schedule_save() self._schedule_save()

View File

@@ -3,11 +3,14 @@
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은 게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다. '외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
조작: 평소에는 **잠겨 있다.** 게임 중에 자막을 실수로 끌고 다니는 일이 없어야 하기
* 드래그 — 위치 이동 때문이다. 자막 화면의 '자막 위치 조정'을 켜야 아래 조작이 열린다.
* 우하단 모서리 — 크기 조절
* 휠 — 글자 크기 조절 위치 조정 모드에서:
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴 * 드래그 — 위치 이동
* 네 모서리 — 크기 조절 (글자 크기도 같은 비율로 따라온다)
* 휠 — 글자 크기 조절
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
""" """
from __future__ import annotations from __future__ import annotations
@@ -38,17 +41,16 @@ def paint_subtitle(
source: str = "", source: str = "",
partial: str = "", partial: str = "",
) -> None: ) -> None:
"""자막 한 화면을 그린다. 오버레이와 설정 미리보기가 이 함수를 공유한다.""" """자막 한 화면을 그린다. 오버레이와 설정 미리보기가 이 함수를 공유한다.
배경 상자는 창 전체가 아니라 **글자를 감싸는 크기**로 그린다. 창 전체를
칠하면 자막이 짧아도 커다란 띠가 남고, 그 상태에서는 여백을 키워도 글자만
안쪽으로 밀릴 뿐 '배경 크기'는 그대로다. 감싸도록 그려야 여백이 곧 배경
크기가 된다.
"""
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, 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]] = [] rows: list[tuple[str, QColor, int]] = []
if st.show_source and source and translated: if st.show_source and source and translated:
rows.append((source, QColor(st.source_text_color), int(st.font_size * 0.62))) rows.append((source, QColor(st.source_text_color), int(st.font_size * 0.62)))
@@ -61,33 +63,60 @@ def paint_subtitle(
if not rows: if not rows:
return return
margin = 18 pad = max(0, st.padding)
available = rect.width() - margin * 2 available = rect.width() - pad * 2
blocks: list[tuple[list[str], QColor, QFont]] = [] blocks: list[tuple[list[str], QColor, QFont]] = []
total_h = 0 total_h = 0
text_w = 0
for text, color, size in rows: for text, color, size in rows:
font = QFont(st.font_family, size) font = QFont(st.font_family, size)
font.setBold(st.bold) font.setBold(st.bold)
lines = _wrap(text, QFontMetrics(font), available, st.max_lines) fm = QFontMetrics(font)
lines = _wrap(text, fm, available, st.max_lines)
blocks.append((lines, color, font)) blocks.append((lines, color, font))
total_h += len(lines) * (QFontMetrics(font).height() + st.line_spacing) total_h += len(lines) * (fm.height() + st.line_spacing)
for line in lines:
text_w = max(text_w, fm.horizontalAdvance(line))
y = rect.y() + max(margin, (rect.height() - total_h) // 2) box = _content_box(rect, text_w, total_h, pad, st.align)
if st.background_opacity > 0:
bg = QColor(st.background_color)
bg.setAlpha(int(255 * st.background_opacity / 100))
painter.setBrush(bg)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(box, 12, 12)
y = box.y() + pad
for lines, color, font in blocks: for lines, color, font in blocks:
painter.setFont(font) painter.setFont(font)
fm = QFontMetrics(font) fm = QFontMetrics(font)
for line in lines: for line in lines:
width = fm.horizontalAdvance(line) width = fm.horizontalAdvance(line)
if st.align == "left": if st.align == "left":
x = rect.x() + margin x = box.x() + pad
elif st.align == "right": elif st.align == "right":
x = rect.right() - margin - width x = box.right() - pad - width
else: else:
x = rect.x() + (rect.width() - width) // 2 x = box.x() + (box.width() - width) // 2
_draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st) _draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st)
y += fm.height() + st.line_spacing y += fm.height() + st.line_spacing
def _content_box(rect: QRect, text_w: int, text_h: int, pad: int, align: str) -> QRect:
"""글자를 감싸는 배경 상자. 창 밖으로는 넘지 않는다."""
width = min(rect.width(), text_w + pad * 2)
height = min(rect.height(), text_h + pad * 2)
if align == "left":
x = rect.x()
elif align == "right":
x = rect.right() - width + 1
else:
x = rect.x() + (rect.width() - width) // 2
y = rect.y() + (rect.height() - height) // 2
return QRect(x, y, width, height)
def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None: def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None:
"""외곽선 있는 글자. """외곽선 있는 글자.
@@ -145,6 +174,10 @@ class SubtitleOverlay(QWidget):
geometry_changed = Signal(int, int, int, int) geometry_changed = Signal(int, int, int, int)
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다) #: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
visibility_changed = Signal(bool) visibility_changed = Signal(bool)
#: 크기 조정으로 글자 크기가 같이 바뀌었을 때 (설정 화면이 따라와야 한다)
font_size_changed = Signal(int)
#: 위치 조정 모드가 켜지고 꺼질 때
edit_mode_changed = Signal(bool)
closed = Signal() closed = Signal()
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None: def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
@@ -156,7 +189,13 @@ class SubtitleOverlay(QWidget):
self._partial_text = "" self._partial_text = ""
self._source_text = "" self._source_text = ""
self._drag_from: QPoint | None = None self._drag_from: QPoint | None = None
self._resize_from: QRect | None = None #: 위치 조정 모드. 설정에 저장하지 않는다 — 다음 실행 때는 다시 잠긴
#: 상태로 시작해야 게임 중에 자막을 실수로 끌고 다니지 않는다.
self._edit_mode = False
self._resize_corner = ""
self._resize_geo = QRect()
self._resize_mouse = QPoint()
self._resize_font = 0
self._fade = QTimer(self) self._fade = QTimer(self)
self._fade.setSingleShot(True) self._fade.setSingleShot(True)
@@ -181,8 +220,11 @@ class SubtitleOverlay(QWidget):
else flags & ~Qt.WindowType.WindowStaysOnTopHint else flags & ~Qt.WindowType.WindowStaysOnTopHint
) )
self.setWindowFlags(flags) self.setWindowFlags(flags)
# 위치 조정 중에는 클릭 통과를 반드시 꺼야 한다. 켜둔 채로는 마우스가
# 창을 그냥 지나가서 잡을 수가 없다.
self.setAttribute( self.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through Qt.WidgetAttribute.WA_TransparentForMouseEvents,
cfg.click_through and not self._edit_mode,
) )
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다. # 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
@@ -192,6 +234,50 @@ class SubtitleOverlay(QWidget):
self.setVisible(cfg.visible) self.setVisible(cfg.visible)
self.update() self.update()
# --- 위치 조정 모드 --------------------------------------------------
@property
def edit_mode(self) -> bool:
return self._edit_mode
def set_edit_mode(self, on: bool) -> None:
"""자막을 옮기고 크기를 바꿀 수 있는 상태로 전환한다.
평소에는 잠겨 있다. 게임 중에 자막을 잘못 건드려 끌고 다니는 일이
없어야 하기 때문이다. 이 모드에서만 드래그·모서리 크기 조절·휠이
동작하고, 켜는 동안에는 자막이 안 보여도 잡을 수 있게 창을 띄운다.
"""
if on == self._edit_mode:
return
self._edit_mode = on
if on and not self.isVisible():
self.setVisible(True)
self.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
self.overlay_cfg.click_through and not on,
)
self.setCursor(
Qt.CursorShape.SizeAllCursor if on else Qt.CursorShape.ArrowCursor
)
if not on:
self._drag_from = None
self._resize_corner = ""
self.update()
self.edit_mode_changed.emit(on)
@property
def _interactive(self) -> bool:
return self._edit_mode or not self.overlay_cfg.locked
def _corner_at(self, pos: QPoint) -> str:
"""모서리 손잡이를 집었는지. 집었으면 어느 모서리인지."""
left = pos.x() <= GRIP
right = pos.x() >= self.width() - GRIP
top = pos.y() <= GRIP
bottom = pos.y() >= self.height() - GRIP
if (left or right) and (top or bottom):
return ("top" if top else "bottom") + ("left" if left else "right")
return ""
def snap_to_anchor(self, anchor: str | None = None, save_only: bool = False) -> None: def snap_to_anchor(self, anchor: str | None = None, save_only: bool = False) -> None:
"""선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식).""" """선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식)."""
cfg = self.overlay_cfg cfg = self.overlay_cfg
@@ -248,53 +334,94 @@ class SubtitleOverlay(QWidget):
self._source_text, self._source_text,
self._partial_text, self._partial_text,
) )
if not (self._final_text or self._partial_text) and not self.overlay_cfg.locked: if self._edit_mode:
self._draw_placeholder(painter) self._draw_edit_chrome(painter)
self._draw_grip(painter)
def _draw_placeholder(self, painter) -> None: def _draw_edit_chrome(self, painter) -> None:
painter.setPen(QColor(255, 255, 255, 90)) """위치 조정 중이라는 것과 어디를 잡으면 되는지 보여준다."""
font = QFont(self.style_cfg.font_family, 13) painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setFont(font) painter.setPen(QPen(QColor(90, 170, 255, 220), 2, Qt.PenStyle.DashLine))
painter.drawText( painter.drawRect(self.rect().adjusted(1, 1, -2, -2))
self.rect(),
Qt.AlignmentFlag.AlignCenter,
"자막이 여기에 표시됩니다\n드래그로 이동 · 휠로 크기 · 우클릭으로 메뉴",
)
def _draw_grip(self, painter) -> None: painter.setPen(Qt.PenStyle.NoPen)
if self.overlay_cfg.locked or self.overlay_cfg.click_through: painter.setBrush(QColor(90, 170, 255, 235))
return for rect in self._corner_handles():
painter.setPen(QPen(QColor(255, 255, 255, 110), 2)) painter.drawRect(rect)
if not (self._final_text or self._partial_text):
painter.setPen(QColor(255, 255, 255, 170))
font = QFont(self.style_cfg.font_family, 13)
painter.setFont(font)
painter.drawText(
self.rect(),
Qt.AlignmentFlag.AlignCenter,
"끌어서 이동 · 모서리를 잡아 크기 조절 (글자 크기도 같이 바뀝니다)",
)
def _corner_handles(self) -> list[QRect]:
w, h = self.width(), self.height() w, h = self.width(), self.height()
for offset in (5, 10, 15): return [
painter.drawLine(w - offset, h - 4, w - 4, h - offset) QRect(0, 0, GRIP, GRIP),
QRect(w - GRIP, 0, GRIP, GRIP),
QRect(0, h - GRIP, GRIP, GRIP),
QRect(w - GRIP, h - GRIP, GRIP, GRIP),
]
# --- 마우스 --------------------------------------------------------- # --- 마우스 ---------------------------------------------------------
def mousePressEvent(self, event) -> None: # noqa: N802 def mousePressEvent(self, event) -> None: # noqa: N802
if self.overlay_cfg.locked or event.button() != Qt.MouseButton.LeftButton: if not self._interactive or event.button() != Qt.MouseButton.LeftButton:
return return
pos = event.position().toPoint() corner = self._corner_at(event.position().toPoint())
if pos.x() > self.width() - GRIP and pos.y() > self.height() - GRIP: if corner:
self._resize_from = QRect(event.globalPosition().toPoint(), self.size()) self._resize_corner = corner
self._resize_geo = QRect(self.geometry())
self._resize_mouse = event.globalPosition().toPoint()
self._resize_font = self.style_cfg.font_size
else: else:
self._drag_from = event.globalPosition().toPoint() - self.pos() self._drag_from = event.globalPosition().toPoint() - self.pos()
def mouseMoveEvent(self, event) -> None: # noqa: N802 def mouseMoveEvent(self, event) -> None: # noqa: N802
if self._resize_from is not None: if self._resize_corner:
delta = event.globalPosition().toPoint() - self._resize_from.topLeft() self._resize_to(event.globalPosition().toPoint())
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: elif self._drag_from is not None:
self.move(event.globalPosition().toPoint() - self._drag_from) self.move(event.globalPosition().toPoint() - self._drag_from)
def _resize_to(self, mouse: QPoint) -> None:
"""잡은 모서리만 움직이고 반대쪽 모서리는 제자리에 둔다.
글자 크기는 높이 비율을 그대로 따라간다. 상자만 커지고 글자는 그대로면
여백만 늘어나므로, 크기 조절이 '자막을 키우는' 조작이 되지 않는다.
"""
start = self._resize_geo
dx = mouse.x() - self._resize_mouse.x()
dy = mouse.y() - self._resize_mouse.y()
left, top = start.left(), start.top()
right, bottom = start.right(), start.bottom()
if "left" in self._resize_corner:
left = min(left + dx, right - self.minimumWidth())
else:
right = max(right + dx, left + self.minimumWidth())
if "top" in self._resize_corner:
top = min(top + dy, bottom - self.minimumHeight())
else:
bottom = max(bottom + dy, top + self.minimumHeight())
rect = QRect(QPoint(left, top), QPoint(right, bottom))
self.setGeometry(rect)
scaled = round(self._resize_font * rect.height() / max(1, start.height()))
size = max(12, min(120, scaled))
if size != self.style_cfg.font_size:
self.style_cfg.font_size = size
self.font_size_changed.emit(size)
self.update()
def mouseReleaseEvent(self, event) -> None: # noqa: N802 def mouseReleaseEvent(self, event) -> None: # noqa: N802
if self._drag_from is None and self._resize_from is None: if self._drag_from is None and not self._resize_corner:
return return
self._drag_from = None self._drag_from = None
self._resize_from = None self._resize_corner = ""
cfg = self.overlay_cfg cfg = self.overlay_cfg
g = self.geometry() g = self.geometry()
@@ -309,10 +436,13 @@ class SubtitleOverlay(QWidget):
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height()) self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
def wheelEvent(self, event) -> None: # noqa: N802 def wheelEvent(self, event) -> None: # noqa: N802
if self.overlay_cfg.locked: if not self._interactive:
return return
step = 2 if event.angleDelta().y() > 0 else -2 step = 2 if event.angleDelta().y() > 0 else -2
self.style_cfg.font_size = max(12, min(120, self.style_cfg.font_size + step)) size = max(12, min(120, self.style_cfg.font_size + step))
if size != self.style_cfg.font_size:
self.style_cfg.font_size = size
self.font_size_changed.emit(size)
self.update() self.update()
def contextMenuEvent(self, event) -> None: # noqa: N802 def contextMenuEvent(self, event) -> None: # noqa: N802
@@ -343,7 +473,11 @@ class SubtitleOverlay(QWidget):
def _set_click_through(self, value: bool) -> None: def _set_click_through(self, value: bool) -> None:
self.overlay_cfg.click_through = value self.overlay_cfg.click_through = value
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, value) # 위치 조정 중이면 통과를 켜지 않는다. 켜는 순간 창을 못 잡게 된다.
self.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
value and not self._edit_mode,
)
self.update() self.update()
def _set_always_on_top(self, value: bool) -> None: def _set_always_on_top(self, value: bool) -> None:

View File

@@ -14,7 +14,12 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from ...audio import capture_capabilities, list_sources from ...audio import (
SOURCE_MODE_INPUT,
SOURCE_MODE_OUTPUT,
capture_capabilities,
list_sources,
)
from ...config import AppConfig from ...config import AppConfig
from ...constants import LANGUAGES from ...constants import LANGUAGES
from ...core.events import EngineState, EngineStatus, TranslationLine from ...core.events import EngineState, EngineStatus, TranslationLine
@@ -65,12 +70,34 @@ class HomePage(QWidget):
self.source_combo.currentIndexChanged.connect(self._on_source_changed) self.source_combo.currentIndexChanged.connect(self._on_source_changed)
layout.addWidget(self.source_combo, 1) layout.addWidget(self.source_combo, 1)
# 마이크 등 입력 장치는 같은 목록에 섞지 않고 전환해서 보여준다.
# 한 목록에 다 넣으면 정작 찾으려던 게임이 라인입력들 사이에 파묻힌다.
self.mode_button = QPushButton()
self.mode_button.setMinimumWidth(170)
self.mode_button.clicked.connect(self._toggle_source_mode)
layout.addWidget(self.mode_button)
refresh = QPushButton("새로고침") refresh = QPushButton("새로고침")
refresh.clicked.connect(self.refresh_sources) refresh.clicked.connect(self.refresh_sources)
layout.addWidget(refresh) layout.addWidget(refresh)
card.add(row) card.add(row)
return card return card
@property
def _mode(self) -> str:
return (
SOURCE_MODE_INPUT
if self.config.audio.source_mode == SOURCE_MODE_INPUT
else SOURCE_MODE_OUTPUT
)
def _toggle_source_mode(self) -> None:
self.config.audio.source_mode = (
SOURCE_MODE_OUTPUT if self._mode == SOURCE_MODE_INPUT else SOURCE_MODE_INPUT
)
self.refresh_sources()
self.config_changed.emit()
def _build_control_card(self) -> Card: def _build_control_card(self) -> Card:
card = Card() card = Card()
row = QWidget() row = QWidget()
@@ -145,29 +172,49 @@ class HomePage(QWidget):
# --- 동작 ----------------------------------------------------------- # --- 동작 -----------------------------------------------------------
def refresh_sources(self) -> None: def refresh_sources(self) -> None:
caps = capture_capabilities() caps = capture_capabilities()
self._sources = list_sources() mode = self._mode
self._sources = list_sources(mode)
icons = {"process": "🎮", "device": "🔊", "input": "🎤"}
self.source_combo.blockSignals(True) self.source_combo.blockSignals(True)
self.source_combo.clear() self.source_combo.clear()
for src in self._sources: for src in self._sources:
prefix = "🎮" if src.kind == "process" else "🔊" prefix = icons.get(src.kind, "🔊")
self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src) self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src)
if not self._sources: if not self._sources:
self.source_combo.addItem("사용 가능한 오디오 대상이 없습니다", None) self.source_combo.addItem(
"마이크 등 입력 장치를 찾지 못했습니다"
if mode == SOURCE_MODE_INPUT
else "사용 가능한 오디오 대상이 없습니다",
None,
)
self._select_saved_source() self._select_saved_source()
self.source_combo.blockSignals(False) self.source_combo.blockSignals(False)
self._on_source_changed() self._on_source_changed()
if caps["process"]: # 버튼 글자는 '지금 상태'가 아니라 '누르면 가는 곳'을 적는다.
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다." if mode == SOURCE_MODE_INPUT:
elif caps["device"]: self.mode_button.setText("프로그램 소리에서 고르기")
self.mode_button.setToolTip("게임 등 프로그램과 출력 장치 목록으로 돌아갑니다.")
msg = ( msg = (
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 " "마이크 등 입력 장치로 듣습니다. 통화 상대 목소리나 내가 말하는 것을 "
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 " "번역할 때 쓰세요."
"프로그램 단위로 분리됩니다." if caps["input"]
else "이 환경에서는 입력 장치를 열 수 없습니다. Windows에서 실행하세요."
) )
else: else:
msg = "이 환경에서는 오디오 캡처를 할 수 없습니다. Windows에서 실행하세요." self.mode_button.setText("마이크 입력에서 고르기")
self.mode_button.setToolTip("목록을 마이크 등 입력 장치로 바꿉니다.")
if caps["process"]:
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
elif caps["device"]:
msg = (
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
"프로그램 단위로 분리됩니다."
)
else:
msg = "이 환경에서는 오디오 캡처를 할 수 없습니다. Windows에서 실행하세요."
self.capability_label.setText(msg) self.capability_label.setText(msg)
def _select_saved_source(self) -> None: def _select_saved_source(self) -> None:
@@ -176,7 +223,11 @@ class HomePage(QWidget):
if cfg.backend == "process" and src.kind == "process" and src.pid == cfg.target_pid: if cfg.backend == "process" and src.kind == "process" and src.pid == cfg.target_pid:
self.source_combo.setCurrentIndex(i) self.source_combo.setCurrentIndex(i)
return return
if cfg.backend == "device" and src.kind == "device" and src.device_index == cfg.device_index: if (
cfg.backend in ("device", "input")
and src.kind == cfg.backend
and src.device_index == cfg.device_index
):
self.source_combo.setCurrentIndex(i) self.source_combo.setCurrentIndex(i)
return return
@@ -190,7 +241,7 @@ class HomePage(QWidget):
cfg.target_pid = src.pid cfg.target_pid = src.pid
cfg.target_process_name = src.label cfg.target_process_name = src.label
else: else:
cfg.backend = "device" cfg.backend = src.kind # "device" 또는 "input"
cfg.device_index = src.device_index cfg.device_index = src.device_index
cfg.target_pid = 0 cfg.target_pid = 0
self.config_changed.emit() self.config_changed.emit()
@@ -226,7 +277,12 @@ class HomePage(QWidget):
self.status_pill.set_status(status.message or status.state.value, kind) self.status_pill.set_status(status.message or status.state.value, kind)
self.level_meter.set_level(status.level) self.level_meter.set_level(status.level)
for widget in (self.source_combo, self.source_lang, self.target_lang): for widget in (
self.source_combo,
self.mode_button,
self.source_lang,
self.target_lang,
):
widget.setEnabled(not running) widget.setEnabled(not running)
self.speech_combo.setEnabled( self.speech_combo.setEnabled(
not running and self.config.models.target_lang == "ko" not running and self.config.models.target_lang == "ko"

View File

@@ -17,8 +17,8 @@ from PySide6.QtWidgets import (
) )
from ...config import AppConfig from ...config import AppConfig
from ...models import Tier, detect_gpu, ordered_tiers from ...models import Tier, detect_gpu, get_tier, ordered_tiers
from ...models.manager import GpuInfo, tier_availability from ...models.manager import GpuInfo, recommended_tier, tier_availability
from ..theme import SPACING, palette from ..theme import SPACING, palette
from ..widgets import Card, PageHeader from ..widgets import Card, PageHeader
@@ -26,7 +26,13 @@ from ..widgets import Card, PageHeader
class TierCard(QFrame): class TierCard(QFrame):
"""티어 하나를 라디오 버튼 카드로.""" """티어 하나를 라디오 버튼 카드로."""
def __init__(self, tier: Tier, gpu: GpuInfo, parent: QWidget | None = None): def __init__(
self,
tier: Tier,
gpu: GpuInfo,
recommended: bool = False,
parent: QWidget | None = None,
):
super().__init__(parent) super().__init__(parent)
self.tier = tier self.tier = tier
gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0 gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
@@ -49,8 +55,10 @@ class TierCard(QFrame):
name = QLabel(f"{tier.order}. {tier.name}") name = QLabel(f"{tier.order}. {tier.name}")
name.setStyleSheet("font-size: 16px; font-weight: 700;") name.setStyleSheet("font-size: 16px; font-weight: 700;")
head.addWidget(name) head.addWidget(name)
if tier.recommended: # '추천'은 표에 박힌 값이 아니라 이 PC 기준으로 계산해서 붙인다.
head.addWidget(_badge("추천", p.accent)) # GPU 가 없는데 10GB 짜리에 추천 배지가 달려 있으면 안내가 아니라 함정이다.
if recommended:
head.addWidget(_badge("CPU 추천" if not gpu.available else "추천", p.accent))
if tier.best_after_finetune: if tier.best_after_finetune:
head.addWidget(_badge("추가학습 최적", p.success)) head.addWidget(_badge("추가학습 최적", p.success))
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb: if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
@@ -106,6 +114,8 @@ class ModelsPage(QWidget):
# 티어 카드마다 조회하면 nvidia-smi 를 다섯 번 부른다. 한 번만 본다. # 티어 카드마다 조회하면 nvidia-smi 를 다섯 번 부른다. 한 번만 본다.
self._gpu = detect_gpu() self._gpu = detect_gpu()
self._gpu_vram_gb = self._gpu.total_vram_mb / 1024 if self._gpu.available else 0.0 self._gpu_vram_gb = self._gpu.total_vram_mb / 1024 if self._gpu.available else 0.0
#: 이 PC 기준 추천 티어. GPU 가 없으면 CPU 에서 실제로 돌아가는 쪽을 권한다.
self._recommended = recommended_tier(self._gpu)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0) root.setContentsMargins(0, 0, 0, 0)
@@ -123,7 +133,7 @@ class ModelsPage(QWidget):
self._group = QButtonGroup(self) self._group = QButtonGroup(self)
self._group.setExclusive(True) self._group.setExclusive(True)
for tier in ordered_tiers(): for tier in ordered_tiers():
card = TierCard(tier, self._gpu) card = TierCard(tier, self._gpu, recommended=tier.key == self._recommended)
self._group.addButton(card.radio, tier.order) self._group.addButton(card.radio, tier.order)
card.radio.toggled.connect( card.radio.toggled.connect(
lambda checked, t=tier: self._on_tier_selected(t) if checked else None lambda checked, t=tier: self._on_tier_selected(t) if checked else None
@@ -163,6 +173,10 @@ class ModelsPage(QWidget):
self.device_combo = QComboBox() self.device_combo = QComboBox()
self.device_combo.addItem("GPU (CUDA)", "cuda") self.device_combo.addItem("GPU (CUDA)", "cuda")
self.device_combo.addItem("CPU", "cpu") self.device_combo.addItem("CPU", "cpu")
# GPU 가 없으면 콤보를 비활성화만 하고 "GPU (CUDA)" 를 그대로 띄워두면
# 실제로는 CPU 로 도는데 화면은 GPU 라고 말하는 상태가 된다. 맞춰둔다.
if not gpu.available:
self.config.models.device = "cpu"
self.device_combo.setCurrentIndex( self.device_combo.setCurrentIndex(
max(0, self.device_combo.findData(self.config.models.device)) max(0, self.device_combo.findData(self.config.models.device))
) )
@@ -171,8 +185,42 @@ class ModelsPage(QWidget):
layout.addWidget(self.device_combo) layout.addWidget(self.device_combo)
card.add(row) card.add(row)
if not gpu.available:
card.add(self._build_cpu_advice_row())
return card return card
def _build_cpu_advice_row(self) -> QWidget:
"""GPU 를 못 찾았을 때 CPU 에서 실제로 쓸 만한 설정을 권한다."""
tier = get_tier(self._recommended)
row = QWidget()
row.setObjectName("Row")
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
advice = QLabel(
f"CPU 에서는 '{tier.order}. {tier.name}' 를 권합니다 — "
f"CPU 로 실시간 자막이 따라오는 가장 무거운 구성입니다. "
f"상위 티어는 한 문장에 수십 초가 걸려 실시간으로 쓸 수 없습니다."
)
advice.setObjectName("Hint")
advice.setWordWrap(True)
layout.addWidget(advice, 1)
apply_button = QPushButton("추천대로 맞추기")
apply_button.clicked.connect(self._apply_recommended)
layout.addWidget(apply_button)
return row
def _apply_recommended(self) -> None:
self.config.models.device = "cpu"
self.device_combo.blockSignals(True)
self.device_combo.setCurrentIndex(max(0, self.device_combo.findData("cpu")))
self.device_combo.blockSignals(False)
self._apply_selection(self._recommended)
self._on_tier_selected(get_tier(self._recommended))
def _build_lora_card(self) -> Card: def _build_lora_card(self) -> Card:
card = Card( card = Card(
"추가학습 어댑터 (선택)", "추가학습 어댑터 (선택)",

View File

@@ -9,6 +9,7 @@ from PySide6.QtWidgets import (
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QMessageBox,
QPushButton, QPushButton,
QSlider, QSlider,
QSpinBox, QSpinBox,
@@ -19,6 +20,7 @@ from PySide6.QtWidgets import (
from ...config import AppConfig from ...config import AppConfig
from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ...models import detect_gpu from ...models import detect_gpu
from ...storage import free_space, human_size
from ..theme import SPACING, palette from ..theme import SPACING, palette
from ..widgets import Card, PageHeader from ..widgets import Card, PageHeader
@@ -45,6 +47,7 @@ class SettingsPage(QWidget):
root.addWidget(self._build_hotkey_card()) root.addWidget(self._build_hotkey_card())
root.addWidget(self._build_audio_card()) root.addWidget(self._build_audio_card())
root.addWidget(self._build_behavior_card()) root.addWidget(self._build_behavior_card())
root.addWidget(self._build_storage_card())
root.addWidget(self._build_about_card()) root.addWidget(self._build_about_card())
root.addStretch(1) root.addStretch(1)
@@ -258,20 +261,61 @@ class SettingsPage(QWidget):
self.log_check.setChecked(self.config.log_transcripts) self.log_check.setChecked(self.config.log_transcripts)
self.log_check.toggled.connect(self._on_behavior_changed) self.log_check.toggled.connect(self._on_behavior_changed)
form.addRow("", self.log_check) form.addRow("", self.log_check)
open_folder = QPushButton("설정 폴더 열기")
open_folder.clicked.connect(self._open_folder)
form.addRow("", open_folder)
return card 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: def _build_about_card(self) -> Card:
card = Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN) return Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN)
path = QLabel(f"설정 위치: {user_data_dir()}")
path.setObjectName("Hint")
path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
path.setWordWrap(True)
card.add(path)
return card
# --- 동작 ----------------------------------------------------------- # --- 동작 -----------------------------------------------------------
def _on_audio_changed(self, *_) -> None: def _on_audio_changed(self, *_) -> None:

View File

@@ -61,6 +61,8 @@ class SubtitlePage(QWidget):
overlay_toggled = Signal(bool) overlay_toggled = Signal(bool)
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜 #: 자막을 띄울 모니터 / 9분할 위치가 바뀜
placement_changed = Signal() placement_changed = Signal()
#: '자막 위치 조정' 버튼 — 오버레이를 조정 가능한 상태로 열고 닫는다
edit_mode_requested = Signal(bool)
def __init__(self, config: AppConfig, parent: QWidget | None = None): def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent) super().__init__(parent)
@@ -88,7 +90,7 @@ class SubtitlePage(QWidget):
card = Card( card = Card(
"표시 위치", "표시 위치",
"모니터를 고르고 네모칸을 누르면 그 자리에 자막이 붙습니다. " "모니터를 고르고 네모칸을 누르면 그 자리에 자막이 붙습니다. "
"직접 드래그해서 옮기면 그 위치를 그대로 기억합니다.", "칸에 없는 자리에 두고 싶으면 아래 '자막 위치 조정'으로 직접 옮기세요.",
) )
# --- on/off + 단축키 안내 --- # --- on/off + 단축키 안내 ---
@@ -159,9 +161,55 @@ class SubtitlePage(QWidget):
margin_layout.addStretch(1) margin_layout.addStretch(1)
card.add(margin_row) card.add(margin_row)
# --- 직접 옮기기 ---
# 자막 창은 기본적으로 잠겨 있다. 게임 중에 잘못 건드려 끌고 다니는
# 사고를 막기 위해서다. 옮기거나 크기를 바꿀 때만 여기서 연다.
adjust_row = QWidget()
adjust_layout = QHBoxLayout(adjust_row)
adjust_layout.setContentsMargins(0, 0, 0, 0)
adjust_layout.setSpacing(10)
self.adjust_button = QPushButton("자막 위치 조정")
self.adjust_button.setCheckable(True)
self.adjust_button.setMinimumWidth(150)
self.adjust_button.toggled.connect(self.edit_mode_requested.emit)
adjust_layout.addWidget(self.adjust_button)
self.adjust_hint = QLabel()
self.adjust_hint.setObjectName("Hint")
self.adjust_hint.setWordWrap(True)
adjust_layout.addWidget(self.adjust_hint, 1)
card.add(adjust_row)
self.set_edit_mode(False)
self.refresh_screens() self.refresh_screens()
return card return card
def set_edit_mode(self, on: bool) -> None:
"""오버레이의 위치 조정 상태를 버튼과 안내에 반영한다."""
self.adjust_button.blockSignals(True)
self.adjust_button.setChecked(on)
self.adjust_button.blockSignals(False)
self.adjust_button.setText("조정 끝내기" if on else "자막 위치 조정")
self.adjust_button.setObjectName("Danger" if on else "")
self.adjust_button.style().polish(self.adjust_button)
self.adjust_hint.setText(
"자막 창을 끌어서 옮기고, 네 모서리를 잡아 크기를 바꾸세요. "
"크기를 바꾸면 글자 크기도 같이 따라옵니다."
if on
else "평소에는 자막이 잠겨 있어 게임 중에 실수로 움직이지 않습니다."
)
def set_font_size(self, size: int) -> None:
"""자막 창에서 크기를 바꿨을 때 설정 화면 숫자를 맞춘다."""
if size == self.size_spin.value():
return
self.size_spin.blockSignals(True)
self.size_spin.setValue(size)
self.size_spin.blockSignals(False)
self.config.subtitle.font_size = size
self._sync_preview()
def _build_preview_card(self) -> Card: def _build_preview_card(self) -> Card:
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.") card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
self.preview = SubtitlePreview(self.config.subtitle) self.preview = SubtitlePreview(self.config.subtitle)
@@ -238,6 +286,17 @@ class SubtitlePage(QWidget):
layout.addWidget(self.opacity_value) layout.addWidget(self.opacity_value)
form.addRow("배경 투명도", opacity_row) form.addRow("배경 투명도", opacity_row)
self.padding_spin = QSpinBox()
self.padding_spin.setRange(0, 80)
self.padding_spin.setSuffix(" px")
self.padding_spin.setValue(st.padding)
self.padding_spin.setToolTip(
"배경 상자는 글자를 감싸는 크기로 그려집니다. 이 값이 글자와 상자 "
"가장자리 사이 간격, 곧 배경 크기를 정합니다."
)
self.padding_spin.valueChanged.connect(self._on_change)
form.addRow("배경 여백", self.padding_spin)
self.align_combo = QComboBox() self.align_combo = QComboBox()
for label, value in (("가운데", "center"), ("왼쪽", "left"), ("오른쪽", "right")): for label, value in (("가운데", "center"), ("왼쪽", "left"), ("오른쪽", "right")):
self.align_combo.addItem(label, value) self.align_combo.addItem(label, value)
@@ -345,6 +404,7 @@ class SubtitlePage(QWidget):
st.outline_width = self.outline_spin.value() st.outline_width = self.outline_spin.value()
st.background_color = self.bg_color.color st.background_color = self.bg_color.color
st.background_opacity = self.opacity_slider.value() st.background_opacity = self.opacity_slider.value()
st.padding = self.padding_spin.value()
st.align = self.align_combo.currentData() st.align = self.align_combo.currentData()
st.max_lines = self.lines_spin.value() st.max_lines = self.lines_spin.value()
st.fade_out_ms = self.fade_spin.value() st.fade_out_ms = self.fade_spin.value()

View File

@@ -0,0 +1,75 @@
"""캡처 대상 목록 — 출력(게임 소리)과 입력(마이크) 전환."""
from __future__ import annotations
import pytest
from livesub import audio
from livesub.audio import (
SOURCE_MODE_INPUT,
SOURCE_MODE_OUTPUT,
AudioSource,
CaptureError,
InputDeviceCapture,
create_capture,
list_sources,
)
from livesub.config import AudioConfig
MIC = AudioSource(kind="input", identifier="3", label="마이크 (Realtek)", detail="기본 입력 장치")
SPEAKER = AudioSource(kind="device", identifier="7", label="스피커", detail="기본 출력 장치")
GAME = AudioSource(kind="process", identifier="4242", label="Overwatch.exe")
@pytest.fixture
def fake_devices(monkeypatch):
monkeypatch.setattr(InputDeviceCapture, "available", staticmethod(lambda: True))
monkeypatch.setattr(InputDeviceCapture, "list_sources", staticmethod(lambda: [MIC]))
monkeypatch.setattr(
audio.WasapiLoopbackCapture, "list_sources", staticmethod(lambda: [SPEAKER])
)
monkeypatch.setattr(
audio.ProcessLoopbackCapture, "available", staticmethod(lambda: True)
)
monkeypatch.setattr(
audio.ProcessLoopbackCapture, "list_sources", staticmethod(lambda: [GAME])
)
def test_기본은_프로그램과_출력_장치를_보여준다(fake_devices):
kinds = [s.kind for s in list_sources()]
assert kinds == ["process", "device"]
assert list_sources() == list_sources(SOURCE_MODE_OUTPUT)
def test_입력_모드는_마이크만_보여준다(fake_devices):
"""두 목록을 섞으면 찾으려던 게임이 라인입력 사이에 파묻힌다."""
sources = list_sources(SOURCE_MODE_INPUT)
assert [s.kind for s in sources] == ["input"]
assert sources[0].label == "마이크 (Realtek)"
def test_입력_장치도_장치_번호를_준다():
assert MIC.device_index == 3
assert MIC.pid == 0
def test_입력_백엔드로_캡처를_만든다(fake_devices):
cfg = AudioConfig(backend="input", device_index=3)
capture = create_capture(cfg)
assert isinstance(capture, InputDeviceCapture)
assert capture.device_index == 3
def test_입력_장치를_못_열면_이유를_알려준다(monkeypatch):
monkeypatch.setattr(InputDeviceCapture, "available", staticmethod(lambda: False))
with pytest.raises(CaptureError, match="입력 장치"):
create_capture(AudioConfig(backend="input", device_index=3))
def test_입력_가능_여부가_환경_보고에_들어간다():
assert "input" in audio.capture_capabilities()
def test_고른_목록_쪽을_설정에_기억한다():
assert AudioConfig().source_mode == SOURCE_MODE_OUTPUT

View 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()

37
tests/test_model_repos.py Normal file
View File

@@ -0,0 +1,37 @@
"""티어에 적힌 Hugging Face 저장소가 실제로 로그인 없이 받아지는지 확인한다.
저장소 이름이 틀리면 사용자 PC에서 "번역 시작"을 누를 때 401/404 로 처음 드러난다.
(v0.3.0-test4: 존재하지 않는 Systran/faster-whisper-large-v3-turbo 를 가리키고 있었다.)
네트워크가 없으면 건너뛴다.
"""
import pytest
from livesub.models.tiers import TIERS
huggingface_hub = pytest.importorskip("huggingface_hub")
def _repos() -> list[str]:
return sorted({r for t in TIERS.values() for r in (t.asr.repo, t.mt.repo)})
def _online() -> bool:
try:
huggingface_hub.HfApi().model_info("Systran/faster-whisper-small", token=False)
except Exception:
return False
return True
@pytest.mark.parametrize("repo", _repos())
def test_repo_is_public_and_has_weights(repo):
if not _online():
pytest.skip("Hugging Face 에 접속할 수 없음")
info = huggingface_hub.HfApi().model_info(repo, token=False)
assert not info.private
assert not info.gated
files = {s.rfilename for s in info.siblings}
assert files & {"model.bin", "model.safetensors"} or any(
f.endswith(".safetensors") for f in files
), f"{repo}: 가중치 파일이 없음 ({sorted(files)})"

View File

@@ -190,3 +190,24 @@ def test_ct2_티어는_gpu_조회_없이도_통과한다():
assert tier_availability(TIERS[key])[0] assert tier_availability(TIERS[key])[0]
finally: finally:
mgr.detect_gpu = original mgr.detect_gpu = original
def test_진입_스크립트는_패키지_없이도_임포트된다():
"""PyInstaller 는 진입 스크립트를 **패키지 없이** 최상위로 실행한다.
그래서 그 파일에 상대 임포트가 하나라도 있으면 exe 가 켜지자마자
`attempted relative import with no known parent package` 로 죽는다.
실제로 그 상태의 exe 가 릴리스까지 나갔고, "8초 뒤에도 살아 있으면 통과"
였던 점검은 오류창이 떠 있는 것을 정상으로 봤다.
`runpy.run_path` 가 바로 그 조건(패키지 문맥 없음)을 만들어준다.
run_name 을 `__main__` 이 아닌 것으로 주면 GUI 는 뜨지 않고 임포트만 돈다.
"""
import runpy
root = SPEC.resolve().parents[1]
entry = root / "src" / "livesub" / "__main__.py"
# spec 이 실제로 이 파일을 진입점으로 쓰는지 확인한다. 아니면 엉뚱한 파일을
# 지키고 있는 셈이 된다.
assert '"livesub" / "__main__.py"' in SPEC.read_text(encoding="utf-8")
runpy.run_path(str(entry), run_name="__livesub_frozen_probe__")

View File

@@ -1,8 +1,30 @@
import numpy as np import numpy as np
import pytest
from livesub.audio import segmenter as segmenter_mod
from livesub.audio.segmenter import Segmenter, SegmenterConfig from livesub.audio.segmenter import Segmenter, SegmenterConfig
from livesub.constants import SAMPLE_RATE from livesub.constants import SAMPLE_RATE
#: webrtcvad 는 선택 의존성이다. 있으면 아래 게이트 테스트가 돌고 없으면 skip 된다.
REAL_WEBRTCVAD = segmenter_mod.webrtcvad
@pytest.fixture(autouse=True)
def _energy_vad_only(monkeypatch):
"""이 파일의 테스트는 에너지 VAD 경로로 고정한다.
입력이 220Hz 순음이기 때문이다. webrtcvad 는 사람 목소리로 학습돼 있어
순음을 발화로 보지 않고, `_is_speech` 는 에너지 판정과 webrtcvad 를 AND 로
묶는다. 그래서 webrtcvad 가 깔린 환경에서는 이 파일의 테스트 5개가 깨졌다 —
포터블 exe 의 requirements 에 webrtcvad-wheels 가 들어 있어 Windows CI 가
정확히 그 환경이고, 개발용 venv 에는 없어서 여태 안 드러났다.
고칠 대상은 판정기가 아니라 테스트의 숨은 전제다. 여기서 보려는 건 분할
상태 기계이므로 선택 의존성을 끊어 환경과 무관하게 같은 경로를 시험한다.
게이트 동작 자체는 test_webrtcvad_gate_filters_pure_tone 이 본다.
"""
monkeypatch.setattr(segmenter_mod, "webrtcvad", None)
def tone(ms: int, amplitude: float = 0.3) -> np.ndarray: def tone(ms: int, amplitude: float = 0.3) -> np.ndarray:
n = SAMPLE_RATE * ms // 1000 n = SAMPLE_RATE * ms // 1000
@@ -76,3 +98,24 @@ def test_lead_in_is_prepended():
assert len(finals) == 1 assert len(finals) == 1
# 0.6초 발화 + 최대 0.2초 리드인 # 0.6초 발화 + 최대 0.2초 리드인
assert finals[0].duration_s > 0.6 assert finals[0].duration_s > 0.6
@pytest.mark.skipif(REAL_WEBRTCVAD is None, reason="webrtcvad 미설치 — 게이트 경로가 없다")
def test_webrtcvad_gate_filters_pure_tone(monkeypatch):
"""webrtcvad 가 깔리면 순음은 에너지 VAD 만일 때보다 덜 통과한다.
포터블 exe 와 Windows CI 가 이 경로를 쓴다. 두 경로가 실제로 다르게
동작한다는 사실을 테스트로 고정해 둔다 — 이게 깨져 있는 걸 몰라서
같은 테스트가 플랫폼에 따라 다른 결과를 냈다.
"""
energy_only = Segmenter(config())
energy_out = energy_only.push(tone(800)) + energy_only.push(silence(600))
monkeypatch.setattr(segmenter_mod, "webrtcvad", REAL_WEBRTCVAD)
gated = Segmenter(config())
gated_out = gated.push(tone(800)) + gated.push(silence(600))
energy_total = sum(s.duration_s for s in energy_out if s.is_final)
gated_total = sum(s.duration_s for s in gated_out if s.is_final)
assert energy_total > 0.7 # 에너지 VAD 는 순음을 발화로 본다
assert gated_total < energy_total # webrtcvad 는 사람 목소리가 아니라고 걸러낸다

124
tests/test_storage.py Normal file
View File

@@ -0,0 +1,124 @@
"""저장 위치 선택 — 고른 곳에 실제로 쌓이고, 다음 실행 때 그대로 따라가는지."""
from __future__ import annotations
import json
import pytest
from livesub import storage
from livesub.config import AppConfig, config_path
from livesub.constants import models_dir, user_data_dir
@pytest.fixture
def os_dir(tmp_path, monkeypatch):
"""OS 표준 설정 폴더(쪽지가 놓이는 곳)를 임시 폴더로 바꾼다."""
base = tmp_path / "appdata" / "LiveSub"
monkeypatch.setattr(storage, "_os_config_dir", lambda: base)
return base
def test_first_run_is_not_configured(os_dir):
assert storage.is_configured() is False
assert storage.stored_data_dir() is None
def test_choice_is_remembered(os_dir, tmp_path):
chosen = tmp_path / "D" / "LiveSub"
storage.set_data_dir(chosen)
assert storage.is_configured() is True
assert storage.stored_data_dir() == chosen.resolve()
assert storage.data_dir() == chosen.resolve()
def test_everything_lands_in_the_chosen_folder(os_dir, tmp_path):
"""쪽지만 OS 폴더에 남고 내용물은 고른 곳으로 가야 한다."""
chosen = tmp_path / "games" / "LiveSub"
storage.set_data_dir(chosen)
assert user_data_dir() == chosen.resolve()
assert config_path().parent == chosen.resolve()
assert models_dir() == chosen.resolve() / "models"
AppConfig().save()
assert (chosen / "config.json").is_file()
assert not (os_dir / "config.json").exists()
assert list(os_dir.iterdir()) == [os_dir / storage.POINTER_NAME]
def test_existing_install_is_not_asked_again(os_dir):
"""위치 선택이 없던 버전에서 올라온 사람에게 다시 묻지 않는다."""
os_dir.mkdir(parents=True)
(os_dir / "models").mkdir()
assert storage.is_configured() is True
assert storage.stored_data_dir() is None # 쪽지는 없지만
assert storage.data_dir() == os_dir # 쓰던 자리를 그대로 쓴다
def test_broken_pointer_falls_back_to_default(os_dir):
os_dir.mkdir(parents=True)
(os_dir / storage.POINTER_NAME).write_text("{ 깨진 json", encoding="utf-8")
assert storage.stored_data_dir() is None
assert storage.data_dir() == os_dir
def test_unusable_location_falls_back_instead_of_crashing(os_dir, tmp_path):
"""고른 디스크가 빠졌거나 경로가 막혔을 때 죽지 않고 기본 위치로 물러난다."""
blocker = tmp_path / "blocker"
blocker.write_text("파일이라 하위 폴더를 만들 수 없다", encoding="utf-8")
os_dir.mkdir(parents=True)
(os_dir / storage.POINTER_NAME).write_text(
json.dumps({"data_dir": str(blocker / "LiveSub")}), encoding="utf-8"
)
assert storage.data_dir() == os_dir
def test_check_target_rejects_unusable_choices(tmp_path):
current = tmp_path / "current"
current.mkdir()
assert storage.check_target("") is not None
assert storage.check_target(current / "inside", current) is not None # 자기 하위
assert storage.check_target(current, current) is None # 같은 곳은 문제 아님
assert storage.check_target(tmp_path / "new") is None # 아직 없어도 만들 수 있으면 OK
def test_move_data_carries_models_and_config(tmp_path):
old, new = tmp_path / "old", tmp_path / "new"
(old / "models" / "whisper").mkdir(parents=True)
(old / "models" / "whisper" / "model.bin").write_bytes(b"x" * 16)
(old / "config.json").write_text("{}", encoding="utf-8")
(old / "livesub.log").write_text("로그는 안 옮긴다", encoding="utf-8")
moved = storage.move_data(old, new)
assert sorted(moved) == ["config.json", "models"]
assert (new / "models" / "whisper" / "model.bin").read_bytes() == b"x" * 16
assert not (old / "models").exists()
assert (old / "livesub.log").is_file() # 실행 중 잠겨 있으므로 제외 대상
def test_move_data_never_overwrites_existing(tmp_path):
old, new = tmp_path / "old", tmp_path / "new"
old.mkdir()
new.mkdir()
(old / "config.json").write_text("옛날 것", encoding="utf-8")
(new / "config.json").write_text("새 위치에 이미 있던 것", encoding="utf-8")
assert storage.move_data(old, new) == []
assert (new / "config.json").read_text(encoding="utf-8") == "새 위치에 이미 있던 것"
assert (old / "config.json").is_file()
def test_move_data_to_same_place_does_nothing(tmp_path):
same = tmp_path / "same"
same.mkdir()
(same / "config.json").write_text("{}", encoding="utf-8")
assert storage.move_data(same, same) == []
assert (same / "config.json").is_file()

149
tests/test_subtitle_box.py Normal file
View File

@@ -0,0 +1,149 @@
"""자막 배경 상자(padding)와 위치 조정 모드."""
from __future__ import annotations
import os
import pytest
pytest.importorskip("PySide6")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QPoint, QRect # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402
from livesub.config import OverlayConfig, SubtitleStyle # noqa: E402
from livesub.ui.overlay import SubtitleOverlay, _content_box # noqa: E402
WINDOW = QRect(0, 0, 1000, 400)
@pytest.fixture(scope="session")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
# --- 배경 상자 -------------------------------------------------------------
def test_배경은_글자를_감싼다():
"""창 전체가 아니라 글자 크기에 맞춰야 여백이 곧 배경 크기가 된다."""
box = _content_box(WINDOW, text_w=300, text_h=60, pad=18, align="center")
assert box.width() == 300 + 18 * 2
assert box.height() == 60 + 18 * 2
assert box.width() < WINDOW.width()
def test_여백을_키우면_배경이_커진다():
small = _content_box(WINDOW, 300, 60, pad=8, align="center")
large = _content_box(WINDOW, 300, 60, pad=40, align="center")
assert large.width() - small.width() == (40 - 8) * 2
assert large.height() - small.height() == (40 - 8) * 2
def test_배경은_창_밖으로_넘지_않는다():
box = _content_box(WINDOW, text_w=5000, text_h=5000, pad=40, align="center")
assert box.width() == WINDOW.width()
assert box.height() == WINDOW.height()
@pytest.mark.parametrize(
("align", "expected_left"),
[("left", 0), ("center", (1000 - 336) // 2), ("right", 1000 - 336)],
)
def test_정렬에_따라_상자가_붙는_쪽이_달라진다(align, expected_left):
box = _content_box(WINDOW, text_w=300, text_h=60, pad=18, align=align)
assert box.width() == 336
assert abs(box.x() - expected_left) <= 1
def test_기본_여백이_설정에_있다():
assert SubtitleStyle().padding > 0
# --- 위치 조정 모드 --------------------------------------------------------
@pytest.fixture
def overlay(qapp):
cfg = OverlayConfig()
cfg.visible = False
widget = SubtitleOverlay(SubtitleStyle(), cfg)
yield widget
widget.close()
def test_기본은_잠겨_있어_움직이지_않는다(overlay):
"""게임 중에 자막을 실수로 끌고 다니는 사고를 막는 기본값이다."""
assert OverlayConfig().locked is True
assert overlay.edit_mode is False
assert overlay._interactive is False
def test_위치_조정을_켜면_잡을_수_있다(overlay):
overlay.set_edit_mode(True)
assert overlay.edit_mode is True
assert overlay._interactive is True
# 클릭 통과가 켜져 있으면 마우스가 창을 그냥 지나가 잡을 수가 없다.
from PySide6.QtCore import Qt
assert not overlay.testAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
overlay.set_edit_mode(False)
assert overlay._interactive is False
def test_모서리를_집었는지_알아본다(overlay):
overlay.setGeometry(0, 0, 600, 200)
assert overlay._corner_at(QPoint(2, 2)) == "topleft"
assert overlay._corner_at(QPoint(598, 2)) == "topright"
assert overlay._corner_at(QPoint(2, 198)) == "bottomleft"
assert overlay._corner_at(QPoint(598, 198)) == "bottomright"
# 가운데는 이동용이지 크기 조절이 아니다
assert overlay._corner_at(QPoint(300, 100)) == ""
def test_크기를_키우면_글자도_같이_커진다(overlay):
overlay.set_edit_mode(True)
overlay.setGeometry(100, 100, 600, 200)
overlay.style_cfg.font_size = 40
overlay._resize_corner = "bottomright"
overlay._resize_geo = QRect(overlay.geometry())
overlay._resize_mouse = QPoint(700, 300)
overlay._resize_font = 40
overlay._resize_to(QPoint(700, 500)) # 높이 200 -> 400
assert overlay.height() == 400
assert overlay.style_cfg.font_size == 80
def test_반대쪽_모서리는_제자리에_있는다(overlay):
"""왼쪽 위를 끌면 오른쪽 아래는 움직이지 않아야 한다."""
overlay.set_edit_mode(True)
overlay.setGeometry(200, 200, 600, 200)
start = QRect(overlay.geometry())
overlay._resize_corner = "topleft"
overlay._resize_geo = start
overlay._resize_mouse = QPoint(200, 200)
overlay._resize_font = overlay.style_cfg.font_size
overlay._resize_to(QPoint(300, 250))
assert overlay.geometry().right() == start.right()
assert overlay.geometry().bottom() == start.bottom()
assert overlay.geometry().x() == 300
def test_최소_크기_아래로는_줄지_않는다(overlay):
overlay.set_edit_mode(True)
overlay.setGeometry(0, 0, 600, 200)
overlay._resize_corner = "bottomright"
overlay._resize_geo = QRect(overlay.geometry())
overlay._resize_mouse = QPoint(600, 200)
overlay._resize_font = overlay.style_cfg.font_size
overlay._resize_to(QPoint(-5000, -5000))
assert overlay.width() >= overlay.minimumWidth()
assert overlay.height() >= overlay.minimumHeight()

View File

@@ -49,3 +49,21 @@ def test_unknown_key_falls_back_to_default():
def test_default_tier_exists(): def test_default_tier_exists():
assert DEFAULT_TIER in TIERS assert DEFAULT_TIER in TIERS
def test_gpu_가_없으면_cpu_에서_도는_티어를_추천한다():
"""표에 박힌 '추천'은 GPU 가 넉넉할 때 얘기다. 없는 사람에게 10GB 짜리를
권하면 안내가 아니라 함정이 된다."""
from livesub.models.manager import GpuInfo, recommended_tier
key = recommended_tier(GpuInfo(False, reason="GPU 없음"))
tier = get_tier(key)
assert tier.mt.backend is MTBackend.CTRANSLATE2
assert tier.order == 1 # CPU 에서는 연산량이 그대로 지연이 된다
def test_vram_이_모자라면_추천도_내려간다():
from livesub.models.manager import GpuInfo, recommended_tier
small = get_tier(recommended_tier(GpuInfo(True, "RTX 5050", 7680, 7000)))
assert small.min_vram_gb <= 7.5