diff --git a/.gitignore b/.gitignore index 4b7d4f5..94c7a2a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .pytest_cache/ .venv/ *.egg-info/ +poc/frames/ diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..4f79801 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,50 @@ +# watch_sceen_ai — 착수 계획 (확정본) + +디스코드 화면공유를 실시간으로 함께 보며 **음성으로** 대화하는 AI. + +## 확정된 결정 (2026-08-09) + +1. **캡처 방식 (눈)** — 봇 프로토콜 수신/셀프봇 ❌. + .9 서버에 **가상 디스플레이(Xvfb) + 진짜 디스코드 클라이언트(웹 Chromium)** 를 띄우고, + 상대의 공유 스트림 화면을 **프레임으로 캡처**한다. + - 근거: Discord 공식 봇은 비디오 송수신을 모두 막음. 셀프봇 수신 라이브러리는 없고(있어도 송출 전용) 유저토큰 필요 → 밴 위험. + - 진짜 클라이언트가 스트림을 정상 디코딩/렌더 → 우리는 그 픽셀만 읽음. 무인 서버 실행 가능. + - WebRTC 특성상 완전 `--headless` 불가 → Xvfb 가상 X 서버 필요. +2. **대화 방식** — 음성 실시간. STT→LLM→TTS 스트리밍으로 지연 최소화, 사용자 인터럽트 지원. +3. **두뇌** — Claude **OAuth**로 가장 싸고 빠른 모델(Haiku 계열). + + .9의 RTX 5050으로 소형 로컬 VLM을 돌려 매 프레임 1차 이해·**화면 변화 감지**를 값싸게 처리, + 어려운 화면만 Haiku로 에스컬레이션 (하이브리드). +4. **실행 위치** — 이 리눅스 호스트(.9). + +## 단계별 착수 목록 + +### 1단계 · 눈: 캡처 파이프라인 ← 지금 +- [x] 호스트 도구 확인 (Node/Xvfb/ffmpeg/google-chrome 존재) +- [ ] **PoC: Xvfb에서 비-헤드리스 Chromium이 재생 화면을 렌더 → 프레임 캡처 검증** (최대 리스크 선제거) +- [ ] 캡처 주기 + 프레임 변화 감지(동일 화면 반복 전송 방지) +- [ ] 프레임을 파이프라인에 공급하는 소스 인터페이스 + +### 2단계 · 디스코드 접속 +- [ ] Xvfb 크롬에서 Discord 웹 로그인 (별도 계정 권장) +- [ ] 음성 채널 참여 + 상대 스트림 "보기" 클릭 자동화 +- [ ] 스트림 video 영역만 크롭 캡처 + +### 3단계 · 두뇌 (하이브리드 비전) +- [ ] 로컬 VLM(예: moondream2 / Qwen2-VL-2B) 프레임 1차 이해 + 변화 감지 +- [ ] Claude OAuth(Haiku)로 어려운 프레임 에스컬레이션 +- [ ] 최신 화면 맥락 저장소 + +### 4단계 · 음성 대화 +- [ ] STT (faster-whisper, 스트리밍/부분전사) +- [ ] 대화 브레인 (화면맥락 + 대화이력) +- [ ] TTS (MeloTTS 등, 이 호스트에 자산 있음) +- [ ] 말 끊기 인터럽트 + 지연 최적화 + +### 5단계 · 통합·운영 +- [ ] 인지 루프 + 대화 루프 동시 실행 +- [ ] proactive(화면 크게 바뀌면 먼저 말 걸기) +- [ ] 끊김 복구, 비용 모니터링, 서비스화 + +## 주의 +- 캡처용 디스코드 계정은 메인 말고 **별도 계정 권장**(자동화 회색지대, 밴 위험 최소화). +- 로컬 GPU 8GB VRAM 제약 → 소형 VLM만. 큰 이해는 클라우드. diff --git a/poc/capture_xvfb.py b/poc/capture_xvfb.py new file mode 100644 index 0000000..1f362e5 --- /dev/null +++ b/poc/capture_xvfb.py @@ -0,0 +1,75 @@ +"""PoC: prove that a non-headless Chromium under a virtual display (Xvfb) can +render a live/animated page and that we can capture it as frames. + +This de-risks the hardest unknown of decision #1 (watch the real Discord screen +share by capturing a real client's rendered pixels) BEFORE touching Discord. + +Run under a virtual display: + + xvfb-run -a --server-args="-screen 0 1280x720x24" \ + .venv/bin/python poc/capture_xvfb.py + +Success = frames are non-blank AND change over time (proving live rendering + +capture actually work on this headless server). +""" + +from __future__ import annotations + +import hashlib +import pathlib +import sys +import time + +from playwright.sync_api import sync_playwright + +HERE = pathlib.Path(__file__).parent +PAGE = (HERE / "test_page.html").resolve() +OUT = HERE / "frames" +N = 6 +INTERVAL = 0.4 + + +def main() -> int: + OUT.mkdir(exist_ok=True) + hashes: list[str] = [] + sizes: list[int] = [] + + with sync_playwright() as p: + browser = p.chromium.launch( + channel="chrome", # use system google-chrome, no browser download + headless=False, # WebRTC/video needs a real (virtual) display + args=["--no-sandbox", "--disable-gpu", "--autoplay-policy=no-user-gesture-required"], + ) + page = browser.new_page(viewport={"width": 1280, "height": 720}) + page.goto(PAGE.as_uri()) + page.wait_for_timeout(500) + + for i in range(N): + png = page.screenshot() # captures the rendered virtual screen + f = OUT / f"frame_{i:02d}.png" + f.write_bytes(png) + h = hashlib.sha256(png).hexdigest()[:12] + hashes.append(h) + sizes.append(len(png)) + print(f" frame {i}: {len(png):>7} bytes sha={h}") + time.sleep(INTERVAL) + + browser.close() + + distinct = len(set(hashes)) + min_size = min(sizes) + print(f"\n{N} frames, {distinct} distinct, min size {min_size} bytes") + + # A blank/black 1280x720 PNG compresses to a few KB; a real rendered scene is + # much larger. Require non-trivial size and that the scene actually moved. + ok_nonblank = min_size > 8_000 + ok_changing = distinct >= 3 + if ok_nonblank and ok_changing: + print("PoC PASS: Xvfb + Chromium rendered a live scene and we captured changing, non-blank frames.") + return 0 + print(f"PoC FAIL: nonblank={ok_nonblank} changing={ok_changing}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/poc/test_page.html b/poc/test_page.html new file mode 100644 index 0000000..393f93a --- /dev/null +++ b/poc/test_page.html @@ -0,0 +1,29 @@ + + +