ASCILINE YouTube Streamer

Project report — shipped state

Goal

Self-hosted service running on a Mac mini that plays any YouTube video — live or VOD — as a real-time ASCII video stream in a web browser. Primary client: the in-car Tesla browser (Chromium, modest Canvas budget).

The user types a YouTube URL, an 11-character video ID, or a free-text search query and hits Play. Audio plays alongside the ASCII video. A 4–8 digit PIN gates access; the page is exposed to the internet through Cloudflare.

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

Components

FileRole
server.pyFastAPI app, routes, auth middleware
auth.pyHMAC cookie, brute-force lockout, PIN check
session.pySingle-active-session state machine
resolver.pyyt-dlp wrapper: ID/URL/search → media URLs + metadata
pipeline.pyffmpeg subprocesses (video rawvideo + audio AAC), encoder, async iterator
config.pyQuality presets, server defaults
static/Frontend (index.html, app.js, style.css, pin.html)
vendor/asciline/Vendored ASCILINE encoder + protocol notes (MIT)

Pipeline pacing

ffmpeg is invoked with -re -fps_mode cfr -r <target_fps>. -re makes the input read at native realtime rate; -fps_mode cfr -r N resamples the output to a constant N fps. Frames arrive on the consumer side evenly paced — no consumer-side pacing is needed. The async frames() iterator is a trivial drain: read from the queue, encode, send.

Wire protocol

Vendored unchanged from ASCILINE. Plain-text INIT message (INIT:fps:mode:cols:rows:pixel) followed by binary frames. Each frame begins with a 4-byte big-endian frame index; the body is mode-dependent (mode 1 = utf-8 text; modes 2/3 = [charCode, r, g, b] per cell). See vendor/asciline/PROTOCOL-NOTES.md.

Quality presets

PresetGridFPSColor modeVerified
LOW (default)120 × 50242 (512 colors)FPS 24/24 JIT ~42 ms (M4)
MED160 × 68303 (32K colors)Paint-bound on M4
HIGH200 × 84303 (32K colors)Paint-bound on M4

LOW is the preset used in the Tesla. MED/HIGH render correctly but the per-cell fillText color path on Chromium caps at ~150–300 K calls/s, which is at the wall for MED and over it for HIGH.

Auth

Process lifecycle

Performance findings

The renderer perf pass that closed the project exposed three things that contradicted the original guesses:

  1. Per-cell fillText is NOT the bottleneck at LOW. Diagnostic measurement on the M4 showed the color render loop at 4.9–7.3 ms per frame (≈140 fps of headroom). The earlier "13/24" figure was the server's irregular delivery, not paint cost. A glyph atlas was tried; it regressed performance because mode-3 (32K colors) thrashes any reasonably-sized LRU. The atlas was reverted. Do not re-add a glyph atlas. If MED/HIGH smoothness ever matters, the known fix is an ImageData/putImageData pixel renderer (the pixelMode path already uses this approach).
  2. Audio failure was the cookie's Secure flag. Over plain http://localhost the browser refuses to send Secure cookies, so the /audio GET arrived without auth and got 403'd, while the WebSocket worked because of its different upgrade handshake. Fix: only set Secure when the auth request itself came in over https.
  3. Video stutter was a bursty ffmpeg producer fighting consumer-side pacing. Five iterations of consumer pacing (relative sleep, absolute target, skip-ahead, re-anchor, latest-frame-pick) all failed the same way. The fix was source-side pacing: -re -fps_mode cfr -r N on ffmpeg makes frames arrive evenly paced; the consumer becomes a trivial drain.

All numbers are from the M4 reference (Mac mini Apple Silicon, server + browser on the same machine). The Tesla in-car browser (Ryzen MCU, Chromium) is the deployment target but is currently untested in this build.

Run

brew install ffmpeg
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python server.py --pin 1234

Or run ./run.sh idempotently — it does the same setup, prompts for a PIN, and starts the server.

Open http://<host>:8000/, enter the PIN, type a video ID / URL / search, hit Play.

Known limitations