Initial commit: ASCILINE YouTube Streamer

ASCII-art YouTube streaming for the Tesla in-car browser.

- FastAPI server on a Mac mini, no Docker.
- yt-dlp resolver: ID/URL/search.
- ffmpeg with -re -fps_mode cfr for source-paced video; trivial drain
  consumer.  Separate ffmpeg for AAC/ADTS audio.
- Vendored ASCILINE renderer (MIT) for the binary wire protocol; pure
  fillText color path, on-demand selection flush.
- HMAC PIN-gated cookie; Secure flag scheme-aware so /audio works on
  plain http during local dev.
- LOW preset (120x50 24fps) verified clean on M4: FPS 24/24, JIT ~42ms.
This commit is contained in:
Erhan Keseli
2026-06-13 18:05:19 +02:00
commit 74f49c7712
21 changed files with 4127 additions and 0 deletions

160
README.md Normal file
View File

@@ -0,0 +1,160 @@
# ASCILINE YouTube Streamer
Plays any YouTube video (live or VOD) as a real-time ASCII art stream in
a web browser. Designed for the Tesla in-car browser, works in any
Chromium browser. Runs directly on a Mac mini — no Docker.
## Requirements
- macOS (Apple Silicon recommended)
- Python 3.11+
- ffmpeg (Homebrew)
## Setup
```bash
brew install ffmpeg
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
Or run `./run.sh` once and it does the same thing idempotently, prompts
for a PIN, and starts the server.
## Running
```bash
source .venv/bin/activate
python server.py --pin 1234
```
The server listens on `http://0.0.0.0:8000` by default. Open it,
enter the PIN, then type a YouTube URL, video ID, or search query and
press Play.
| Flag | Default | Description |
|------|---------|-------------|
| `--pin` | required | 48 digit PIN (or `ASCIILINE_PIN` env var) |
| `--port` | 8000 | TCP port to listen on |
| `--bind` | 0.0.0.0 | Bind address |
| `--max-source-height` | 720 | Maximum source video resolution |
## Quality presets
| Preset | Grid | FPS | Notes |
|--------|------|-----|-------|
| **Low** (default) | 120×50 | 24 | Tesla / target |
| **Med** | 160×68 | 30 | Desktop |
| **High** | 200×84 | 30 | Desktop |
LOW is the only preset that's been verified clean on the M4 reference
(FPS 24/24, JIT ~42 ms). MED/HIGH are paint-bound on the per-cell
`fillText` loop on the M4 — they work, but with visible jitter. The
Tesla in-car browser runs LOW.
Preset changes take effect on the next Play (server restarts ffmpeg with
the new dimensions).
## Architecture
```
Browser Mac mini
───────── ────────────────────────────────────────
GET / ──▶ static/index.html (or pin.html if no cookie)
POST /api/auth ──▶ HMAC cookie issued (Secure only on https)
POST /api/play ──▶ resolver.py (yt-dlp) → ResolvedMedia
pipeline.py spawns ffmpeg ×2 (video + audio)
WS /ws/video ◀── INIT message + binary ASCILINE frames
GET /audio ◀── AAC/ADTS chunked stream
```
Wire format and rendering are vendored from
[ASCILINE](https://github.com/YusufB5/ASCILINE). See
`vendor/asciline/PROTOCOL-NOTES.md`.
### Pipeline pacing
ffmpeg is invoked with `-re -fps_mode cfr -r <target_fps>` so frames
arrive evenly paced at the target rate. The consumer is then a trivial
drain — read from the queue, encode, send. Earlier iterations tried
consumer-side pacing (relative sleep, absolute targets, skip-ahead,
re-anchor) and all failed the same way: a bursty producer cannot be
smoothed by any consumer-side scheme. Source-side pacing fixed it in
one flag.
## Cloudflare Tunnel
The service is designed to run behind a Cloudflare Tunnel for internet
access from the Tesla:
```bash
brew install cloudflare/cloudflare/cloudflared
cloudflared tunnel login
cloudflared tunnel create asciline
cloudflared tunnel route dns asciline yourdomain.com
cloudflared tunnel run asciline
```
The PIN brute-force lockout uses `CF-Connecting-IP`, so each car/device
is locked out independently — one device's 5 bad attempts does not lock
out the others.
The auth cookie is marked `Secure` only when the auth request itself
came in over https. Over plain `http://localhost` (local dev) the
cookie is set without `Secure` so the browser will actually send it on
the `/audio` GET; otherwise audio would silently 403 while video kept
working.
## Tesla browser notes
- **Audio** is AAC/ADTS via `<audio>`; no extra codecs needed.
- **Touch targets** are ≥ 48 px; PIN keypad has large tap targets.
- **Cookie** persists 30 days; you should not re-enter the PIN often.
- **Preset**: stick to LOW.
## Known limitations
- **A/V drift on long VODs.** Video and audio run as two independent
ffmpeg processes with no shared clock. They start together but drift
is inevitable over hours. Negligible on live streams; audible after
2040 min on long VODs. Timestamp-level sync is out of scope.
- **Signed stream URL expiry (~6 h).** YouTube googlevideo.com URLs
carry time-limited signatures. Live sessions left running past ~6 h
will stop when the manifest URL expires. Press Play again to
re-resolve.
- **In-memory PIN lockout** resets on restart (acceptable for a personal
service).
- **No seeking, no pause/resume.** Stop and re-Play restarts from the
beginning (VOD) or live head (live).
- **Single active session.** A new Play immediately kills the previous.
- **MED/HIGH paint-bound on M4.** The per-cell `fillText` color render
caps at ~150300 K calls/s on Chromium; MED at 30 fps × 10880 cells is
near the wall, HIGH is past it. The known fix if MED/HIGH smoothness
ever matters is an `ImageData`/`putImageData` pixel renderer (the
`pixelMode` path already does this for upstream pixel-mode streams) —
**NOT a glyph atlas** (we tried; LRU thrashes on mode-3 colors and the
result regressed to single-digit FPS).
- **Tesla in-car untested** in this code state; numbers above are M4
proxy.
## Test matrix
| Test | Input | Expected |
|------|-------|----------|
| VOD | `dQw4w9WgXcQ` | ASCII video, audio, no LIVE badge |
| Live | `https://www.youtube.com/watch?v=X4VbdwhkE10` | ASCII + audio + LIVE badge |
| Search | `lofi hip hop` | First search hit, plays |
| Invalid | `not-a-real-id-!!` | Error shown in status bar |
## Process cleanup
The server registers `atexit` + SIGTERM/SIGINT handlers. Both ffmpeg
processes get SIGTERM in parallel; SIGKILL after 3 s if needed. No
orphans survive a clean shutdown.
```bash
# Verify after Ctrl-C or kill:
ps aux | grep ffmpeg
# Expected: (nothing)
```