feat(capture): lock decision #1 and prove Xvfb+Chromium frame capture
Decision #1: watch the real Discord screen share by running a real web client under a virtual display (Xvfb) and capturing rendered frames — not bot/selfbot protocol receive. PLAN.md records all locked decisions and the step list. PoC (poc/capture_xvfb.py + test_page.html) launches system Chrome non-headless under Xvfb via Playwright and captures 6 changing, non-blank frames -> de-risks the display+capture chain on the .9 host.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.venv/
|
.venv/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
poc/frames/
|
||||||
|
|||||||
50
PLAN.md
Normal file
50
PLAN.md
Normal file
@@ -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만. 큰 이해는 클라우드.
|
||||||
75
poc/capture_xvfb.py
Normal file
75
poc/capture_xvfb.py
Normal file
@@ -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())
|
||||||
29
poc/test_page.html
Normal file
29
poc/test_page.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><title>capture test</title>
|
||||||
|
<style>html,body{margin:0;background:#101418}</style></head>
|
||||||
|
<body>
|
||||||
|
<canvas id="c" width="1280" height="720"></canvas>
|
||||||
|
<script>
|
||||||
|
// An always-changing scene, so captured frames must differ frame-to-frame and
|
||||||
|
// be non-blank. This stands in for "a Discord screen share is playing".
|
||||||
|
const c = document.getElementById('c'), x = c.getContext('2d');
|
||||||
|
let t = 0;
|
||||||
|
function draw() {
|
||||||
|
t++;
|
||||||
|
x.fillStyle = '#101418'; x.fillRect(0, 0, 1280, 720);
|
||||||
|
// moving box
|
||||||
|
const px = (t * 7) % 1180;
|
||||||
|
x.fillStyle = `hsl(${(t*3)%360} 80% 55%)`;
|
||||||
|
x.fillRect(px, 300 + 120 * Math.sin(t / 15), 100, 100);
|
||||||
|
// big live counter text
|
||||||
|
x.fillStyle = '#e8eef2'; x.font = 'bold 90px monospace';
|
||||||
|
x.fillText('FRAME ' + t, 60, 140);
|
||||||
|
x.font = '28px monospace';
|
||||||
|
x.fillText(new Date().toISOString(), 60, 200);
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user