From 2fcd961f8f97a5aeffb86575b11da6bc99045895 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Wed, 23 Sep 2026 03:11:31 +0900 Subject: [PATCH] =?UTF-8?q?ci:=20Windows=20=EC=9E=90=EB=8F=99=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(Gitea=20Actions=20+=20?= =?UTF-8?q?=EC=8A=A4=EB=AA=A8=ED=81=AC=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 개발이 리눅스에서 이뤄지다 보니 Windows 전용 경로는 한 번도 실행해 본 적이 없다. 오디오 캡처, 전역 단축키, 모니터 열거, exe 빌드가 전부 여기 해당하고, "리눅스에서 다 통과했는데 윈도우에서 안 되더라"가 나올 수 있는 지점이다. - .gitea/workflows/windows.yml push 때마다 Windows 러너에서 테스트 -> 스모크 -> 포터블 exe 빌드 -> 빌드된 exe 기동 확인까지 하고 exe·스크린샷을 artifact 로 올린다. 의존성을 packaging/requirements-portable.txt 로 깔아 torch 없는 포터블 조건을 그대로 재현하고, torch 가 섞이면 바로 실패하게 했다. - packaging/windows_smoke.py 리눅스에서 확인 불가능한 항목만 모은 점검기. WASAPI 장치 열거, 소리 내는 프로그램 열거, 전역 단축키 등록/해제, 실제 모니터 열거, GPU 인식, 사용 가능 티어, 자막 오버레이 실제 렌더링(스크린샷 저장). 한 항목이 실패해도 나머지를 계속 돌려 전체 그림을 한 번에 보여준다. 러너 환경에 따라 없을 수 있는 항목(오디오 장치·보조 exe·GPU)은 경고로만 두고, 코드 문제인 항목만 빌드를 실패시킨다. - docs/WINDOWS-TESTING.md 러너 등록 절차와, 러너 없이 직접 돌려보는 방법. 러너가 없으면 워크플로는 대기만 하므로 지금 상태에 영향이 없다. 검증: pytest 189개 통과, ruff clean, 워크플로 YAML 파싱 확인, 스모크 스크립트를 리눅스에서 실행해 항목별 판정과 종료코드 확인 Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/windows.yml | 87 ++++++++++++++ .gitignore | 1 + README.md | 12 ++ docs/WINDOWS-TESTING.md | 111 ++++++++++++++++++ packaging/windows_smoke.py | 218 +++++++++++++++++++++++++++++++++++ 5 files changed, 429 insertions(+) create mode 100644 .gitea/workflows/windows.yml create mode 100644 docs/WINDOWS-TESTING.md create mode 100644 packaging/windows_smoke.py diff --git a/.gitea/workflows/windows.yml b/.gitea/workflows/windows.yml new file mode 100644 index 0000000..45fead5 --- /dev/null +++ b/.gitea/workflows/windows.yml @@ -0,0 +1,87 @@ +# Windows 검증 — Gitea Actions +# +# 이 저장소에 push 하면 Windows 러너가 자동으로 +# 1) 테스트를 돌리고 +# 2) 포터블 exe 를 빌드하고 +# 3) 앱이 실제로 뜨는지 확인한 뒤 +# 4) exe 와 스크린샷을 artifact 로 올립니다. +# +# 러너 등록 방법은 docs/WINDOWS-TESTING.md 참고. +# 러너가 없으면 이 워크플로는 그냥 대기 상태로 남고 아무 일도 하지 않습니다. + +name: Windows + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + verify: + runs-on: windows + timeout-minutes: 60 + + steps: + - name: 소스 받기 + uses: actions/checkout@v4 + + - name: Python 준비 + shell: pwsh + run: | + python --version + python -m venv .venv-ci + .\.venv-ci\Scripts\python -m pip install --upgrade pip --quiet + + # torch 없이 설치한다. 포터블 빌드와 같은 조건을 만들어야 + # "포터블에서 1~3티어가 된다"는 주장을 실제로 검증할 수 있다. + - name: 의존성 설치 (포터블 조건 = torch 없음) + shell: pwsh + run: | + .\.venv-ci\Scripts\python -m pip install -r packaging\requirements-portable.txt + .\.venv-ci\Scripts\python -m pip install pytest ruff + .\.venv-ci\Scripts\python -c "import importlib.util; assert importlib.util.find_spec('torch') is None; print('torch 없음 - 포터블 조건 OK')" + + - name: 테스트 + shell: pwsh + env: + PYTHONPATH: src + run: | + .\.venv-ci\Scripts\python -m pytest tests -q + .\.venv-ci\Scripts\python -m ruff check src tests scripts + + # 여기부터가 리눅스에서 못 하던 것들이다. + - name: Windows 전용 경로 확인 (오디오 장치·단축키·프로세스 열거) + shell: pwsh + env: + PYTHONPATH: src + run: .\.venv-ci\Scripts\python packaging\windows_smoke.py + + - name: 프로그램별 캡처 보조 프로그램 빌드 + shell: pwsh + continue-on-error: true # VS Build Tools 가 없는 러너도 있을 수 있다 + run: | + cmake -S native\process_loopback -B native\process_loopback\build -A x64 + cmake --build native\process_loopback\build --config Release + + - name: 포터블 exe 빌드 + shell: pwsh + run: | + .\.venv-ci\Scripts\python -m pip install pyinstaller + .\.venv-ci\Scripts\python -m PyInstaller packaging\livesub.spec --noconfirm + $exe = Get-Item dist\LiveSub.exe + "빌드 완료: {0:N1} MB" -f ($exe.Length / 1MB) + + - name: 빌드된 exe 가 실제로 실행되는지 + shell: pwsh + run: .\.venv-ci\Scripts\python packaging\windows_smoke.py --exe dist\LiveSub.exe + + - name: 결과물 업로드 + uses: actions/upload-artifact@v4 + if: always() + with: + name: LiveSub-portable + path: | + dist/LiveSub.exe + artifacts/*.png + artifacts/*.txt + retention-days: 14 diff --git a/.gitignore b/.gitignore index 112cc04..ea53f71 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ transcripts/ .idea/ *.swp .DS_Store +artifacts/ diff --git a/README.md b/README.md index d07608f..016ada2 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,18 @@ ruff check src tests scripts 구조는 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)를 보세요. +### Windows 자동 검증 + +개발은 리눅스에서 하므로 오디오 캡처·전역 단축키·exe 빌드는 여기서 테스트할 수 +없습니다. Windows 러너를 붙이면 push할 때마다 자동으로 확인되고 exe와 +스크린샷이 artifact로 올라옵니다 — [docs/WINDOWS-TESTING.md](docs/WINDOWS-TESTING.md). + +러너 없이 그때그때 확인만 하려면: + +```powershell +.\.venv\Scripts\python packaging\windows_smoke.py +``` + ## 라이선스 MIT. 사용하는 모델은 각자의 라이선스를 따릅니다 diff --git a/docs/WINDOWS-TESTING.md b/docs/WINDOWS-TESTING.md new file mode 100644 index 0000000..c8af672 --- /dev/null +++ b/docs/WINDOWS-TESTING.md @@ -0,0 +1,111 @@ +# Windows에서 자동 검증 붙이기 + +개발은 리눅스에서 하고 있어서 **Windows 전용 경로는 테스트가 불가능**합니다. +오디오 캡처, 전역 단축키, 모니터 열거, exe 빌드가 전부 여기에 해당합니다. +그래서 "리눅스에서 다 통과했는데 윈도우에서 안 되더라"가 나올 수 있습니다. + +Windows 러너를 하나 붙이면 push할 때마다 이 경로들이 자동으로 검증됩니다. + +## 무엇이 자동으로 확인되나 + +| 항목 | 리눅스에서 | Windows 러너에서 | +|---|---|---| +| 단위 테스트 189개 | ✅ | ✅ | +| WASAPI 루프백 장치 열거 | ❌ | ✅ | +| 소리 내는 프로그램 열거 | ❌ | ✅ | +| 전역 단축키 등록/해제 | ❌ | ✅ | +| 모니터 열거·자막 배치 | 가상 화면만 | ✅ 실제 모니터 | +| 포터블 exe 빌드 | ❌ | ✅ | +| exe가 실제로 뜨는지 | ❌ | ✅ | +| GPU 인식 (CUDA) | 이 서버 GPU | ✅ 실제 게임 PC GPU | +| 실제 화면 스크린샷 | offscreen | ✅ 진짜 화면 | + +결과물(exe, 스크린샷, 로그)은 Gitea Actions의 artifact로 올라오므로 +원격에서도 눈으로 확인할 수 있습니다. + +--- + +## 설치 (약 10분, 한 번만) + +### 1. 필요한 것 설치 + +```powershell +winget install Python.Python.3.12 +winget install Kitware.CMake +winget install Microsoft.VisualStudio.2022.BuildTools # C++ 데스크톱 워크로드 선택 +``` + +### 2. Gitea 러너 내려받기 + + 에서 +`act_runner-<버전>-windows-amd64.exe` 를 받아 `C:\gitea-runner\act_runner.exe` 로 둡니다. + +### 3. 등록 토큰 받기 + +Gitea 웹에서 저장소 → **설정 → Actions → 러너 → 등록 토큰 생성** +(주소: `https://git.tkrmagid.kr/tkrmagid/live-app-translator/settings/actions/runners`) + +### 4. 등록하고 실행 + +```powershell +cd C:\gitea-runner +.\act_runner.exe register --no-interactive ` + --instance https://git.tkrmagid.kr ` + --token <위에서 받은 토큰> ` + --name windows-gaming-pc ` + --labels windows + +.\act_runner.exe daemon +``` + +`--labels windows` 가 중요합니다. 워크플로의 `runs-on: windows` 와 맞아야 합니다. + +### 5. 확인 + +아무거나 push 하면 저장소 **Actions** 탭에 `Windows` 워크플로가 돌기 시작합니다. + +--- + +## 항상 켜두고 싶다면 (선택) + +부팅할 때 자동으로 뜨게 하려면 서비스로 등록합니다. + +```powershell +# 관리자 PowerShell +sc.exe create gitea-runner binPath= "C:\gitea-runner\act_runner.exe daemon --config C:\gitea-runner\config.yaml" start= auto +sc.exe start gitea-runner +``` + +게임할 때 방해되면 그냥 꺼두고, 확인이 필요할 때만 `daemon` 을 켜도 됩니다. +러너가 꺼져 있으면 워크플로는 대기만 하고 아무 일도 하지 않습니다. + +--- + +## 안전에 대해 + +- 러너는 **저장소에 커밋된 워크플로 파일만** 실행합니다. 대화로 임의 명령을 + 넣을 수 없고, 무엇이 실행됐는지는 git 기록에 그대로 남습니다. +- 실행되는 내용은 `.gitea/workflows/windows.yml` 한 파일이며 언제든 읽고 + 고칠 수 있습니다. 테스트·빌드·스크린샷 외의 일은 하지 않습니다. +- 개인 PC가 부담스러우면 Proxmox에 Windows 11 VM을 만들어 거기에 러너를 + 붙여도 됩니다. GPU와 실제 게임 소리 확인은 안 되지만, 빌드·단축키· + 모니터·UI는 전부 검증됩니다. + +--- + +## 러너 없이 직접 돌려보기 + +러너를 안 붙이고 그때그때 확인만 하고 싶다면 이것만 실행하고 결과를 +보내주셔도 됩니다. + +```powershell +git clone https://git.tkrmagid.kr/tkrmagid/live-app-translator +cd live-app-translator +py -3.12 -m venv .venv +.\.venv\Scripts\python -m pip install -r packaging\requirements-portable.txt pytest +$env:PYTHONPATH="src" +.\.venv\Scripts\python -m pytest tests -q +.\.venv\Scripts\python packaging\windows_smoke.py +``` + +`artifacts\windows-smoke.txt` 와 `artifacts\*.png` 를 보내주시면 됩니다. diff --git a/packaging/windows_smoke.py b/packaging/windows_smoke.py new file mode 100644 index 0000000..6b14efa --- /dev/null +++ b/packaging/windows_smoke.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Windows 전용 경로 점검. + +리눅스 개발 환경에서는 확인이 불가능한 것들만 모아놨다. 여기서 실패하면 +"리눅스에서 테스트 다 통과했는데 윈도우에서 안 되더라" 가 되는 지점이다. + + python packaging/windows_smoke.py # 소스에서 점검 + python packaging/windows_smoke.py --exe dist\\LiveSub.exe # 빌드된 exe 점검 + +결과는 artifacts/ 에 로그와 스크린샷으로 남는다. CI 가 그걸 업로드하므로 +원격에서도 실제 화면을 눈으로 확인할 수 있다. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACTS = ROOT / "artifacts" +sys.path.insert(0, str(ROOT / "src")) + +_results: list[tuple[str, bool, str]] = [] + + +def check(name: str): + """점검 하나. 예외가 나도 나머지는 계속 돈다.""" + + def wrapper(fn): + try: + detail = fn() or "" + ok = True + except Exception as exc: # noqa: BLE001 - 점검 결과로 기록하는 게 목적 + detail, ok = f"{type(exc).__name__}: {exc}", False + _results.append((name, ok, str(detail))) + print(f" [{'OK ' if ok else '실패'}] {name} {detail}") + return fn + + return wrapper + + +# --- 소스 기준 점검 --------------------------------------------------------- + + +def run_source_checks() -> None: + print("\n=== Windows 전용 경로 ===") + + @check("WASAPI 루프백 장치 열거") + def _devices(): + from livesub.audio.wasapi_loopback import WasapiLoopbackCapture + + if not WasapiLoopbackCapture.available(): + raise RuntimeError("PyAudioWPatch 없음 — 오디오 캡처 불가") + sources = WasapiLoopbackCapture.list_sources() + if not sources: + raise RuntimeError("루프백 장치가 하나도 없음") + return f"{len(sources)}개: {sources[0].label}" + + @check("소리 내는 프로그램 열거 (pycaw)") + def _processes(): + from livesub.audio.process_loopback import ProcessLoopbackCapture + + sources = ProcessLoopbackCapture.list_sources() + return f"{len(sources)}개" + (f" (예: {sources[0].label})" if sources else " (지금 소리 내는 앱 없음)") + + @check("프로그램별 캡처 보조 프로그램") + def _helper(): + from livesub.audio.process_loopback import helper_path + + path = helper_path() + if path is None: + raise RuntimeError("livesub_capture.exe 없음 — 장치 전체 캡처로 폴백됨") + return str(path) + + @check("전역 단축키 등록/해제") + def _hotkey(): + from PySide6.QtWidgets import QApplication + + from livesub.ui.hotkeys import GlobalHotkeys + + app = QApplication.instance() or QApplication([]) + keys = GlobalHotkeys() + if not keys.supported: + raise RuntimeError("이 플랫폼은 전역 단축키 미지원") + fired = [] + if not keys.register("Ctrl+Alt+F12", lambda: fired.append(1)): + raise RuntimeError(keys.last_error) + keys.unregister_all() + _ = app + return "Ctrl+Alt+F12 등록·해제 성공" + + @check("모니터 열거") + def _screens(): + from PySide6.QtWidgets import QApplication + + from livesub.ui.placement import list_screens + + app = QApplication.instance() or QApplication([]) + screens = list_screens() + if not screens: + raise RuntimeError("모니터를 찾지 못함") + _ = app + return " / ".join(s.label for s in screens) + + @check("GPU 인식") + def _gpu(): + from livesub.models import detect_gpu + + gpu = detect_gpu() + if not gpu.available: + raise RuntimeError(gpu.reason) + return f"{gpu.name} · {gpu.total_vram_mb / 1024:.1f}GB" + + @check("사용 가능한 모델 티어") + def _tiers(): + from livesub.models.manager import has_torch, tier_availability + from livesub.models.tiers import ordered_tiers + + usable = [t.name for t in ordered_tiers() if tier_availability(t)[0]] + return f"torch={'있음' if has_torch() else '없음'} → {', '.join(usable)}" + + @check("자막 오버레이 실제 렌더링") + def _overlay(): + os.environ.setdefault("QT_QPA_PLATFORM", "windows") + from PySide6.QtWidgets import QApplication + + from livesub.config import AppConfig + from livesub.ui.main_window import MainWindow + + app = QApplication.instance() or QApplication([]) + cfg = AppConfig() + cfg.models.preload_on_start = False + win = MainWindow(cfg) + try: + win.show() + win.overlay.set_subtitle_visible(True) + win.overlay.set_line("적이 왼쪽에서 들어온다, 지금 바로 빠져!", "Enemy from the left") + for _ in range(10): + app.processEvents() + time.sleep(0.05) + ARTIFACTS.mkdir(exist_ok=True) + win.grab().save(str(ARTIFACTS / "windows-main.png")) + win.overlay.grab().save(str(ARTIFACTS / "windows-overlay.png")) + finally: + win.engine.shutdown() + win.overlay.close() + win.close() + return "스크린샷 2장 저장 (artifacts/)" + + +# --- 빌드된 exe 점검 -------------------------------------------------------- + + +def run_exe_check(exe: Path) -> None: + print(f"\n=== 빌드된 exe 점검: {exe} ===") + + @check("exe 존재와 크기") + def _size(): + if not exe.is_file(): + raise RuntimeError("파일 없음") + mb = exe.stat().st_size / 1024 / 1024 + if mb > 1500: + raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인") + return f"{mb:.1f} MB" + + @check("exe 실행 후 8초 생존") + def _launch(): + # 창을 띄우고 잠깐 살아 있는지 본다. 바로 죽으면 의존성 누락이다. + proc = subprocess.Popen([str(exe)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + time.sleep(8) + alive = proc.poll() is None + if not alive: + err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:] + raise RuntimeError(f"즉시 종료 (코드 {proc.returncode}): {err}") + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + return "정상 기동" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Windows 전용 점검") + parser.add_argument("--exe", type=Path, help="빌드된 exe 를 점검") + args = parser.parse_args() + + if args.exe: + run_exe_check(args.exe) + else: + run_source_checks() + + ARTIFACTS.mkdir(exist_ok=True) + report = "\n".join( + f"[{'OK' if ok else 'FAIL'}] {name}: {detail}" for name, ok, detail in _results + ) + (ARTIFACTS / "windows-smoke.txt").write_text(report, encoding="utf-8") + + failed = [n for n, ok, _ in _results if not ok] + print(f"\n{len(_results) - len(failed)}/{len(_results)} 통과") + if failed: + print("실패:", ", ".join(failed)) + # 오디오 장치·보조 프로그램은 러너 환경에 따라 없을 수 있어 경고로만 둔다. + fatal = [n for n in failed if n not in { + "프로그램별 캡처 보조 프로그램", + "소리 내는 프로그램 열거 (pycaw)", + "WASAPI 루프백 장치 열거", + "GPU 인식", + }] + return 1 if fatal else 0 + + +if __name__ == "__main__": + raise SystemExit(main())