23 Commits

Author SHA1 Message Date
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
EJClaw
f9fd86464d fix: .gitignore 가 소스 패키지 src/livesub/models 를 통째로 먹고 있었다
Some checks failed
Windows / verify (push) Has been cancelled
`models/` 처럼 슬래시 없이 쓴 규칙은 깊이에 상관없이 같은 이름의 폴더를 전부
무시한다. 의도는 루트의 모델 캐시였는데 소스 패키지까지 같이 걸렸고, 그래서
asr/translator/manager/tiers/glossary/packs/speech_level 8개 파일이 한 번도
커밋된 적이 없다.

증상: Gitea 에서 clone 하면 `ModuleNotFoundError: No module named
'livesub.models'` 로 앱이 아예 임포트되지 않는다. 로컬 작업 트리에는 파일이
있으니 개발 중에는 절대 안 보인다. 방금 붙인 Windows CI VM 이 첫 검증에서
바로 잡아냈다.

/models/, /adapters/, /data/ 로 루트에 고정하고 빠진 파일을 추가한다.
2026-09-25 23:01:31 +09:00
EJClaw
ae295dc308 fix: 무인 프로비저닝이 통째로 안 돌던 인코딩/파이프라인 결함 3건
실제 설치에서 드러난 것들이다. 셋 다 증상이 원인과 전혀 안 닮아서 기록해 둔다.

1) provision.ps1 이 1행부터 파싱 실패 - 'Missing closing }'
   Windows PowerShell 5.1 은 BOM 없는 .ps1 을 ANSI(CP1252)로 읽는다.
   한글 주석의 UTF-8 바이트가 CP1252 에서 스마트따옴표(0x93/0x94 = " ")로
   변하고, 파서가 그걸 문자열 시작으로 보고 뒤를 전부 삼킨다.
   -> build.sh 가 ISO 에 넣을 때 UTF-8 BOM 을 붙인다.

2) stage2.ps1 의 1행이 주석으로 인식되지 않음
   agent 가 받은 본문에 BOM 이 있으면 Set-Content -Encoding UTF8 이 BOM 을
   한 번 더 붙여 BOM 이 두 개가 된다. PowerShell 은 첫 개만 벗겨내고 남은
   U+FEFF 때문에 '#' 이 줄 맨 앞이 아니게 된다.
   -> agent 가 TrimStart(U+FEFF), 서버는 charset=utf-8 을 명시한다.

3) Git 설치가 'Cannot convert System.Object[] to System.String' 로 실패
   Log 가 Write-Output 이라 함수 안에서 부르면 그 함수의 반환값에 로그가
   섞인다. Get-File 이 배열을 돌려주니 Start-Process 가 죽었다.
   -> Log 를 Write-Host 로 바꿨다.

덤으로 시리얼/HTTP 로그의 한글이 '?' 로 깨지던 것도 고쳤다 (SerialPort 기본
인코딩이 ASCII). 그리고 러너 토큰이 없어도 워크플로와 같은 단계를 VM 안에서
직접 돌리는 검증 절차를 stage2 에 붙였다.
2026-09-25 22:50:08 +09:00
EJClaw
e41d0cb441 ci: .9 안 Windows CI VM 무인 구축 자동화
Windows 설치 화면을 사람이 클릭할 수 없으므로 전 과정을 무인으로 짰다.
autounattend.xml 이 설치와 OOBE 를 끝내고, provision.ps1 이 virtio 드라이버를
오프라인으로 넣어 네트워크를 살리고, 상주 agent.ps1 이 호스트 HTTP 에서
stage2.ps1 을 받아 Git/pwsh7/Python/Node/CMake/act_runner 를 설치한다.

설치 내용을 ISO 에 박지 않고 호스트가 HTTP 로 내려주는 구조로 한 이유는
ISO 를 한 번 구우면 못 고쳐서, 한 줄 고칠 때마다 Windows 재설치가 되기
때문이다. stage2.ps1 만 고치면 게스트가 20초 안에 반영한다.

- 시스템 디스크는 SATA. virtio-blk 로 하면 Setup 이 디스크를 못 봐서
  WinPE 드라이버 주입이 필요해진다
- VM 은 systemd --user 트랜지언트 유닛으로 띄워 봇 cgroup 밖에 둔다
  (MemoryHigh=16G 안에 8G VM 이 들어가면 봇이 OOM 으로 죽는 전례)
- Node 포함. act_runner host 모드가 JS 액션을 PATH 의 node 로 돌린다
2026-09-25 22:13:55 +09:00
EJClaw
d0028abdac docs: 100% 부하 실측 — .9 CPU 증설은 오히려 해롭다 (이전 권고 정정)
Some checks failed
Windows / verify (push) Has been cancelled
"VM 이 실제 윈도우와 다른가, 100% 부하에서도 자원이 괜찮은가" 에 대한 답.
앞선 측정은 한가할 때 값이라 부하를 걸어 다시 쟀고, 결론이 하나 바뀌었다.

이전에 "여유용으로 vCPU 12까지 가능" 이라고 적었는데 틀렸다. 늘리면 .5 의
운영 컨테이너 19개가 느려진다. 물리 호스트가 i7-11700 16스레드뿐이라
.9 의 지분이 커지는 만큼 .5 가 밀린다. .5 응답 속도 실측:

  .9 한가할 때        616~628ms   기준
  .9 가 4코어 사용    657~678ms   +7%    <- VM 100% 인 실제 상황, 무시 가능
  .9 가 8코어 전부   1487~1491ms  +140%  <- 봇까지 동시에 바쁠 때

VM 4 vCPU 는 안전하다. .9 내부 영향도 작다 (파이프라인 9.8 -> 11.0초).
최악(8코어 전부)을 막으려면 CPUQuota=400% + CPUWeight=50 으로 띄운다.
CPUQuota=200% 로 재봤을 때 .5 응답이 649~653ms 로 기준선과 같았다.

RAM 은 최악을 다 더해도 21.6GB / 30GB 로 8.4GB 여유가 있다.
QEMU 오버헤드는 -m 8192 기준 VmSize 8.22GB 로 약 0.2GB.

VM 의 Windows 가 실제와 다른지도 정리했다. 같은 바이너리이고 WASAPI,
RegisterHotKey, 모니터 열거, PyInstaller 모두 코드 경로가 동일하다.
다른 건 GPU 없음, 실제 게임 소리 없음, 속도/지연 세 가지뿐이다.

부수 수확: virtio-vga,max_outputs=2 가 KVM 으로 기동되는 것을 확인했다.
--vga std 는 화면이 1개라 모니터 선택 기능을 검증할 수 없는데, 이걸 쓰면
화면 2개를 줄 수 있어 사용자가 요청한 모니터 선택 기능도 자동 검증 가능하다
(게스트에 virtio-gpu 드라이버 필요).

결론: CPU·RAM 모두 증설 없이 진행. RAM 만 원하면 38GB 까지 가능. CPU 는 금지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 04:05:31 +09:00
EJClaw
fb88cea427 docs: .9 안 Windows VM 크기와 호스트 증설 필요량을 실측으로 확정
Some checks failed
Windows / verify (push) Has been cancelled
"VM 만들기 전에 CPU/RAM 을 얼마나 늘려줄까" 에 대한 답. 감이 아니라 재봤다.

코어는 4개에서 포화한다. 더 줘도 빨라지지 않는다.
  파이프라인(음성->자막)  2코어 13초 / 4코어 10초 / 8코어 11초
  테스트 190개            4코어 5.9초 / 8코어 5.8초
CI 작업 대부분이 단일 스레드고, 병렬인 음성인식도 4개에서 멈춘다.

물리 호스트는 i7-11700(8코어/16스레드) / RAM 62.6GB.
호스트 스펙은 Proxmox 에 SSH 가 안 되어 .5 LXC 를 통해 확인했다.
/proc/meminfo 는 lxcfs 가 컨테이너 한도로 가려서 16GB 로 보이지만
/sys/devices/system/node/node0/meminfo 는 가려지지 않아 실제 값이 나온다.

.9 는 그중 8 vCPU / 30GB 를 쓰고 있고, 실측상 증설이 거의 불필요하다.
  ejclaw 최대 7.2GB (상한 16GB 는 여유분) + VM 8GB = 15.2GB / 30GB
  코어 8개면 VM 4 + 봇 4 로 충분

결론: VM 은 vCPU 4 / RAM 8GB / 디스크 80GB. 호스트는 그대로 둬도 되고,
여유를 두려면 vCPU 12 / RAM 38GB 가 상한이다 (호스트 16스레드 중 .5 가
4개를 쓰고, 호스트 여유 RAM 이 페이지 캐시 포함 약 24GB 이므로).
.5 에서 운영 컨테이너 19개가 도는 점도 같이 적어뒀다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:59:53 +09:00
EJClaw
8a86e7e7ac fix: 티어 게이트가 실제 제약(autoawq·VRAM)을 보도록 수정
Some checks failed
Windows / verify (push) Has been cancelled
실제 GPU 로 검증을 돌리다 발견한 버그다. tier_availability() 가 torch 유무만
봐서, RTX 5050(7.5GB)에서도 정밀·극한 티어를 "사용 가능"이라고 답했다.

  정밀(AWQ Int4)  autoawq 가 있어야 로드된다. requirements.txt 에 없는
                  선택 의존성이라 대개 없다.
  극한(Qwen3-8B)  min_vram_gb=16. 7.5GB 카드에서는 영영 불가능하다.

둘 다 "고를 수 있다"고 해놓고 로드에서 죽는다. 사용자는 원인을 알 수 없다.
UI 에 min_vram_gb 경고 배지는 있었지만 경고만 하고 선택은 막지 않았다.

- tier_availability(tier, gpu=None) 에 autoawq·VRAM 검사 추가.
  새 필드를 만들지 않고 이미 있던 min_vram_gb 를 쓴다.
  free 가 아니라 total 로 본다 — free 로 막으면 다른 프로그램 때문에
  "아까는 되던 티어가 지금은 안 보인다"가 된다.
  CT2 티어(1~3)는 조기 반환이라 detect_gpu 를 아예 부르지 않는다.
  (포터블에서 torch 도 GPU 도 없이 돌아야 하므로)

- GpuInfo 를 인자로 받아 루프에서 nvidia-smi 반복 호출을 막았다.
  models_page 는 티어 카드 5개를 만들며 매번 조회하던 것을 __init__ 에서
  한 번만 하도록 바꿨다. 빌더 함수의 부수효과에 의존하던 것도 제거.

- 점검기의 판정 기준도 고쳤다. 기존엔 "4·5티어가 전부 열려야 통과"였는데
  VRAM 작은 GPU 에서 막히는 건 정상이라 오탐이었다. 이제 게이트는 판정
  없이 보고만 하고, "게이트가 된다고 한 티어가 실제로 올라가는지" 가
  판정한다. 게이트가 전부 막았으면 검증 대상이 없다는 사실을 남기고
  통과시킨다 — 하드웨어 한계이지 회귀가 아니다.

- scripts/gpu-check.sh 추가. CTranslate2 가 libcublas.so.12 를 직접 찾는데
  torch 의 nvidia 패키지 안에 있어 LD_LIBRARY_PATH 가 필요하다. 없으면
  GPU 번역만 조용히 실패한다. 스크립트가 경로를 자동으로 잡는다.

검증: pytest 190 passed (신규 4개 중 3개는 torch 필요 -> gpu-venv 에서
      실제로 실행해 9 passed 확인), ruff clean,
      RTX 5050 에서 scripts/gpu-check.sh 6/6 통과 (종료코드 0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:55:22 +09:00
EJClaw
48b7106ac6 docs: .9 안 중첩 Windows VM 가능 여부를 실측으로 정리
Some checks failed
Windows / verify (push) Has been cancelled
"Proxmox 말고 .9 안에서 Windows VM 을 돌릴 수 없나" 에 대한 답.

가능하다. 주장이 아니라 실제로 띄워서 확인했다.
  /dev/kvm 존재 / kvm_intel nested=Y
  qemu -enable-kvm -> "kvm support: enabled"
  여유 RAM 25GB, 디스크 294GB (8G VM 수용 가능)

가장 큰 이점은 성능이 아니라 **사람 손이 안 간다는 것**이다. .9 안이라
에이전트가 VM 생성부터 러너 등록까지 직접 할 수 있고 Proxmox 자격증명이
필요 없다. 지금까지 막혀 있던 지점이 정확히 그거였다.

다만 GPU 문제는 전혀 해결되지 않는다.
  IOMMU 그룹 0개, 커널 cmdline 에 iommu 옵션 없음 -> 중첩 패스스루 불가
되게 하려면 Proxmox 가 vIOMMU 를 열고 .9 커널에 intel_iommu=on 을 넣고
GPU 를 vfio-pci 에 묶어야 하는데, 그러면 .9 가 GPU 를 잃는다. 결국
"GPU 를 누가 가질 것인가" 로 되돌아온다.

운영상 함정 하나를 실측으로 확인해 문서에 박아뒀다. 에이전트 턴에서 그냥
qemu 를 띄우면 VM 이 ejclaw.service cgroup(MemoryHigh=16G, 평시 5G 사용)
안에서 돌아 8G VM 이 상한을 치고 봇을 OOM 으로 죽인다(전례 있음).
systemd-run --user --scope 를 쓰면 app.slice 형제 scope 로 빠지는 것을
확인했다 — 독립 상한이 실제로 적용됨.

A(.9 중첩) vs B(Proxmox) 비교표 추가. CI 타임아웃 30분에 파이프라인이
27초라 중첩 오버헤드는 문제되지 않으므로 A 를 먼저 권한다.

부수 변경(이 저장소 밖): .9 에 qemu-system-x86/ovmf 설치, claude 계정을
kvm 그룹에 추가. /dev/kvm 이 root:kvm 0660 이라 권한이 없으면 qemu 가
"Could not access KVM kernel module: Permission denied" 로 죽는다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:49:55 +09:00
EJClaw
88b9d931eb ci: GPU 러너 동시 실행 차단 + 티어 검증을 실제 로드로 교체
Some checks failed
Windows / verify (push) Has been cancelled
러너를 여러 프로젝트가 공유한다는 전제에서 두 가지 구멍이 있었다.

1) 동시 실행 — GPU 는 나눠 쓸 수 없다. 두 작업이 같이 VRAM 을 잡으면
   둘 다 OOM 이거나, 더 나쁘게는 하나가 조용히 느려져 원인을 못 찾는다.
   concurrency(같은 저장소) + 러너 capacity:1(저장소 무관, 진짜 보장)을
   두 겹으로 두고, 문서에 capacity 확인을 필수 절차로 넣었다.
   한 대에 러너 프로세스를 여러 개 띄우면 안 된다는 것도 명시.

2) 티어 검증이 주장보다 얕았다 — "4·5티어 검증"이라 해놓고 실제로는
   tier_availability() 만 봤고, 그 함수는 torch 유무만 본다. 즉

     - 4티어(Seed-X AWQ Int4)는 autoawq 가 없어도 "사용 가능"
     - 5티어(Qwen3-8B)는 VRAM 11GB 가 필요한데 8GB 카드에서도 "사용 가능"

   게이트가 통과인데 실제로는 못 올리는 상태를 CI 가 못 잡고 있었다.
   그래서 게이트 검사는 얕은 검사임을 이름과 주석에 명시하고, 그 옆에
   **진짜로 모델을 올려 번역까지 하는** 항목을 새로 뒀다. 러너 GPU 가
   무엇일지 모르므로 여유 VRAM 과 autoawq 유무로 후보를 거른 뒤 올릴 수
   있는 것 중 가장 무거운 티어를 고르고, 건너뛴 티어와 그 이유를 결과에
   남긴다. 하나도 못 올리면 실패다 (조용히 통과하지 않는다).

   게이트는 통과인데 실제 로드가 실패하면 그게 찾아야 할 버그다 — 앱이
   사용자에게 "쓸 수 있다"고 하고선 못 올리는 상황. 러너가 붙으면 실제
   증거를 보고 tier_availability() 를 고칠지 판단한다.

- autoawq 설치 단계 추가(4티어 전용, Windows 휠이 없을 때가 있어 선택)
- 1~3티어 CUDA 경로 번역 검사는 유지하고 unload 를 finally 로 보장

검증: pytest 189개 통과, ruff clean, 워크플로 파싱·concurrency 확인,
      --gpu 를 torch 없는 환경에서 실행해 6개 중 5개가 의도대로 실패

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:47:15 +09:00
EJClaw
bb385c1d7b ci: GPU 검증을 별도 러너 라벨로 분리 (windows-gpu)
Some checks failed
Windows / verify (push) Has been cancelled
GPU 는 한 VM 만 독점한다 — 컨슈머 NVIDIA 는 vGPU/SR-IOV 를 지원하지 않아
쪼개 쓸 방법이 없고, 지금 RTX 5050 은 .9(리눅스)에 물려 있다. 따라서 GPU
없는 상시 CI VM 에서는 CUDA·VRAM·4~5티어를 영영 확인할 수 없다.

코드로 우회할 수 없는 제약이므로, 대신 **나중에 GPU 머신이 생겼을 때 그대로
붙도록** 구조를 지금 잡아뒀다. 이 러너를 다른 프로젝트 GPU 테스트에도 쓸
계획이라면 이 구분이 그쪽에도 그대로 적용된다.

  windows:host      GPU 없는 서버 VM   main push 마다 자동
                    테스트·Windows 경로·오디오→자막·포터블 exe
  windows-gpu:host  GPU 달린 머신      수동 실행만
                    CUDA 인식·VRAM 상한·4·5티어·GPU 번역

- windows_smoke.py --gpu
  GPU 있는 머신에서만 의미 있는 항목만 모았다. CUDA 가용성, 앱의 GPU 인식,
  VRAM 상한이 실제로 걸리는지, torch 필요 티어(4·5)가 열리는지, GPU 로 실제
  번역까지. 다른 모드와 달리 **GPU 부재를 치명적 실패로 처리**한다 —
  GPU 를 확인하겠다고 부른 작업이 조용히 통과하면 안 되기 때문이다.

- .gitea/workflows/windows-gpu.yml
  수동 실행 전용. 러너가 없으면 큐에 쌓이기만 하고 아무 일도 없다.
  포터블 CI 와 달리 torch CUDA 빌드를 설치하고, 설치 직후 CPU 빌드가
  깔리지 않았는지 확인한다. nvidia-smi 가 없으면 그 자리에서 멈춘다.

- 문서에 두 러너 구조와 GPU 머신 확보 3가지 방안(서버에 GPU 추가 /
  .9 에서 옮기기 / 필요할 때만 실물 PC) + 슬롯 확인 명령 추가.

검증: pytest 189개 통과, ruff clean, 두 워크플로 파싱·라벨·트리거 확인,
      --gpu 를 torch 없는 환경에서 실행해 의도대로 실패(종료코드 1)하는지 확인

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:40:01 +09:00
48 changed files with 4590 additions and 56 deletions

View File

@@ -0,0 +1,88 @@
# Windows GPU 검증 — GPU 가 달린 러너에서만
#
# GPU 는 한 VM 만 독점한다(컨슈머 NVIDIA 는 vGPU 미지원). 그래서 GPU 없는
# 상시 CI VM(runs-on: windows)에서는 CUDA·VRAM·4~5티어를 확인할 수 없다.
# 그 항목만 떼어내 별도 라벨로 돌린다.
#
# 러너 라벨: windows-gpu:host
# - GPU 가 달린 Windows 머신에 등록한다 (두 번째 GPU를 꽂은 VM, 또는 실물 PC)
# - 상시로 띄울 필요 없다. 필요할 때만 daemon 을 켜면 된다
#
# 그래서 트리거도 **수동 실행만** 이다. 러너가 없으면 큐에 쌓이기만 하고
# 아무 일도 일어나지 않는다. 자동으로 돌리고 싶으면 push 트리거를 켜면 된다.
name: Windows GPU
on:
workflow_dispatch:
# GPU 는 나눠 쓸 수 없다. 두 작업이 동시에 VRAM 을 잡으면 둘 다 OOM 으로
# 죽거나, 더 나쁘게는 하나가 조용히 느려져 원인을 못 찾는다.
# 이건 같은 저장소 안에서만 막아준다. **다른 저장소까지 포함한 진짜 보장은
# 러너의 capacity: 1** 이다 (docs/WINDOWS-TESTING.md 참고).
concurrency:
group: windows-gpu
cancel-in-progress: false
jobs:
gpu:
runs-on: windows-gpu
timeout-minutes: 90
steps:
# 라벨이 안 맞으면 Gitea 가 기본 ubuntu 컨테이너로 떨어뜨린다.
# GPU 를 확인하겠다고 부른 작업이 엉뚱한 데서 도는 일은 막아야 한다.
- name: Windows 인지 확인
shell: pwsh
run: |
Write-Host "RUNNER_OS=$env:RUNNER_OS"
if ($env:RUNNER_OS -ne "Windows") {
Write-Host "::error::Windows 러너가 아닙니다 ($env:RUNNER_OS)."
Write-Host "::error::러너를 'windows-gpu:host' 라벨로 등록했는지 확인하세요."
exit 1
}
- name: NVIDIA GPU 가 실제로 보이는지
shell: pwsh
run: |
nvidia-smi --query-gpu=name,memory.total --format=csv,noheader
if ($LASTEXITCODE -ne 0) {
Write-Host "::error::nvidia-smi 실패 — 이 러너에는 GPU 가 없습니다."
exit 1
}
- name: 소스 받기
uses: actions/checkout@v4
# 포터블 CI 와 달리 여기서는 torch CUDA 빌드가 필요하다.
# 4~5티어(Seed-X / Qwen3)가 torch 위에서 돌기 때문이다.
- name: 의존성 설치 (torch CUDA 포함)
shell: pwsh
run: |
python -m venv .venv-gpu
.\.venv-gpu\Scripts\python -m pip install --upgrade pip --quiet
.\.venv-gpu\Scripts\python -m pip install torch --index-url https://download.pytorch.org/whl/cu128
.\.venv-gpu\Scripts\python -m pip install -r requirements.txt
.\.venv-gpu\Scripts\python -c "import torch; assert torch.cuda.is_available(), 'CUDA 안 잡힘 — CPU 빌드가 깔렸는지 확인'; print('CUDA OK:', torch.cuda.get_device_name(0))"
# 4티어(Seed-X AWQ Int4)에만 필요하고 Windows 휠이 없을 때가 있다.
# 실패해도 멈추지 않는다 — 점검기가 autoawq 유무를 보고 4티어를
# 건너뛰면서 "건너뜀: 정밀(autoawq 없음)" 이라고 분명히 남긴다.
- name: autoawq (4티어 전용, 선택)
shell: pwsh
continue-on-error: true
run: .\.venv-gpu\Scripts\python -m pip install autoawq
- name: GPU 전용 점검
shell: pwsh
env:
PYTHONPATH: src
run: .\.venv-gpu\Scripts\python packaging\windows_smoke.py --gpu
- name: 결과물 업로드
uses: actions/upload-artifact@v4
if: always()
with:
name: LiveSub-gpu-report
path: artifacts/*.txt
retention-days: 14

18
.gitignore vendored
View File

@@ -10,14 +10,22 @@ venv/
.ruff_cache/
# 네이티브 빌드 산출물
#
# ⚠️ 패키지 이름은 livesub 다. 예전 이름(hearo)이 그대로 남아 있어서 빌드한
# livesub_capture.exe 가 무시 대상에서 빠져 있었다.
native/process_loopback/build/
src/hearo/resources/bin/*.exe
src/hearo/resources/bin/*.pdb
src/livesub/resources/bin/*.exe
src/livesub/resources/bin/*.pdb
# 모델 캐시 / 학습 산출물 / 사용자 데이터
models/
adapters/
data/
#
# ⚠️ 반드시 슬래시로 시작해 저장소 루트에 고정할 것. `models/` 로 쓰면 깊이에
# 상관없이 같은 이름의 폴더를 전부 먹는다. 실제로 이것 때문에 소스 패키지
# src/livesub/models/ 8개 파일이 한 번도 커밋되지 않았고, Gitea 에서 clone 한
# 쪽은 앱이 아예 임포트되지 않았다. Windows CI VM 첫 검증에서 잡혔다.
/models/
/adapters/
/data/
*.jsonl
config.json
glossary.json

View File

@@ -113,8 +113,8 @@ Enemy coming from the left 존댓말 → 적이 왼쪽에서 옵니다
powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1
```
`dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고 첫 실행 때
`%APPDATA%\LiveSub\models`로 내려받습니다.
`dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고, **첫 실행 때
고른 폴더**로 내려받습니다.
---
@@ -169,6 +169,25 @@ livesub
## 처음 쓸 때
### 먼저 저장 위치를 고릅니다
모델이 티어에 따라 2~17GB를 차지합니다. C 드라이브가 좁으면 곤란하니
**처음 켜면 어디에 저장할지 물어봅니다.** 고른 폴더에 모델·설정·번역 기록이
전부 들어갑니다.
![저장 위치 선택](docs/images/first-run.png)
한 번 고르면 기억하므로 다음부터는 바로 뜹니다. Windows 표준 폴더
(`%APPDATA%\LiveSub`)가 기본 제안이고 그대로 두셔도 됩니다 — 거기에는
"어느 폴더를 쓰는지" 적은 쪽지 한 장만 남습니다.
나중에 바꾸려면 `설정 > 저장 위치 > 위치 변경`입니다. 받아둔 모델을 새 위치로
**옮길지 물어보므로** 다시 받을 필요는 없습니다.
![위치 변경](docs/images/change-location.png)
### 그다음
1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다
2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다
3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다)
@@ -179,7 +198,7 @@ livesub
VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다.
모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름).
저장 위치는 `%APPDATA%\LiveSub\models` 입니다.
저장 위치는 처음 켤 때 고른 그 폴더입니다.
![모델 화면](docs/images/models.png)

View File

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

View File

@@ -22,11 +22,387 @@ Windows 러너를 하나 붙이면 `main` 에 push할 때(또는 Actions 탭에
| **오디오 → 한국어 자막 (전 과정)** | ❌ CPU로 됨 | ✅ |
| CUDA 인식·VRAM 상한 | ✅ | ⚠️ 경고만 (빌드 통과) |
GPU가 필요한 건 마지막 한 줄뿐이고, 그건 **이 서버(.9, 리눅스)에 RTX 5050이
물려 있어 이미 거기서 검증**하고 있습니다. GPU는 한 VM만 독점할 수 있으니
Windows VM에는 안 주는 게 맞습니다.
### GPU 검증은 `.9`(리눅스)에서 — 이미 돌아갑니다
### Proxmox에 Windows VM 만들기
GPU는 `.9`에 고정입니다. 그래서 GPU 검증은 Windows가 아니라 **여기서** 합니다.
한 줄이면 됩니다.
```bash
scripts/gpu-check.sh
```
RTX 5050 실측 결과입니다.
```
[OK] CUDA 사용 가능 RTX 5050 · 7.5GB
[OK] VRAM 상한이 실제로 걸리는지 35% 상한 적용됨
[OK] GPU 로 실제 번역 '적 왼쪽에서 오는'
[OK] 티어 게이트가 뭐라고 하는지 정밀=autoawq 없음 / 극한=VRAM 부족(16GB 필요, 7.5GB)
6/6 통과
```
**이 GPU로는 4·5티어를 쓸 수 없습니다.** 정밀은 `autoawq`가 필요하고
(`pip install autoawq`), 극한은 16GB가 필요한데 카드가 7.5GB입니다.
극한은 카드를 바꾸지 않는 한 영영 불가능합니다. 1~3티어는 정상 동작합니다.
### GPU가 필요한 테스트는 어떻게 하나
**GPU는 한 VM만 독점합니다.** 컨슈머 NVIDIA는 vGPU/SR-IOV를 지원하지 않아서
쪼개 쓸 방법이 없습니다. 지금 RTX 5050은 `.9`(리눅스)에 물려 있습니다.
그래서 러너를 **라벨로 둘로 나눠뒀습니다.** 지금은 앞의 것만 있으면 되고,
나중에 GPU 머신이 생기면 뒤의 것을 붙이기만 하면 됩니다. 워크플로는 이미
그렇게 짜여 있습니다.
| 러너 라벨 | 어디에 | 언제 도나 | 무엇을 |
|---|---|---|---|
| `windows:host` | GPU 없는 서버 VM | `main` push마다 자동 | 테스트·Windows 경로·오디오→자막·exe 빌드 |
| `windows-gpu:host` | GPU 달린 머신 | **수동 실행만** | CUDA 인식·VRAM 상한·4·5티어·GPU 번역 |
GPU 러너를 상시로 띄울 필요는 없습니다. 필요할 때만 `daemon`을 켜고
Actions 탭에서 `Windows GPU` → `Run workflow` 하면 됩니다. 러너가 없으면
큐에 쌓이기만 하고 아무 일도 일어나지 않습니다.
GPU 워크플로는 GPU가 없으면 **분명히 실패합니다.** GPU를 확인하겠다고 부른
작업이 조용히 통과하면 안 되니까요. `nvidia-smi`가 없으면 그 자리에서 멈춥니다.
#### 여러 프로젝트가 GPU 러너를 같이 쓸 때 — 동시에 돌리면 안 됩니다
**이게 가장 중요합니다.** GPU는 나눠 쓸 수 없어서, 두 작업이 동시에 VRAM을
잡으면 둘 다 OOM으로 죽거나 하나가 조용히 느려져 원인을 못 찾습니다.
CPU 러너와 달리 GPU 러너는 **반드시 한 번에 한 작업만** 돌아야 합니다.
보장 장치가 두 겹입니다.
1. **러너 `capacity: 1`** — 진짜 보장입니다. 저장소가 달라도 막힙니다.
`act_runner` 설정 파일(`config.yaml`)에서 확인하세요. 기본값이 1이지만
GPU 러너에서는 반드시 확인하고 넘어가세요.
```yaml
runner:
capacity: 1 # GPU 러너는 절대 올리지 말 것
```
설정 파일이 없으면 만들고 `--config config.yaml` 로 띄웁니다.
```powershell
.\act_runner.exe generate-config > config.yaml
.\act_runner.exe daemon --config config.yaml
```
2. **워크플로 `concurrency`** — 같은 저장소 안에서 겹치는 것만 막습니다.
`windows-gpu.yml` 에 이미 들어 있습니다. 다른 저장소에는 효력이 없으니
1번을 대신하지 못합니다.
한 대에 러너 프로세스를 **여러 개 띄우지도 마세요.** capacity 1짜리 러너가
둘이면 결국 동시에 두 작업이 돕니다.
#### GPU 머신을 어디서 구하나
다른 프로젝트도 이 러너에서 GPU 테스트를 돌리실 거라면 셋 중 하나입니다.
1. **서버에 GPU 한 장 더** — 가장 깔끔합니다. PCIe 슬롯과 파워 여유만 있으면
그 GPU를 Windows VM에 패스스루하고 `windows-gpu` 라벨로 등록하면 끝입니다.
이후로는 두 러너가 각자 계속 돕니다.
2. **RTX 5050을 Windows VM으로 옮기기** — `.9`가 GPU를 포기해야 합니다.
`.9`에서 하던 GPU 작업(이미지 생성 등)을 못 하게 되니 득실을 따져보세요.
3. **필요할 때만 실물 PC에 GPU 러너** — 평소엔 꺼두고 GPU 검증이 필요할 때만
켭니다. 수동 실행이라 게임 중에 갑자기 도는 일은 없습니다.
1번이 가능한지는 서버에서 이걸로 확인할 수 있습니다.
```bash
lspci | grep -i vga # GPU가 몇 장 꽂혀 있나
dmidecode -t slot | grep -i "in use" # 빈 PCIe 슬롯이 있나
```
### 선택지 A — `.9` 안에 Windows VM (중첩 가상화)
Proxmox를 건드리지 않고 `.9`(우분투) 안에서 바로 Windows VM을 돌릴 수 있습니다.
**실제로 확인했습니다.**
```
/dev/kvm 존재
kvm_intel nested Y
qemu -enable-kvm kvm support: enabled ← 실측
여유 RAM / 디스크 25GB / 294GB
```
**가장 큰 장점은 사람 손이 안 간다는 것입니다.** `.9` 안이라 에이전트가 VM
생성부터 러너 등록까지 직접 합니다. Proxmox 자격증명도 필요 없습니다.
**단, GPU는 여전히 안 됩니다.** 중첩 패스스루가 불가능합니다.
```
IOMMU 그룹 수 0개
커널 cmdline iommu 옵션 없음
```
되게 하려면 Proxmox가 `.9`에 vIOMMU를 열어주고 `.9` 커널에 `intel_iommu=on`을
넣은 뒤 GPU를 `vfio-pci`에 묶어야 하는데, 그러면 **`.9`가 GPU를 잃습니다.**
결국 "GPU를 누가 가질 것인가" 문제로 되돌아오므로 중첩으로는 해결되지 않습니다.
#### 만드는 법 — 스크립트 한 줄, 사람 손 없음
`scripts/win-ci/` 에 전부 들어 있습니다. Windows 설치 화면을 사람이 클릭할
방법이 없으므로 **무인 설치**로 짰습니다.
```bash
scripts/win-ci/build.sh # ISO 받기 + 무인설치 ISO 굽기 + 디스크 생성
/home/claude/win-ci/start-vm.sh # VM + 제어 서버 기동
/home/claude/win-ci/boot-cd.sh reset # CD 부팅 프롬프트 넘기기 (최초 1회)
/home/claude/win-ci/status.sh # 진행 상황
/home/claude/win-ci/shot.sh a.png # 화면 스냅샷
```
네 부분이 이어달리기를 합니다.
| 파일 | 어디서 도나 | 하는 일 |
|---|---|---|
| `unattend/autounattend.xml` | Windows Setup | 디스크 분할·설치·OOBE 건너뛰기·`ci` 자동 로그온 |
| `unattend/setup/provision.ps1` | 첫 로그온 1회 | virtio 드라이버(네트워크·화면), 화면꺼짐/잠금 해제 |
| `unattend/setup/agent.ps1` | 로그온마다 상주 | 호스트에서 `stage2.ps1` 을 받아 실행 |
| `serve/stage2.ps1` | 호스트가 HTTP 로 제공 | Git·pwsh7·Python·Node·CMake·act_runner 설치 |
**왜 설치 내용을 ISO 에 안 넣었나** — ISO 는 한 번 구우면 못 고칩니다. 설치
스크립트를 ISO 에 박으면 한 줄 고칠 때마다 Windows 를 다시 깔아야 합니다.
그래서 ISO 에는 껍데기(agent)만 넣고, 실제 설치 내용은 호스트의 HTTP 로
가져옵니다. `serve/stage2.ps1` 을 고치면 게스트가 20초 안에 알아서 새로
받아 실행합니다.
호스트와 게스트를 잇는 통로는 두 개입니다.
| 방향 | 통로 | 왜 |
|---|---|---|
| 호스트 → 게스트 | HTTP `10.0.2.2:8099` (QEMU user networking) | 스크립트·러너 토큰 전달 |
| 게스트 → 호스트 | COM1 → `serial.log`, HTTP POST `/log` → `guest.log` | 네트워크가 **없는** 드라이버 설치 단계도 봐야 하므로 시리얼이 필수 |
| 게스트 → 호스트 | HTTP POST `/artifact/<이름>` → `results/<이름>.log` | 검증 출력 **전문**. 바로 아래 참고 |
##### 검증 결과는 `results/` 에 파일로 남습니다 — 그게 기준선입니다
`stage2.ps1` 의 `Step` 이 각 단계 출력을 게스트 `C:\win-ci\out\` 에 한 줄씩
기록하면서 호스트 `/home/claude/win-ci/results/` 로 올립니다. 판정 요약은
`results/summary.log` 이고 **검증한 리비전이 박혀** 있습니다.
처음에는 로그 채널로 마지막 몇 줄만 흘렸는데, `serial.log` 는 VM 이 뜰 때마다
truncate 되고 `guest.log` 는 `stage2` 가 다시 돌 때만 채워집니다. 그래서 pytest
실패 트레이스백이 호스트에 한 줄도 남지 않아, 화면을 읽고 옮겨 적는 수밖에
없었습니다. 고친 뒤에 고쳐졌다고 말할 근거가 없는 상태였습니다.
재검증은 리비전으로 요청합니다.
```bash
cd scripts/win-ci && ./sync.sh <sha> # stage2 의 $TargetRev 를 그 커밋으로 박아 배포
tail -f /home/claude/win-ci/guest.log # 약 10분, 끝나면 results/summary.log 확인
```
게스트 agent 는 `stage2.ps1` **내용 해시가 바뀔 때만** 다시 돕니다. 커밋만
푸시하면 재검증이 걸리지 않으니 sha 를 넘기세요. `build.sh` 를 다시 돌리면
안 됩니다 — 지워둔 Windows ISO 4.8GB 를 다시 내려받습니다.
배포할 포터블 exe 도 같은 통로로 돌아옵니다. 빌드와 기동이 **둘 다** 통과했을
때만 `results/LiveSub.exe` 로 올라오므로, 거기 파일이 있다는 것 자체가
"검증을 통과한 산출물" 이라는 뜻입니다. 그대로 릴리스에 올리면 됩니다.
##### exe 빌드 단계가 거짓 PASS 를 내던 함정
앞 회차의 `exe기동` 점검이 띄운 `LiveSub.exe` 가 파일을 잡고 있으면 PyInstaller
가 `PermissionError` 로 죽습니다. 그런데 **옛 exe 는 그대로 남아 있어서**
`Test-Path dist\LiveSub.exe` 검사는 통과했고, 결국 빌드가 실패한 회차가 PASS 로
보고됐습니다. 지금은 빌드 전에 프로세스를 죽이고 `build`·`dist` 를 지운 뒤
PyInstaller 종료코드까지 확인합니다. **산출물의 존재를 성공의 근거로 쓰면
안 됩니다** — 이전 성공이 남긴 것일 수 있습니다.
##### MSVC 가 왜 VM 에 깔려 있나
`native\process_loopback` 의 `livesub_capture.exe` 는 WASAPI 프로세스 루프백을
씁니다. 그 헤더(`audioclientactivationparams.h`)와 WRL 은 Windows SDK + MSVC
에만 있어서 cmake 만으로는 못 만듭니다. 그래서 `stage2.ps1` 이 VS 2022 Build
Tools(C++ 워크로드)를 설치합니다 — 첫 설치에 약 9분 걸리고 그 뒤로는 건너뜁니다.
이게 없으면 **포터블 빌드는 그대로 성공하되** 프로그램별 캡처가 출력 장치
전체 캡처로 조용히 폴백됩니다. 기능이 빠진 exe 가 PASS 로 나오는 조합이라,
검증 순서에서 `native_capture` 를 `windows_smoke` 와 `pyinstaller` **앞에**
둡니다. 뒤에 두면 둘 다 "없음"을 정상으로 보고 지나갑니다.
실제로 이 경로는 MSVC 를 깔기 전까지 **한 번도 컴파일된 적이 없었고**, 깔자마자
`main.cpp` 가 C2275 로 죽었습니다. CI 의 네이티브 빌드 단계가
`continue-on-error` 였던 탓에 그때까지 드러나지 않았습니다.
접속 정보: 계정 `ci` / 비밀번호는 `~/.config/ejclaw/secrets.json` 의
`win-ci.9` 항목. RDP 는 호스트 `127.0.0.1:13389`, VNC 는 `127.0.0.1:5919`.
재부팅해도 살아납니다. `~/.config/systemd/user/win-ci{,-http}.service` 로
등록돼 있고 `linger=yes` 라 로그인 없이 `default.target` 이 끌어올립니다.
`systemd-run` 트랜지언트로 띄우면 `/run` 에만 남아 재부팅에 사라지니,
디버깅으로 한 번 띄웠더라도 마지막에는 반드시 아래로 되돌려 놓으세요.
```bash
systemctl --user start default.target # 부팅과 같은 경로로 기동
systemctl --user is-enabled win-ci.service # enabled 여야 한다. transient 면 안 된다
systemctl --user show win-ci.service -p FragmentPath # ~/.config/... 를 가리켜야 한다
```
##### 설치가 끝난 뒤 무엇을 지우나 — 기준
지우는 기준은 "VM 이 도는 데 계속 필요한가" 하나입니다.
| 파일 | 설치 후 | 왜 |
|---|---|---|
| `iso/win11-ltsc.iso` (4.8GB) | **지운다** | 설치 미디어. 다시 깔 때만 필요하고 `build.sh` 가 받아온다 |
| `iso/virtio-win.iso` (837MB) | 남긴다 | VM 에 계속 물려 있다. 드라이버 재설치·재프로비저닝이 여기서 읽는다 |
| `unattend.iso` (0.4MB) | 남긴다 | VM 에 계속 물려 있다 |
| `win-ci.qcow2` (14GB) | 남긴다 | VM 그 자체 |
ISO 를 지우면 `-drive` 가 없는 파일을 가리켜 **qemu 가 기동 자체에 실패**합니다.
재부팅 후 러너 호스트가 조용히 사라지므로, `run-vm.sh` 는 cdrom 을 있을 때만
붙입니다. 그리고 같은 ISO 를 버전별로 여러 개 남겨두지 마세요. 어느 것이
물려 있는지 헷갈리면 고친 줄 알았던 스크립트가 반영되지 않습니다. 현재
물려 있는 것은 `query-block` 으로 확인합니다.
##### 무인 설치에서 실제로 걸린 것들
- **`Press any key to boot from CD`** — UEFI 는 이걸 5초 안에 안 누르면 CD 를
포기합니다. 무인인데 누를 사람이 없어서 QMP `send-key` 로 밀어 넣습니다.
단, **설치가 시작된 뒤에는 절대 키를 더 보내면 안 됩니다.** 남는 Enter 가
설치 화면의 `Cancel` 을 눌러 "Are you sure you want to quit?" 를 띄웁니다.
반대로 설치가 끝난 뒤 재부팅 때는 아무 키도 안 누르는 게 정답입니다.
그래야 CD 를 건너뛰고 디스크로 부팅합니다.
- **시스템 디스크는 SATA(AHCI)** 로 둡니다. virtio-blk/scsi 로 하면 Windows
Setup 이 디스크 자체를 못 봐서 WinPE 단계 드라이버 주입이 필요해집니다.
네트워크·화면만 virtio 로 가고, 그 드라이버는 설치 후 ISO 에서 오프라인으로
넣습니다.
- **Windows ISO 는 UDF** 입니다. ISO9660 층에는 `README.TXT` 하나뿐이라
`xorriso` 로 열면 비어 보입니다. `mount -t udf` 로 봐야 합니다.
- `install.wim` 의 **인덱스 1** 이 `Windows 11 Enterprise LTSC 2024 Evaluation`
입니다(이미지 2개). `wiminfo` 로 확인하고 박았습니다.
- **LTSC 에는 winget 이 없습니다.** 스토어가 빠져 있어서 설치 파일을 직접
받습니다.
- **Node.js 가 필요합니다.** `act_runner` 의 host 모드는 `actions/checkout`,
`upload-artifact` 같은 JS 액션을 PATH 의 `node` 로 돌립니다. 빠뜨리면 그
단계들만 조용히 실패합니다.
- **러너는 서비스가 아니라 로그온 예약 작업**이어야 합니다. 서비스로 돌리면
데스크톱 세션이 없어 GUI 테스트와 스크린샷이 전부 실패합니다.
#### VM 에 얼마를 줄까 — 실측 근거
**vCPU 4개 / RAM 8GB / 디스크 80GB.** 추측이 아니라 재보고 정한 값입니다.
CPU 는 4개에서 포화합니다. 더 줘도 빨라지지 않습니다.
```
파이프라인(음성→자막) 2코어 13초 · 4코어 10초 · 8코어 11초
테스트 190개 4코어 5.9초 · 8코어 5.8초
```
CI 작업 대부분이 단일 스레드라 코어를 늘려도 효과가 없습니다. 음성인식만
병렬인데 그것도 4개에서 멈춥니다.
RAM 8GB 는 Windows 11 이 쾌적하게 도는 최소선이고, 그 안에서 도는
음성인식+번역이 2~3GB 를 씁니다. 디스크 80GB 는 Windows 30GB + 모델 캐시
+ 빌드 산출물 기준입니다.
#### VM 의 Windows 는 진짜 Windows 인가
**맞습니다. 같은 Windows 이고 같은 바이너리입니다.** 우리가 검증하려는 것들은
코드 경로가 동일합니다.
| | VM 에서 | 비고 |
|---|---|---|
| WASAPI 오디오 캡처 API | 동일 | 에뮬레이트된 사운드 장치를 잡을 뿐 API 는 같음 |
| 전역 단축키(`RegisterHotKey`) | 동일 | |
| 모니터 열거 API | 동일 | `max_outputs=2` 로 화면 2개를 줄 수 있음 |
| PyInstaller exe 빌드·실행 | 동일 | |
| CPU 명령어 집합 | 동일 | 중첩 KVM 이 호스트 CPU 기능을 그대로 노출 |
다른 점은 셋입니다.
- **GPU 없음** — CUDA·VRAM 검증은 `.9`(리눅스)에서 합니다. 위 참고.
- **실제 게임 소리 없음** — 게임을 띄워 진짜 캡처하는 건 못 합니다.
대신 음성 파일을 넣어 자막까지 나오는지 자동 검증합니다.
- **속도/지연이 다름** — 체감 지연 측정값은 실제 PC 기준이 아닙니다.
모니터 선택을 검증하려면 화면을 2개 줘야 합니다. `--vga std` 는 1개뿐입니다.
```
-device virtio-vga,max_outputs=2 # KVM 기동 확인 완료
```
Windows 쪽에 virtio-gpu 드라이버(virtio-win ISO)가 깔려 있어야 2개로 잡힙니다.
#### 100% 부하에서도 괜찮은가 — 재봤습니다
**VM 4 vCPU 까지는 안전하고, `.9` 의 CPU 를 늘리면 오히려 해롭습니다.**
`.9` 안에서 VM 이 4코어를 100% 써도 영향은 작습니다.
```
부하 없음 VM 4코어 100%
테스트 5.9초 -> 6.1초 (+3%)
파이프라인 9.8초 -> 11.0초 (+12%)
```
문제는 호스트입니다. 물리 호스트는 i7-11700(8코어/**16스레드**)뿐인데
`.5` 에서 운영 컨테이너 19개가 돌고 있습니다. `.5` 의 응답 속도를 재봤습니다.
```
.9 가 한가할 때 616~628ms 기준
.9 가 4코어 사용 657~678ms +7% ← VM 이 100% 인 실제 상황
.9 가 8코어 전부 사용 1487~1491ms +140% ← 봇까지 동시에 바쁠 때
```
**그래서 `.9` 의 vCPU 는 늘리면 안 됩니다.** 늘릴수록 호스트 지분이 커져
`.5` 의 운영 서비스가 느려집니다. 지금 8개가 적정 상한입니다.
최악(8코어 전부)을 아예 막으려면 VM 을 CPU 상한과 함께 띄웁니다.
```bash
systemd-run --user --scope -p CPUQuota=400% -p CPUWeight=50 --unit win-ci qemu-system-x86_64 -enable-kvm ...
```
`CPUWeight=50` 은 봇과 경합할 때 VM 이 양보한다는 뜻입니다.
`CPUQuota=200%` 로 재봤을 때 `.5` 응답이 649~653ms 로 기준선과 같았습니다.
RAM 은 최악을 다 더해도 여유가 있습니다.
```
ejclaw 봇 (실측 최대) 7.2 GB
Windows VM 게스트 8.0 GB
QEMU 오버헤드 0.4 GB
GPU 작업 동시 실행 3.0 GB
tmpfs 워치독 경보선 3.0 GB
─────────────────────────────────────
최악 합계 21.6 GB / 30 GB (여유 8.4 GB)
```
**결론: CPU·RAM 모두 증설 없이 진행하면 됩니다.** RAM 만 여유를 두고 싶다면
30 → 38GB 까지는 호스트가 감당합니다(호스트 62.6GB, 여유 약 24GB).
CPU 는 늘리지 마세요.
#### ⚠️ 메모리 cgroup 함정 — 반드시 `systemd-run --scope`
에이전트 턴에서 그냥 `qemu`를 띄우면 VM이 **봇의 cgroup 안에서** 돕니다.
`ejclaw.service`는 `MemoryHigh=16G`이고 평상시 5G를 쓰므로, 8G짜리 VM이
그 안에 들어가면 상한에 부딪혀 봇이 OOM으로 죽습니다(전례 있음).
```bash
# 봇 cgroup 밖(app.slice 형제)에 VM 을 띄운다 — 실측으로 확인됨
systemd-run --user --scope -p MemoryMax=10G --unit win-ci qemu-system-x86_64 -enable-kvm -m 8192 ...
```
#### A vs B 비교
| | A: `.9` 안 중첩 VM | B: Proxmox VM |
|---|---|---|
| 만드는 사람 | **에이전트가 직접** | 사용자 |
| GPU | ❌ | ❌ (별도 GPU 추가 시 ⭕) |
| 속도 | 중첩이라 다소 느림 | 네이티브 |
| `.9` 재부팅 시 | 같이 죽음 | 영향 없음 |
| RAM | `.9`의 30GB를 나눠 씀 | 독립 |
CI는 타임아웃이 30분인데 파이프라인이 27초라 속도 여유가 충분합니다.
**먼저 A로 띄워보고, 부족하면 B로 옮기는 편을 권합니다.**
### 선택지 B — Proxmox에 Windows VM 만들기
```bash
# Proxmox 호스트에서 (VMID 는 비어 있는 번호로)

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

View File

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

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

@@ -159,6 +159,39 @@ def run_source_checks() -> None:
win.close()
return "스크린샷 2장 저장 (artifacts/)"
@check("저장 위치 선택 화면")
def _data_location():
"""첫 실행 때 뜨는 위치 선택 화면이 실제 Windows 에서 제대로 그려지는지.
경로 표기(`C:\\...`)와 디스크 여유 공간 조회는 OS 마다 다르게 도는
부분이라 리눅스에서 통과해도 여기서 깨질 수 있다.
"""
from livesub.storage import data_dir, default_data_dir, free_space, human_size
from livesub.ui.data_location import DataLocationDialog
from livesub.ui.theme import stylesheet
app = _qt_app()
ARTIFACTS.mkdir(exist_ok=True)
shots = 0
for name, start, first in (
("windows-first-run.png", default_data_dir(), True),
("windows-change-location.png", data_dir(), False),
):
dialog = DataLocationDialog(start, first_run=first)
try:
dialog.setStyleSheet(stylesheet("dark"))
dialog.resize(560, dialog.sizeHint().height())
dialog.show()
app.processEvents()
if not dialog.ok_button.isEnabled():
raise RuntimeError(f"{start} 를 못 쓴다고 판정됨: {dialog.status.text()}")
dialog.grab().save(str(ARTIFACTS / name))
shots += 1
finally:
dialog.close()
dialog.deleteLater()
return f"{default_data_dir()} · 여유 {human_size(free_space(default_data_dir()))} · 스크린샷 {shots}장"
# --- 전 파이프라인 점검 -----------------------------------------------------
@@ -230,6 +263,133 @@ def run_pipeline_check(timeout_s: int = 900) -> None:
return f"{line.source_text!r} -> {line.translated_text!r}"
# --- GPU 전용 점검 ----------------------------------------------------------
def run_gpu_checks() -> None:
"""GPU 가 있는 머신에서만 의미가 있는 것들.
GPU 는 한 VM 만 독점하므로(컨슈머 NVIDIA 는 vGPU 미지원) GPU 없는 CI VM
에서는 확인할 수 없다. 그래서 별도 러너 라벨(windows-gpu)로 떼어놨다.
여기서는 GPU 가 없으면 **분명히 실패**해야 한다 — GPU 를 확인하겠다고
부른 작업이 조용히 통과하면 안 된다.
"""
print(f"\n=== GPU 전용 (플랫폼: {sys.platform}) ===")
@check("CUDA 사용 가능")
def _cuda():
import torch
if not torch.cuda.is_available():
raise RuntimeError(
"torch 가 CUDA 를 못 씁니다. CPU 빌드가 깔렸는지 확인하세요 "
"(pip install torch --index-url https://download.pytorch.org/whl/cu128)"
)
idx = torch.cuda.current_device()
free, total = torch.cuda.mem_get_info(idx)
return f"{torch.cuda.get_device_name(idx)} · {total / 1024**3:.1f}GB (여유 {free / 1024**3:.1f}GB)"
@check("앱의 GPU 인식")
def _detect():
from livesub.models import detect_gpu
gpu = detect_gpu()
if not gpu.available:
raise RuntimeError(gpu.reason)
return f"{gpu.name} · {gpu.total_vram_mb / 1024:.1f}GB"
@check("VRAM 상한이 실제로 걸리는지")
def _vram():
from livesub.models.manager import apply_vram_limit
if not apply_vram_limit(0.35):
raise RuntimeError("상한 적용 실패 — 게임과 같이 쓸 때 VRAM 을 못 막습니다")
return "35% 상한 적용됨"
@check("티어 게이트가 뭐라고 하는지")
def _gate():
"""앱이 각 torch 티어를 쓸 수 있다고 하는지, 아니면 왜 막는지.
여기서는 판정하지 않고 그대로 보고만 한다. VRAM 이 작은 GPU 에서
4·5티어가 막히는 건 정상이라 실패로 처리하면 안 된다. 진짜 판정은
아래 "실제 로드" 가 한다 — 게이트가 된다고 한 걸 못 올리면 그게 버그다.
"""
from livesub.models.manager import detect_gpu, has_torch, tier_availability
from livesub.models.tiers import MTBackend, ordered_tiers
if not has_torch():
raise RuntimeError("torch 없음 — GPU 검증에는 일반 설치가 필요합니다")
gpu = detect_gpu()
parts = []
for t in ordered_tiers():
if t.mt.backend is not MTBackend.TRANSFORMERS:
continue
ok, reason = tier_availability(t, gpu)
parts.append(f"{t.name}={'가능' if ok else reason}")
return " / ".join(parts)
@check("게이트가 된다고 한 티어가 실제로 올라가는지")
def _real_load():
"""게이트 판정을 **실물로 검증**한다.
게이트가 "가능"이라 한 torch 티어를 실제로 올려 번역까지 시킨다.
여기서 죽으면 앱이 사용자에게 거짓말을 하고 있다는 뜻이다.
게이트가 전부 막았다면 검증할 대상이 없다. 그건 이 GPU 의 한계이지
회귀가 아니므로 실패로 처리하지 않고 이유를 남긴다.
"""
from livesub.models.glossary import Glossary
from livesub.models.manager import detect_gpu, tier_availability
from livesub.models.tiers import MTBackend, ordered_tiers
from livesub.models.translator import create_translator
gpu = detect_gpu()
allowed, blocked = [], []
for t in ordered_tiers():
if t.mt.backend is not MTBackend.TRANSFORMERS:
continue
ok, reason = tier_availability(t, gpu)
(allowed if ok else blocked).append(t if ok else f"{t.name}({reason})")
if not allowed:
return (
"검증 대상 없음 — 이 GPU 로는 torch 티어를 못 씁니다. "
f"{'; '.join(blocked)}"
)
tier = allowed[-1] # 열린 것 중 가장 무거운 것
translator = create_translator(tier.mt, device="cuda")
translator.load()
try:
out = translator.translate("Enemy coming from the left.", "en", "ko", Glossary())
finally:
translator.unload()
if not any("\uac00" <= ch <= "\ud7a3" for ch in out):
raise RuntimeError(f"{tier.name}: 한국어가 아닙니다 — {out!r}")
return f"{tier.name} 로드·번역 성공 {out!r}"
@check("GPU 로 실제 번역 (1~3티어 CUDA 경로)")
def _translate():
from livesub.models.glossary import Glossary
from livesub.models.packs import build_glossary
from livesub.models.tiers import get_tier
from livesub.models.translator import create_translator
tier = get_tier("swift") # CT2 지만 device=cuda 로 GPU 경로를 탄다
translator = create_translator(tier.mt, device="cuda")
translator.load()
glossary = build_glossary(Glossary(), ["fps-common", "pubg"])
try:
out = translator.translate("Enemy coming from the left.", "en", "ko", glossary)
finally:
translator.unload()
if not any("\uac00" <= ch <= "\ud7a3" for ch in out):
raise RuntimeError(f"한국어가 아닙니다: {out!r}")
ARTIFACTS.mkdir(exist_ok=True)
(ARTIFACTS / "gpu-translate.txt").write_text(out + "\n", encoding="utf-8")
return repr(out)
# --- 빌드된 exe 점검 --------------------------------------------------------
@@ -269,10 +429,16 @@ def main() -> int:
"--pipeline", action="store_true",
help="오디오 파일로 전 파이프라인 점검 (모델을 내려받으므로 느립니다)",
)
parser.add_argument(
"--gpu", action="store_true",
help="GPU 가 있는 머신에서만 되는 항목 점검 (CUDA·VRAM 상한·4/5티어)",
)
args = parser.parse_args()
if args.exe:
run_exe_check(args.exe)
elif args.gpu:
run_gpu_checks()
elif args.pipeline:
run_pipeline_check()
else:
@@ -288,13 +454,15 @@ def main() -> int:
print(f"\n{len(_results) - len(failed)}/{len(_results)} 통과")
if failed:
print("실패:", ", ".join(failed))
# 오디오 장치·보조 프로그램은 러너 환경에 따라 없을 수 있어 경고로만 둔다.
fatal = [n for n in failed if n not in {
# 오디오 장치·보조 프로그램·GPU 는 러너 환경에 따라 없을 수 있어 경고로만 둔다.
# 다만 --gpu 로 부른 경우엔 GPU 가 없는 것 자체가 실패다.
lenient = {
"프로그램별 캡처 보조 프로그램",
"소리 내는 프로그램 열거 (pycaw)",
"WASAPI 루프백 장치 열거",
"GPU 인식",
}]
}
fatal = failed if args.gpu else [n for n in failed if n not in lenient]
return 1 if fatal else 0

View File

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

34
scripts/gpu-check.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# GPU 검증 — RTX 5050 이 달린 .9(리눅스)에서 돌린다.
#
# GPU 는 한 VM 만 독점하므로 Windows CI VM 에는 GPU 가 없다. 그래서 CUDA
# 인식·VRAM 상한·티어 게이트·실제 GPU 번역은 GPU 가 실제로 있는 여기서
# 확인한다. Windows 전용 경로(오디오·단축키·exe)는 Windows 러너가 맡는다.
#
# scripts/gpu-check.sh
#
# 종료코드 0 이면 전부 통과. 항목 하나라도 실패하면 1.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENV="${GPU_VENV:-/home/claude/gpu-venv}"
if [[ ! -x "$VENV/bin/python" ]]; then
echo "GPU 용 파이썬이 없습니다: $VENV/bin/python" >&2
echo "GPU_VENV 로 경로를 지정하거나 torch(CUDA 빌드)가 깔린 venv 를 만드세요." >&2
exit 1
fi
# CTranslate2 가 libcublas.so.12 를 직접 찾는다. torch 가 딸려온 nvidia
# 패키지 안에 있으므로 경로를 잡아준다. 없으면
# "Library libcublas.so.12 is not found" 로 GPU 번역만 조용히 실패한다.
NV="$("$VENV/bin/python" -c 'import nvidia,os;print(os.path.dirname(nvidia.__file__))' 2>/dev/null || true)"
if [[ -n "$NV" ]]; then
for lib in cublas cudnn; do
[[ -d "$NV/$lib/lib" ]] && export LD_LIBRARY_PATH="$NV/$lib/lib:${LD_LIBRARY_PATH:-}"
done
fi
cd "$ROOT"
export PYTHONPATH="src"
exec "$VENV/bin/python" packaging/windows_smoke.py --gpu

44
scripts/win-ci/boot-cd.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/bin/bash
# "Press any key to boot from CD or DVD" 를 넘긴다.
#
# UEFI 부팅 시 Windows ISO 는 매번 이 프롬프트를 띄우고 약 5초 안에 키가
# 없으면 CD 부팅을 포기한다. 무인 설치인데 사람이 눌러줄 수 없으므로
# 리셋 직후부터 키를 계속 밀어 넣는다.
#
# ⚠️ 설치가 시작된 뒤에는 절대 실행하지 말 것. 남는 Enter 가 설치 화면의
# Cancel 버튼을 눌러 "Are you sure you want to quit?" 가 뜬다 (실제로 겪음).
# 설치가 끝난 뒤 재부팅 때는 키를 안 누르는 게 정답이다 - 그래야 CD 를
# 건너뛰고 디스크로 부팅한다.
#
# 사용법: ./boot-cd.sh [reset] - reset 을 주면 VM 을 먼저 리셋한다
set -euo pipefail
MODE="${1:-}"
python3 - "$MODE" <<'PY'
import json, socket, sys, time
mode = sys.argv[1] if len(sys.argv) > 1 else ""
s = socket.socket(socket.AF_UNIX); s.settimeout(20)
s.connect("/home/claude/win-ci/qmp.sock")
f = s.makefile("rwb")
f.readline()
def cmd(o):
f.write((json.dumps(o) + "\n").encode()); f.flush()
while True:
line = f.readline()
if not line: raise SystemExit("qmp closed")
m = json.loads(line)
if "return" in m or "error" in m: return m
cmd({"execute": "qmp_capabilities"})
if mode == "reset":
print(cmd({"execute": "system_reset"}))
time.sleep(3)
# 프롬프트 창은 5초뿐이지만 펌웨어 POST 시간이 들쭉날쭉하다. 25초면 충분하고,
# 그 이상 밀면 설치 화면까지 Enter 가 새어 들어간다.
deadline = time.time() + 25
n = 0
while time.time() < deadline:
cmd({"execute": "send-key",
"arguments": {"keys": [{"type": "qcode", "data": "ret"}]}})
n += 1
time.sleep(0.4)
print("sent", n, "keys")
PY

64
scripts/win-ci/build.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/bin/bash
# win-ci Windows VM 을 처음부터 만든다 (.9 안 중첩 KVM, 무인 설치).
#
# ./build.sh ISO 내려받기 + 무인설치 ISO 굽기 + 디스크 생성
# ./start-vm.sh VM + 제어 서버 기동 (systemd 트랜지언트 유닛)
# ./boot-cd.sh reset "Press any key to boot from CD" 넘기기 (최초 1회만)
# ./status.sh 진행 상황
# ./shot.sh out.png 화면 스냅샷
#
# 사람이 클릭할 일은 없다. autounattend.xml 이 설치를 끝내고, provision.ps1 이
# virtio 드라이버를 넣고, agent.ps1 이 호스트에서 stage2.ps1 을 받아 개발도구와
# act_runner 를 깐다.
#
# 필요 패키지: qemu-system-x86 ovmf swtpm swtpm-tools xorriso
# 그리고 실행 계정이 kvm 그룹에 있어야 한다 (없으면 sg kvm -c '...' 로 감싼다).
set -euo pipefail
D="${WIN_CI_DIR:-/home/claude/win-ci}"
HERE="$(cd "$(dirname "$0")" && pwd)"
WIN_ISO_URL='https://go.microsoft.com/fwlink/p/?linkid=2289029' # Win11 Ent LTSC 2024 평가판(90일, 키 불필요)
VIRTIO_URL='https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso'
mkdir -p "$D/iso" "$D/serve" "$D/swtpm" "$D/unattend-root/setup"
fetch() { # url dest 최소크기MB
if [ -f "$2" ] && [ "$(stat -c%s "$2")" -gt $(( $3 * 1024 * 1024 )) ]; then
echo "이미 있음: $2"; return
fi
echo "다운로드: $2"
curl -fL --retry 3 -o "$2" "$1"
}
fetch "$WIN_ISO_URL" "$D/iso/win11-ltsc.iso" 4000
fetch "$VIRTIO_URL" "$D/iso/virtio-win.iso" 500
# 무인 설치 미디어. Windows Setup 이 붙어 있는 모든 드라이브 루트에서
# autounattend.xml 을 찾으므로 CD 로 물려주면 된다.
cp "$HERE/unattend/autounattend.xml" "$D/unattend-root/"
# ⚠️ .ps1 은 반드시 UTF-8 BOM 으로 넣는다.
# Windows PowerShell 5.1 은 BOM 이 없으면 현재 ANSI 코드페이지(en-US -> CP1252)로
# 읽는다. 한글 주석의 UTF-8 바이트가 CP1252 에서 스마트따옴표(“ ” ‘)로 변하고,
# PowerShell 은 그걸 문자열 시작으로 보고 스크립트 뒤를 전부 삼킨다. 증상은
# 엉뚱하게도 "Missing closing '}' in statement block" 이다. 실제로 이것 때문에
# 첫 설치의 provision.ps1 이 통째로 안 돌았다.
for f in "$HERE/unattend/setup/"*.ps1; do
out="$D/unattend-root/setup/$(basename "$f")"
printf '\xef\xbb\xbf' > "$out"
cat "$f" >> "$out"
done
xorriso -as mkisofs -V UNATTEND -J -joliet-long -R -o "$D/unattend.iso" "$D/unattend-root/"
# 호스트 제어 채널이 내려줄 스크립트
cp "$HERE/serve/stage2.ps1" "$D/serve/"
cp "$HERE"/*.sh "$HERE"/serve-host.py "$D/"
chmod +x "$D"/*.sh
[ -f "$D/win-ci.qcow2" ] || qemu-img create -f qcow2 "$D/win-ci.qcow2" 80G
# SecureBoot 용 - MS 키가 미리 들어 있는 변수 이미지여야 Windows 부트로더가 통과한다
[ -f "$D/OVMF_VARS.fd" ] || cp /usr/share/OVMF/OVMF_VARS_4M.ms.fd "$D/OVMF_VARS.fd"
echo
echo "준비 끝. 다음: $D/start-vm.sh 그리고 $D/boot-cd.sh reset"

28
scripts/win-ci/click.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# VM 화면의 좌표를 클릭한다 (usb-tablet 절대좌표).
# 사용법: ./click.sh <x> <y> [화면폭 화면높이] 기본 1280x800
set -euo pipefail
python3 - "$@" <<'PY'
import json, socket, sys, time
x, y = int(sys.argv[1]), int(sys.argv[2])
W = int(sys.argv[3]) if len(sys.argv) > 3 else 1280
H = int(sys.argv[4]) if len(sys.argv) > 4 else 800
s = socket.socket(socket.AF_UNIX); s.settimeout(20)
s.connect("/home/claude/win-ci/qmp.sock"); f = s.makefile("rwb"); f.readline()
def cmd(o):
f.write((json.dumps(o)+"\n").encode()); f.flush()
while True:
m = json.loads(f.readline())
if "return" in m or "error" in m: return m
cmd({"execute": "qmp_capabilities"})
ax, ay = int(x / W * 32767), int(y / H * 32767)
ev = [{"type": "abs", "data": {"axis": "x", "value": ax}},
{"type": "abs", "data": {"axis": "y", "value": ay}}]
print(cmd({"execute": "input-send-event", "arguments": {"events": ev}}))
time.sleep(0.3)
print(cmd({"execute": "input-send-event", "arguments": {"events": [
{"type": "btn", "data": {"down": True, "button": "left"}}]}}))
time.sleep(0.15)
print(cmd({"execute": "input-send-event", "arguments": {"events": [
{"type": "btn", "data": {"down": False, "button": "left"}}]}}))
PY

68
scripts/win-ci/run-vm.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# win-ci Windows VM 기동 (.9 안 중첩 KVM)
#
# 이 스크립트를 직접 실행하지 말 것. start-vm.sh 가 systemd 트랜지언트 유닛으로
# 띄운다. 에이전트 턴에서 그냥 qemu 를 띄우면 ejclaw.service 의 cgroup 안에서
# 돌아 MemoryHigh=16G 상한을 쳐서 봇이 OOM 으로 죽는다 (2026-07-26 전례).
set -euo pipefail
D=/home/claude/win-ci
cd "$D"
# TPM 2.0 - Windows 11 요구사항
if [ ! -S "$D/swtpm/sock" ]; then
swtpm socket --tpmstate "dir=$D/swtpm" \
--ctrl "type=unixio,path=$D/swtpm/sock" --tpm2 -d
for i in $(seq 1 20); do [ -S "$D/swtpm/sock" ] && break; sleep 0.3; done
fi
rm -f "$D/qmp.sock"
# 설치가 끝나면 Windows ISO(4.8GB)는 필요 없어서 지운다. 그래도 유닛이 뜨도록
# 없는 ISO 는 그냥 건너뛴다. 없는 파일을 -drive 로 주면 qemu 가 기동에 실패해
# 재부팅 후 러너 호스트가 조용히 사라진다.
CDROMS=()
add_cd() { # 파일 버스 [bootindex]
[ -f "$1" ] || { echo "cdrom 건너뜀 (없음): $1" >&2; return; }
local id="cd$(basename "$1" | tr -cd 'a-z0-9')"
CDROMS+=( -drive "file=$1,if=none,id=$id,media=cdrom,readonly=on" )
if [ -n "${3:-}" ]; then
CDROMS+=( -device "ide-cd,drive=$id,bus=$2,bootindex=$3" )
else
CDROMS+=( -device "ide-cd,drive=$id,bus=$2" )
fi
}
add_cd "$D/iso/win11-ltsc.iso" ide.1 1
add_cd "$D/iso/virtio-win.iso" ide.2
add_cd "$D/unattend.iso" ide.3
exec qemu-system-x86_64 \
-name win-ci,process=win-ci \
-machine q35,smm=on,vmport=off \
-accel kvm \
-cpu host,hv_relaxed,hv_spinlocks=0x1fff,hv_vapic,hv_time \
-smp 4,sockets=1,cores=4,threads=1 \
-m 8192 \
-rtc base=localtime,driftfix=slew \
-global driver=cfi.pflash01,property=secure,value=on \
-global ICH9-LPC.disable_s3=1 \
-drive if=pflash,format=raw,unit=0,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.secboot.fd \
-drive if=pflash,format=raw,unit=1,file="$D/OVMF_VARS.fd" \
-chardev "socket,id=chrtpm,path=$D/swtpm/sock" \
-tpmdev emulator,id=tpm0,chardev=chrtpm \
-device tpm-tis,tpmdev=tpm0 \
-device virtio-vga,max_outputs=2,xres=1920,yres=1080 \
-audiodev none,id=aud0 \
-device intel-hda \
-device hda-duplex,audiodev=aud0 \
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:13389-:3389 \
-device virtio-net-pci,netdev=net0 \
-device qemu-xhci,id=xhci \
-device usb-kbd -device usb-tablet \
-drive "file=$D/win-ci.qcow2,if=none,id=sysdisk,format=qcow2,cache=writeback,discard=unmap,aio=threads" \
-device ide-hd,drive=sysdisk,bus=ide.0,bootindex=2 \
"${CDROMS[@]}" \
-serial "file:$D/serial.log" \
-qmp "unix:$D/qmp.sock,server=on,wait=off" \
-vnc 127.0.0.1:19 \
-boot menu=off,strict=on

129
scripts/win-ci/serve-host.py Executable file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""win-ci VM 과의 통신 채널.
게스트(Windows)는 QEMU user networking 안에 있어서 호스트를 10.0.2.2 로 본다.
이 서버는 그 반대편이다.
GET /ping 게스트가 네트워크 올라왔는지 확인용
GET /stage2.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로)
GET /<file> serve/ 안의 아무 파일 (러너 등록 토큰 등)
POST /log 게스트 로그 -> guest.log 에 append
POST /artifact/<name> 게스트 명령 출력 원본 -> results/<name> 에 파일로 저장
/artifact 가 필요한 이유: 게스트 검증 결과를 사람이 화면에서 읽고 옮겨 적으면
"고쳤다"를 증명할 기준선이 안 남는다. serial.log 는 VM 이 뜰 때마다 truncate 되고
로그 채널은 마지막 몇 줄만 흘린다. pytest 트레이스백처럼 전문이 필요한 출력은
반드시 이 엔드포인트로 호스트 파일에 떨어뜨린다.
127.0.0.1 만 바인딩한다. slirp 의 10.0.2.2 는 호스트 루프백으로 들어오므로
외부에 열 필요가 없다.
"""
import http.server
import os
import posixpath
import socketserver
import sys
from datetime import datetime
ROOT = "/home/claude/win-ci/serve"
LOG = "/home/claude/win-ci/guest.log"
RESULTS = "/home/claude/win-ci/results"
PORT = 8099
# 로그 전문뿐 아니라 빌드된 포터블 exe(100MB+)도 이 통로로 회수한다.
# 32MB 로 두면 배포할 exe 가 413 으로 튕긴다.
MAX_ARTIFACT = 1024 * 1024 * 1024
CHUNK = 1024 * 1024
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=ROOT, **kw)
def guess_type(self, path):
# .ps1 은 charset 을 반드시 명시한다. 없으면 PowerShell 쪽에서
# ISO-8859-1 로 디코딩해 한글 주석이 깨지고, 깨진 바이트가 CP1252 의
# 스마트따옴표가 되어 파서가 스크립트를 통째로 삼킨다.
if str(path).endswith(".ps1"):
return "text/plain; charset=utf-8"
return super().guess_type(path)
def do_GET(self):
if self.path.rstrip("/") == "/ping":
body = b"pong\n"
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
super().do_GET()
def do_POST(self):
path = self.path.rstrip("/")
if path == "/log":
self._recv_log()
elif path.startswith("/artifact/"):
self._recv_artifact(path[len("/artifact/") :])
else:
self.send_error(404)
def _recv_log(self):
n = int(self.headers.get("Content-Length") or 0)
data = self.rfile.read(n).decode("utf-8", "replace").strip()
with open(LOG, "a", encoding="utf-8") as f:
f.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} {data}\n")
self.send_response(204)
self.end_headers()
def _recv_artifact(self, name):
# 게스트가 주는 이름을 그대로 경로에 쓰면 안 된다. 파일명만 취한다.
name = posixpath.basename(name.replace("\\", "/"))
if not name or name.startswith("."):
self.send_error(400, "bad artifact name")
return
n = int(self.headers.get("Content-Length") or 0)
if n > MAX_ARTIFACT:
self.send_error(413, "artifact too large")
return
os.makedirs(RESULTS, exist_ok=True)
# 통째로 read() 하지 않는다. exe 는 100MB 가 넘어서 메모리에 다 올리면
# 봇 cgroup 의 메모리 상한을 건드린다. 조각으로 받아 바로 디스크에 쓴다.
#
# 그리고 임시 이름으로 받아 끝난 뒤에 옮긴다. 전송 중에 끊기면 반쪽짜리
# 파일이 정상 결과물처럼 남아 그대로 릴리스에 올라갈 수 있다.
final = os.path.join(RESULTS, name)
tmp = final + ".part"
left = n
with open(tmp, "wb") as f:
while left > 0:
block = self.rfile.read(min(CHUNK, left))
if not block:
break
f.write(block)
left -= len(block)
if left:
os.remove(tmp)
self.send_error(400, "artifact truncated")
return
os.replace(tmp, final)
print(f"artifact {name} {n} bytes", flush=True)
self.send_response(204)
self.end_headers()
def log_message(self, fmt, *args):
# 접근 로그는 stdout 으로만. journald 가 받는다.
sys.stdout.write(f"{self.address_string()} {fmt % args}\n")
sys.stdout.flush()
class Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
if __name__ == "__main__":
os.makedirs(ROOT, exist_ok=True)
os.makedirs(RESULTS, exist_ok=True)
with Server(("127.0.0.1", PORT), Handler) as httpd:
print(f"win-ci control server on 127.0.0.1:{PORT}, root={ROOT}", flush=True)
httpd.serve_forever()

View File

@@ -0,0 +1,425 @@
# win-ci VM 설치 스크립트 (호스트가 HTTP 로 내려준다 - agent.ps1 이 실행)
#
# 이 파일은 호스트의 /home/claude/win-ci/serve/stage2.ps1 이다. 고치면 게스트가
# 20초 안에 알아서 새로 받아 실행한다. Windows 재설치 필요 없다.
#
# 규칙: 반드시 idempotent. 이미 깔린 건 건너뛴다. 중간에 죽어도 다시 돌 수 있다.
#
# LTSC 2024 에는 winget(스토어)이 없다. 그래서 설치 파일을 직접 받는다.
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# 자식 프로세스 stdout 을 UTF-8 로 해독한다.
#
# 이게 없으면 PowerShell 이 자식의 출력을 콘솔 코드페이지(LTSC en-US = 437)로
# 읽는다. python 쪽은 PYTHONUTF8=1 로 UTF-8 을 내보내므로 한글이 전부 깨져서
# 호스트로 회수한 증거 파일의 실패 메시지를 읽을 수 없다. 여기서 바꾸는 건
# '해독' 쪽이라 파일 저장 인코딩(StreamWriter 는 UTF-8 고정)과는 무관하다.
try { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch { }
$Dl = 'C:\win-ci\dl'
New-Item -ItemType Directory -Force -Path $Dl | Out-Null
# Write-Output 를 쓰면 안 된다. 함수 안에서 부르면 그 함수의 반환값에
# 로그 문자열이 섞여 들어가 $f 가 배열이 되고, Start-Process 가
# "Cannot convert System.Object[] to System.String" 으로 죽는다.
# 실제로 이것 때문에 Git 설치가 실패했다.
function Log($m) { Write-Host "$m" }
function Get-File($url, $name) {
$out = Join-Path $Dl $name
if ((Test-Path $out) -and (Get-Item $out).Length -gt 1mb) { Log "캐시 사용 $name"; return $out }
for ($i = 1; $i -le 3; $i++) {
try {
Log "다운로드 ($i/3) $name"
Invoke-WebRequest -Uri $url -OutFile $out -UseBasicParsing -TimeoutSec 600
return $out
} catch {
Log "실패: $($_.Exception.Message)"
if ($i -eq 3) { throw }
Start-Sleep -Seconds 10
}
}
}
function Sync-Path {
$m = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$u = [Environment]::GetEnvironmentVariable('Path', 'User')
$env:Path = "$m;$u"
}
function Have($exe) { Sync-Path; return [bool](Get-Command $exe -ErrorAction SilentlyContinue) }
# ------------------------------------------------------------------ Git
# actions/checkout@v4 가 git 을 쓴다. 없으면 소스를 못 받는다.
if (Have 'git') {
Log "git 이미 있음: $(git --version)"
} else {
$f = Get-File 'https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.5/Git-2.55.0.5-64-bit.exe' 'git.exe'
Log "Git 설치 중"
Start-Process $f -ArgumentList '/VERYSILENT', '/NORESTART', '/NOCANCEL', '/SP-' -Wait
if (-not (Have 'git')) { throw 'git 설치 실패' }
Log "git 설치 완료: $(git --version)"
}
# ------------------------------------------------------------------ pwsh 7
# 워크플로가 shell: pwsh 를 쓴다. Windows 기본은 5.1 이라 별도로 필요하다.
if (Have 'pwsh') {
Log "pwsh 이미 있음: $(pwsh --version)"
} else {
$f = Get-File 'https://github.com/PowerShell/PowerShell/releases/download/v7.4.6/PowerShell-7.4.6-win-x64.msi' 'pwsh.msi'
Log "PowerShell 7 설치 중"
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart', 'ADD_PATH=1' -Wait
if (-not (Have 'pwsh')) { throw 'pwsh 설치 실패' }
Log "pwsh 설치 완료: $(pwsh --version)"
}
# ------------------------------------------------------------------ Python 3.12
if (Have 'python') {
Log "python 이미 있음: $(python --version 2>&1)"
} else {
$f = Get-File 'https://www.python.org/ftp/python/3.12.10/python-3.12.10-amd64.exe' 'python.exe'
Log "Python 3.12 설치 중"
Start-Process $f -ArgumentList '/quiet', 'InstallAllUsers=1', 'PrependPath=1', `
'Include_test=0', 'Include_launcher=1', 'AssociateFiles=0' -Wait
if (-not (Have 'python')) { throw 'python 설치 실패' }
Log "python 설치 완료: $(python --version 2>&1)"
}
# ------------------------------------------------------------------ Node
# act_runner 의 host 모드는 JS 액션(checkout, upload-artifact)을 돌리려고
# PATH 의 node 를 쓴다. 없으면 그 단계들이 전부 실패한다.
if (Have 'node') {
Log "node 이미 있음: $(node --version)"
} else {
$f = Get-File 'https://nodejs.org/dist/v22.20.0/node-v22.20.0-x64.msi' 'node.msi'
Log "Node.js 설치 중"
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart' -Wait
if (-not (Have 'node')) { throw 'node 설치 실패' }
Log "node 설치 완료: $(node --version)"
}
# ------------------------------------------------------------------ CMake
# native\process_loopback 빌드용. 워크플로에서 continue-on-error 지만 있으면 검증된다.
if (Have 'cmake') {
Log "cmake 이미 있음: $(cmake --version | Select-Object -First 1)"
} else {
$f = Get-File 'https://github.com/Kitware/CMake/releases/download/v3.31.6/cmake-3.31.6-windows-x86_64.msi' 'cmake.msi'
Log "CMake 설치 중"
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart', 'ADD_CMAKE_TO_PATH=System' -Wait
Sync-Path
if (Have 'cmake') { Log "cmake 설치 완료: $(cmake --version | Select-Object -First 1)" }
else { Log "WARN cmake 설치 실패 - 워크플로의 native 빌드 단계는 건너뛰어진다" }
}
# ------------------------------------------------------ VS 2022 Build Tools (C++)
# livesub_capture.exe (native\process_loopback) 는 WASAPI 프로세스 루프백을 쓴다.
# 그 헤더(audioclientactivationparams.h)와 WRL 은 Windows SDK + MSVC 에만 있다.
# cmake 만 깔면 "generator 는 있는데 컴파일러가 없다"로 configure 에서 죽는다.
#
# 왜 빠뜨리면 안 되나: 이 exe 가 없으면 포터블 빌드가 그대로 성공하되
# 프로그램별 캡처가 장치 전체 캡처로 조용히 폴백된다. 즉 "게임 소리만 받는다"는
# 핵심 기능이 빠진 채로 exe 가 나온다. 실제로 그 상태의 exe 가 한 번 나왔다.
$VsWhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
function Get-MsvcRoot {
if (-not (Test-Path $VsWhere)) { return $null }
$p = & $VsWhere -latest -products * `
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
-property installationPath 2>$null
if ($p) { return ($p | Select-Object -First 1) }
return $null
}
$msvc = Get-MsvcRoot
if ($msvc) {
Log "MSVC 이미 있음: $msvc"
} else {
$f = Get-File 'https://aka.ms/vs/17/release/vs_BuildTools.exe' 'vs_BuildTools.exe'
Log "VS 2022 Build Tools 설치 중 (C++ 워크로드, 수 GB - 20분 이상 걸린다)"
# --wait 없이 Start-Process -Wait 만 쓰면 부트스트래퍼가 먼저 반환해 버려
# 설치가 끝나기 전에 다음 단계로 넘어간다. 둘 다 있어야 한다.
$p = Start-Process $f -Wait -PassThru -ArgumentList `
'--quiet', '--wait', '--norestart', '--nocache', `
'--add', 'Microsoft.VisualStudio.Workload.VCTools', '--includeRecommended'
# 3010 = 설치는 됐고 재부팅만 권고. 실패가 아니다.
Log "Build Tools 설치기 종료코드 $($p.ExitCode)"
$msvc = Get-MsvcRoot
if ($msvc) { Log "MSVC 설치 완료: $msvc" }
else { Log "WARN MSVC 설치 실패 - livesub_capture.exe 를 빌드할 수 없다" }
}
# ------------------------------------------------------------------ act_runner
$RunnerDir = 'C:\gitea-runner'
$RunnerExe = Join-Path $RunnerDir 'act_runner.exe'
New-Item -ItemType Directory -Force -Path $RunnerDir | Out-Null
if (Test-Path $RunnerExe) {
Log "act_runner 이미 있음"
} else {
Log "act_runner 다운로드"
Invoke-WebRequest -Uri 'https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-windows-amd64.exe' `
-OutFile $RunnerExe -UseBasicParsing -TimeoutSec 300
Log "act_runner 받음: $((Get-Item $RunnerExe).Length) bytes"
}
# GPU 러너와 달리 이건 CPU 러너지만, 같은 원칙으로 capacity 1 로 고정한다.
# 이 VM 은 vCPU 4개뿐이라 두 작업이 동시에 돌면 둘 다 느려지고 타임아웃 위험이 생긴다.
$cfg = Join-Path $RunnerDir 'config.yaml'
if (-not (Test-Path $cfg)) {
@'
log:
level: info
runner:
capacity: 1
timeout: 60m
shutdown_timeout: 3m
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
container:
network: ""
privileged: false
force_pull: false
host:
workdir_parent: C:\gitea-runner\work
'@ | Set-Content -Path $cfg -Encoding ASCII
Log "config.yaml 생성 (capacity 1)"
}
# ------------------------------------------- 러너 등록 (토큰이 호스트에 있을 때만)
# 호스트의 serve/runner-token.txt 에 등록 토큰을 넣어주면 여기서 등록한다.
# 없으면 이 단계만 건너뛰고 나머지는 전부 완료로 본다.
$registered = Test-Path (Join-Path $RunnerDir '.runner')
if ($registered) {
Log "러너 이미 등록됨"
} else {
$tok = $null
try {
$tok = (Invoke-WebRequest -Uri 'http://10.0.2.2:8099/runner-token.txt' -UseBasicParsing -TimeoutSec 10).Content
if ($tok -is [byte[]]) { $tok = [Text.Encoding]::ASCII.GetString($tok) }
$tok = $tok.Trim()
} catch { $tok = $null }
if ([string]::IsNullOrWhiteSpace($tok)) {
Log "등록 토큰 없음 (serve/runner-token.txt) - 러너 등록은 건너뜀. 사전 설치는 전부 끝났다."
} else {
Log "러너 등록 시도 (토큰 길이 $($tok.Length))"
Push-Location $RunnerDir
$out = & $RunnerExe register --no-interactive `
--instance 'https://git.tkrmagid.kr' `
--token $tok `
--name 'win-ci' `
--labels 'windows:host' 2>&1 | Out-String
Pop-Location
Log "register -> $($out.Trim())"
if (-not (Test-Path (Join-Path $RunnerDir '.runner'))) { throw "러너 등록 실패" }
# 서비스로 만들지 않는다. SYSTEM 권한이 되고, 데스크톱 세션이 없어
# GUI 테스트/스크린샷이 전부 실패한다. 로그온 예약 작업이어야 한다.
& schtasks.exe /Create /TN 'gitea-runner' /SC ONLOGON /RL HIGHEST /F `
/TR "cmd.exe /c cd /d C:\gitea-runner && act_runner.exe daemon --config config.yaml" | Out-Null
& schtasks.exe /Run /TN 'gitea-runner' | Out-Null
Log "러너 등록 + 로그온 작업 등록 완료, daemon 시작"
}
}
# ------------------------------------------------------------------ 요약
Sync-Path
Log "=== 설치 요약 ==="
foreach ($c in 'git', 'pwsh', 'python', 'node', 'cmake') {
$v = try { (& $c --version 2>&1 | Select-Object -First 1) } catch { '없음' }
Log ("{0,-8} {1}" -f $c, $v)
}
Log "act_runner $(if (Test-Path $RunnerExe) { 'OK' } else { '없음' }) 등록=$(Test-Path (Join-Path $RunnerDir '.runner'))"
# 모니터 2개가 실제로 잡혔는지 - virtio-gpu 드라이버가 들어갔는지 확인하는 지표다.
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
$screens = [System.Windows.Forms.Screen]::AllScreens
Log "화면 $($screens.Count)개: $(($screens | ForEach-Object { $_.Bounds.Width.ToString() + 'x' + $_.Bounds.Height }) -join ', ')"
$gpu = Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }
Log "비디오 어댑터: $($gpu -join ', ')"
} catch {
Log "화면 열거 실패: $($_.Exception.Message)"
}
Log "=== stage2 완료 ==="
# ==================================================================
# Windows 전용 경로 실제 검증
#
# 러너 등록 토큰이 없어도 여기서 워크플로와 똑같은 단계를 직접 돌린다.
# 이게 VM 을 만든 진짜 이유다 - 리눅스에서 못 돌리던 경로(WASAPI, 전역
# 단축키, 모니터 열거, PyInstaller exe)가 실제로 통과하는지 본다.
#
# 통째로 try/catch 로 감싼다. 검증이 실패해도 stage2 자체는 성공이어야
# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다.
# ==================================================================
$Repo = 'C:\win-ci\live-app-translator'
$OutDir = 'C:\win-ci\out'
# 검증 대상 리비전. 'main' 이면 origin/main 최신.
# 호스트에서 `./sync.sh <sha>` 를 쓰면 이 한 줄이 그 sha 로 바뀐 사본이 배포되고,
# 내용이 바뀌므로 게스트 agent 가 해시 차이를 보고 20초 안에 다시 돈다.
# = "이 커밋을 검증해라" 라는 요청이 곧 재실행 트리거다.
$TargetRev = 'main'
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
# 출력 원본을 호스트 파일로 회수한다.
#
# 왜 필요한가: 로그 채널로는 마지막 몇 줄만 흘렸고 serial.log 는 VM 이 뜰 때마다
# truncate 된다. 그래서 pytest 실패 트레이스백이 호스트에 한 줄도 안 남았고
# 화면에서 읽어 옮겨 적는 상태였다. 파일로 된 기준선이 없으면 고친 뒤에
# 고쳐졌다는 증명을 할 수 없다.
function Send-Artifact($name, $path) {
if (-not (Test-Path $path)) { return }
try {
$bytes = [IO.File]::ReadAllBytes($path)
# 백틱 줄바꿈을 쓰지 않는다. LF 로 저장된 스크립트에서 토크나이저가
# 어긋나 스크립트 전체가 무효가 된 전례가 있다 (NOTES.md 참고).
# 타임아웃은 넉넉히. 로그는 수십 KB 지만 포터블 exe 는 100MB 를 넘고
# slirp(QEMU user networking) 는 그만큼 빠르지 않다.
$req = @{ Uri = "http://10.0.2.2:8099/artifact/$name"; Method = 'Post'; TimeoutSec = 1800; Body = $bytes; ContentType = 'application/octet-stream' }
Invoke-RestMethod @req | Out-Null
Log " -> 호스트로 $name ($($bytes.Length) bytes)"
} catch {
Log " -> $name 업로드 실패: $($_.Exception.Message)"
}
}
function Step($name, $block) {
$log = Join-Path $OutDir "$name.log"
Remove-Item $log -Force -ErrorAction SilentlyContinue
Log "--- [$name] 시작"
# StreamWriter + AutoFlush 로 한 줄씩 즉시 디스크에 쓴다. 중간에 예외로
# 죽어도 거기까지의 출력이 남아야 한다 (Tee-Object 는 그 보장이 없다).
$sw = [IO.StreamWriter]::new($log, $false, [Text.UTF8Encoding]::new($false))
$sw.AutoFlush = $true
$tail = [System.Collections.Generic.Queue[string]]::new()
$ok = $true
try {
& $block 2>&1 | ForEach-Object {
$line = "$_"
$sw.WriteLine($line)
$tail.Enqueue($line)
if ($tail.Count -gt 25) { [void]$tail.Dequeue() }
}
} catch {
$ok = $false
$sw.WriteLine("[step 예외] $($_.Exception.Message)")
}
$sw.Close()
foreach ($l in $tail) { Log " $l" }
if ($ok) { Log "--- [$name] 통과" } else { Log "--- [$name] 실패" }
Send-Artifact "$name.log" $log
return $ok
}
$ErrorActionPreference = 'Continue'
try {
Sync-Path
if (-not (Test-Path $Repo)) {
Log "저장소 clone"
& git clone -q https://git.tkrmagid.kr/tkrmagid/live-app-translator $Repo
}
$ref = if ($TargetRev -eq 'main') { 'origin/main' } else { $TargetRev }
Log "저장소 갱신 -> $ref"
Push-Location $Repo
& git fetch --all -q
& git reset --hard $ref -q
Pop-Location
Push-Location $Repo
$py = Join-Path $Repo '.venv-ci\Scripts\python.exe'
if (-not (Test-Path $py)) { & python -m venv .venv-ci }
& $py -m pip install --upgrade pip --quiet
& $py -m pip install -r packaging\requirements-portable.txt --quiet
& $py -m pip install pytest ruff --quiet
# 워크플로와 같은 조건인지 - torch 가 없어야 "포터블에서 1~3티어가 된다"를
# 실제로 검증한 셈이 된다.
& $py -c "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('torch') is None else 1)"
Log "포터블 조건(torch 없음) = $($LASTEXITCODE -eq 0)"
$env:PYTHONPATH = 'src'
# Windows 콘솔 기본 인코딩(cp1252)에서는 한글 print 가 UnicodeEncodeError 로
# 죽는다. 검증 스크립트가 한글을 찍으므로 UTF-8 로 고정한다.
$env:PYTHONIOENCODING = 'utf-8'
$env:PYTHONUTF8 = '1'
# 출력을 자르지 않는다. 전문은 Step 이 호스트 results/<name>.log 로 올린다.
# 여기서 truncate 하면 실패 원인이 있는 트레이스백 본문이 날아간다.
$r = [ordered]@{}
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q -rf --tb=long; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
$r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
# 캡처 도우미를 먼저 만든다. windows_smoke 가 이 exe 의 존재를 점검하고,
# livesub.spec 도 있을 때만 포터블 exe 안에 담는다. 순서가 바뀌면 둘 다
# "없음"으로 통과해서 기능이 빠진 exe 가 나온다.
$r['캡처도우미'] = Step 'native_capture' {
& cmake -S native\process_loopback -B native\process_loopback\build -A x64
if ($LASTEXITCODE -ne 0) { throw "cmake configure exit $LASTEXITCODE" }
& cmake --build native\process_loopback\build --config Release
if ($LASTEXITCODE -ne 0) { throw "cmake build exit $LASTEXITCODE" }
$cap = 'src\livesub\resources\bin\livesub_capture.exe'
if (-not (Test-Path $cap)) { throw "$cap 없음" }
"livesub_capture.exe $((Get-Item $cap).Length) bytes"
}
$r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
# 점검이 찍은 스크린샷을 묶어서 호스트로 가져온다. 화면 관련 회귀는 로그로는
# 안 보이고, 문서에 넣을 그림도 실제 Windows 에서 찍은 것이어야 한다.
try {
$png = Join-Path $Repo 'artifacts'
if (Test-Path $png) {
$zip = Join-Path $OutDir 'screenshots.zip'
Remove-Item $zip -Force -ErrorAction SilentlyContinue
Compress-Archive -Path (Join-Path $png '*.png') -DestinationPath $zip -Force
Send-Artifact 'screenshots.zip' $zip
}
} catch { Log "스크린샷 회수 실패: $($_.Exception.Message)" }
$r['exe빌드'] = Step 'pyinstaller' {
& $py -m pip install pyinstaller --quiet
# 이전 산출물을 반드시 지우고 시작한다. 남겨두면 두 가지가 겹쳐 터진다.
# 1) 앞 회차의 exe기동 점검이 띄운 LiveSub 가 파일을 잡고 있어
# PyInstaller 가 EXE 를 덮어쓰지 못하고 PermissionError 로 죽는다.
# 2) 그런데 옛 exe 는 그대로 남아 있으니 Test-Path 는 통과한다.
# 즉 빌드가 실패했는데 PASS 로 보고된다 - 실제로 그렇게 오보가 났다.
Get-Process LiveSub -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 2
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
& $py -m PyInstaller packaging\livesub.spec --noconfirm
if ($LASTEXITCODE -ne 0) { throw "PyInstaller exit $LASTEXITCODE" }
if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }
"exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB"
}
$r['exe기동'] = Step 'exe_smoke' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
# 빌드와 기동이 둘 다 통과했을 때만 배포용으로 회수한다. 실패한 exe 를
# 호스트로 올리면 그게 릴리스에 올라갈 위험이 있다.
if ($r['exe빌드'] -and $r['exe기동']) {
Send-Artifact 'LiveSub.exe' (Join-Path $Repo 'dist\LiveSub.exe')
} else {
Log "exe 회수 건너뜀 (빌드=$($r['exe빌드']) 기동=$($r['exe기동']))"
}
# 판정 요약도 파일로 남긴다. 어떤 리비전을 검증한 결과인지 같이 박아야
# 나중에 "언제 통과한 건지" 를 되짚을 수 있다.
$rev = (& git rev-parse --short HEAD 2>&1 | Select-Object -First 1)
$sum = Join-Path $OutDir 'summary.log'
$lines = @("rev=$rev", "utc=$([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss'))")
Log "=== Windows 검증 결과 (rev $rev) ==="
foreach ($k in $r.Keys) {
$line = "{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })
Log $line
$lines += $line
}
Set-Content -Path $sum -Value $lines -Encoding UTF8
Send-Artifact 'summary.log' $sum
Log "=== 검증 끝 ==="
Pop-Location
} catch {
Log "검증 준비 단계에서 실패: $($_.Exception.Message)"
try { Pop-Location } catch {}
}

31
scripts/win-ci/shot.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# win-ci VM 화면 스냅샷. QMP screendump 로 찍는다.
# 사용법: ./shot.sh [출력경로.png]
set -euo pipefail
OUT="${1:-/home/claude/EJClaw/data/attachments/generated/win-ci-$(date +%H%M%S).png}"
mkdir -p "$(dirname "$OUT")"
python3 - "$OUT" <<'PY'
import json, socket, sys, time
out = sys.argv[1]
s = socket.socket(socket.AF_UNIX)
s.settimeout(20)
s.connect("/home/claude/win-ci/qmp.sock")
f = s.makefile("rwb")
f.readline() # greeting
def cmd(o):
f.write((json.dumps(o) + "\n").encode()); f.flush()
while True:
line = f.readline()
if not line: raise SystemExit("qmp closed")
m = json.loads(line)
if "return" in m or "error" in m: return m
cmd({"execute": "qmp_capabilities"})
r = cmd({"execute": "screendump", "arguments": {"filename": out, "format": "png"}})
if "error" in r:
# 구버전 qemu 는 format 인자를 모른다 -> ppm 으로 받는다
r = cmd({"execute": "screendump", "arguments": {"filename": out + ".ppm"}})
print(json.dumps(r))
else:
print("ok", out)
PY
ls -l "$OUT"* 2>/dev/null

33
scripts/win-ci/start-vm.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# win-ci VM + 호스트 제어 서버를 systemd 트랜지언트 유닛으로 띄운다.
#
# 반드시 봇(ejclaw.service) cgroup 밖에서 돌아야 한다. 안에서 돌면 8GB VM 이
# MemoryHigh=16G 를 쳐서 봇이 OOM 으로 죽는다. --user 유닛으로 띄우면
# systemd --user 의 자식이 되어 app.slice 형제로 빠지고, 에이전트 턴이
# 끝나도 계속 살아 있는다.
#
# CPUQuota=400% / CPUWeight=50 - 물리 호스트가 i7-11700(16스레드)뿐이고 .5 에서
# 운영 컨테이너 19개가 돈다. 4코어를 넘기면 .5 응답이 +140% 느려진다(실측).
set -euo pipefail
systemctl --user reset-failed win-ci.service win-ci-http.service 2>/dev/null || true
# 게스트 <-> 호스트 제어 채널 (게스트는 10.0.2.2:8099 로 본다)
if ! systemctl --user is-active --quiet win-ci-http.service; then
systemd-run --user --unit=win-ci-http \
--description="win-ci guest control channel" \
-p Restart=always \
/usr/bin/python3 /home/claude/win-ci/serve-host.py
fi
# VM
systemd-run --user --unit=win-ci \
--description="win-ci Windows CI VM (nested KVM)" \
-p MemoryMax=10G \
-p CPUQuota=400% \
-p CPUWeight=50 \
-p WorkingDirectory=/home/claude/win-ci \
/bin/bash /home/claude/win-ci/run-vm.sh
sleep 2
systemctl --user --no-pager status win-ci-http.service win-ci.service | head -40

19
scripts/win-ci/status.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
# win-ci VM 현재 상태를 한 번에 본다.
cd /home/claude/win-ci
echo "=== 유닛 ==="
systemctl --user is-active win-ci.service win-ci-http.service | paste -sd' ' -
systemctl --user show win-ci.service -p MemoryCurrent -p CPUUsageNSec --value | paste -sd' ' -
echo
echo "=== 디스크 사용량 (설치가 진행되면 커진다) ==="
du -h --apparent-size win-ci.qcow2 2>/dev/null | cut -f1
du -h win-ci.qcow2 | cut -f1
echo
echo "=== 게스트 로그 (HTTP, 최근 25줄) ==="
tail -n 25 guest.log 2>/dev/null || echo "(아직 없음 - 네트워크 올라오기 전)"
echo
echo "=== 시리얼 로그 (최근 25줄) ==="
tail -n 25 serial.log 2>/dev/null | tr -d '\r' || echo "(아직 없음)"
echo
echo "=== 러너 등록 토큰 ==="
if [ -s serve/runner-token.txt ]; then echo "있음 ($(wc -c < serve/runner-token.txt) bytes)"; else echo "없음 - serve/runner-token.txt 에 넣으면 게스트가 20초 안에 자동 등록"; fi

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

67
scripts/win-ci/type.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/bin/bash
# VM 게스트에 키보드 입력을 보낸다 (QMP send-key).
#
# ./type.sh text "hello world" 문자열 입력
# ./type.sh key ret 단일 키 (qcode)
# ./type.sh key alt-f4 조합키 (하이픈으로 연결)
#
# 게스트에 네트워크가 없을 때 유일한 제어 수단이므로 지우지 말 것.
set -euo pipefail
python3 - "$@" <<'PY'
import json, socket, sys, time
MODE = sys.argv[1]
ARG = sys.argv[2]
s = socket.socket(socket.AF_UNIX); s.settimeout(30)
s.connect("/home/claude/win-ci/qmp.sock"); f = s.makefile("rwb"); f.readline()
def cmd(o):
f.write((json.dumps(o) + "\n").encode()); f.flush()
while True:
m = json.loads(f.readline())
if "return" in m or "error" in m:
if "error" in m: print("ERR", m, file=sys.stderr)
return m
cmd({"execute": "qmp_capabilities"})
def send(qcodes):
cmd({"execute": "send-key", "arguments": {
"keys": [{"type": "qcode", "data": q} for q in qcodes]}})
time.sleep(0.04)
SHIFTED = {
'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7',
'*': '8', '(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left',
'}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe',
'<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent',
}
PLAIN = {
' ': 'spc', '-': 'minus', '=': 'equal', '[': 'bracket_left',
']': 'bracket_right', '\\': 'backslash', ';': 'semicolon',
"'": 'apostrophe', ',': 'comma', '.': 'dot', '/': 'slash',
'`': 'grave_accent', '\n': 'ret', '\t': 'tab',
}
def char_keys(c):
if c.isalpha():
q = c.lower()
return ['shift', q] if c.isupper() else [q]
if c.isdigit():
return [c]
if c in SHIFTED:
return ['shift', SHIFTED[c]]
if c in PLAIN:
return [PLAIN[c]]
raise SystemExit("unsupported char: %r" % c)
if MODE == "text":
for c in ARG:
send(char_keys(c))
elif MODE == "key":
send(ARG.split("-"))
else:
raise SystemExit("usage: type.sh text <str> | type.sh key <qcode>")
print("ok")
PY

View File

@@ -0,0 +1,190 @@
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage>
<UILanguage>en-US</UILanguage>
</SetupUILanguage>
<InputLocale>0409:00000409</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<!-- Win11 하드웨어 검사 우회. TPM/SecureBoot 는 실제로 제공하지만
중첩 가상화라 CPU/스토리지 검사가 튕길 수 있으므로 안전망으로 둔다. -->
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>4</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>5</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassStorageCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
<DiskConfiguration>
<WillShowUI>OnError</WillShowUI>
<Disk wcm:action="add">
<DiskID>0</DiskID>
<WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add">
<Order>1</Order>
<Type>EFI</Type>
<Size>512</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>2</Order>
<Type>MSR</Type>
<Size>16</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>3</Order>
<Type>Primary</Type>
<Extend>true</Extend>
</CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add">
<Order>1</Order>
<PartitionID>1</PartitionID>
<Label>System</Label>
<Format>FAT32</Format>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>2</Order>
<PartitionID>2</PartitionID>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>3</Order>
<PartitionID>3</PartitionID>
<Label>Windows</Label>
<Format>NTFS</Format>
<Letter>C</Letter>
</ModifyPartition>
</ModifyPartitions>
</Disk>
</DiskConfiguration>
<ImageInstall>
<OSImage>
<InstallFrom>
<MetaData wcm:action="add">
<Key>/IMAGE/INDEX</Key>
<Value>1</Value>
</MetaData>
</InstallFrom>
<InstallTo>
<DiskID>0</DiskID>
<PartitionID>3</PartitionID>
</InstallTo>
<WillShowUI>OnError</WillShowUI>
</OSImage>
</ImageInstall>
<UserData>
<AcceptEula>true</AcceptEula>
<FullName>ci</FullName>
<Organization>livesub</Organization>
</UserData>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>WIN-CI</ComputerName>
<TimeZone>Korea Standard Time</TimeZone>
</component>
<!-- RDP 로 붙을 수 있게 열어둔다. 호스트에서 127.0.0.1:13389 로 포워딩된다. -->
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<fDenyTSConnections>false</fDenyTSConnections>
</component>
<component name="Microsoft-Windows-TerminalServices-RDP-WinStationExtensions" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<UserAuthentication>0</UserAuthentication>
</component>
<component name="Networking-MPSSVC-Svc" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<FirewallGroups>
<FirewallGroup wcm:action="add" wcm:keyValue="RemoteDesktop">
<Active>true</Active>
<Group>@FirewallAPI.dll,-28752</Group>
<Profile>all</Profile>
</FirewallGroup>
</FirewallGroups>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>ci</Name>
<DisplayName>ci</DisplayName>
<Group>Administrators</Group>
<Password>
<Value>LiveSubCI!2026</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Username>ci</Username>
<Enabled>true</Enabled>
<LogonCount>999</LogonCount>
<Password>
<Value>LiveSubCI!2026</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>provision windows ci runner</Description>
<RequiresUserInput>false</RequiresUserInput>
<CommandLine>cmd.exe /c "for %d in (D E F G H I J K L M) do @if exist %d:\setup\provision.ps1 start /wait powershell.exe -NoProfile -ExecutionPolicy Bypass -File %d:\setup\provision.ps1"</CommandLine>
</SynchronousCommand>
</FirstLogonCommands>
<TimeZone>Korea Standard Time</TimeZone>
</component>
</settings>
</unattend>

View File

@@ -0,0 +1,96 @@
# Windows CI VM 상주 에이전트
#
# 하는 일은 하나다: 호스트가 주는 stage2.ps1 을 받아서 돌린다.
#
# 왜 이렇게 하나 - ISO 는 한 번 구우면 못 고친다. 설치 스크립트를 ISO 에
# 박아두면 한 줄 고칠 때마다 Windows 를 다시 설치해야 한다. 그래서 ISO 에는
# 이 껍데기만 넣고, 실제 설치 내용은 호스트의 HTTP 로 가져온다.
# QEMU user networking 에서 호스트는 항상 10.0.2.2 다.
#
# 로그는 COM1(호스트 serial.log) + 호스트 HTTP(POST /log) + 로컬 파일.
$ErrorActionPreference = 'Continue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$Host_ = 'http://10.0.2.2:8099'
$WorkDir = 'C:\win-ci'
$Stage2 = Join-Path $WorkDir 'stage2.ps1'
$HashFile = Join-Path $WorkDir 'stage2.hash'
$LogFile = Join-Path $WorkDir 'agent.log'
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
function Say([string]$m) {
$line = "[agent $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $m"
try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 } catch {}
try {
$p = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One'
# 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다
$p.Encoding = [Text.Encoding]::UTF8
$p.Open(); $p.WriteLine($line); $p.Close()
} catch {}
try {
Invoke-RestMethod -Uri "$Host_/log" -Method Post -TimeoutSec 5 `
-Body ([Text.Encoding]::UTF8.GetBytes($line)) -ContentType "text/plain; charset=utf-8" | Out-Null
} catch {}
}
# 같은 에이전트가 두 개 돌면 설치가 겹친다. 뮤텍스로 하나만 남긴다.
$mutex = New-Object System.Threading.Mutex($false, 'Global\win-ci-agent')
if (-not $mutex.WaitOne(0)) { Say "이미 다른 agent 가 돌고 있다. 종료."; exit 0 }
Say "agent 시작. user=$env:USERNAME"
# 네트워크가 올라오길 기다린다. virtio-net 드라이버 설치 직후라 몇 초 걸린다.
$netOk = $false
for ($i = 0; $i -lt 60; $i++) {
try {
Invoke-WebRequest -Uri "$Host_/ping" -TimeoutSec 4 -UseBasicParsing | Out-Null
$netOk = $true; break
} catch { Start-Sleep -Seconds 5 }
}
if ($netOk) { Say "호스트 연결 OK ($Host_)" }
else { Say "ERROR 5분 동안 호스트에 못 붙었다. virtio-net 드라이버를 확인해야 한다." }
# 이후로는 계속 돌면서 stage2 가 바뀔 때마다 실행한다.
while ($true) {
try {
# 반드시 바이트로 받아 UTF-8 로 직접 디코딩한다. Invoke-WebRequest 의
# .Content 는 Content-Type 에 charset 이 없으면 ISO-8859-1 로 디코딩해
# 한글 주석이 깨지고, 깨진 바이트가 CP1252 의 스마트따옴표(0x93/0x94)가
# 되어 PowerShell 파서가 그 뒤를 전부 문자열로 삼킨다. 실제로 겪은 사고다.
$wc = New-Object Net.WebClient
$body = [Text.Encoding]::UTF8.GetString($wc.DownloadData("$Host_/stage2.ps1"))
# 받은 쪽에 BOM 이 있으면 문자열 맨 앞에 U+FEFF 가 남는다. 아래에서
# Set-Content -Encoding UTF8 이 BOM 을 또 붙이므로 BOM 이 두 개가 되고,
# PowerShell 은 첫 개만 벗겨내 남은 U+FEFF 때문에 1행이 주석으로
# 인식되지 않는다. 그래서 여기서 반드시 떼어낸다.
$body = $body.TrimStart([char]0xFEFF)
$sha = [BitConverter]::ToString(
[Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($body))
).Replace('-', '')
$prev = if (Test-Path $HashFile) { (Get-Content $HashFile -Raw).Trim() } else { '' }
if ($sha -ne $prev) {
Say "stage2 새 버전 감지 ($($sha.Substring(0,12))) - 실행"
Set-Content -Path $Stage2 -Value $body -Encoding UTF8
try {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Stage2 2>&1 |
ForEach-Object { Say "stage2| $_" }
if ($LASTEXITCODE -eq 0 -or $null -eq $LASTEXITCODE) {
# 성공했을 때만 해시를 저장한다. 중간에 죽었으면 다음 턴에 다시 돈다.
Set-Content -Path $HashFile -Value $sha
Say "stage2 성공"
} else {
Say "stage2 실패 exit=$LASTEXITCODE - 다음 폴링에 재시도"
}
} catch {
Say "stage2 예외: $($_.Exception.Message) - 다음 폴링에 재시도"
}
}
} catch {
Say "폴링 실패: $($_.Exception.Message)"
}
Start-Sleep -Seconds 20
}

View File

@@ -0,0 +1,110 @@
# Windows CI VM 최초 부팅 프로비저닝 (무인)
#
# 이 스크립트는 autounattend.xml 의 FirstLogonCommands 로 한 번만 실행된다.
# 네트워크가 아직 없는 상태에서 시작하므로, 순서가 중요하다.
# 1) virtio 드라이버 설치 (ISO 에서 오프라인) -> 이걸 해야 네트워크가 생긴다
# 2) 화면 꺼짐/잠금 끄기 (GUI 테스트가 잠금화면에서 실패하지 않게)
# 3) agent.ps1 을 C:\win-ci 에 심고 로그온 예약 작업으로 등록
# 4) agent 를 바로 실행 -> 이후 모든 설치는 호스트가 주는 stage2.ps1 이 한다
#
# 로그는 COM1(호스트의 serial.log)과 C:\win-ci\provision.log 양쪽에 남긴다.
# 네트워크가 없는 1) 단계를 호스트에서 관찰할 수 있어야 하기 때문이다.
$ErrorActionPreference = 'Continue'
New-Item -ItemType Directory -Force -Path 'C:\win-ci' | Out-Null
$script:Port = $null
try {
$script:Port = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One'
# 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다
$script:Port.Encoding = [Text.Encoding]::UTF8
$script:Port.Open()
} catch { $script:Port = $null }
function Say([string]$m) {
$line = "[provision $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $m"
try { Add-Content -Path 'C:\win-ci\provision.log' -Value $line -Encoding UTF8 } catch {}
if ($script:Port) { try { $script:Port.WriteLine($line) } catch {} }
}
Say "start. user=$env:USERNAME computer=$env:COMPUTERNAME"
# ---------------------------------------------------------------- 1) virtio
# virtio-win ISO 가 몇 번째 드라이브로 잡힐지 모른다. 전부 훑어 찾는다.
$virtio = $null
foreach ($d in [char[]](68..77)) { # D..M
$p = "${d}:\virtio-win-guest-tools.exe"
if (Test-Path $p) { $virtio = "${d}:"; break }
}
if (-not $virtio) {
Say "ERROR virtio-win ISO 를 못 찾음. 네트워크/화면 드라이버 없이 계속한다."
} else {
Say "virtio ISO = $virtio"
# 드라이버 스토어에 직접 넣는 쪽을 먼저 한다. 설치 프로그램보다 확실하다.
$infs = @(
"$virtio\NetKVM\w11\amd64\netkvm.inf", # 네트워크 - 가장 중요
"$virtio\viogpudo\w11\amd64\viogpudo.inf", # 화면 - 모니터 2개에 필요
"$virtio\Balloon\w11\amd64\balloon.inf",
"$virtio\vioserial\w11\amd64\vioser.inf",
"$virtio\viostor\w11\amd64\viostor.inf",
"$virtio\vioscsi\w11\amd64\vioscsi.inf",
"$virtio\vioinput\w11\amd64\vioinput.inf"
)
foreach ($inf in $infs) {
if (Test-Path $inf) {
$out = & pnputil.exe /add-driver $inf /install 2>&1 | Out-String
Say "pnputil $(Split-Path $inf -Leaf) -> $($out.Trim() -replace '\s+', ' ')"
} else {
Say "skip (없음) $inf"
}
}
# guest-tools 는 balloon 서비스/qemu-ga 까지 붙여준다. 실패해도 위에서 드라이버는 들어갔다.
try {
Say "virtio-win-guest-tools 설치 시작"
$p = Start-Process -FilePath "$virtio\virtio-win-guest-tools.exe" `
-ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru
Say "virtio-win-guest-tools exit=$($p.ExitCode)"
} catch {
Say "virtio-win-guest-tools 실패: $($_.Exception.Message)"
}
}
# ---------------------------------------------------- 2) 화면 꺼짐 / 잠금 끄기
# 러너가 GUI 테스트와 스크린샷을 찍으므로 데스크톱이 살아 있어야 한다.
& powercfg.exe /change monitor-timeout-ac 0
& powercfg.exe /change standby-timeout-ac 0
& powercfg.exe /change disk-timeout-ac 0
& powercfg.exe /change hibernate-timeout-ac 0
Say "powercfg 적용 (모니터/대기/디스크/최대절전 전부 끔)"
# 화면 보호기 + 잠금화면
reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f | Out-Null
reg add "HKCU\Control Panel\Desktop" /v ScreenSaverIsSecure /t REG_SZ /d 0 /f | Out-Null
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f | Out-Null
# 절전 시 암호 요구 끔
reg add "HKLM\SOFTWARE\Policies\Microsoft\Power\PowerSettings\0e796bdb-100d-47d6-a2d5-f7d2daa51f51" /v ACSettingIndex /t REG_DWORD /d 0 /f | Out-Null
Say "잠금화면/화면보호기 끔"
# 첫 부팅 때 뜨는 방해 요소들
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer" /v NoDriveTypeAutoRun /t REG_DWORD /d 255 /f | Out-Null
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoRebootWithLoggedOnUsers /t REG_DWORD /d 1 /f | Out-Null
Say "자동실행/업데이트 자동재부팅 끔"
# ------------------------------------------- 3) agent 설치 + 로그온 예약 작업
$src = $PSScriptRoot
Copy-Item -Path (Join-Path $src 'agent.ps1') -Destination 'C:\win-ci\agent.ps1' -Force
Say "agent.ps1 복사 완료"
$taskCmd = 'powershell.exe'
$taskArg = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\win-ci\agent.ps1'
& schtasks.exe /Create /TN 'win-ci-agent' /SC ONLOGON /RL HIGHEST /TR "$taskCmd $taskArg" /F 2>&1 | Out-String | ForEach-Object { Say "schtasks -> $($_.Trim())" }
# ------------------------------------------------------------- 4) agent 기동
Say "agent 시작 - 이후 로그는 [agent] 접두어로 나온다"
Start-Process -FilePath $taskCmd -ArgumentList $taskArg -WindowStyle Hidden
Say "provision 끝"
if ($script:Port) { try { $script:Port.Close() } catch {} }

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

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

View File

@@ -12,7 +12,14 @@ from .constants import DEFAULT_TIER, user_data_dir
log = logging.getLogger(__name__)
CONFIG_PATH = user_data_dir() / "config.json"
def config_path() -> Path:
"""설정 파일 경로.
import 시점에 상수로 계산하면 안 된다. 그때는 사용자가 저장 위치를 아직
고르지 않았고, 나중에 설정에서 위치를 바꿔도 옛 경로를 계속 가리킨다.
"""
return user_data_dir() / "config.json"
@dataclass
@@ -149,7 +156,7 @@ class AppConfig:
return asdict(self)
def save(self, path: Path | None = None) -> Path:
target = path or CONFIG_PATH
target = path or config_path()
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(".json.tmp")
tmp.write_text(
@@ -160,7 +167,7 @@ class AppConfig:
@classmethod
def load(cls, path: Path | None = None) -> AppConfig:
target = path or CONFIG_PATH
target = path or config_path()
if not target.exists():
return cls()
try:

View File

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

View File

@@ -0,0 +1,47 @@
"""모델 레이어 (음성인식 · 번역 · 용어집)."""
from .asr import SpeechRecognizer, Transcript
from .glossary import Glossary, GlossaryEntry
from .manager import GpuInfo, ModelManager, detect_gpu
from .speech_level import (
Politeness,
SpeechLevel,
apply_speech_level,
detect_politeness,
to_casual,
)
from .tiers import (
DEFAULT_TIER,
TIERS,
MTBackend,
PromptStyle,
Tier,
get_tier,
ordered_tiers,
)
from .translator import Translator, build_seedx_prompt, create_translator
__all__ = [
"DEFAULT_TIER",
"Glossary",
"GlossaryEntry",
"GpuInfo",
"MTBackend",
"ModelManager",
"Politeness",
"PromptStyle",
"SpeechLevel",
"SpeechRecognizer",
"TIERS",
"Tier",
"Transcript",
"Translator",
"apply_speech_level",
"build_seedx_prompt",
"detect_politeness",
"create_translator",
"detect_gpu",
"get_tier",
"ordered_tiers",
"to_casual",
]

95
src/livesub/models/asr.py Normal file
View File

@@ -0,0 +1,95 @@
"""음성인식(ASR) 래퍼 — faster-whisper(CTranslate2) 기반."""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass
import numpy as np
from ..constants import models_dir
from .tiers import AsrSpec
log = logging.getLogger(__name__)
@dataclass
class Transcript:
text: str
language: str
confidence: float = 0.0
duration_s: float = 0.0
class SpeechRecognizer:
"""faster-whisper 모델 한 개를 감싼다. 스레드 안전."""
def __init__(self, spec: AsrSpec, device: str = "cuda", device_index: int = 0) -> None:
self.spec = spec
self.device = device
self.device_index = device_index
self._model = None
self._lock = threading.Lock()
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
from faster_whisper import WhisperModel
compute_type = self.spec.compute_type
if self.device == "cpu" and compute_type.endswith("float16"):
compute_type = "int8" # CPU에서는 float16이 오히려 느리다
log.info("음성인식 모델 로드: %s (%s/%s)", self.spec.repo, self.device, compute_type)
self._model = WhisperModel(
self.spec.repo,
device=self.device,
device_index=self.device_index,
compute_type=compute_type,
download_root=str(models_dir()),
)
def unload(self) -> None:
with self._lock:
self._model = None
def transcribe(
self,
audio: np.ndarray,
language: str | None = None,
fast: bool = False,
prompt: str = "",
) -> Transcript:
"""오디오 한 덩어리를 텍스트로.
`fast=True` 는 중간 결과용으로 beam search를 끄고 빠르게 돌린다.
"""
if self._model is None:
self.load()
assert self._model is not None
with self._lock:
segments, info = self._model.transcribe(
audio.astype(np.float32, copy=False),
language=language,
beam_size=1 if fast else self.spec.beam_size,
temperature=0.0,
condition_on_previous_text=False,
initial_prompt=prompt or None,
vad_filter=not fast,
vad_parameters={"min_silence_duration_ms": 300},
word_timestamps=False,
)
parts = [s.text for s in segments]
text = " ".join(p.strip() for p in parts if p.strip()).strip()
return Transcript(
text=text,
language=getattr(info, "language", language or "") or "",
confidence=float(getattr(info, "language_probability", 0.0) or 0.0),
duration_s=float(getattr(info, "duration", 0.0) or 0.0),
)

View File

@@ -0,0 +1,235 @@
"""용어집(고유명사·게임/방송 용어) 처리.
추가학습 없이도 즉시 효과를 내는 레이어다. 두 가지 방식으로 동작한다.
1. 플레이스홀더 보호 (`protect` / `restore`)
원문의 등록 용어를 `⟦0⟧` 같은 토큰으로 치환해 번역 모델이 건드리지 못하게 하고,
번역이 끝난 뒤 지정한 역어로 되돌린다. 어떤 모델에서도 쓸 수 있다.
2. 프롬프트 주입 (`prompt_hint`)
해당 문장에 등장한 용어만 골라 프롬프트에 "이 용어는 이렇게 옮겨라"로 넣는다.
조사·어미까지 문맥에 맞게 붙어 결과가 더 자연스럽다.
어느 쪽을 쓸지는 모델이 정한다. 2번은 **지시문을 이해하는 instruct 모델에서만**
동작한다. Seed-X 처럼 번역만 하도록 학습된 completion 모델은 지시문을 넣으면
학습 분포를 벗어나 오히려 번역이 망가지므로 1번을 쓴다. NLLB 같은 seq2seq 도
마찬가지다. 판정은 `MTSpec.supports_prompt_glossary` 가 한다.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
#: 번역 모델이 원문 그대로 통과시켜야 하는 자리표시자.
#:
#: 형식 선택은 취향이 아니라 실측 결과다. 처음에 쓰던 `⟦0⟧` 는 NLLB 가
#: 괄호를 통째로 날려 `0` 만 남겼고(생존률 0/3), 그 결과 용어가 자막에서
#: 사라졌다. 후보 8종을 실제 모델로 돌려 비교한 끝에 `#0#` 를 골랐다
#: (생존률 3/3, 다중 치환 4/5). 입력이 음성인식 결과라 `#` 가 자연스럽게
#: 등장할 일이 거의 없다는 점도 같이 봤다.
PLACEHOLDER = "#{}#" # #0#
PLACEHOLDER_RE = re.compile(r"#(\d+)#")
@dataclass
class GlossaryEntry:
"""용어 하나. `targets` 는 언어코드 → 역어."""
source: str
targets: dict[str, str] = field(default_factory=dict)
note: str = ""
#: 비우면 모든 소스 언어에 적용
source_lang: str = ""
#: 문장 전체가 이 표현일 때만 치환한다.
#:
#: "get in the car" 같은 절을 긴 문장 한가운데서 통째로 치환하면
#: 번역 모델이 문법을 세울 근거를 잃어 "어부 들어오는, 차 타!" 같은
#: 결과가 나온다. 반대로 그 말만 단독으로 나왔을 때는 통째로 바꾸는
#: 것이 가장 자연스럽다. 그래서 적용 범위를 문장 전체로 한정한다.
whole_only: bool = False
def target_for(self, lang: str) -> str:
return self.targets.get(lang, "")
class Glossary:
def __init__(self, entries: list[GlossaryEntry] | None = None, case_sensitive: bool = False):
self.case_sensitive = case_sensitive
self.entries: list[GlossaryEntry] = list(entries or [])
self._pattern: re.Pattern[str] | None = None
self._index: dict[str, GlossaryEntry] = {}
self._rebuild()
# --- 영속화 --------------------------------------------------------
@classmethod
def load(cls, path: str | Path, case_sensitive: bool = False) -> Glossary:
p = Path(path)
if not p.is_file():
return cls(case_sensitive=case_sensitive)
raw = json.loads(p.read_text(encoding="utf-8"))
entries = [
GlossaryEntry(
source=item["source"],
targets=dict(item.get("targets", {})),
note=item.get("note", ""),
source_lang=item.get("source_lang", ""),
whole_only=bool(item.get("whole_only", False)),
)
for item in raw.get("entries", [])
if item.get("source")
]
return cls(entries, case_sensitive=raw.get("case_sensitive", case_sensitive))
def save(self, path: str | Path) -> Path:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
payload = {
"case_sensitive": self.case_sensitive,
"entries": [
{
"source": e.source,
"targets": e.targets,
"note": e.note,
"source_lang": e.source_lang,
"whole_only": e.whole_only,
}
for e in self.entries
],
}
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return p
# --- 편집 ----------------------------------------------------------
def add(self, entry: GlossaryEntry) -> None:
key = self._key(entry.source)
self.entries = [e for e in self.entries if self._key(e.source) != key]
self.entries.append(entry)
self._rebuild()
def remove(self, source: str) -> None:
key = self._key(source)
self.entries = [e for e in self.entries if self._key(e.source) != key]
self._rebuild()
def __len__(self) -> int:
return len(self.entries)
# --- 사용 ----------------------------------------------------------
def matches(self, text: str, target_lang: str) -> list[GlossaryEntry]:
"""문장에 실제로 등장하고 해당 언어 역어가 있는 항목만."""
if not self._pattern or not text:
return []
found: list[GlossaryEntry] = []
seen: set[str] = set()
for m in self._pattern.finditer(text):
entry = self._index.get(self._key(m.group(0)))
if entry is None or not entry.target_for(target_lang):
continue
key = self._key(entry.source)
if key not in seen:
seen.add(key)
found.append(entry)
return found
def protect(self, text: str, target_lang: str) -> tuple[str, list[str]]:
"""등록 용어를 자리표시자로 치환. (치환된 문장, 역어 목록) 반환."""
replacements: list[str] = []
if not text:
return text, replacements
whole = self._match_whole(text, target_lang)
if whole is not None:
# 문장 전체가 등록된 표현 — 통째로 바꾸는 게 가장 자연스럽다.
return PLACEHOLDER.format(0), [whole]
if not self._pattern:
return text, replacements
def _sub(m: re.Match[str]) -> str:
entry = self._index.get(self._key(m.group(0)))
if entry is None:
return m.group(0)
target = entry.target_for(target_lang)
if not target:
return m.group(0)
replacements.append(target)
return PLACEHOLDER.format(len(replacements) - 1)
return self._pattern.sub(_sub, text), replacements
@staticmethod
def surviving_placeholders(text: str) -> set[int]:
"""번역 결과에 살아남은 자리표시자 번호."""
return {int(m) for m in PLACEHOLDER_RE.findall(text)}
@classmethod
def missing_placeholders(cls, text: str, replacements: list[str]) -> list[int]:
"""번역 중에 사라진 자리표시자 번호.
번역 모델이 문장 일부를 통째로 누락하면 자리표시자도 같이 사라진다.
그대로 복원하면 해당 용어가 자막에서 증발하므로, 호출부가 이걸로
확인해 용어집 없이 다시 번역하는 쪽을 택할 수 있게 한다.
"""
present = cls.surviving_placeholders(text)
return [i for i in range(len(replacements)) if i not in present]
@staticmethod
def restore(text: str, replacements: list[str]) -> str:
"""플레이스홀더를 역어로 되돌린다."""
if not replacements:
return text
def _sub(m: re.Match[str]) -> str:
idx = int(m.group(1))
return replacements[idx] if 0 <= idx < len(replacements) else ""
return PLACEHOLDER_RE.sub(_sub, text)
def prompt_hint(self, text: str, target_lang: str, limit: int = 12) -> str:
"""LLM 프롬프트에 붙일 용어 지시문. 해당 없으면 빈 문자열."""
hits = self.matches(text, target_lang)[:limit]
if not hits:
return ""
lines = [f"- {e.source} -> {e.target_for(target_lang)}" for e in hits]
return "다음 용어는 반드시 지정된 역어로 번역하세요:\n" + "\n".join(lines)
def _match_whole(self, text: str, target_lang: str) -> str | None:
"""문장 전체가 등록 표현과 같으면 그 역어를 돌려준다."""
stripped = self._key(text.strip().rstrip(".!?…~,"))
entry = self._index.get(stripped)
if entry is None:
return None
return entry.target_for(target_lang) or None
# --- 내부 ----------------------------------------------------------
def _key(self, text: str) -> str:
return text if self.case_sensitive else text.casefold()
def _rebuild(self) -> None:
self._index = {self._key(e.source): e for e in self.entries}
if not self.entries:
self._pattern = None
return
# whole_only 항목은 부분 매칭에서 제외한다 (문장 전체일 때만 쓴다).
partial = [e.source for e in self.entries if not e.whole_only]
if not partial:
self._pattern = None
return
# 긴 용어를 먼저 매칭해야 "헤드샷"이 "헤드"에 잡아먹히지 않는다.
sources = sorted(partial, key=len, reverse=True)
flags = 0 if self.case_sensitive else re.IGNORECASE
self._pattern = re.compile(
"|".join(_bounded(re.escape(s)) for s in sources), flags
)
def _bounded(escaped: str) -> str:
"""영문/숫자로 시작·끝나는 용어에만 단어 경계를 건다.
한국어·일본어·중국어에는 \\b 가 의미 없으므로 적용하지 않는다.
"""
prefix = r"\b" if re.match(r"[A-Za-z0-9]", escaped.replace("\\", "")) else ""
suffix = r"\b" if re.search(r"[A-Za-z0-9]$", escaped.replace("\\", "")) else ""
return f"{prefix}{escaped}{suffix}"

View File

@@ -0,0 +1,242 @@
"""티어에 맞는 ASR + MT 모델 쌍을 관리한다."""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, replace
from ..config import ModelConfig
from .asr import SpeechRecognizer
from .tiers import TIERS, MTBackend, Tier, get_tier
from .translator import Translator, create_translator
log = logging.getLogger(__name__)
@dataclass
class GpuInfo:
available: bool
name: str = ""
total_vram_mb: int = 0
free_vram_mb: int = 0
reason: str = ""
def detect_gpu() -> GpuInfo:
"""CUDA 사용 가능 여부와 VRAM. torch 가 없으면 nvidia-smi 로 대체 조회."""
try:
import torch
if torch.cuda.is_available():
idx = torch.cuda.current_device()
free, total = torch.cuda.mem_get_info(idx)
return GpuInfo(
available=True,
name=torch.cuda.get_device_name(idx),
total_vram_mb=total // (1024 * 1024),
free_vram_mb=free // (1024 * 1024),
)
return GpuInfo(False, reason="CUDA를 사용할 수 없습니다 (드라이버/빌드 확인).")
except ImportError:
pass
import shutil
import subprocess
smi = shutil.which("nvidia-smi")
if not smi:
return GpuInfo(False, reason="PyTorch와 nvidia-smi 모두 찾지 못했습니다.")
try:
out = subprocess.run(
[smi, "--query-gpu=name,memory.total,memory.free",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5, check=True,
).stdout.strip().splitlines()
name, total, free = (v.strip() for v in out[0].split(","))
return GpuInfo(True, name, int(total), int(free),
reason="PyTorch 미설치 — 정보만 조회했습니다.")
except Exception as exc: # noqa: BLE001
return GpuInfo(False, reason=f"GPU 조회 실패: {exc}")
def apply_vram_limit(ratio: float, device_index: int = 0) -> bool:
"""이 프로세스가 쓸 수 있는 VRAM 상한을 건다.
게임과 동시에 돌 때 가장 큰 문제는 연산 시간이 아니라 **VRAM 고갈**이다.
게임이 쓸 메모리를 우리가 먹어버리면 텍스처 스트리밍이 시스템 메모리로
밀려나면서 프레임이 뚝뚝 끊긴다. 상한을 걸면 그 대신 우리 쪽이 먼저
OOM 을 맞으므로, 티어를 낮추라는 안내로 이어진다.
"""
if ratio <= 0 or ratio >= 1:
return False
try:
import torch
if not torch.cuda.is_available():
return False
torch.cuda.set_per_process_memory_fraction(ratio, device_index)
log.info("VRAM 상한 적용: 전체의 %d%%", int(ratio * 100))
return True
except Exception as exc: # noqa: BLE001 - 상한 실패가 치명적이지는 않다
log.warning("VRAM 상한을 걸지 못했습니다: %s", exc)
return False
def has_torch() -> bool:
"""PyTorch 가 설치돼 있는가.
포터블 exe 빌드는 torch 를 넣지 않는다. torch+CUDA 만 2.5GB 라
한 파일로 묶으면 실행할 때마다 그걸 임시폴더에 풀어야 해서 쓸 수 없다.
CTranslate2 기반 1~3티어는 torch 없이 동작하므로, 포터블은 그쪽만 담는다.
"""
import importlib.util
return importlib.util.find_spec("torch") is not None
def has_awq() -> bool:
"""4티어(AWQ Int4) 로드에 필요한 autoawq 가 있는지."""
import importlib.util
return importlib.util.find_spec("awq") is not None
def tier_availability(tier: Tier, gpu: GpuInfo | None = None) -> tuple[bool, str]:
"""이 설치 환경에서 해당 티어를 쓸 수 있는지와, 안 되면 그 이유.
torch 유무만 보면 부족하다. 실제로 막히는 경우가 둘 더 있다.
- 4티어(AWQ Int4)는 autoawq 가 있어야 로드된다. requirements.txt 에
없는 선택 의존성이라 없을 때가 많다.
- VRAM 이 모자라면 설치가 아무리 멀쩡해도 안 올라간다. 기존에도
min_vram_gb 로 경고 배지는 띄웠지만 선택은 막지 않아서, 골랐다가
로드에서 죽었다. 예: RTX 5050(7.5GB)은 정밀 10GB / 극한 16GB 라
둘 다 영영 못 쓴다.
"사용 가능"이라고 해놓고 로드에서 죽으면 사용자는 원인을 알 수 없다.
그래서 고를 수 있다고 말하기 전에 여기서 걸러낸다.
"""
if tier.mt.backend is not MTBackend.TRANSFORMERS:
return True, ""
if not has_torch():
return False, "PyTorch가 없어 사용할 수 없습니다 (포터블 버전은 1~3티어만 지원)"
if tier.mt.compute_type == "int4" and not has_awq():
return False, "autoawq가 없어 사용할 수 없습니다 (pip install autoawq)"
info = detect_gpu() if gpu is None else gpu
# free 가 아니라 total 로 본다. free 는 다른 프로그램 때문에 수시로
# 변해서, 그걸로 막으면 "아까는 되던 티어가 지금은 안 보인다"가 된다.
have_gb = info.total_vram_mb / 1024
if info.available and have_gb < tier.min_vram_gb:
return False, (
f"VRAM이 부족합니다 "
f"({tier.min_vram_gb:g}GB 필요, 이 GPU는 {have_gb:.1f}GB)"
)
return True, ""
def first_available_tier(preferred: str) -> str:
"""선호 티어가 불가능하면 쓸 수 있는 가장 좋은 티어로 내린다."""
tier = get_tier(preferred)
gpu = detect_gpu()
if tier_availability(tier, gpu)[0]:
return tier.key
usable = [t for t in TIERS.values() if tier_availability(t, gpu)[0]]
if not usable:
return preferred
return max(usable, key=lambda t: t.order).key
def downgrade_compute(compute_type: str) -> str:
"""저부하 모드에서 쓸 더 가벼운 연산 정밀도."""
if compute_type in ("float16", "float32"):
return "int8_float16"
return compute_type
class ModelManager:
"""현재 선택된 티어의 모델 두 개를 들고 있는다."""
def __init__(self, config: ModelConfig, low_power: bool = False) -> None:
self.config = config
#: 저부하(게임) 모드. 연산 정밀도를 낮춰 VRAM과 GPU 점유를 줄인다.
self.low_power = low_power
self._tier: Tier = get_tier(config.tier)
self._asr: SpeechRecognizer | None = None
self._mt: Translator | None = None
self._lock = threading.Lock()
@property
def tier(self) -> Tier:
return self._tier
@property
def ready(self) -> bool:
return bool(self._asr and self._asr.loaded and self._mt and self._mt.loaded)
def effective_asr_spec(self):
"""저부하 모드가 켜져 있으면 정밀도를 낮춘 사양을 돌려준다."""
spec = self._tier.asr
if not self.low_power:
return spec
return replace(
spec, compute_type=downgrade_compute(spec.compute_type), beam_size=1
)
def effective_mt_spec(self):
spec = self._tier.mt
# 양자화 LLM(int4)은 이미 최소 상태라 더 낮출 수 없다.
if not self.low_power or spec.compute_type == "int4":
return spec
return replace(spec, compute_type=downgrade_compute(spec.compute_type))
def recognizer(self) -> SpeechRecognizer:
with self._lock:
if self._asr is None:
self._asr = SpeechRecognizer(
self.effective_asr_spec(), self.config.device, self.config.device_index
)
return self._asr
def translator(self) -> Translator:
with self._lock:
if self._mt is None:
self._mt = create_translator(
self.effective_mt_spec(),
self.config.device,
self.config.device_index,
self.config.lora_adapter_path,
)
return self._mt
def preload(self, progress=None) -> None:
"""두 모델을 미리 올린다. progress(str, float) 콜백으로 상태를 보고."""
def report(msg: str, value: float) -> None:
if progress:
progress(msg, value)
report(f"'{self._tier.name}' 음성인식 모델 준비 중…", 0.1)
self.recognizer().load()
report(f"'{self._tier.name}' 번역 모델 준비 중…", 0.6)
self.translator().load()
report("준비 완료", 1.0)
def switch_tier(self, tier_key: str) -> Tier:
"""티어 변경. 기존 모델은 내려서 VRAM을 즉시 돌려준다."""
if tier_key == self._tier.key:
return self._tier
self.unload()
self.config.tier = tier_key
self._tier = get_tier(tier_key)
log.info("모델 티어 변경: %s", self._tier.name)
return self._tier
def unload(self) -> None:
with self._lock:
if self._asr is not None:
self._asr.unload()
self._asr = None
if self._mt is not None:
self._mt.unload()
self._mt = None

152
src/livesub/models/packs.py Normal file
View File

@@ -0,0 +1,152 @@
"""기본 제공 게임 용어집 팩.
`resources/glossaries/*.json` 에 게임별로 묶어둔 용어를 읽어, 사용자가 켠 팩만
사용자 용어집과 합쳐 하나의 `Glossary` 로 만든다.
합칠 때 **사용자가 직접 등록한 항목이 항상 이긴다.** 기본 팩의 역어가 마음에
안 들면 용어집 화면에서 같은 단어를 등록하면 그쪽이 적용된다.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from .glossary import Glossary, GlossaryEntry
log = logging.getLogger(__name__)
PACK_DIR = Path(__file__).resolve().parent.parent / "resources" / "glossaries"
#: 여러 팩을 동시에 켰을 때 같은 단어의 역어가 갈리는 것을 막기 위한 계층.
#: 숫자가 클수록 나중에 적용되어 이긴다.
SCOPE_PRIORITY = {"generic": 0, "game": 1}
@dataclass(frozen=True)
class GlossaryPack:
key: str
name: str
description: str
entries: tuple[GlossaryEntry, ...]
#: "generic" = 장르 공통, "game" = 특정 게임 전용
scope: str = "game"
def __len__(self) -> int:
return len(self.entries)
@property
def priority(self) -> int:
return SCOPE_PRIORITY.get(self.scope, 1)
@property
def is_generic(self) -> bool:
return self.scope == "generic"
@lru_cache(maxsize=1)
def available_packs() -> tuple[GlossaryPack, ...]:
"""번들된 팩 목록. 파일이 깨져 있으면 그 팩만 건너뛴다."""
if not PACK_DIR.is_dir():
return ()
packs: list[GlossaryPack] = []
for path in sorted(PACK_DIR.glob("*.json")):
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
log.warning("용어집 팩을 읽지 못했습니다 (%s): %s", path.name, exc)
continue
entries = tuple(
GlossaryEntry(
source=item["source"],
targets=dict(item.get("targets", {})),
note=raw.get("name", path.stem),
whole_only=bool(item.get("whole_only", False)),
)
for item in raw.get("entries", [])
if item.get("source")
)
if not entries:
continue
packs.append(
GlossaryPack(
key=raw.get("key", path.stem),
name=raw.get("name", path.stem),
description=raw.get("description", ""),
entries=entries,
scope=raw.get("scope", "game"),
)
)
return tuple(packs)
def get_pack(key: str) -> GlossaryPack | None:
for pack in available_packs():
if pack.key == key:
return pack
return None
def resolve_packs(enabled_packs: list[str]) -> list[GlossaryPack]:
"""켜둔 팩을 적용 순서대로 돌려준다 (범용 먼저, 게임 전용 나중).
`knocked` 는 FPS 공통에서 "기절", 배그에서 "눕혔다"다. 어느 쪽이 맞는지는
지금 무슨 게임을 하느냐로 갈리므로, **게임 전용 팩이 항상 범용을 이겨야**
한다. 그렇지 않으면 체크박스를 누른 순서에 따라 결과가 달라진다.
"""
packs: list[GlossaryPack] = []
for key in enabled_packs:
pack = get_pack(key)
if pack is None:
log.warning("알 수 없는 용어집 팩입니다: %s", key)
continue
packs.append(pack)
# 같은 계층 안에서는 사용자가 켠 순서를 유지한다 (stable sort).
return sorted(packs, key=lambda p: p.priority)
def find_conflicts(enabled_packs: list[str], lang: str = "ko") -> dict[str, list[tuple[str, str]]]:
"""켜둔 게임 전용 팩끼리 역어가 갈리는 단어를 찾는다.
범용 팩은 게임 팩에 확실히 지므로 충돌로 보지 않는다. 게임 팩 둘 이상이
같은 단어를 다르게 번역하면 그건 사용자가 판단해야 할 문제라 UI로 알린다.
"""
seen: dict[str, list[tuple[str, str]]] = {}
for pack in resolve_packs(enabled_packs):
if pack.is_generic:
continue
for entry in pack.entries:
target = entry.target_for(lang)
if not target:
continue
seen.setdefault(entry.source.casefold(), []).append((pack.name, target))
return {
source: owners
for source, owners in seen.items()
if len({t for _, t in owners}) > 1
}
def build_glossary(
user_glossary: Glossary,
enabled_packs: list[str],
case_sensitive: bool = False,
) -> Glossary:
"""켜둔 팩 + 사용자 용어집을 하나로 합친다.
적용 순서: 범용 팩 → 게임 전용 팩 → 사용자 항목.
뒤에 오는 쪽이 이기므로 사용자가 직접 등록한 것이 항상 최우선이다.
"""
merged: list[GlossaryEntry] = []
for pack in resolve_packs(enabled_packs):
merged.extend(pack.entries)
merged.extend(user_glossary.entries)
combined = Glossary(case_sensitive=case_sensitive)
for entry in merged:
combined.add(entry) # add() 가 같은 원문을 교체해준다
return combined

View File

@@ -0,0 +1,310 @@
"""말투(존댓말/반말) 처리.
규칙은 두 줄로 요약된다.
1. 원문이 **실제로 존댓말**이면 반말 모드여도 존댓말을 지킨다.
상대가 정중하게 말했는데 자막이 반말로 나오면 뉘앙스가 통째로 뒤집힌다.
2. 원문에 높임이 **없거나 알 수 없으면** 사용자가 고른 모드를 따른다.
영어·중국어는 문법적 높임이 없으므로 항상 여기에 해당한다.
한국어로 번역할 때만 동작한다. 영어·중국어는 대상 언어일 때 적용할 문법이
없고, 일본어 경어 변환은 동사 활용이 필요해 규칙만으로는 위험하다.
변환 방향도 한쪽만 한다. 번역 모델의 한국어 출력은 거의 항상 격식체
(합니다체/해요체)이므로 **존댓말 모드는 손대지 않고**, 반말 모드일 때만
낮춤 변환을 건다. 반대 방향(반말→존댓말)은 어간 정보가 없으면 훨씬 자주
틀리므로 시도하지 않는다.
"""
from __future__ import annotations
import re
from enum import Enum
class SpeechLevel(str, Enum):
"""사용자가 고르는 출력 말투."""
POLITE = "polite" # 존댓말 (기본)
CASUAL = "casual" # 반말
class Politeness(str, Enum):
"""원문에서 감지한 높임 정도."""
UNKNOWN = "unknown" # 높임이 없는 언어이거나 판단 불가
POLITE = "polite" # 확실히 존댓말
CASUAL = "casual" # 확실히 반말
#: 문법적 높임 체계가 있는 언어. 나머지는 항상 UNKNOWN 이다.
HONORIFIC_LANGUAGES = frozenset({"ko", "ja"})
# --- 원문 높임 감지 ---------------------------------------------------------
_KO_POLITE = re.compile(
r"(습니다|습니까|ㅂ니다|ㅂ니까|십시오|십시요|세요|셔요|시죠"
r"|어요|아요|에요|예요|해요|네요|지요|죠|드려|드립|주세|감사합)"
)
_JA_POLITE = re.compile(
r"(です|ます|ません|でした|ました|ましょう|ください|下さい"
r"|ございま|でしょう|いたし|申し上げ|なさい)"
)
_HAS_HANGUL = re.compile(r"[가-힣]")
_HAS_JAPANESE = re.compile(r"[぀-ヿ一-鿿]")
def detect_politeness(text: str, lang: str) -> Politeness:
"""원문의 높임 정도를 판단한다.
높임 체계가 없는 언어(영어·중국어)는 항상 UNKNOWN 이다. "please" 같은
공손 표현은 문법적 높임이 아니라 어조라, 이걸로 존댓말을 확정하면
오탐이 너무 많아진다.
한국어·일본어는 존댓말 표지가 있으면 POLITE, 없으면 CASUAL 로 본다.
두 언어 모두 문장은 둘 중 하나로 끝나므로 "중립"이라는 선택지가 사실상
없고, 어미를 일일이 열거하는 것보다 이 편이 덜 틀린다.
"""
text = (text or "").strip()
if not text or lang not in HONORIFIC_LANGUAGES:
return Politeness.UNKNOWN
polite, script = (
(_KO_POLITE, _HAS_HANGUL) if lang == "ko" else (_JA_POLITE, _HAS_JAPANESE)
)
if polite.search(text):
return Politeness.POLITE
if script.search(text):
return Politeness.CASUAL
return Politeness.UNKNOWN
# --- 한국어 낮춤 변환 -------------------------------------------------------
_HANGUL_BASE = 0xAC00
_JONGSUNG_COUNT = 28
_JUNGSUNG_COUNT = 21
_HANGUL_COUNT = 11172
# 중성 인덱스
_A, _AE, _EO, _E, _YEO, _O, _WA, _U, _WO, _EU, _I = 0, 1, 4, 5, 6, 8, 9, 13, 14, 18, 20
#: '아'로 이어지는 밝은 모음 (모음조화)
_BRIGHT = frozenset({_A, _O, 2, _WA})
#: 받침 없는 어간 + 어/아 의 축약. 오+아 -> 와, 지+어 -> 져
_CONTRACTION = {_O: _WA, _U: _WO, _I: _YEO, _EU: _EO}
#: 붙여도 모양이 그대로인 어간 모음. 가+아 -> 가, 서+어 -> 서
_ABSORBING = frozenset({_A, _EO, _AE, _E, _YEO, _WA, _WO})
_RIEUL = 8 # 종성 ㄹ
_SSANG_SIOT = 20 # 종성 ㅆ — 과거형 '았/었' 판별용
def _decompose(char: str) -> tuple[int, int, int] | None:
code = ord(char) - _HANGUL_BASE
if not (0 <= code < _HANGUL_COUNT):
return None
return (
code // (_JUNGSUNG_COUNT * _JONGSUNG_COUNT),
(code // _JONGSUNG_COUNT) % _JUNGSUNG_COUNT,
code % _JONGSUNG_COUNT,
)
def _compose(cho: int, jung: int, jong: int) -> str:
return chr(
_HANGUL_BASE + (cho * _JUNGSUNG_COUNT + jung) * _JONGSUNG_COUNT + jong
)
def _attach_eo(stem: str) -> str:
"""어간에 어/아 를 붙인다 (해체 활용).
먹->먹어, 좋->좋아, 하->해, 오->와, 주->줘, 빠지->빠져, 가->가.
한국어 활용의 실제 규칙(모음조화 + 축약)을 그대로 구현한 것이라
어미 목록을 나열하는 방식보다 훨씬 넓게 맞는다.
"""
if not stem:
return stem
parts = _decompose(stem[-1])
if parts is None:
return stem + "어"
cho, jung, jong = parts
if jong == _SSANG_SIOT:
# 과거형 '았/었/였' 뒤에는 모음조화와 무관하게 항상 '어'가 온다.
# (남았아 X -> 남았어 O)
return stem + "어"
if jong: # 받침이 있으면 그냥 붙인다
return stem + ("아" if jung in _BRIGHT else "어")
if cho == 18 and jung == _A: # 하 -> 해
return stem[:-1] + _compose(cho, _AE, 0)
if jung in _CONTRACTION: # 오+아 -> 와, 지+어 -> 져
return stem[:-1] + _compose(cho, _CONTRACTION[jung], 0)
if jung in _ABSORBING: # 가+아 -> 가
return stem
return stem + ("아" if jung in _BRIGHT else "어")
def _attach_lge(stem: str) -> str:
"""'~겠습니다' -> '~ㄹ게'. 치->칠게, 가->갈게, 먹->먹을게."""
if not stem:
return stem
parts = _decompose(stem[-1])
if parts is None:
return stem + "을게"
cho, jung, jong = parts
if jong:
return stem + "을게"
return stem[:-1] + _compose(cho, jung, _RIEUL) + "게"
#: 받침 없는 어간 + ㅂ니다 축약형. 규칙으로 풀기 어려운 것만 표로 둔다.
_CONTRACTED = {
"입니다": "야",
"됩니다": "돼",
"드립니다": "줄게",
}
#: (패턴, 치환) — 긴 어미부터 적용해야 짧은 어미가 먼저 먹지 않는다.
_CASUAL_RULES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"있습니다"), "있어"),
(re.compile(r"없습니다"), "없어"),
(re.compile(r"이에요"), "이야"),
(re.compile(r"예요"), "야"),
(re.compile(r"네요"), "네"),
(re.compile(r"지요"), "지"),
(re.compile(r"죠"), "지"),
]
#: 어간을 활용해야 하는 어미들. 앞 글자를 잡아 _attach_* 로 넘긴다.
_GESSEUMNIDA = re.compile(r"([가-힣])겠습니다")
_SEUMNIDA = re.compile(r"([가-힣])습니다")
_BNIDA = re.compile(r"([가-힣])ᆸ니다|([가-힣])ㅂ니다")
_SEYO = re.compile(r"([가-힣])세요")
_SEUMNIKKA = re.compile(r"([가-힣])습니까")
#: 명령형 존대 '~십시오' 도 어간을 활용해야 한다. 조심하십시오 -> 조심해
_SIPSIO = re.compile(r"([가-힣])십시[오요]")
#: 문장 끝의 '요'만 떼어낸다. 문장 중간의 '요'(중요, 필요)는 건드리면 안 된다.
_TRAILING_YO = re.compile(r"([가-힣])요(\s*[.!?…,~]*)\s*$")
_TRAILING_YO_MID = re.compile(r"([가-힣])요(\s*[.!?…,~]+\s+)")
def _split_bnida(text: str) -> str:
"""'갑니다' 처럼 어간에 ㅂ 이 합쳐진 형태를 푼다. 갑니다 -> 가 + 어."""
def _sub(match: re.Match[str]) -> str:
syllable = match.group(1)
parts = _decompose(syllable)
if parts is None:
return match.group(0)
cho, jung, jong = parts
if jong != 17: # ㅂ 받침이 아니면 대상이 아니다
return match.group(0)
return _attach_eo(_compose(cho, jung, 0))
return re.sub(r"([가-힣])니다", _sub, text)
def to_casual(text: str) -> str:
"""한국어 존댓말 문장을 반말로 낮춘다.
번역 모델이 내놓는 격식체 어미를 대상으로 한 규칙 기반 변환이라
100%는 아니다. 다만 게임 자막에서 실제로 나오는 어미는 종류가
많지 않고, 활용은 실제 문법 규칙으로 처리하므로 대부분을 덮는다.
"""
if not text:
return text
out = text
for source, target in _CONTRACTED.items():
out = out.replace(source, target)
out = _GESSEUMNIDA.sub(lambda m: _attach_lge(m.group(1)), out)
out = _SIPSIO.sub(lambda m: _attach_eo(m.group(1)), out)
for pattern, target in _CASUAL_RULES:
out = pattern.sub(target, out)
out = _SEUMNIKKA.sub(lambda m: _attach_eo(m.group(1)) + "?", out)
out = _SEUMNIDA.sub(lambda m: _attach_eo(m.group(1)), out)
out = _SEYO.sub(lambda m: _attach_eo(m.group(1)), out)
out = _split_bnida(out)
# 문장 끝 '요' 떼기 — 문장 중간의 '중요/필요'가 다치면 안 된다.
out = _TRAILING_YO_MID.sub(r"\1\2", out)
out = _TRAILING_YO.sub(r"\1\2", out)
return out.strip()
def apply_speech_level(
translated: str,
target_lang: str,
level: SpeechLevel,
source_politeness: Politeness = Politeness.UNKNOWN,
) -> str:
"""번역 결과에 말투 설정을 적용한다.
존댓말 모드는 아무것도 하지 않는다. 번역 모델의 한국어 출력이 이미
격식체이고, 한국어에서 존댓말은 어떤 상황에서도 틀린 선택이 아니다.
"""
if target_lang != "ko" or level is not SpeechLevel.CASUAL:
return translated
# 사용자 규칙: 원문이 진짜 존댓말이면 반말 모드여도 존댓말을 지킨다.
if source_politeness is Politeness.POLITE:
return translated
return to_casual(translated)
def prompt_instruction(level: SpeechLevel, source_politeness: Politeness) -> str:
"""지시문을 이해하는 LLM(Qwen3 등)에 넣을 말투 지시.
Seed-X 처럼 지시문을 못 알아듣는 번역 전용 모델에는 쓰지 않는다.
그쪽은 `apply_speech_level` 후처리로만 말투를 맞춘다.
"""
if source_politeness is Politeness.POLITE:
return "The speaker is being polite, so use Korean polite speech (존댓말)."
if level is SpeechLevel.CASUAL:
return "Use casual Korean speech (반말), the way friends talk while gaming."
return "Use polite Korean speech (존댓말)."
# --- 조사 교정 --------------------------------------------------------------
#: 앞말의 받침 유무로 갈리는 조사 쌍 (받침 있음, 받침 없음)
_PARTICLE_PAIRS = [("을", "를"), ("이", "가"), ("은", "는"), ("과", "와"), ("으로", "로")]
_PARTICLE_RE = re.compile(
r"([가-힣])(" + "|".join(sorted(
{p for pair in _PARTICLE_PAIRS for p in pair}, key=len, reverse=True
)) + r")(?=\s|$|[.!?,…~])"
)
_PARTICLE_LOOKUP = {}
for _with, _without in _PARTICLE_PAIRS:
_PARTICLE_LOOKUP[_with] = (_with, _without)
_PARTICLE_LOOKUP[_without] = (_with, _without)
def fix_particles(text: str) -> str:
"""앞말 받침에 맞게 조사를 고친다. 자기장를 -> 자기장을.
용어집이 단어를 통째로 바꿔치기하기 때문에 생기는 문제다. 번역 모델은
자리표시자를 기준으로 조사를 골랐는데, 복원된 역어의 받침이 그와 다르면
"자기장를" 같은 어긋남이 남는다. 받침은 한글 코드에서 바로 읽을 수 있어
규칙만으로 정확히 고칠 수 있다.
"""
if not text:
return text
def _sub(match: re.Match[str]) -> str:
head, particle = match.group(1), match.group(2)
pair = _PARTICLE_LOOKUP.get(particle)
if pair is None:
return match.group(0)
parts = _decompose(head)
if parts is None:
return match.group(0)
has_batchim = parts[2] != 0
# '으로/로' 는 ㄹ 받침이면 '로'를 쓴다 (서울로, 칼로).
if pair == ("으로", "로") and parts[2] == _RIEUL:
return head + "로"
return head + (pair[0] if has_batchim else pair[1])
return _PARTICLE_RE.sub(_sub, text)

209
src/livesub/models/tiers.py Normal file
View File

@@ -0,0 +1,209 @@
"""모델 품질 티어 정의.
사용자에게는 실제 모델 이름 대신 5단계 티어(속도우선 → 품질우선)로 노출한다.
각 티어는 ASR(음성인식) 1개 + MT(번역) 1개의 조합이다.
선정 근거는 docs/MODELS.md 참고.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from ..constants import DEFAULT_TIER
class MTBackend(str, Enum):
"""번역 모델 실행 백엔드."""
CTRANSLATE2 = "ctranslate2" # NLLB 계열 seq2seq
TRANSFORMERS = "transformers" # LLM 계열 (Seed-X / Qwen3)
class PromptStyle(str, Enum):
"""LLM 백엔드의 프롬프트 형식. 모델 계열마다 요구사항이 다르다."""
#: 해당 없음 (seq2seq)
NONE = "none"
#: Seed-X 전용. chat template 이 없는 번역 전용 completion 모델이라
#: 모델 카드에 적힌 고정 형식 + 끝의 `<언어코드>` 태그를 그대로 지켜야 한다.
SEEDX = "seedx"
#: Qwen3 등 일반 instruct 모델. chat template + 자유 지시문 사용 가능.
INSTRUCT = "instruct"
@dataclass(frozen=True)
class AsrSpec:
repo: str
compute_type: str
beam_size: int = 1
vram_mb: int = 0
@dataclass(frozen=True)
class MTSpec:
repo: str
backend: MTBackend
compute_type: str = "float16"
vram_mb: int = 0
prompt_style: PromptStyle = PromptStyle.NONE
#: LoRA 추가학습 난이도 1(쉬움) ~ 3(까다로움)
finetune_ease: int = 2
@property
def supports_prompt_glossary(self) -> bool:
"""프롬프트로 용어집을 지시할 수 있는가.
지시문을 이해하는 instruct 모델만 가능하다. Seed-X 는 번역만 하도록
학습된 completion 모델이라 "이 용어는 이렇게 옮겨라" 같은 문장을
넣으면 학습 분포를 벗어나 오히려 번역이 망가진다. seq2seq 도 마찬가지다.
이 둘은 플레이스홀더 치환 방식으로 용어를 보호한다.
"""
return self.prompt_style is PromptStyle.INSTRUCT
@dataclass(frozen=True)
class Tier:
key: str
order: int
name: str
tagline: str
asr: AsrSpec
mt: MTSpec
approx_latency_s: float
min_vram_gb: float
notes: str = ""
recommended: bool = False
best_after_finetune: bool = False
@property
def total_vram_mb(self) -> int:
return self.asr.vram_mb + self.mt.vram_mb
@property
def download_gb(self) -> float:
return round(self.total_vram_mb / 1024 * 1.25, 1)
TIERS: dict[str, Tier] = {}
def _register(tier: Tier) -> Tier:
TIERS[tier.key] = tier
return tier
LIGHTNING = _register(
Tier(
key="lightning",
order=1,
name="번개",
tagline="속도 최우선 · 저사양 GPU",
asr=AsrSpec("Systran/faster-whisper-small", "int8_float16", 1, 700),
mt=MTSpec(
"entai2965/nllb-200-distilled-600M-ctranslate2",
MTBackend.CTRANSLATE2,
"int8_float16",
800,
finetune_ease=1,
),
approx_latency_s=0.6,
min_vram_gb=2,
notes="짧은 대사·게임 음성에 충분. 긴 문장이나 전문용어는 품질이 떨어질 수 있습니다.",
)
)
SWIFT = _register(
Tier(
key="swift",
order=2,
name="신속",
tagline="빠르면서 인식률은 최상급",
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "int8_float16", 1, 1600),
mt=MTSpec(
"entai2965/nllb-200-distilled-600M-ctranslate2",
MTBackend.CTRANSLATE2,
"float16",
1400,
finetune_ease=1,
),
approx_latency_s=0.9,
min_vram_gb=4,
notes="음성인식을 최상급으로 올리고 번역만 경량으로 유지한 구성. 가성비가 가장 좋습니다.",
)
)
BALANCE = _register(
Tier(
key="balance",
order=3,
name="균형",
tagline="기본값 · 속도와 품질의 중간",
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "float16", 2, 2400),
mt=MTSpec(
"entai2965/nllb-200-distilled-1.3B-ctranslate2",
MTBackend.CTRANSLATE2,
"float16",
2900,
finetune_ease=2,
),
approx_latency_s=1.2,
min_vram_gb=6,
notes="8GB VRAM 환경에서 가장 무난한 선택.",
)
)
PRECISION = _register(
Tier(
key="precision",
order=4,
name="정밀",
tagline="추천 · 현 시점 최고 번역 품질",
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "float16", 2, 2400),
mt=MTSpec(
"ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4",
MTBackend.TRANSFORMERS,
"int4",
5600,
prompt_style=PromptStyle.SEEDX,
finetune_ease=3,
),
approx_latency_s=2.0,
min_vram_gb=10,
notes="번역 전용으로 학습된 7B 모델. 한/영/일/중 구어체와 문맥 처리가 확연히 좋습니다.",
recommended=True,
)
)
ULTIMATE = _register(
Tier(
key="ultimate",
order=5,
name="극한",
tagline="품질 최우선 · 추가학습(LoRA)에 최적",
asr=AsrSpec("Systran/faster-whisper-large-v3", "float16", 5, 3100),
mt=MTSpec(
"Qwen/Qwen3-8B",
MTBackend.TRANSFORMERS,
"float16",
11000,
prompt_style=PromptStyle.INSTRUCT,
finetune_ease=1,
),
approx_latency_s=3.0,
min_vram_gb=16,
notes="게임·방송 용어를 LoRA로 추가학습해 붙일 때 가장 잘 먹히는 구성입니다.",
best_after_finetune=True,
)
)
assert DEFAULT_TIER in TIERS, f"constants.DEFAULT_TIER({DEFAULT_TIER}) 가 티어 목록에 없습니다"
def ordered_tiers() -> list[Tier]:
return sorted(TIERS.values(), key=lambda t: t.order)
def get_tier(key: str) -> Tier:
return TIERS.get(key, TIERS[DEFAULT_TIER])

View File

@@ -0,0 +1,376 @@
"""번역(MT) 래퍼.
두 백엔드를 지원한다.
* CTranslate2 — NLLB 계열 seq2seq. 가볍고 빠르며 추가학습이 쉽다.
* Transformers — Seed-X / Qwen3 같은 LLM. 품질이 좋고 LoRA 어댑터를 얹을 수 있다.
LLM 백엔드는 다시 두 가지 프롬프트 형식으로 갈린다. 같은 "LLM"이라도 요구사항이
정반대라 하나로 합치면 한쪽이 반드시 망가진다.
* PromptStyle.SEEDX — Seed-X 는 chat template 이 없는 **번역 전용 completion 모델**이다.
모델 카드가 정한 고정 문장과 끝의 `<언어코드>` 태그를 그대로 지켜야 하고,
그 밖의 지시문을 끼워 넣으면 학습 분포를 벗어난다. 따라서 용어집은 프롬프트가
아니라 **플레이스홀더 치환**으로 넣는다.
* PromptStyle.INSTRUCT — Qwen3 등. chat template 을 쓰고 용어집을 지시문으로 넣는다.
세 경우 모두 `Translator` 인터페이스를 따르므로 상위 레이어는 구분하지 않는다.
"""
from __future__ import annotations
import abc
import logging
import re
import threading
from ..constants import LANGUAGES, models_dir
from .glossary import Glossary
from .speech_level import Politeness, SpeechLevel, fix_particles, prompt_instruction
from .tiers import MTBackend, MTSpec, PromptStyle
log = logging.getLogger(__name__)
#: 문장 끝에 LLM이 붙이곤 하는 군더더기를 걷어내기 위한 패턴
_TRAILING_NOISE = re.compile(r"^\s*(?:번역[::]|Translation[::])\s*", re.IGNORECASE)
def _restore_or_retranslate(out: str, replacements: list[str], retry) -> str:
"""자리표시자를 역어로 되돌린다. 하나라도 사라졌으면 용어집 없이 다시 번역.
번역 모델이 문장 일부를 통째로 누락하면 그 안의 자리표시자도 같이
사라진다. 그대로 복원하면 용어가 자막에서 증발하는데, 그건 용어집을
아예 안 쓴 것보다 나쁘다. 그래서 그럴 때는 보호 없이 한 번 더 번역해
원문 내용이 살아 있는 결과를 쓴다.
"""
missing = Glossary.missing_placeholders(out, replacements)
if not missing:
return Glossary.restore(out, replacements)
log.debug("자리표시자 %s 소실 — 용어집 없이 재번역", missing)
return retry()
def build_seedx_prompt(text: str, source_lang: str, target_lang: str) -> str:
"""Seed-X 모델 카드가 요구하는 정확한 프롬프트.
Translate the following English sentence into Chinese:
May the force be with you <zh>
끝의 `<언어코드>` 태그는 PPO 학습에 쓰인 것이라 **필수**다. 빠지면 번역
품질이 크게 흔들린다. 형식을 바꾸면 안 되므로 지시문이나 용어집 설명을
여기 끼워 넣지 말 것.
출처: https://huggingface.co/ByteDance-Seed/Seed-X-PPO-7B
"""
src = LANGUAGES[source_lang]["english"]
tgt = LANGUAGES[target_lang]["english"]
tag = LANGUAGES[target_lang]["seedx"]
return f"Translate the following {src} sentence into {tgt}:\n{text} <{tag}>"
class Translator(abc.ABC):
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0) -> None:
self.spec = spec
self.device = device
self.device_index = device_index
self._lock = threading.Lock()
@property
@abc.abstractmethod
def loaded(self) -> bool: ...
@abc.abstractmethod
def load(self) -> None: ...
@abc.abstractmethod
def unload(self) -> None: ...
@abc.abstractmethod
def _translate(
self,
text: str,
source_lang: str,
target_lang: str,
glossary: Glossary | None,
speech_level: SpeechLevel,
source_politeness: Politeness,
) -> str: ...
def translate(
self,
text: str,
source_lang: str,
target_lang: str,
glossary: Glossary | None = None,
speech_level: SpeechLevel = SpeechLevel.POLITE,
source_politeness: Politeness = Politeness.UNKNOWN,
) -> str:
"""말투(speech_level)는 지시문을 이해하는 모델에만 프롬프트로 전달된다.
나머지 백엔드는 상위에서 `apply_speech_level` 후처리로 맞춘다.
"""
text = text.strip()
if not text:
return ""
if source_lang == target_lang:
return text
out = self._translate(
text, source_lang, target_lang, glossary, speech_level, source_politeness
)
out = _TRAILING_NOISE.sub("", out).strip()
if target_lang == "ko":
# 용어집이 단어를 바꿔치기하면 앞말 받침이 달라져 조사가 어긋난다.
# (자기장를 -> 자기장을)
out = fix_particles(out)
return out
class CTranslate2Translator(Translator):
"""NLLB 계열. 용어집은 플레이스홀더 보호 방식으로 적용한다."""
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0) -> None:
super().__init__(spec, device, device_index)
self._model = None
self._tokenizer = None
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
import ctranslate2
from huggingface_hub import snapshot_download
from transformers import AutoTokenizer
local = snapshot_download(self.spec.repo, cache_dir=str(models_dir()))
compute = self.spec.compute_type
if self.device == "cpu" and "float16" in compute:
compute = "int8"
log.info("번역 모델 로드: %s (%s/%s)", self.spec.repo, self.device, compute)
self._model = ctranslate2.Translator(
local,
device=self.device,
device_index=self.device_index,
compute_type=compute,
)
self._tokenizer = AutoTokenizer.from_pretrained(local, cache_dir=str(models_dir()))
def unload(self) -> None:
with self._lock:
self._model = None
self._tokenizer = None
def _run_model(self, text: str, source_lang: str, target_lang: str) -> str:
"""용어집 처리를 뺀 순수 번역 한 번."""
src_code = LANGUAGES[source_lang]["nllb"]
tgt_code = LANGUAGES[target_lang]["nllb"]
with self._lock:
self._tokenizer.src_lang = src_code
tokens = self._tokenizer.convert_ids_to_tokens(
self._tokenizer.encode(text, truncation=True, max_length=512)
)
results = self._model.translate_batch(
[tokens],
target_prefix=[[tgt_code]],
beam_size=2,
max_decoding_length=512,
repetition_penalty=1.1,
)
hyp = results[0].hypotheses[0]
if hyp and hyp[0] == tgt_code:
hyp = hyp[1:]
return self._tokenizer.decode(
self._tokenizer.convert_tokens_to_ids(hyp), skip_special_tokens=True
)
def _translate(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE, source_politeness=Politeness.UNKNOWN):
# seq2seq 는 프롬프트가 없어 말투를 지시할 수 없다. 후처리로 맞춘다.
if self._model is None:
self.load()
assert self._model is not None and self._tokenizer is not None
if glossary is None or not len(glossary):
return self._run_model(text, source_lang, target_lang)
protected, replacements = glossary.protect(text, target_lang)
if not replacements:
return self._run_model(text, source_lang, target_lang)
out = self._run_model(protected, source_lang, target_lang)
return _restore_or_retranslate(
out, replacements,
lambda: self._run_model(text, source_lang, target_lang),
)
class LLMTranslator(Translator):
"""Seed-X / Qwen3 등 LLM 백엔드. 용어집은 프롬프트로 직접 지시한다."""
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0,
lora_path: str = "") -> None:
super().__init__(spec, device, device_index)
self.lora_path = lora_path
self._model = None
self._tokenizer = None
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
dtype = torch.float16 if self.device == "cuda" else torch.float32
log.info("번역 LLM 로드: %s (%s)", self.spec.repo, self.device)
self._tokenizer = AutoTokenizer.from_pretrained(
self.spec.repo, cache_dir=str(models_dir())
)
try:
self._model = AutoModelForCausalLM.from_pretrained(
self.spec.repo,
cache_dir=str(models_dir()),
torch_dtype=dtype,
device_map={"": self.device_index} if self.device == "cuda" else "cpu",
)
except (ImportError, RuntimeError, ValueError) as exc:
if self.spec.compute_type == "int4":
# AWQ 가중치는 전용 커널이 있어야 읽힌다. 메시지가 모호해서
# 사용자가 원인을 못 찾는 일이 잦으므로 해결 방법을 같이 알려준다.
raise RuntimeError(
f"양자화 번역 모델({self.spec.repo})을 불러오지 못했습니다.\n"
"AWQ 커널이 필요합니다: pip install autoawq\n"
"설치가 어려우면 '모델' 화면에서 다른 티어를 선택하세요.\n"
f"원인: {exc}"
) from exc
raise
if self.lora_path:
from peft import PeftModel
log.info("LoRA 어댑터 적용: %s", self.lora_path)
self._model = PeftModel.from_pretrained(self._model, self.lora_path)
self._model.eval()
def unload(self) -> None:
with self._lock:
self._model = None
self._tokenizer = None
try:
import torch
torch.cuda.empty_cache()
except Exception: # noqa: BLE001
pass
def _build_prompt(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE,
source_politeness=Politeness.UNKNOWN) -> str:
if self.spec.prompt_style is PromptStyle.SEEDX:
# Seed-X 는 지시문을 못 알아듣는다. 말투는 후처리로만 맞춘다.
return build_seedx_prompt(text, source_lang, target_lang)
return self._build_instruct_prompt(
text, source_lang, target_lang, glossary, speech_level, source_politeness
)
def _build_instruct_prompt(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE,
source_politeness=Politeness.UNKNOWN) -> str:
"""지시문을 이해하는 모델용. 가능하면 chat template 을 태운다."""
src = LANGUAGES[source_lang]["english"]
tgt = LANGUAGES[target_lang]["english"]
hint = glossary.prompt_hint(text, target_lang) if glossary else ""
parts = [
f"Translate the following {src} text into {tgt}. "
"It is a live spoken line from a game or broadcast, so keep the tone "
"casual and natural. Output only the translation."
]
if target_lang == "ko":
parts.append(prompt_instruction(speech_level, source_politeness))
if hint:
parts.append(hint)
parts.append(f"{src}: {text}\n{tgt}:")
instruction = "\n\n".join(parts)
template = getattr(self._tokenizer, "chat_template", None)
if not template:
return instruction
# Qwen3 는 기본적으로 thinking 모드가 켜져 실시간 자막에는 너무 느리다.
try:
return self._tokenizer.apply_chat_template(
[{"role": "user", "content": instruction}],
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
except TypeError:
# enable_thinking 을 모르는 템플릿
return self._tokenizer.apply_chat_template(
[{"role": "user", "content": instruction}],
tokenize=False,
add_generation_prompt=True,
)
def _generate(self, prompt: str) -> str:
"""프롬프트 하나를 돌려 첫 줄만 돌려준다."""
import torch
with self._lock:
inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
with torch.inference_mode():
generated = self._model.generate(
**inputs,
max_new_tokens=256,
do_sample=False,
num_beams=1,
repetition_penalty=1.05,
pad_token_id=self._tokenizer.eos_token_id,
)
new_tokens = generated[0][inputs["input_ids"].shape[-1] :]
out = self._tokenizer.decode(new_tokens, skip_special_tokens=True)
return out.strip().split("\n")[0]
def _translate(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE, source_politeness=Politeness.UNKNOWN):
if self._model is None:
self.load()
assert self._model is not None and self._tokenizer is not None
def run(source: str, gloss) -> str:
return self._generate(
self._build_prompt(
source, source_lang, target_lang, gloss,
speech_level, source_politeness,
)
)
# Seed-X 는 지시문을 못 알아들으므로 용어집을 자리표시자로 보호한다.
# 지시문을 이해하는 모델은 프롬프트로 넣으므로 보호가 필요 없다.
needs_placeholder = (
self.spec.prompt_style is PromptStyle.SEEDX
and glossary is not None
and len(glossary)
)
if not needs_placeholder:
return run(text, glossary)
protected, replacements = glossary.protect(text, target_lang)
if not replacements:
return run(text, glossary)
out = run(protected, None)
return _restore_or_retranslate(
out, replacements, lambda: run(text, None)
)
def create_translator(
spec: MTSpec, device: str = "cuda", device_index: int = 0, lora_path: str = ""
) -> Translator:
if spec.backend is MTBackend.CTRANSLATE2:
return CTranslate2Translator(spec, device, device_index)
return LLMTranslator(spec, device, device_index, lora_path)

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

@@ -18,7 +18,7 @@ from PySide6.QtWidgets import (
from ...config import AppConfig
from ...models import Tier, detect_gpu, ordered_tiers
from ...models.manager import tier_availability
from ...models.manager import GpuInfo, tier_availability
from ..theme import SPACING, palette
from ..widgets import Card, PageHeader
@@ -26,10 +26,11 @@ from ..widgets import Card, PageHeader
class TierCard(QFrame):
"""티어 하나를 라디오 버튼 카드로."""
def __init__(self, tier: Tier, gpu_vram_gb: float, parent: QWidget | None = None):
def __init__(self, tier: Tier, gpu: GpuInfo, parent: QWidget | None = None):
super().__init__(parent)
self.tier = tier
self.available, self.unavailable_reason = tier_availability(tier)
gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
self.available, self.unavailable_reason = tier_availability(tier, gpu)
self.setObjectName("Card")
p = palette("dark")
@@ -102,6 +103,9 @@ class ModelsPage(QWidget):
super().__init__(parent)
self.config = config
self._cards: list[TierCard] = []
# 티어 카드마다 조회하면 nvidia-smi 를 다섯 번 부른다. 한 번만 본다.
self._gpu = detect_gpu()
self._gpu_vram_gb = self._gpu.total_vram_mb / 1024 if self._gpu.available else 0.0
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
@@ -119,7 +123,7 @@ class ModelsPage(QWidget):
self._group = QButtonGroup(self)
self._group.setExclusive(True)
for tier in ordered_tiers():
card = TierCard(tier, self._gpu_vram_gb)
card = TierCard(tier, self._gpu)
self._group.addButton(card.radio, tier.order)
card.radio.toggled.connect(
lambda checked, t=tier: self._on_tier_selected(t) if checked else None
@@ -133,8 +137,7 @@ class ModelsPage(QWidget):
# --- 구성 -----------------------------------------------------------
def _build_gpu_card(self) -> Card:
gpu = detect_gpu()
self._gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
gpu = self._gpu
p = palette("dark")
card = Card("실행 환경")

View File

@@ -9,6 +9,7 @@ from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSlider,
QSpinBox,
@@ -19,6 +20,7 @@ from PySide6.QtWidgets import (
from ...config import AppConfig
from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ...models import detect_gpu
from ...storage import free_space, human_size
from ..theme import SPACING, palette
from ..widgets import Card, PageHeader
@@ -45,6 +47,7 @@ class SettingsPage(QWidget):
root.addWidget(self._build_hotkey_card())
root.addWidget(self._build_audio_card())
root.addWidget(self._build_behavior_card())
root.addWidget(self._build_storage_card())
root.addWidget(self._build_about_card())
root.addStretch(1)
@@ -258,20 +261,61 @@ class SettingsPage(QWidget):
self.log_check.setChecked(self.config.log_transcripts)
self.log_check.toggled.connect(self._on_behavior_changed)
form.addRow("", self.log_check)
open_folder = QPushButton("설정 폴더 열기")
open_folder.clicked.connect(self._open_folder)
form.addRow("", open_folder)
return card
# --- 저장 위치 -------------------------------------------------------
def _build_storage_card(self) -> Card:
card = Card(
"저장 위치",
"모델과 설정, 번역 기록이 여기에 저장됩니다. 모델은 품질 티어에 따라 "
"2~16GB 를 차지하므로 여유 있는 디스크가 좋습니다.",
)
self.storage_label = QLabel()
self.storage_label.setObjectName("Hint")
self.storage_label.setWordWrap(True)
self.storage_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
card.add(self.storage_label)
row = QWidget()
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
change = QPushButton("위치 변경…")
change.clicked.connect(self._change_storage)
open_folder = QPushButton("폴더 열기")
open_folder.clicked.connect(self._open_folder)
row_layout.addWidget(change)
row_layout.addWidget(open_folder)
row_layout.addStretch(1)
card.add(row)
self._refresh_storage_label()
return card
def _refresh_storage_label(self) -> None:
path = user_data_dir()
self.storage_label.setText(f"{path}\n이 디스크의 남은 공간 {human_size(free_space(path))}")
def _change_storage(self) -> None:
from ..data_location import ask_change_location
target = ask_change_location(self)
if target is None:
return
# 옮기지 않기를 골랐다면 새 위치에 설정 파일이 없다. 지금 상태를 써둔다.
self.config.save()
self._refresh_storage_label()
QMessageBox.information(
self,
"저장 위치를 바꿨습니다",
f"이제 여기를 씁니다.\n\n{target}\n\n"
"이미 메모리에 올라가 있는 모델은 그대로 쓰고, 다음에 받는 것부터 "
"새 위치에 저장됩니다. 로그 파일은 다음 실행부터 옮겨집니다.",
)
def _build_about_card(self) -> Card:
card = Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN)
path = QLabel(f"설정 위치: {user_data_dir()}")
path.setObjectName("Hint")
path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
path.setWordWrap(True)
card.add(path)
return card
return Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN)
# --- 동작 -----------------------------------------------------------
def _on_audio_changed(self, *_) -> None:

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

View File

@@ -139,3 +139,54 @@ def test_app_window_builds_without_torch(tmp_path, monkeypatch):
win.overlay.close()
win.close()
_ = app
# --- 티어 게이트가 실제 제약을 보는지 -------------------------------------
#
# 예전에는 torch 유무만 봤다. 그래서 8GB 카드에서도 "극한(16GB 필요) 사용
# 가능"이라고 답했고, 사용자가 고르면 로드에서 죽었다. 실제 GPU 로 검증하다
# 발견한 버그다.
def _gpu(total_gb: float):
from livesub.models.manager import GpuInfo
return GpuInfo(available=True, name="test", total_vram_mb=int(total_gb * 1024))
@pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다")
@pytest.mark.parametrize("key", ["precision", "ultimate"])
def test_vram_이_모자라면_티어를_막는다(key, monkeypatch):
monkeypatch.setattr("livesub.models.manager.has_awq", lambda: True)
ok, reason = tier_availability(TIERS[key], _gpu(7.5))
assert not ok
assert "VRAM" in reason and "7.5GB" in reason
@pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다")
def test_vram_이_충분하면_통과한다(monkeypatch):
monkeypatch.setattr("livesub.models.manager.has_awq", lambda: True)
assert tier_availability(TIERS["ultimate"], _gpu(24))[0]
@pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다")
def test_autoawq_가_없으면_int4_티어를_막는다(monkeypatch):
monkeypatch.setattr("livesub.models.manager.has_awq", lambda: False)
ok, reason = tier_availability(TIERS["precision"], _gpu(24))
assert not ok
assert "autoawq" in reason
def test_ct2_티어는_gpu_조회_없이도_통과한다():
"""1~3티어는 torch 도 GPU 도 안 따진다 — 포터블에서 돌아야 하므로."""
def _boom():
raise AssertionError("CT2 티어는 detect_gpu 를 부르면 안 된다")
import livesub.models.manager as mgr
original, mgr.detect_gpu = mgr.detect_gpu, _boom
try:
for key in ("lightning", "swift", "balance"):
assert tier_availability(TIERS[key])[0]
finally:
mgr.detect_gpu = original

View File

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

124
tests/test_storage.py Normal file
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()