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

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Python build artifacts
.venv/
__pycache__/
*.pyc
*.pyo
*.egg-info/
# Runtime / secrets
.secret
*.log
# Process / IDE noise
.DS_Store
.claude/
# Internal-process docs — keep README.md only
ASCILINE-YOUTUBE-SPEC.md
PROGRESS.md
handover/
docs/*.md
vendor/asciline/PROTOCOL-NOTES.md

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)
```

162
auth.py Normal file
View File

@@ -0,0 +1,162 @@
"""PIN gate.
- Signing secret loaded from / generated into .secret (survives restarts).
- HMAC-signed session cookie via itsdangerous TimestampSigner.
- Per-IP brute-force counter: 5 failures → 10-minute lockout.
- Middleware enforces the cookie on every route; WS rejects with code 4003.
"""
from __future__ import annotations
import logging
import secrets
import time
from pathlib import Path
from typing import Optional
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
log = logging.getLogger("auth")
COOKIE_NAME = "asciline_auth"
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
_TOKEN_PAYLOAD = "ok" # fixed payload; signature is the proof
_MAX_ATTEMPTS = 5
_LOCKOUT_SECONDS = 600 # 10 minutes
SECRET_FILE = Path(__file__).parent / ".secret"
def _load_or_create_secret() -> str:
if SECRET_FILE.exists():
secret = SECRET_FILE.read_text().strip()
if secret:
return secret
secret = secrets.token_hex(32)
SECRET_FILE.write_text(secret)
SECRET_FILE.chmod(0o600)
log.info("Generated new signing secret → %s", SECRET_FILE)
return secret
_SECRET: Optional[str] = None
_SIGNER: Optional[TimestampSigner] = None
def _signer() -> TimestampSigner:
if _SIGNER is None:
raise RuntimeError("auth not initialised — call auth.init() first")
return _SIGNER
def init() -> None:
"""Load (or generate) the signing secret. Must be called before serving."""
global _SECRET, _SIGNER
_SECRET = _load_or_create_secret()
_SIGNER = TimestampSigner(_SECRET)
# Brute-force tracker keyed by IP. In-memory is enough for a personal service.
# {ip: {"count": int, "locked_until": float}}
_attempts: dict[str, dict] = {}
def _ip_from_scope(scope) -> str:
"""CF-Connecting-IP first (the real client behind Cloudflare), then peer."""
headers = dict(scope.get("headers") or [])
cf_ip = headers.get(b"cf-connecting-ip", b"").decode("latin-1").strip()
if cf_ip:
return cf_ip
client = scope.get("client")
if client:
return client[0]
return "unknown"
def is_locked_out(ip: str) -> bool:
entry = _attempts.get(ip)
if entry is None:
return False
if entry["locked_until"] and time.monotonic() < entry["locked_until"]:
return True
if entry["locked_until"] and time.monotonic() >= entry["locked_until"]:
_attempts.pop(ip, None)
return False
def record_failure(ip: str) -> bool:
"""Record a failed attempt; return True if this IP is now locked out."""
entry = _attempts.setdefault(ip, {"count": 0, "locked_until": 0.0})
entry["count"] += 1
if entry["count"] >= _MAX_ATTEMPTS:
entry["locked_until"] = time.monotonic() + _LOCKOUT_SECONDS
log.warning("IP %s locked out for %ds after %d failures",
ip, _LOCKOUT_SECONDS, entry["count"])
return True
return False
def record_success(ip: str) -> None:
_attempts.pop(ip, None)
def make_cookie_value() -> str:
return _signer().sign(_TOKEN_PAYLOAD).decode()
def verify_cookie(value: str) -> bool:
try:
_signer().unsign(value, max_age=COOKIE_MAX_AGE)
return True
except (BadSignature, SignatureExpired):
return False
def cookie_header(value: str, *, secure: bool = True) -> str:
"""Build the Set-Cookie header string.
Pass ``secure=False`` only when the request that triggered this
Set-Cookie was itself served over plain http — browsers refuse to send
Secure cookies over plain http, which would silently 403 the
/audio GET on http://localhost:* while the WebSocket still works
(different upgrade path).
"""
parts = [
f"{COOKIE_NAME}={value}",
f"Max-Age={COOKIE_MAX_AGE}",
"Path=/",
"HttpOnly",
"SameSite=Lax",
]
if secure:
parts.append("Secure")
return "; ".join(parts)
_PIN: Optional[str] = None
def set_pin(pin: str) -> None:
global _PIN
_PIN = pin
def check_pin(provided: str) -> bool:
return _PIN is not None and provided == _PIN
def get_cookie(scope) -> Optional[str]:
"""Extract the auth cookie value from a Starlette scope's headers."""
headers = scope.get("headers") or []
for name, value in headers:
if name.lower() == b"cookie":
for part in value.decode("latin-1").split(";"):
part = part.strip()
if part.startswith(COOKIE_NAME + "="):
return part[len(COOKIE_NAME) + 1:]
return None
def is_authenticated(scope) -> bool:
value = get_cookie(scope)
return value is not None and verify_cookie(value)

33
config.py Normal file
View File

@@ -0,0 +1,33 @@
"""Quality presets and server defaults.
Preset change requires re-Play (server restarts ffmpeg with new scale).
"""
PRESETS = {
"low": {
"cols": 120,
"rows": 50,
"fps_cap": 24,
"mode": 2, # 512 colors
},
"med": {
"cols": 160,
"rows": 68,
"fps_cap": 30,
"mode": 3, # 32K colors
},
"high": {
"cols": 200,
"rows": 84,
"fps_cap": 30,
"mode": 3, # 32K colors
},
}
DEFAULT_PRESET = "low"
SERVER_DEFAULTS = {
"port": 8000,
"bind": "0.0.0.0",
"max_source_height": 720,
}

172
docs/PROJECT-REPORT.html Normal file
View File

@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASCILINE YouTube Streamer — Project Report</title>
<style>
:root {
--bg: #0a0a0a;
--surface: #111111;
--border: #2a2a2a;
--amber: #F5A623;
--amber-dim:#b07518;
--text: #e0e0e0;
--muted: #888888;
--green: #4ec94e;
--red: #e05555;
--mono: 'Courier New', Courier, monospace;
--sans: Georgia, 'Times New Roman', serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 16px;
line-height: 1.7;
padding: 2rem 1rem 4rem;
}
.page { max-width: 900px; margin: 0 auto; }
header { border-bottom: 2px solid var(--amber); padding-bottom: 1.5rem; margin-bottom: 2.5rem; }
header h1 { font-size: 2rem; color: var(--amber); font-family: var(--mono); letter-spacing: 0.05em; text-transform: uppercase; }
header .subtitle { color: var(--muted); font-size: 0.95rem; font-family: var(--mono); margin-top: 0.4rem; }
section { margin-bottom: 3rem; }
h2 { font-size: 1.2rem; color: var(--amber); font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.08em; border-left: 3px solid var(--amber); padding-left: 0.75rem; margin-bottom: 1.25rem; }
h3 { font-size: 1rem; color: var(--amber-dim); font-family: var(--mono); margin: 1.25rem 0 0.5rem; }
p { margin-bottom: 0.9rem; }
ul, ol { margin: 0 0 0.9rem 1.5rem; }
li { margin-bottom: 0.4rem; }
code { font-family: var(--mono); color: var(--amber); background: var(--surface); padding: 0.1rem 0.35rem; border-radius: 2px; font-size: 0.9em; }
pre { font-family: var(--mono); background: var(--surface); border: 1px solid var(--border); padding: 0.8rem 1rem; border-radius: 3px; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin-bottom: 0.9rem; color: var(--text); }
table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; font-size: 0.92rem; }
th, td { text-align: left; padding: 0.55rem 0.8rem; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--amber); font-family: var(--mono); font-weight: normal; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.78rem; }
tr:hover td { background: rgba(245,166,35,0.04); }
.muted { color: var(--muted); }
.green { color: var(--green); }
.red { color: var(--red); }
footer { border-top: 1px solid var(--border); padding-top: 1.5rem; margin-top: 3rem; color: var(--muted); font-size: 0.85rem; font-family: var(--mono); text-align: center; }
</style>
</head>
<body>
<div class="page">
<header>
<h1>ASCILINE YouTube Streamer</h1>
<div class="subtitle">Project report — shipped state</div>
</header>
<section>
<h2>Goal</h2>
<p>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).</p>
<p>The user types a YouTube URL, an 11-character video ID, or a free-text search query and hits <strong>Play</strong>. Audio plays alongside the ASCII video. A 48 digit PIN gates access; the page is exposed to the internet through Cloudflare.</p>
</section>
<section>
<h2>Architecture</h2>
<pre>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</pre>
<h3>Components</h3>
<table>
<tr><th>File</th><th>Role</th></tr>
<tr><td><code>server.py</code></td><td>FastAPI app, routes, auth middleware</td></tr>
<tr><td><code>auth.py</code></td><td>HMAC cookie, brute-force lockout, PIN check</td></tr>
<tr><td><code>session.py</code></td><td>Single-active-session state machine</td></tr>
<tr><td><code>resolver.py</code></td><td>yt-dlp wrapper: ID/URL/search → media URLs + metadata</td></tr>
<tr><td><code>pipeline.py</code></td><td>ffmpeg subprocesses (video rawvideo + audio AAC), encoder, async iterator</td></tr>
<tr><td><code>config.py</code></td><td>Quality presets, server defaults</td></tr>
<tr><td><code>static/</code></td><td>Frontend (index.html, app.js, style.css, pin.html)</td></tr>
<tr><td><code>vendor/asciline/</code></td><td>Vendored ASCILINE encoder + protocol notes (MIT)</td></tr>
</table>
<h3>Pipeline pacing</h3>
<p>ffmpeg is invoked with <code>-re -fps_mode cfr -r &lt;target_fps&gt;</code>. <code>-re</code> makes the input read at native realtime rate; <code>-fps_mode cfr -r N</code> resamples the output to a constant N fps. Frames arrive on the consumer side evenly paced — no consumer-side pacing is needed. The async <code>frames()</code> iterator is a trivial drain: read from the queue, encode, send.</p>
<h3>Wire protocol</h3>
<p>Vendored unchanged from ASCILINE. Plain-text INIT message (<code>INIT:fps:mode:cols:rows:pixel</code>) 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 = <code>[charCode, r, g, b]</code> per cell). See <code>vendor/asciline/PROTOCOL-NOTES.md</code>.</p>
</section>
<section>
<h2>Quality presets</h2>
<table>
<tr><th>Preset</th><th>Grid</th><th>FPS</th><th>Color mode</th><th>Verified</th></tr>
<tr><td>LOW (default)</td><td>120 × 50</td><td>24</td><td>2 (512 colors)</td><td class="green">FPS 24/24 JIT ~42 ms (M4)</td></tr>
<tr><td>MED</td><td>160 × 68</td><td>30</td><td>3 (32K colors)</td><td>Paint-bound on M4</td></tr>
<tr><td>HIGH</td><td>200 × 84</td><td>30</td><td>3 (32K colors)</td><td>Paint-bound on M4</td></tr>
</table>
<p>LOW is the preset used in the Tesla. MED/HIGH render correctly but the per-cell <code>fillText</code> color path on Chromium caps at ~150300 K calls/s, which is at the wall for MED and over it for HIGH.</p>
</section>
<section>
<h2>Auth</h2>
<ul>
<li>Required at startup: <code>--pin &lt;4-8 digits&gt;</code> or <code>ASCIILINE_PIN</code> env.</li>
<li>HMAC-signed cookie (<code>itsdangerous.TimestampSigner</code>); secret persisted to <code>.secret</code> across restarts; 30-day expiry.</li>
<li>Per-IP lockout: 5 failures → 10-minute lockout; <code>CF-Connecting-IP</code> used behind Cloudflare so each car/device is locked out independently.</li>
<li>Cookie is marked <code>Secure</code> only when the auth request itself came in over https. Over plain http (local dev) the cookie omits <code>Secure</code> so the browser actually sends it on the <code>/audio</code> GET; otherwise audio would silently 403 while video kept working.</li>
<li>Middleware on every route. WebSocket upgrades close with code 4003.</li>
</ul>
</section>
<section>
<h2>Process lifecycle</h2>
<ul>
<li>Single-active-session state machine: <code>idle | resolving | playing | error</code>.</li>
<li>A new <code>POST /api/play</code> kills the previous session: SIGTERM to both ffmpegs in parallel, SIGKILL after 3 s.</li>
<li>Pipeline teardown is dispatched as a background task so <code>play()</code> returns immediately.</li>
<li><code>atexit</code> + SIGINT/SIGTERM handlers; on shutdown both pipelines are torn down synchronously and no orphan ffmpeg survives.</li>
</ul>
</section>
<section>
<h2>Performance findings</h2>
<p>The renderer perf pass that closed the project exposed three things that contradicted the original guesses:</p>
<ol>
<li><strong>Per-cell <code>fillText</code> is NOT the bottleneck at LOW.</strong> Diagnostic measurement on the M4 showed the color render loop at 4.97.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. <strong>Do not re-add a glyph atlas.</strong> If MED/HIGH smoothness ever matters, the known fix is an <code>ImageData</code>/<code>putImageData</code> pixel renderer (the <code>pixelMode</code> path already uses this approach).</li>
<li><strong>Audio failure was the cookie's <code>Secure</code> flag.</strong> Over plain <code>http://localhost</code> the browser refuses to send Secure cookies, so the <code>/audio</code> GET arrived without auth and got 403'd, while the WebSocket worked because of its different upgrade handshake. Fix: only set <code>Secure</code> when the auth request itself came in over https.</li>
<li><strong>Video stutter was a bursty ffmpeg producer fighting consumer-side pacing.</strong> 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: <code>-re -fps_mode cfr -r N</code> on ffmpeg makes frames arrive evenly paced; the consumer becomes a trivial drain.</li>
</ol>
<p>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.</p>
</section>
<section>
<h2>Run</h2>
<pre>brew install ffmpeg
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python server.py --pin 1234</pre>
<p>Or run <code>./run.sh</code> idempotently — it does the same setup, prompts for a PIN, and starts the server.</p>
<p>Open <code>http://&lt;host&gt;:8000/</code>, enter the PIN, type a video ID / URL / search, hit Play.</p>
</section>
<section>
<h2>Known limitations</h2>
<ul>
<li><strong>A/V drift on long VODs.</strong> Two independent ffmpeg processes; no shared clock. Audible drift after 2040 minutes on long VODs; negligible on live.</li>
<li><strong>~6-hour stream URL expiry.</strong> YouTube googlevideo.com URLs carry time-limited signatures. Live sessions left running past ~6 h stop on manifest expiry. Press Play again to re-resolve.</li>
<li><strong>In-memory PIN lockout</strong> — resets on restart.</li>
<li><strong>No seeking, pause, resume.</strong> Stop and re-Play restarts.</li>
<li><strong>Single active session.</strong> A new Play kills the previous.</li>
<li><strong>Paint ceiling at MED/HIGH on M4.</strong> Documented above.</li>
<li><strong>Tesla in-car untested</strong> in this build.</li>
</ul>
</section>
<footer>
ASCILINE YouTube Streamer — vendored from
<a href="https://github.com/YusufB5/ASCILINE" style="color: var(--amber);">github.com/YusufB5/ASCILINE</a>
(MIT, anti-advertisement clause)
</footer>
</div>
</body>
</html>

354
pipeline.py Normal file
View File

@@ -0,0 +1,354 @@
"""Video and audio pipelines.
Video: ffmpeg reads the resolved media URL at native realtime rate
(`-re`), scales to cols×rows, and emits a constant-rate raw BGR24 stream
(`-fps_mode cfr -r N`). Frames are read on a worker thread, encoded to
the ASCILINE color frame format on an executor, and yielded straight to
the WebSocket. No consumer-side pacing — the producer's source pacing
is what makes the cadence even.
Audio: a second ffmpeg encodes the same source to AAC/ADTS and pipes
chunks to the /audio HTTP endpoint.
"""
from __future__ import annotations
import asyncio
import logging
import struct
import subprocess
import threading
from dataclasses import dataclass
from typing import AsyncIterator, Optional
import cv2
import numpy as np
from vendor.asciline.ascii_video_player2 import AsciiMapper
log = logging.getLogger("pipeline")
# Quantisation bits per render mode (PROTOCOL-NOTES §4). Mode 2 = 5 bits
# stripped → 8-color-per-channel = 512 colors. Mode 3 = 3 bits → 32 K.
_QB_BY_MODE = {1: 0, 2: 5, 3: 3, 4: 2, 5: 0}
# At-most-this-many encoded frames waiting for the consumer. With ffmpeg
# `-re` paced at the source frame rate the queue rarely exceeds 1.
_FRAME_QUEUE_MAX = 2
_AUDIO_CHUNK_SIZE = 4096
@dataclass
class PresetSpec:
cols: int
rows: int
fps_cap: int
mode: int # 1-5; we ship 2 (Low) and 3 (Med/High)
def _build_ffmpeg_cmd(media_url: str, preset: PresetSpec) -> list[str]:
# `-re` (before `-i`) makes ffmpeg read the input at native realtime
# rate, so frames arrive on the consumer side evenly paced rather than
# in bursts. `-fps_mode cfr -r N` then duplicates/drops to a constant
# N fps stream so the wire frame count matches `targetFps` exactly.
# Live HLS sources are already realtime — `-re` is then redundant but
# harmless (it caps read speed; live is already capped).
return [
"ffmpeg",
"-hide_banner",
"-loglevel", "warning",
"-nostdin",
"-re",
"-i", media_url,
"-an",
"-vf", f"scale={preset.cols}:{preset.rows}:flags=fast_bilinear",
"-fps_mode", "cfr",
"-r", str(preset.fps_cap),
"-f", "rawvideo",
"-pix_fmt", "bgr24",
"pipe:1",
]
def _build_audio_ffmpeg_cmd(audio_url: str) -> list[str]:
return [
"ffmpeg",
"-hide_banner",
"-loglevel", "warning",
"-nostdin",
"-i", audio_url,
"-vn",
"-c:a", "aac",
"-f", "adts",
"pipe:1",
]
class VideoPipeline:
"""One-shot streaming pipeline. Build a fresh instance per Play."""
def __init__(self, media_url: str, preset: PresetSpec):
self.media_url = media_url
self.preset = preset
self._proc: Optional[subprocess.Popen] = None
self._reader: Optional[threading.Thread] = None
self._stderr_drain: Optional[threading.Thread] = None
self._queue: Optional[asyncio.Queue] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._stop = threading.Event()
self.frames_read = 0
self.frames_dropped = 0
self.frames_sent = 0
def init_message(self) -> str:
"""Plain-text INIT message (PROTOCOL-NOTES §2)."""
p = self.preset
return f"INIT:{float(p.fps_cap)}:{p.mode}:{p.cols}:{p.rows}:0"
def start(self) -> None:
self._loop = asyncio.get_running_loop()
self._queue = asyncio.Queue(maxsize=_FRAME_QUEUE_MAX)
cmd = _build_ffmpeg_cmd(self.media_url, self.preset)
log.info("ffmpeg start: %sx%s @ %s fps mode=%s",
self.preset.cols, self.preset.rows,
self.preset.fps_cap, self.preset.mode)
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
self._reader = threading.Thread(
target=self._reader_loop, name="pipeline-reader", daemon=True,
)
self._reader.start()
self._stderr_drain = threading.Thread(
target=self._stderr_loop, name="pipeline-stderr", daemon=True,
)
self._stderr_drain.start()
def stop(self) -> None:
self._stop.set()
proc = self._proc
if proc and proc.poll() is None:
try:
proc.terminate()
except Exception:
pass
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
log.info("ffmpeg did not exit on SIGTERM, sending SIGKILL")
proc.kill()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
pass
if self._reader and self._reader.is_alive():
self._reader.join(timeout=2)
if self._stderr_drain and self._stderr_drain.is_alive():
self._stderr_drain.join(timeout=1)
log.info(
"pipeline stopped — frames_read=%d frames_dropped=%d frames_sent=%d",
self.frames_read, self.frames_dropped, self.frames_sent,
)
def _reader_loop(self) -> None:
"""Worker thread: blocking reads of fixed-size BGR frames."""
proc = self._proc
loop = self._loop
if proc is None or loop is None or proc.stdout is None:
return
frame_bytes = self.preset.cols * self.preset.rows * 3
try:
while not self._stop.is_set():
# Loop the read explicitly so a short final read does not
# get treated as a full frame.
chunks: list[bytes] = []
remaining = frame_bytes
while remaining > 0:
chunk = proc.stdout.read(remaining)
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
if remaining > 0:
break # EOF
self.frames_read += 1
data = b"".join(chunks) if len(chunks) > 1 else chunks[0]
loop.call_soon_threadsafe(self._enqueue, data)
finally:
loop.call_soon_threadsafe(self._signal_eof)
def _stderr_loop(self) -> None:
proc = self._proc
if proc is None or proc.stderr is None:
return
for raw in iter(proc.stderr.readline, b""):
line = raw.decode("utf-8", errors="replace").rstrip()
if line:
log.warning("ffmpeg: %s", line)
def _enqueue(self, raw_bgr: bytes) -> None:
"""Loop-thread: enqueue, dropping the oldest frame on overflow."""
q = self._queue
if q is None:
return
if q.full():
try:
q.get_nowait()
self.frames_dropped += 1
except asyncio.QueueEmpty:
pass
try:
q.put_nowait(raw_bgr)
except asyncio.QueueFull:
self.frames_dropped += 1
def _signal_eof(self) -> None:
q = self._queue
if q is None:
return
# Empty bytes is the EOS sentinel for frames(). Displace any
# pending frame so the consumer wakes promptly.
if q.full():
try:
q.get_nowait()
except asyncio.QueueEmpty:
pass
try:
q.put_nowait(b"")
except asyncio.QueueFull:
pass
async def frames(self) -> AsyncIterator[bytes]:
"""Drain the queue, encode each frame, yield wire-format bytes."""
assert self._queue is not None
mapper = AsciiMapper()
char_byte_lut = np.array([ord(c) for c in mapper._lut], dtype=np.uint8)
n_chars = mapper._n
qb = _QB_BY_MODE.get(self.preset.mode, 0)
cols, rows = self.preset.cols, self.preset.rows
send_buf = bytearray(4 + rows * cols * 4)
loop = asyncio.get_running_loop()
frame_index = 0
while True:
raw = await self._queue.get()
if not raw:
return # EOF sentinel
# Encode in the executor — luminance/quantisation are CPU-bound
# (a few ms at HIGH 200×84) and we don't want them on the loop.
payload = await loop.run_in_executor(
None,
_encode_frame,
raw, frame_index, cols, rows, char_byte_lut, n_chars, qb, send_buf,
)
yield payload
frame_index += 1
class AudioPipeline:
"""One-shot AAC/ADTS audio pipeline. Build a fresh instance per Play."""
def __init__(self, audio_url: str):
self.audio_url = audio_url
self._proc: Optional[subprocess.Popen] = None
self._stderr_drain: Optional[threading.Thread] = None
self._stop = threading.Event()
def start(self) -> None:
cmd = _build_audio_ffmpeg_cmd(self.audio_url)
log.info("audio ffmpeg start: url=%s", self.audio_url[:60])
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
self._stderr_drain = threading.Thread(
target=self._stderr_loop, name="audio-stderr", daemon=True,
)
self._stderr_drain.start()
def stop(self) -> None:
self._stop.set()
proc = self._proc
if proc and proc.poll() is None:
try:
proc.terminate()
except Exception:
pass
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
log.info("audio ffmpeg did not exit on SIGTERM, sending SIGKILL")
proc.kill()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
pass
if self._stderr_drain and self._stderr_drain.is_alive():
self._stderr_drain.join(timeout=1)
log.info("audio pipeline stopped")
def _stderr_loop(self) -> None:
proc = self._proc
if proc is None or proc.stderr is None:
return
for raw in iter(proc.stderr.readline, b""):
line = raw.decode("utf-8", errors="replace").rstrip()
if line:
log.warning("audio ffmpeg: %s", line)
async def chunks(self) -> AsyncIterator[bytes]:
proc = self._proc
if proc is None or proc.stdout is None:
return
loop = asyncio.get_running_loop()
stdout = proc.stdout
stop = self._stop
def _read() -> bytes:
return stdout.read(_AUDIO_CHUNK_SIZE)
while not stop.is_set():
chunk = await loop.run_in_executor(None, _read)
if not chunk:
break
yield chunk
def _encode_frame(
raw: bytes,
frame_index: int,
cols: int,
rows: int,
char_byte_lut: np.ndarray,
n_chars: int,
qb: int,
send_buf: bytearray,
) -> bytes:
"""BGR24 raw frame → ASCILINE color frame bytes (modes 2-5)."""
bgr = np.frombuffer(raw, dtype=np.uint8).reshape(rows, cols, 3)
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
indices = np.floor_divide(gray, max(1, 256 // n_chars))
np.clip(indices, 0, n_chars - 1, out=indices)
char_codes = char_byte_lut[indices]
rgb = bgr[:, :, ::-1]
if qb > 0:
rgb = (rgb >> qb) << qb
out = np.empty((rows, cols, 4), dtype=np.uint8)
out[:, :, 0] = char_codes
out[:, :, 1:] = rgb
struct.pack_into(">I", send_buf, 0, frame_index)
send_buf[4:] = out.tobytes()
# Copy out — caller may dispatch concurrently while we reuse send_buf.
return bytes(send_buf)

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
opencv-python==4.9.0.80
numpy==1.26.4
websockets==12.0
yt-dlp
itsdangerous==2.2.0

203
resolver.py Normal file
View File

@@ -0,0 +1,203 @@
"""Map user input (video ID / YouTube URL / search query) to media URLs.
Resolution order:
1. YouTube URL (youtube.com / youtu.be) → use directly.
2. Bare 11-char video ID → https://www.youtube.com/watch?v=<id>. If
yt-dlp reports "video unavailable", fall back to search.
3. Anything else → prefix with `ytsearch1:` and let yt-dlp pick the
first hit.
Returns a ResolvedMedia. Raises ResolverError on failure.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
import yt_dlp
_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
_YOUTUBE_URL_RE = re.compile(
r"^(https?://)?(www\.)?(youtube\.com|youtu\.be)/", re.IGNORECASE
)
class ResolverError(Exception):
pass
@dataclass
class ResolvedMedia:
title: str
is_live: bool
duration: Optional[float] # seconds; None for live
video_url: str # direct stream URL for ffmpeg
audio_url: Optional[str] # separate audio URL, or None if muxed
video_id: str
format_note: str
def _ydl_opts(max_height: int) -> dict:
# For live streams yt-dlp picks the HLS manifest automatically when
# the format filter matches. Prefer split video+audio so the two
# pipelines can be independent; accept muxed as fallback.
fmt = (
f"bestvideo[height<={max_height}][ext=mp4]"
f"+bestaudio[ext=m4a]"
f"/bestvideo[height<={max_height}]+bestaudio"
f"/best[height<={max_height}]"
f"/best"
)
return {
"format": fmt,
"quiet": True,
"no_warnings": True,
"extract_flat": False,
}
def _extract(url: str, max_height: int) -> dict:
opts = _ydl_opts(max_height)
with yt_dlp.YoutubeDL(opts) as ydl:
try:
info = ydl.extract_info(url, download=False)
except yt_dlp.utils.DownloadError as exc:
raise ResolverError(str(exc)) from exc
if info is None:
raise ResolverError(f"yt-dlp returned no info for: {url}")
# ytsearch1: wraps the result in an "entries" list.
if "entries" in info:
entries = list(info["entries"])
if not entries:
raise ResolverError(f"No results for query: {url}")
info = entries[0]
if info.get("url") is None and info.get("webpage_url"):
with yt_dlp.YoutubeDL(opts) as ydl2:
try:
info = ydl2.extract_info(info["webpage_url"], download=False)
except yt_dlp.utils.DownloadError as exc:
raise ResolverError(str(exc)) from exc
return info
def _pick_urls(info: dict) -> tuple[str, Optional[str]]:
"""(video_url, audio_url). audio_url is None when video_url is muxed."""
requested = info.get("requested_formats")
if requested and len(requested) >= 2:
video_fmt = next(
(f for f in requested if f.get("vcodec") not in (None, "none")), None
)
audio_fmt = next(
(f for f in requested
if f.get("acodec") not in (None, "none")
and f.get("vcodec") in (None, "none")),
None,
)
if video_fmt and audio_fmt:
return video_fmt["url"], audio_fmt["url"]
if video_fmt:
return video_fmt["url"], None
url = info.get("url")
if not url:
raise ResolverError("yt-dlp returned no playable URL")
return url, None
def _build_format_note(info: dict) -> str:
requested = info.get("requested_formats")
if requested:
parts = []
for f in requested:
h = f.get("height")
codec = f.get("vcodec") or f.get("acodec") or "?"
note = f.get("format_note") or ""
parts.append(f"{codec} {h}p {note}".strip() if h else f"{codec} {note}".strip())
return " + ".join(parts)
return (
f"{info.get('format_note', '')} "
f"{info.get('height', '')}p "
f"{info.get('ext', '')}".strip()
)
def _is_unavailable(err: ResolverError) -> bool:
msg = str(err).lower()
return any(
kw in msg
for kw in ("video unavailable", "this video is not available", "private video",
"has been removed", "not available in your country")
)
def resolve(query: str, max_height: int = 720) -> ResolvedMedia:
"""Resolve user input to a ResolvedMedia. Raises ResolverError."""
query = query.strip()
if _YOUTUBE_URL_RE.match(query):
yt_url = query
is_id_attempt = False
elif _VIDEO_ID_RE.match(query):
yt_url = f"https://www.youtube.com/watch?v={query}"
is_id_attempt = True
else:
yt_url = f"ytsearch1:{query}"
is_id_attempt = False
try:
info = _extract(yt_url, max_height)
except ResolverError as exc:
# ID-failure → retry as search.
if is_id_attempt and _is_unavailable(exc):
info = _extract(f"ytsearch1:{query}", max_height)
else:
raise
video_url, audio_url = _pick_urls(info)
return ResolvedMedia(
title=info.get("title") or "Unknown",
is_live=bool(info.get("is_live")),
duration=info.get("duration"),
video_url=video_url,
audio_url=audio_url,
video_id=info.get("id") or "",
format_note=_build_format_note(info),
)
# CLI test entry: python -m resolver "<input>"
if __name__ == "__main__":
import sys
import json
if len(sys.argv) < 2:
print("Usage: python -m resolver \"<video ID | URL | search query>\"")
sys.exit(1)
user_input = " ".join(sys.argv[1:])
print(f"Resolving: {user_input!r}")
try:
result = resolve(user_input)
except ResolverError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
print(json.dumps(
{
"title": result.title,
"video_id": result.video_id,
"is_live": result.is_live,
"duration": result.duration,
"format_note": result.format_note,
"video_url": result.video_url[:80] + "" if len(result.video_url) > 80 else result.video_url,
"audio_url": (
(result.audio_url[:80] + "" if len(result.audio_url) > 80 else result.audio_url)
if result.audio_url else None
),
},
indent=2,
))

55
run.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Idempotent setup + launch.
#
# ./run.sh # prompts for PIN if ASCIILINE_PIN not set
# ASCIILINE_PIN=1234 ./run.sh # non-interactive
# ./run.sh --port 8787 # extra args are passed to server.py
#
# Safe to re-run: skips venv creation when .venv already exists.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "Checking ffmpeg..."
if ! command -v ffmpeg &>/dev/null; then
echo
echo "ERROR: ffmpeg not found in PATH."
echo "Install it with: brew install ffmpeg"
exit 1
fi
echo " ffmpeg: $(ffmpeg -version 2>&1 | head -1)"
if [ ! -d ".venv" ]; then
echo "Creating Python virtual environment..."
python3 -m venv .venv
echo " .venv created"
else
echo " .venv already exists — skipping creation"
fi
# shellcheck disable=SC1091
source .venv/bin/activate
echo " Python: $(python --version)"
echo "Installing / verifying Python dependencies..."
pip install -q -r requirements.txt
echo " Dependencies OK"
if [ -z "${ASCIILINE_PIN:-}" ]; then
echo
read -r -s -p "Enter PIN (4-8 digits): " ASCIILINE_PIN
echo
if [ -z "$ASCIILINE_PIN" ]; then
echo "ERROR: PIN is required."
exit 1
fi
fi
export ASCIILINE_PIN
echo
echo "Starting ASCILINE YouTube Streamer..."
echo " Press Ctrl-C to stop (ffmpeg processes will be cleaned up automatically)"
echo
exec python server.py "$@"

352
server.py Normal file
View File

@@ -0,0 +1,352 @@
"""FastAPI application entry point.
Every route is gated by AuthMiddleware: HTTP gets the PIN page or 403,
WebSocket upgrades close with 4003. /api/auth is exempt so the PIN can
be submitted in the first place.
"""
from __future__ import annotations
import argparse
import atexit
import logging
import os
import shutil
import signal
import sys
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import (
FileResponse,
HTMLResponse,
JSONResponse,
Response,
StreamingResponse,
)
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.websockets import WebSocketState
import auth
from config import PRESETS, DEFAULT_PRESET, SERVER_DEFAULTS
from pipeline import PresetSpec
from resolver import ResolverError
from session import (
Session, State,
_atexit_cleanup, _signal_handler,
get_manager, init_manager,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger("server")
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static")
PIN_PAGE = os.path.join(STATIC_DIR, "pin.html")
class AuthMiddleware(BaseHTTPMiddleware):
"""Enforce the PIN cookie on every route.
- WebSocket upgrades: 403 here so the handshake fails cleanly; the
ws_video handler also re-checks and closes with code 4003.
- /api/* without cookie: 403 JSON.
- /audio without cookie: 403 plain (it's a streaming endpoint, not a page).
- Everything else without cookie: serve pin.html.
- /api/auth itself is exempt so the PIN can be submitted.
"""
async def dispatch(self, request: Request, call_next):
path = request.url.path
if path == "/api/auth":
return await call_next(request)
if auth.is_authenticated(request.scope):
return await call_next(request)
upgrade = request.headers.get("upgrade", "").lower()
if upgrade == "websocket":
return Response(status_code=403, content="Unauthorized")
if path.startswith("/api/"):
return JSONResponse(status_code=403, content={"error": "unauthorized"})
if path == "/audio":
return Response(status_code=403, content="Unauthorized")
with open(PIN_PAGE, "rb") as f:
return HTMLResponse(content=f.read().decode("utf-8"), status_code=200)
def check_ffmpeg() -> None:
if not shutil.which("ffmpeg"):
print(
"ERROR: ffmpeg not found in PATH.\n"
"Install it with: brew install ffmpeg",
file=sys.stderr,
)
sys.exit(1)
@asynccontextmanager
async def lifespan(app: FastAPI):
# atexit + signal handlers so no orphan ffmpeg survives a crash.
atexit.register(_atexit_cleanup)
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
yield
try:
mgr = get_manager()
await mgr.stop()
except Exception:
pass
app = FastAPI(title="ASCILINE YouTube Streamer", lifespan=lifespan)
app.add_middleware(AuthMiddleware)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/")
async def index():
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
@app.get("/audio")
async def audio_stream(request: Request):
session_id = request.query_params.get("session")
if not session_id:
return Response(status_code=400, content="missing session id")
session = get_manager().get(session_id)
if session is None:
return Response(status_code=404, content="unknown or stale session id")
audio_pipeline = session.audio_pipeline
if audio_pipeline is None:
return Response(status_code=503, content="audio pipeline not ready")
return StreamingResponse(
audio_pipeline.chunks(),
media_type="audio/aac",
headers={
"Cache-Control": "no-cache",
"X-Content-Type-Options": "nosniff",
},
)
class AuthRequest(BaseModel):
pin: str
@app.post("/api/auth")
async def api_auth(req: AuthRequest, request: Request):
ip = auth._ip_from_scope(request.scope)
if auth.is_locked_out(ip):
return JSONResponse(
status_code=429,
content={"error": "too many attempts", "retry_after": auth._LOCKOUT_SECONDS},
)
if not auth.check_pin(req.pin):
locked = auth.record_failure(ip)
if locked:
return JSONResponse(
status_code=429,
content={"error": "too many attempts", "retry_after": auth._LOCKOUT_SECONDS},
)
return JSONResponse(status_code=401, content={"error": "wrong pin"})
auth.record_success(ip)
cookie_value = auth.make_cookie_value()
response = JSONResponse(content={"ok": True})
# Mark Secure only when this request itself came in over https; over
# plain http the browser would refuse to send a Secure cookie back on
# the /audio GET.
is_https = (
request.url.scheme == "https"
or request.headers.get("x-forwarded-proto", "").lower() == "https"
)
response.headers["Set-Cookie"] = auth.cookie_header(cookie_value, secure=is_https)
log.info("Successful auth from %s", ip)
return response
class PlayRequest(BaseModel):
query: str
preset: str = DEFAULT_PRESET
@app.post("/api/play")
async def api_play(req: PlayRequest):
preset_name = req.preset if req.preset in PRESETS else DEFAULT_PRESET
mgr = get_manager()
try:
session = await mgr.play(req.query, preset_name)
except ResolverError as exc:
return JSONResponse(
status_code=422,
content={"error": str(exc), "query": req.query},
)
if session.state == State.PLAYING and session.media is not None:
preset_cfg = PRESETS[session.preset_name if session.preset_name in PRESETS else DEFAULT_PRESET]
return {
"session_id": session.session_id,
"title": session.media.title,
"is_live": session.media.is_live,
"cols": preset_cfg["cols"],
"rows": preset_cfg["rows"],
"fps": preset_cfg["fps_cap"],
"mode": preset_cfg["mode"],
}
# Superseded during resolve (rare race — another Play beat us).
return JSONResponse(
status_code=409,
content={"error": "session superseded by a newer play request"},
)
@app.post("/api/stop")
async def api_stop():
await get_manager().stop()
return {"ok": True}
@app.get("/api/status")
async def api_status():
return get_manager().status()
async def _run_video_ws(websocket: WebSocket, session: Session) -> None:
"""Stream ASCII frames for an already-playing session."""
preset_name = session.preset_name if session.preset_name in PRESETS else DEFAULT_PRESET
preset_cfg = PRESETS[preset_name]
preset = PresetSpec(
cols=preset_cfg["cols"],
rows=preset_cfg["rows"],
fps_cap=preset_cfg["fps_cap"],
mode=preset_cfg["mode"],
)
await websocket.accept()
pipeline = session.pipeline
if pipeline is None or session.state != State.PLAYING:
await websocket.send_text("Error: session not ready")
await websocket.close()
return
try:
await websocket.send_text(pipeline.init_message())
first_frame_logged = False
deflate_negotiated = _ws_offered_deflate(websocket)
async for payload in pipeline.frames():
if websocket.client_state != WebSocketState.CONNECTED:
break
if session.cancel_event.is_set():
break
await websocket.send_bytes(payload)
pipeline.frames_sent += 1
if not first_frame_logged:
import zlib
approx = len(zlib.compress(payload, 6))
log.info(
"first frame: raw=%d bytes deflate≈%d bytes "
"(ratio=%.2f) preset=%s permessage-deflate=%s",
len(payload), approx, approx / max(1, len(payload)),
preset_name, deflate_negotiated,
)
first_frame_logged = True
except (WebSocketDisconnect, ConnectionError):
log.info("ws client disconnected")
except Exception:
log.exception("video ws error")
finally:
if websocket.client_state == WebSocketState.CONNECTED:
try:
await websocket.close()
except Exception:
pass
def _ws_offered_deflate(websocket: WebSocket) -> bool:
headers = dict(websocket.scope.get("headers") or [])
raw = headers.get(b"sec-websocket-extensions", b"")
return b"permessage-deflate" in raw.lower()
async def _reject_ws(websocket: WebSocket, reason: str) -> None:
await websocket.accept()
await websocket.send_text(f"Error: {reason}")
await websocket.close()
@app.websocket("/ws/video")
async def ws_video(websocket: WebSocket):
if not auth.is_authenticated(websocket.scope):
await websocket.close(code=4003)
return
session_id = websocket.query_params.get("session")
if not session_id:
await _reject_ws(websocket, "missing session id — call /api/play first")
return
session = get_manager().get(session_id)
if session is None:
await _reject_ws(websocket, f"unknown or stale session id: {session_id!r}")
return
await _run_video_ws(websocket, session)
def main() -> None:
parser = argparse.ArgumentParser(description="ASCILINE YouTube Streamer")
parser.add_argument("--port", type=int, default=SERVER_DEFAULTS["port"])
parser.add_argument("--bind", default=SERVER_DEFAULTS["bind"])
parser.add_argument(
"--max-source-height", type=int, default=SERVER_DEFAULTS["max_source_height"]
)
parser.add_argument(
"--pin",
default=os.environ.get("ASCIILINE_PIN"),
help="48 digit PIN (or set ASCIILINE_PIN env var)",
)
args = parser.parse_args()
check_ffmpeg()
if not args.pin:
print(
"ERROR: PIN required. Pass --pin <4-8 digits> or set ASCIILINE_PIN.",
file=sys.stderr,
)
sys.exit(1)
auth.init()
auth.set_pin(args.pin)
init_manager(max_source_height=args.max_source_height)
print(f"ASCILINE starting on http://{args.bind}:{args.port}")
uvicorn.run(
app,
host=args.bind,
port=args.port,
ws_ping_interval=None,
ws_ping_timeout=None,
ws_per_message_deflate=True,
log_level="info",
)
if __name__ == "__main__":
main()

273
session.py Normal file
View File

@@ -0,0 +1,273 @@
"""Single-active-session state machine.
States: idle | resolving | playing | error(message). Exactly one session
can be active at a time; a new play() terminates the previous session's
pipeline before starting the new one. Pipeline teardown is dispatched
as a background task so /api/play returns quickly while the previous
ffmpeg exits.
"""
from __future__ import annotations
import asyncio
import logging
import signal
import uuid
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional
from config import PRESETS, DEFAULT_PRESET, SERVER_DEFAULTS
from pipeline import AudioPipeline, PresetSpec, VideoPipeline
from resolver import ResolvedMedia, ResolverError, resolve
log = logging.getLogger("session")
class State(Enum):
IDLE = auto()
RESOLVING = auto()
PLAYING = auto()
ERROR = auto()
@dataclass
class Session:
session_id: str
query: str
preset_name: str
state: State = State.RESOLVING
error_msg: str = ""
media: Optional[ResolvedMedia] = None
pipeline: Optional[VideoPipeline] = None
audio_pipeline: Optional[AudioPipeline] = None
# Set when the session is superseded or stopped.
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
class SessionManager:
"""Single-session lifecycle manager.
All methods are called from asyncio tasks on a single event loop.
Pipeline stop() calls are dispatched as background tasks so the state
machine never blocks waiting for ffmpeg to exit.
"""
def __init__(self, max_source_height: int = SERVER_DEFAULTS["max_source_height"]):
self._max_height = max_source_height
self._current: Optional[Session] = None
self._lock = asyncio.Lock()
async def play(self, query: str, preset_name: str) -> Session:
"""Start a new session.
Kills the previous session, resolves the query, starts the
pipeline, returns a Session with state=PLAYING. Raises
ResolverError if resolution fails (state set to ERROR first).
"""
async with self._lock:
await self._kill_current()
session_id = uuid.uuid4().hex[:16]
session = Session(
session_id=session_id,
query=query,
preset_name=preset_name,
)
self._current = session
log.info("session %s: resolving %r preset=%s", session_id, query, preset_name)
# Resolve outside the lock so a hammered Play can supersede us.
try:
media = await asyncio.to_thread(resolve, query, self._max_height)
except ResolverError as exc:
async with self._lock:
if self._current is session:
session.state = State.ERROR
session.error_msg = str(exc)
raise
async with self._lock:
if self._current is not session:
log.info("session %s superseded during resolve — discarding", session_id)
return session
preset_cfg = PRESETS.get(preset_name) or PRESETS[DEFAULT_PRESET]
preset = PresetSpec(
cols=preset_cfg["cols"],
rows=preset_cfg["rows"],
fps_cap=preset_cfg["fps_cap"],
mode=preset_cfg["mode"],
)
pipeline = VideoPipeline(media.video_url, preset)
pipeline.start()
# Audio: prefer the separate audio URL; fall back to the muxed
# video URL (ffmpeg -vn extracts the audio track).
audio_src = media.audio_url if media.audio_url else media.video_url
audio_pipeline = AudioPipeline(audio_src)
audio_pipeline.start()
session.media = media
session.pipeline = pipeline
session.audio_pipeline = audio_pipeline
session.state = State.PLAYING
log.info(
"session %s: playing %r is_live=%s title=%r",
session_id, query, media.is_live, media.title,
)
return session
async def stop(self) -> None:
async with self._lock:
await self._kill_current()
def get(self, session_id: str) -> Optional[Session]:
sess = self._current
if sess is not None and sess.session_id == session_id:
return sess
return None
@property
def current(self) -> Optional[Session]:
return self._current
def status(self) -> dict:
sess = self._current
if sess is None:
return {"state": "idle", "session_id": None, "title": None}
state_str = {
State.IDLE: "idle",
State.RESOLVING: "resolving",
State.PLAYING: "playing",
State.ERROR: "error",
}.get(sess.state, "unknown")
result: dict = {"state": state_str, "session_id": sess.session_id}
if sess.state == State.ERROR:
result["error"] = sess.error_msg
if sess.media:
result["title"] = sess.media.title
result["is_live"] = sess.media.is_live
else:
result["title"] = None
return result
async def _kill_current(self) -> None:
"""Signal the current session to stop and dispatch teardown.
Must be called with self._lock held. Returns immediately; the
teardown task runs concurrently so callers are not delayed.
"""
sess = self._current
if sess is None:
return
sess.cancel_event.set()
pipeline = sess.pipeline
audio_pipeline = sess.audio_pipeline
if pipeline is not None:
sess.pipeline = None
asyncio.ensure_future(_stop_pipeline(pipeline, sess.session_id))
if audio_pipeline is not None:
sess.audio_pipeline = None
asyncio.ensure_future(_stop_audio_pipeline(audio_pipeline, sess.session_id))
self._current = None
def stop_sync(self) -> None:
"""Synchronous teardown for atexit / signal handlers.
Sends SIGTERM to both pipelines first, then waits on each, so the
worst-case time is max(video_wait, audio_wait), not their sum.
"""
sess = self._current
if sess is None:
return
sess.cancel_event.set()
pipeline = sess.pipeline
audio_pipeline = sess.audio_pipeline
sess.pipeline = None
sess.audio_pipeline = None
if pipeline is not None:
log.info("atexit: terminating session %s video pipeline", sess.session_id)
pipeline._stop.set()
proc = pipeline._proc
if proc and proc.poll() is None:
try:
proc.terminate()
except Exception:
pass
if audio_pipeline is not None:
log.info("atexit: terminating session %s audio pipeline", sess.session_id)
audio_pipeline._stop.set()
aproc = audio_pipeline._proc
if aproc and aproc.poll() is None:
try:
aproc.terminate()
except Exception:
pass
import subprocess as _sp
if pipeline is not None and pipeline._proc:
try:
pipeline._proc.wait(timeout=3)
except _sp.TimeoutExpired:
try:
pipeline._proc.kill()
pipeline._proc.wait(timeout=2)
except Exception:
pass
if audio_pipeline is not None and audio_pipeline._proc:
try:
audio_pipeline._proc.wait(timeout=3)
except _sp.TimeoutExpired:
try:
audio_pipeline._proc.kill()
audio_pipeline._proc.wait(timeout=2)
except Exception:
pass
log.info("atexit: both pipelines stopped")
async def _stop_pipeline(pipeline: VideoPipeline, session_id: str) -> None:
log.info("session %s: tearing down pipeline in background", session_id)
await asyncio.to_thread(pipeline.stop)
log.info("session %s: pipeline torn down", session_id)
async def _stop_audio_pipeline(pipeline: AudioPipeline, session_id: str) -> None:
log.info("session %s: tearing down audio pipeline in background", session_id)
await asyncio.to_thread(pipeline.stop)
log.info("session %s: audio pipeline torn down", session_id)
# Module-level singleton: server.py imports it; signal handlers register here.
_manager: Optional[SessionManager] = None
def init_manager(max_source_height: int = SERVER_DEFAULTS["max_source_height"]) -> SessionManager:
global _manager
_manager = SessionManager(max_source_height=max_source_height)
return _manager
def get_manager() -> SessionManager:
if _manager is None:
raise RuntimeError("SessionManager not initialised — call init_manager() first")
return _manager
def _atexit_cleanup() -> None:
if _manager is not None:
_manager.stop_sync()
def _signal_handler(signum: int, frame) -> None:
import os
log.info("received signal %d — stopping pipeline before exit", signum)
if _manager is not None:
_manager.stop_sync()
signal.signal(signum, signal.SIG_DFL)
os.kill(os.getpid(), signum)

527
static/app.js Normal file
View File

@@ -0,0 +1,527 @@
// ASCILINE YouTube streamer — Tesla browser frontend.
//
// Wire protocol (unchanged from vendor/asciline): text INIT message
// "INIT:fps:mode:cols:rows:pixel" then binary frames whose first 4 bytes
// are a big-endian frame index and whose body is mode-dependent (mode 1
// = utf-8 text; modes 2/3 = [charCode,r,g,b] per cell).
'use strict';
const queryInput = document.getElementById('query-input');
const btnPlay = document.getElementById('btn-play');
const btnStop = document.getElementById('btn-stop');
const presetBtns = document.querySelectorAll('.preset-btn');
const presetHint = document.getElementById('preset-hint');
const btnMute = document.getElementById('btn-mute');
const volumeSlider = document.getElementById('volume-slider');
const playerContainer = document.getElementById('player-container');
const asciiPlayer = document.getElementById('ascii-player');
const canvas = document.getElementById('ascii-canvas');
const ctx = canvas.getContext('2d');
const idleOverlay = document.getElementById('idle-overlay');
const audioEl = document.getElementById('ascii-audio');
const statusTitle = document.getElementById('status-title');
const statusLive = document.getElementById('status-live');
const statusState = document.getElementById('status-state');
const statusFps = document.getElementById('status-fps');
const statusPerf = document.getElementById('status-perf-hint');
let appState = 'idle'; // idle | resolving | playing | error
let ws = null;
let sessionId = null;
let pollTimer = null;
let currentPreset = 'low';
let selectedPreset = 'low';
const WS_MAX_RETRIES = 5;
let wsRetryCount = 0;
let wsRetryTimer = null;
// Renderer state. Master clock is wall-clock, anchored on the first
// server frame (audio's currentTime updates in coarse ~250 ms steps which
// caused stall→batch-drop bursts on the M4). A small lead lets the very
// first frame paint immediately instead of waiting half a second.
const frameBuffer = [];
const FRAME_BUFFER_MAX = 3; // server queue is 2; 3 keeps ~125 ms of slack
const PRESENT_LEAD_MS = 60;
let targetFps = 24;
let renderMode = 1;
let pixelMode = false;
let readyToRender = false;
let gridCols = 0, gridRows = 0;
let charWidth = 0, charHeight = 0;
let xPos = null, yPos = null;
let dotImageData = null;
let selectionBuffer = null;
let clockAnchored = false;
let streamStartTime = 0;
const textDecoder = new TextDecoder();
const CHAR_LUT = Array.from({ length: 128 }, (_, i) => String.fromCharCode(i));
// HUD: painted FPS plus worst paint-to-paint gap (JIT) and current client
// buffer depth. JIT exposes bursts that a 1-second average would smear.
let frameCount = 0;
let measuredFps = 0;
let lastFpsUpdate = 0;
let fpsWindowFrames = 0;
let fpsWindowStart = 0;
let fpsBelowThreshold = false;
let lastPaintTime = 0;
let maxPaintInterval = 0;
// ───────── Preset selection UI ─────────
presetBtns.forEach(btn => {
btn.addEventListener('click', () => {
const p = btn.dataset.preset;
if (p === selectedPreset) return;
selectedPreset = p;
presetBtns.forEach(b => b.classList.toggle('active', b.dataset.preset === p));
if (appState === 'playing') presetHint.classList.remove('hidden');
});
});
// ───────── Volume / mute ─────────
let isMuted = false;
volumeSlider.addEventListener('input', () => {
if (!audioEl) return;
audioEl.volume = parseFloat(volumeSlider.value);
isMuted = audioEl.volume === 0;
updateMuteBtn();
});
btnMute.addEventListener('click', () => {
isMuted = !isMuted;
if (audioEl) audioEl.muted = isMuted;
updateMuteBtn();
});
function updateMuteBtn() {
btnMute.textContent = isMuted ? '🔇' : '🔊';
btnMute.classList.toggle('muted', isMuted);
}
// ───────── Canvas setup ─────────
function buildCanvas(cols, rows) {
gridCols = cols;
gridRows = rows;
const syncSize = (el) => {
el.style.width = playerContainer.clientWidth + 'px';
el.style.height = playerContainer.clientHeight + 'px';
el.style.objectFit = 'contain';
el.style.position = 'absolute';
el.style.top = '0';
el.style.left = '0';
};
if (pixelMode) {
canvas.width = cols;
canvas.height = rows;
canvas.style.display = 'block';
canvas.style.imageRendering = 'pixelated';
dotImageData = ctx.createImageData(cols, rows);
const d = dotImageData.data;
for (let i = 3; i < d.length; i += 4) d[i] = 255;
syncSize(canvas);
asciiPlayer.style.display = 'none';
return;
}
canvas.style.imageRendering = '';
dotImageData = null;
ctx.font = 'bold 8px Courier New';
charWidth = ctx.measureText('M').width;
charHeight = 8;
canvas.width = cols * charWidth;
canvas.height = rows * charHeight;
canvas.style.display = 'block';
selectionBuffer = new Uint8Array((cols + 1) * rows);
for (let r = 0; r < rows; r++) selectionBuffer[r * (cols + 1) + cols] = 10;
syncSize(canvas);
const cw = playerContainer.clientWidth;
const ch = playerContainer.clientHeight;
const fs = Math.min(cw / canvas.width, ch / canvas.height);
const ox = (cw - canvas.width * fs) / 2;
const oy = (ch - canvas.height * fs) / 2;
asciiPlayer.style.width = canvas.width + 'px';
asciiPlayer.style.height = canvas.height + 'px';
asciiPlayer.style.position = 'absolute';
asciiPlayer.style.top = '0';
asciiPlayer.style.left = '0';
asciiPlayer.style.transformOrigin = 'top left';
asciiPlayer.style.transform = `translate(${ox}px,${oy}px) scale(${fs})`;
asciiPlayer.style.fontSize = '8px';
asciiPlayer.style.lineHeight = '8px';
ctx.font = 'bold 8px Courier New';
ctx.textBaseline = 'top';
xPos = new Float32Array(cols);
yPos = new Float32Array(rows);
for (let c = 0; c < cols; c++) xPos[c] = c * charWidth;
for (let r = 0; r < rows; r++) yPos[r] = r * charHeight;
}
// ───────── Render loop ─────────
function renderFrame(now) {
if (appState !== 'playing' || !readyToRender) return;
requestAnimationFrame(renderFrame);
if (frameBuffer.length === 0) return;
if (!clockAnchored) {
streamStartTime = now - frameBuffer[0].time * 1000 - PRESENT_LEAD_MS;
clockAnchored = true;
}
const masterClock = (now - streamStartTime) / 1000.0;
// Drop frames that fell visibly behind (>100 ms) so a tab that was
// backgrounded for a moment doesn't replay a backlog on resume.
while (frameBuffer.length > 1 && frameBuffer[0].time < masterClock - 0.1) {
frameBuffer.shift();
}
if (frameBuffer[0].time > masterClock + 0.05) return;
const frameObj = frameBuffer.shift();
const frame = frameObj.data;
frameCount++;
if (lastPaintTime > 0) {
const gap = now - lastPaintTime;
if (gap > maxPaintInterval) maxPaintInterval = gap;
}
lastPaintTime = now;
if (now - lastFpsUpdate >= 1000) {
measuredFps = frameCount;
frameCount = 0;
lastFpsUpdate = now;
const jitter = Math.round(maxPaintInterval);
maxPaintInterval = 0;
statusFps.textContent =
`FPS ${measuredFps}/${Math.round(targetFps)} ` +
`JIT ${jitter}ms BUF ${frameBuffer.length}`;
}
fpsWindowFrames++;
if (now - fpsWindowStart >= 5000) {
const windowFps = fpsWindowFrames / 5;
fpsBelowThreshold = windowFps < targetFps * 0.70;
statusPerf.classList.toggle('hidden', !fpsBelowThreshold);
fpsWindowFrames = 0;
fpsWindowStart = now;
}
if (renderMode === 1) {
asciiPlayer.style.display = 'block';
asciiPlayer.style.color = '#fff';
asciiPlayer.textContent = frame;
return;
}
if (pixelMode) {
const view = frame;
const data = dotImageData.data;
for (let src = 0, dst = 0; src < view.length; src += 3, dst += 4) {
data[dst] = view[src + 2];
data[dst + 1] = view[src + 1];
data[dst + 2] = view[src];
}
ctx.putImageData(dotImageData, 0, 0);
return;
}
// Color ASCII path: per-cell fillText with a same-color run optim.
// Selection buffer is kept in memory every frame; the transparent <pre>'s
// textContent is built on demand in the selectstart/copy handlers so the
// paint loop never triggers a layout reflow.
const view = frame;
const buf = selectionBuffer;
const stride = gridCols + 1;
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 8px Courier New';
ctx.textBaseline = 'top';
let col = 0, row = 0, prevPacked = -1;
for (let idx = 0; idx < view.length; idx += 4) {
const ch = view[idx];
const r = view[idx + 1];
const g = view[idx + 2];
const b = view[idx + 3];
const packed = (r << 16) | (g << 8) | b;
if (packed !== prevPacked) {
ctx.fillStyle = `rgb(${r},${g},${b})`;
prevPacked = packed;
}
ctx.fillText(CHAR_LUT[ch], xPos[col], yPos[row]);
buf[row * stride + col] = ch;
col++;
if (col >= gridCols) { col = 0; row++; }
}
asciiPlayer.style.display = 'block';
asciiPlayer.style.color = 'transparent';
}
// ───────── WebSocket ─────────
function connectWs(sid) {
frameBuffer.length = 0;
frameCount = 0;
measuredFps = 0;
fpsWindowFrames = 0;
fpsWindowStart = 0;
fpsBelowThreshold = false;
readyToRender = false;
clockAnchored = false;
lastPaintTime = 0;
maxPaintInterval = 0;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws/video?session=${encodeURIComponent(sid)}`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => setStatus('BUFFERING', 'resolving');
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
if (event.data.startsWith('Error:')) {
setError(event.data.replace(/^Error:\s*/, ''));
closeWs();
return;
}
if (event.data.startsWith('INIT:')) {
wsRetryCount = 0;
const p = event.data.split(':');
targetFps = parseFloat(p[1]);
renderMode = parseInt(p[2]);
pixelMode = (p.length > 5 && parseInt(p[5]) === 1);
buildCanvas(parseInt(p[3]), parseInt(p[4]));
setStatus('PLAYING', 'playing');
idleOverlay.classList.add('hidden');
if (audioEl) {
audioEl.pause();
audioEl.src = `/audio?session=${encodeURIComponent(sid)}&_=${Date.now()}`;
audioEl.muted = isMuted;
audioEl.volume = parseFloat(volumeSlider.value);
audioEl.load();
audioEl.play().catch((err) => {
// Surface failures rather than swallow them — a
// Secure-cookie-on-http misconfig used to 403 /audio
// silently.
statusPerf.textContent = `audio: ${err && err.name || 'error'}`;
statusPerf.classList.remove('hidden');
});
}
readyToRender = true;
lastFpsUpdate = performance.now();
fpsWindowStart = lastFpsUpdate;
requestAnimationFrame(renderFrame);
return;
}
// Mode-1 text frame: "<idx>\n<frame text>".
const nl = event.data.indexOf('\n');
const idx = parseInt(event.data.substring(0, nl));
frameBuffer.push({ data: event.data.substring(nl + 1), time: idx / targetFps });
} else {
const view = new DataView(event.data);
const idx = view.getUint32(0, false);
frameBuffer.push({ data: new Uint8Array(event.data, 4), time: idx / targetFps });
}
while (frameBuffer.length > FRAME_BUFFER_MAX) frameBuffer.shift();
};
ws.onclose = (evt) => {
// 1000 = normal close from our own stop/replace; 4003 = auth rejected.
if (appState !== 'playing' || evt.code === 1000 || evt.code === 4003) {
if (appState === 'playing') {
setStatus('ENDED', 'idle');
finishPlayback();
}
return;
}
wsRetryCount++;
if (wsRetryCount > WS_MAX_RETRIES) {
setError(`Stream disconnected — reconnect failed after ${WS_MAX_RETRIES} attempts`);
finishPlayback();
return;
}
const delay = wsRetryCount * 1000;
setStatus(`RECONNECTING (${wsRetryCount}/${WS_MAX_RETRIES})…`, 'resolving');
wsRetryTimer = setTimeout(() => {
wsRetryTimer = null;
if (appState === 'playing' && sessionId === sid) connectWs(sid);
}, delay);
};
ws.onerror = () => { /* onclose drives reconnect */ };
}
// ───────── Control API ─────────
async function doPlay() {
const query = queryInput.value.trim();
if (!query) { queryInput.focus(); return; }
presetHint.classList.add('hidden');
stopPoll();
closeWs();
clearCanvas();
wsRetryCount = 0;
if (wsRetryTimer) { clearTimeout(wsRetryTimer); wsRetryTimer = null; }
appState = 'resolving';
currentPreset = selectedPreset;
setUiState('resolving');
setStatus('RESOLVING…', 'resolving');
statusTitle.textContent = query;
statusLive.classList.add('hidden');
statusFps.textContent = '';
statusPerf.classList.add('hidden');
let resp, data;
try {
resp = await fetch('/api/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, preset: currentPreset }),
});
data = await resp.json();
} catch (err) {
setError('Network error — ' + err.message);
setUiState('idle');
return;
}
if (!resp.ok) {
setError(data.error || `Server error ${resp.status}`);
setUiState('idle');
return;
}
sessionId = data.session_id;
appState = 'playing';
setUiState('playing');
updateTitle(data.title, data.is_live);
connectWs(sessionId);
startPoll();
}
async function doStop() {
stopPoll();
wsRetryCount = 0;
if (wsRetryTimer) { clearTimeout(wsRetryTimer); wsRetryTimer = null; }
try { await fetch('/api/stop', { method: 'POST' }); } catch (_) {}
closeWs();
finishPlayback();
setStatus('IDLE', 'idle');
statusTitle.textContent = '';
statusLive.classList.add('hidden');
presetHint.classList.add('hidden');
}
// ───────── Status polling ─────────
function startPoll() {
stopPoll();
pollTimer = setInterval(async () => {
if (appState !== 'playing') { stopPoll(); return; }
let data;
try {
const r = await fetch('/api/status');
data = await r.json();
} catch (_) { return; }
if (data.title) updateTitle(data.title, data.is_live);
if (data.state === 'error') {
setError(data.error || 'Stream error');
closeWs();
finishPlayback();
stopPoll();
}
}, 2000);
}
function stopPoll() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
// ───────── UI helpers ─────────
function setUiState(state) {
btnPlay.disabled = false;
btnStop.disabled = !(state === 'resolving' || state === 'playing');
}
function setStatus(label, stateClass) {
statusState.textContent = label;
statusState.className = 'status-segment status-state';
if (stateClass) statusState.classList.add('state-' + stateClass);
}
function setError(msg) {
appState = 'error';
statusTitle.textContent = '⚠ ' + msg;
setStatus('ERROR', 'error');
statusLive.classList.add('hidden');
statusFps.textContent = '';
statusPerf.classList.add('hidden');
setUiState('idle');
idleOverlay.classList.remove('hidden');
}
function updateTitle(title, isLive) {
if (title) statusTitle.textContent = title;
statusLive.classList.toggle('hidden', !isLive);
}
function finishPlayback() {
appState = 'idle';
readyToRender = false;
frameBuffer.length = 0;
if (audioEl) { audioEl.pause(); audioEl.src = ''; }
clearCanvas();
idleOverlay.classList.remove('hidden');
setUiState('idle');
statusFps.textContent = '';
statusPerf.classList.add('hidden');
fpsBelowThreshold = false;
}
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.style.display = 'none';
asciiPlayer.textContent = '';
asciiPlayer.style.display = 'none';
}
function closeWs() {
if (!ws) return;
ws.onclose = null;
ws.onerror = null;
try { ws.close(); } catch (_) {}
ws = null;
}
// ───────── Selection / copy ─────────
// The transparent <pre> exists so users can drag-select and copy the ASCII
// frame. Materializing its textContent on every paint forces a layout
// reflow per frame; instead, we materialize only when the user actually
// starts a selection or fires a copy.
const flushSelectionLayer = () => {
if (!selectionBuffer || appState !== 'playing' || renderMode === 1) return;
asciiPlayer.textContent = textDecoder.decode(selectionBuffer);
};
asciiPlayer.addEventListener('selectstart', flushSelectionLayer);
document.addEventListener('copy', flushSelectionLayer);
// ───────── Wiring ─────────
btnPlay.addEventListener('click', doPlay);
btnStop.addEventListener('click', doStop);
queryInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doPlay(); });
window.addEventListener('resize', () => {
if (gridCols > 0 && appState === 'playing') buildCanvas(gridCols, gridRows);
});

84
static/index.html Normal file
View File

@@ -0,0 +1,84 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ASCILINE</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- ── TOP BAR ─────────────────────────────────────────────────── -->
<header id="topbar">
<div id="brand">ASCILINE</div>
<input
id="query-input"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
placeholder="Video ID, URL or search…"
aria-label="Video query"
>
<button id="btn-play" class="btn btn-primary" aria-label="Play">
<span class="btn-icon"></span>
<span class="btn-label">PLAY</span>
</button>
<button id="btn-stop" class="btn btn-secondary" aria-label="Stop" disabled>
<span class="btn-icon"></span>
<span class="btn-label">STOP</span>
</button>
</header>
<!-- ── SUB BAR ─────────────────────────────────────────────────── -->
<div id="subbar">
<div id="preset-group" role="group" aria-label="Quality preset">
<span class="subbar-label">PRESET</span>
<button class="preset-btn active" data-preset="low">LOW</button>
<button class="preset-btn" data-preset="med">MED</button>
<button class="preset-btn" data-preset="high">HIGH</button>
</div>
<div id="preset-hint" class="subbar-hint hidden">
Preset change takes effect on next Play
</div>
<div id="volume-group">
<span class="subbar-label">VOL</span>
<button id="btn-mute" class="icon-btn" aria-label="Toggle mute">🔊</button>
<input type="range" id="volume-slider" min="0" max="1" step="0.05" value="1"
aria-label="Volume">
</div>
</div>
<!-- ── CANVAS PLAYER ───────────────────────────────────────────── -->
<main id="player-container">
<pre id="ascii-player"></pre>
<canvas id="ascii-canvas"></canvas>
<div id="idle-overlay">
<div id="idle-content">
<div id="idle-logo">ASCILINE</div>
<div id="idle-sub">ASCII video streaming</div>
<div id="idle-arrow">↑ type a video above and hit PLAY</div>
</div>
</div>
</main>
<!-- ── STATUS BAR ──────────────────────────────────────────────── -->
<footer id="statusbar">
<span id="status-title" class="status-segment"></span>
<span id="status-live" class="live-badge hidden">● LIVE</span>
<span id="status-state" class="status-segment status-state">IDLE</span>
<span id="status-fps" class="status-segment status-fps"></span>
<span id="status-perf-hint" class="status-segment status-perf hidden">↓ try a lower preset</span>
</footer>
<!-- hidden audio element; no src until Play -->
<audio id="ascii-audio" preload="auto"></audio>
<script src="/static/app.js"></script>
</body>
</html>

285
static/pin.html Normal file
View File

@@ -0,0 +1,285 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ASCILINE — Enter PIN</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<style>
/* Inline minimal theme — matches style.css variables without importing it
so this page loads before /static/ is auth-gated. */
:root {
--bg: #000000;
--bg2: #0d0d0d;
--bg3: #1a1a1a;
--border: #2a2a2a;
--border-hi: #3d3d3d;
--accent: #F5A623;
--accent-dim:#b87a18;
--fg: #e8e8e8;
--fg-dim: #888;
--fg-faint: #444;
--red: #e84040;
--green: #3ecf6e;
--font-ui: 'Arial Narrow', Arial, sans-serif;
--font-mono: 'Courier New', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg);
color: var(--fg);
font-family: var(--font-ui);
display: flex;
align-items: center;
justify-content: center;
-webkit-font-smoothing: antialiased;
}
#card {
width: 320px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
#brand {
font-size: 28px;
font-weight: 700;
letter-spacing: 6px;
color: var(--accent);
margin-bottom: 6px;
user-select: none;
}
#subtitle {
font-size: 12px;
letter-spacing: 3px;
color: var(--fg-faint);
text-transform: uppercase;
margin-bottom: 32px;
user-select: none;
}
/* PIN dots */
#pin-display {
display: flex;
gap: 14px;
margin-bottom: 24px;
min-height: 20px;
align-items: center;
justify-content: center;
}
.pin-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--border-hi);
background: transparent;
transition: background 120ms, border-color 120ms;
}
.pin-dot.filled {
background: var(--accent);
border-color: var(--accent);
}
/* Status message */
#msg {
height: 22px;
font-size: 13px;
font-family: var(--font-mono);
letter-spacing: 0.5px;
color: var(--fg-dim);
margin-bottom: 20px;
text-align: center;
}
#msg.error { color: var(--red); }
#msg.locked { color: var(--red); }
/* Keypad */
#keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
width: 100%;
}
.key {
height: 72px;
background: var(--bg3);
border: 1.5px solid var(--border-hi);
border-radius: 8px;
color: var(--fg);
font-size: 26px;
font-weight: 600;
font-family: var(--font-ui);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
transition: background 80ms, border-color 80ms, transform 60ms;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.key:active:not(:disabled) {
background: var(--accent-dim);
border-color: var(--accent);
transform: scale(0.94);
}
.key:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.key-clear {
font-size: 16px;
font-weight: 700;
letter-spacing: 1px;
color: var(--fg-dim);
}
.key-zero {
/* 0 sits in the middle column of the last row */
}
.key-submit {
background: var(--accent);
border-color: var(--accent);
color: #000;
font-size: 20px;
}
.key-submit:hover:not(:disabled) {
background: #ffb838;
border-color: #ffb838;
}
</style>
</head>
<body>
<div id="card">
<div id="brand">ASCILINE</div>
<div id="subtitle">Enter PIN to continue</div>
<div id="pin-display"></div>
<div id="msg"></div>
<div id="keypad">
<button class="key" data-digit="1">1</button>
<button class="key" data-digit="2">2</button>
<button class="key" data-digit="3">3</button>
<button class="key" data-digit="4">4</button>
<button class="key" data-digit="5">5</button>
<button class="key" data-digit="6">6</button>
<button class="key" data-digit="7">7</button>
<button class="key" data-digit="8">8</button>
<button class="key" data-digit="9">9</button>
<button class="key key-clear" data-action="clear">CLR</button>
<button class="key key-zero" data-digit="0">0</button>
<button class="key key-submit" data-action="submit">&#10003;</button>
</div>
</div>
<script>
(function () {
const MAX_LEN = 8;
let pin = "";
let locked = false;
const display = document.getElementById("pin-display");
const msg = document.getElementById("msg");
const keys = document.querySelectorAll(".key");
function renderDots(len) {
display.innerHTML = "";
for (let i = 0; i < Math.max(len, 4); i++) {
const d = document.createElement("div");
d.className = "pin-dot" + (i < len ? " filled" : "");
display.appendChild(d);
}
}
function setMsg(text, cls) {
msg.textContent = text;
msg.className = cls || "";
}
function setDisabled(state) {
keys.forEach(k => k.disabled = state);
}
renderDots(0);
document.getElementById("keypad").addEventListener("click", function (e) {
if (locked) return;
const btn = e.target.closest(".key");
if (!btn || btn.disabled) return;
const digit = btn.dataset.digit;
const action = btn.dataset.action;
if (digit !== undefined) {
if (pin.length < MAX_LEN) {
pin += digit;
renderDots(pin.length);
setMsg("");
}
} else if (action === "clear") {
pin = "";
renderDots(0);
setMsg("");
} else if (action === "submit") {
if (pin.length === 0) { setMsg("Enter your PIN", ""); return; }
submit();
}
});
// Also support physical keyboard for desktop testing.
document.addEventListener("keydown", function (e) {
if (locked) return;
if (e.key >= "0" && e.key <= "9") {
if (pin.length < MAX_LEN) {
pin += e.key;
renderDots(pin.length);
setMsg("");
}
} else if (e.key === "Backspace") {
pin = pin.slice(0, -1);
renderDots(pin.length);
setMsg("");
} else if (e.key === "Enter") {
submit();
}
});
function submit() {
setDisabled(true);
setMsg("Checking…", "");
fetch("/api/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pin: pin }),
})
.then(function (r) { return r.json().then(function (d) { return { status: r.status, data: d }; }); })
.then(function (res) {
if (res.status === 200) {
setMsg("OK", "");
// Small delay so the user sees "OK" before redirect.
setTimeout(function () { window.location.replace("/"); }, 300);
} else if (res.status === 429) {
locked = true;
setDisabled(true);
const secs = res.data.retry_after || 600;
setMsg("Too many attempts. Wait " + Math.ceil(secs / 60) + " min.", "locked");
} else {
pin = "";
renderDots(0);
setDisabled(false);
setMsg("Wrong PIN", "error");
}
})
.catch(function () {
setDisabled(false);
setMsg("Network error", "error");
});
}
})();
</script>
</body>
</html>

412
static/style.css Normal file
View File

@@ -0,0 +1,412 @@
/* ── ASCILINE Phase 4 — Tesla Dashboard UI ──────────────────────────────
Aesthetic: industrial utility. Barlow Condensed (labels/buttons) +
JetBrains Mono (status readout). Amber accent on pure black.
Touch targets ≥ 48px throughout. ──────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
/* ── CSS variables ────────────────────────────────────────────────────── */
:root {
--bg: #000000;
--bg2: #0d0d0d;
--bg3: #1a1a1a;
--border: #2a2a2a;
--border-hi: #3d3d3d;
--accent: #F5A623;
--accent-dim: #b87a18;
--accent-glow: rgba(245,166,35,.18);
--fg: #e8e8e8;
--fg-dim: #888;
--fg-faint: #444;
--red: #e84040;
--green: #3ecf6e;
--font-ui: 'Barlow Condensed', 'Arial Narrow', Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'Courier New', monospace;
--topbar-h: 60px;
--subbar-h: 48px;
--statusbar-h: 40px;
}
/* ── Reset ────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg);
color: var(--fg);
font-family: var(--font-ui);
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* ── TOP BAR ──────────────────────────────────────────────────────────── */
#topbar {
position: fixed;
top: 0; left: 0; right: 0;
height: var(--topbar-h);
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
z-index: 100;
}
#brand {
font-family: var(--font-ui);
font-weight: 700;
font-size: 20px;
letter-spacing: 4px;
color: var(--accent);
white-space: nowrap;
flex-shrink: 0;
user-select: none;
}
#query-input {
flex: 1;
height: 44px;
background: var(--bg3);
border: 1.5px solid var(--border-hi);
border-radius: 6px;
color: var(--fg);
font-family: var(--font-ui);
font-size: 20px;
font-weight: 600;
letter-spacing: 0.5px;
padding: 0 16px;
outline: none;
transition: border-color 150ms, box-shadow 150ms;
min-width: 0;
caret-color: var(--accent);
}
#query-input::placeholder { color: var(--fg-faint); }
#query-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
/* ── Buttons ──────────────────────────────────────────────────────────── */
.btn {
display: flex;
align-items: center;
gap: 7px;
height: 44px;
padding: 0 20px;
border-radius: 6px;
border: 2px solid transparent;
font-family: var(--font-ui);
font-size: 18px;
font-weight: 700;
letter-spacing: 2px;
cursor: pointer;
user-select: none;
white-space: nowrap;
flex-shrink: 0;
transition: background 120ms, border-color 120ms, opacity 120ms, transform 80ms;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.btn:active:not(:disabled) { transform: scale(0.96); }
.btn-primary {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) {
background: #ffb838;
border-color: #ffb838;
}
.btn-primary:disabled {
background: var(--fg-faint);
border-color: var(--fg-faint);
color: #666;
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
color: var(--fg);
border-color: var(--border-hi);
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--fg-dim);
color: #fff;
}
.btn-secondary:disabled {
color: var(--fg-faint);
border-color: var(--fg-faint);
cursor: not-allowed;
}
.btn-icon { font-size: 14px; }
.btn-label { line-height: 1; }
/* ── SUB BAR ──────────────────────────────────────────────────────────── */
#subbar {
position: fixed;
top: var(--topbar-h);
left: 0; right: 0;
height: var(--subbar-h);
display: flex;
align-items: center;
gap: 0;
padding: 0 14px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
z-index: 99;
}
.subbar-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 2.5px;
color: var(--fg-faint);
margin-right: 8px;
user-select: none;
}
#preset-group {
display: flex;
align-items: center;
gap: 0;
margin-right: 18px;
}
.preset-btn {
height: 32px;
padding: 0 16px;
background: transparent;
color: var(--fg-dim);
border: 1.5px solid var(--border-hi);
font-family: var(--font-ui);
font-size: 14px;
font-weight: 700;
letter-spacing: 1.5px;
cursor: pointer;
transition: background 120ms, color 120ms, border-color 120ms;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
margin-left: -1px; /* collapse borders */
}
.preset-btn:first-of-type { border-radius: 5px 0 0 5px; margin-left: 0; }
.preset-btn:last-of-type { border-radius: 0 5px 5px 0; }
.preset-btn:active { background: var(--accent-dim); }
.preset-btn.active {
background: var(--accent);
color: #000;
border-color: var(--accent);
z-index: 1;
}
.preset-btn:hover:not(.active) {
background: var(--bg3);
color: var(--fg);
border-color: var(--fg-dim);
z-index: 1;
}
#preset-hint {
font-size: 13px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.5px;
padding: 0 12px;
opacity: 0.85;
transition: opacity 300ms;
}
#volume-group {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.icon-btn {
height: 32px;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1.5px solid var(--border-hi);
border-radius: 5px;
color: var(--fg-dim);
font-size: 16px;
cursor: pointer;
transition: border-color 120ms, color 120ms;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.icon-btn:hover { border-color: var(--fg-dim); color: var(--fg); }
.icon-btn.muted { color: var(--fg-faint); }
#volume-slider {
width: 110px;
accent-color: var(--accent);
cursor: pointer;
height: 32px;
}
/* ── PLAYER CONTAINER ─────────────────────────────────────────────────── */
#player-container {
position: fixed;
top: calc(var(--topbar-h) + var(--subbar-h));
left: 0; right: 0;
bottom: var(--statusbar-h);
background: #050505;
overflow: hidden;
}
/* vendored renderer sets these directly via style; we provide base values */
#ascii-player {
margin: 0;
white-space: pre;
font-family: var(--font-mono);
display: none;
}
#ascii-canvas {
display: none;
background: #050505;
}
/* ── IDLE OVERLAY ─────────────────────────────────────────────────────── */
#idle-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #050505;
z-index: 10;
transition: opacity 250ms ease;
}
#idle-overlay.hidden {
opacity: 0;
pointer-events: none;
}
#idle-content {
text-align: center;
user-select: none;
}
#idle-logo {
font-family: var(--font-ui);
font-size: clamp(48px, 10vw, 96px);
font-weight: 700;
letter-spacing: 16px;
color: var(--accent);
line-height: 1;
margin-bottom: 10px;
}
#idle-sub {
font-size: 16px;
letter-spacing: 4px;
color: var(--fg-faint);
text-transform: uppercase;
margin-bottom: 28px;
}
#idle-arrow {
font-size: 15px;
color: var(--fg-dim);
letter-spacing: 1px;
font-family: var(--font-ui);
font-weight: 600;
animation: pulse-arrow 2.4s ease-in-out infinite;
}
@keyframes pulse-arrow {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
/* ── STATUS BAR ────────────────────────────────────────────────────────── */
#statusbar {
position: fixed;
bottom: 0; left: 0; right: 0;
height: var(--statusbar-h);
display: flex;
align-items: center;
gap: 0;
padding: 0 14px;
background: var(--bg2);
border-top: 1px solid var(--border);
z-index: 100;
font-family: var(--font-mono);
font-size: 13px;
overflow: hidden;
}
.status-segment {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#status-title {
flex: 1;
color: var(--fg-dim);
min-width: 0;
margin-right: 12px;
font-weight: 500;
}
.live-badge {
color: var(--red);
font-weight: 700;
font-size: 11px;
letter-spacing: 1.5px;
border: 1.5px solid var(--red);
border-radius: 3px;
padding: 0 6px;
height: 22px;
display: inline-flex;
align-items: center;
flex-shrink: 0;
margin-right: 10px;
animation: live-pulse 1.8s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.status-state {
color: var(--fg-faint);
letter-spacing: 1.5px;
font-size: 11px;
font-weight: 500;
flex-shrink: 0;
margin-right: 14px;
text-transform: uppercase;
}
.status-state.state-playing { color: var(--green); }
.status-state.state-resolving { color: var(--accent); }
.status-state.state-error { color: var(--red); }
.status-fps {
color: var(--fg-faint);
font-size: 12px;
flex-shrink: 0;
margin-right: 10px;
}
.status-perf {
color: var(--accent);
font-size: 11px;
letter-spacing: 0.5px;
flex-shrink: 0;
font-family: var(--font-ui);
font-weight: 700;
}
/* shared utility */
.hidden { display: none !important; }

0
vendor/__init__.py vendored Normal file
View File

26
vendor/asciline/LICENSE vendored Normal file
View File

@@ -0,0 +1,26 @@
MIT License (with Anti-Advertisement Restriction)
Copyright (c) 2026 YusufB5
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
ANTI-ADVERTISEMENT RESTRICTION: The Software shall not be used, in whole or
in part, for the purpose of serving, delivering, or displaying digital
advertisements, sponsored content, or any form of commercial marketing to
end-users. Any such use immediately terminates this license.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

0
vendor/asciline/__init__.py vendored Normal file
View File

354
vendor/asciline/app.js vendored Normal file
View File

@@ -0,0 +1,354 @@
/**
* ASCILINE ENGINE - Pure & Performant Logic
* =========================================
* No decorative animations. Pure WebSocket streaming
* and high-performance canvas rendering.
* Includes an "Invisible Selection Layer" for text selection.
*
* VENDORED — do not modify. See PROTOCOL-NOTES.md for wire format.
*/
const player = document.getElementById('ascii-player');
const canvas = document.getElementById('ascii-canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const container = document.getElementById('player-container');
const overlay = document.getElementById('play-overlay');
const audioEl = document.getElementById('ascii-audio');
const volumeSlider = document.getElementById('volume-slider');
// ── STATE ──
let state = 'IDLE'; // IDLE | PLAYING
let ws = null;
const frameBuffer = [];
const BUFFER_SIZE = 4;
let targetFps = 24;
let frameInterval = 1000 / targetFps;
let renderMode = 1;
let pixelMode = false;
let readyToRender = false;
// Grid & Dimensions
let gridCols = 0, gridRows = 0;
let charWidth = 0, charHeight = 0;
let xPos = null, yPos = null;
// Pixel Mode (--pixel) — ImageData pixel buffer
let dotImageData = null;
// Selection Layer optimization
const textDecoder = new TextDecoder();
let selectionBuffer = null;
// Timing & Metrics
let lastRenderTime = 0;
let frameCount = 0, currentFps = 0, lastFpsUpdate = 0;
let streamStartTime = 0;
const CHAR_LUT = new Array(128);
for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i);
// ═══════════════════════════════════════
// CANVAS SETUP
// ═══════════════════════════════════════
function buildCanvas(cols, rows) {
gridCols = cols;
gridRows = rows;
const syncSize = (el) => {
el.style.width = container.clientWidth + 'px';
el.style.height = container.clientHeight + 'px';
el.style.objectFit = 'contain';
el.style.position = 'absolute';
el.style.top = '0';
el.style.left = '0';
};
if (pixelMode) {
canvas.width = cols;
canvas.height = rows;
canvas.style.display = 'block';
canvas.style.imageRendering = 'pixelated';
dotImageData = ctx.createImageData(cols, rows);
const d = dotImageData.data;
for (let i = 3; i < d.length; i += 4) d[i] = 255;
syncSize(canvas);
player.style.display = 'none';
} else {
canvas.style.imageRendering = '';
dotImageData = null;
ctx.font = 'bold 8px Courier New';
charWidth = ctx.measureText('M').width;
charHeight = 8;
canvas.width = cols * charWidth;
canvas.height = rows * charHeight;
canvas.style.display = 'block';
selectionBuffer = new Uint8Array((cols + 1) * rows);
for (let r = 0; r < rows; r++) selectionBuffer[r * (cols + 1) + cols] = 10;
syncSize(canvas);
const containerW = container.clientWidth;
const containerH = container.clientHeight;
const fitScaleX = containerW / canvas.width;
const fitScaleY = containerH / canvas.height;
const fitScale = Math.min(fitScaleX, fitScaleY);
const renderedW = canvas.width * fitScale;
const renderedH = canvas.height * fitScale;
const offsetX = (containerW - renderedW) / 2;
const offsetY = (containerH - renderedH) / 2;
player.style.width = canvas.width + 'px';
player.style.height = canvas.height + 'px';
player.style.position = 'absolute';
player.style.top = '0';
player.style.left = '0';
player.style.transformOrigin = 'top left';
player.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${fitScale})`;
player.style.fontSize = '8px';
player.style.lineHeight = '8px';
ctx.font = 'bold 8px Courier New';
ctx.textBaseline = 'top';
xPos = new Float32Array(cols);
yPos = new Float32Array(rows);
for (let c = 0; c < cols; c++) xPos[c] = c * charWidth;
for (let r = 0; r < rows; r++) yPos[r] = r * charHeight;
}
}
// ═══════════════════════════════════════
// STREAM CONTROL
// ═══════════════════════════════════════
function startStream() {
if (state !== 'IDLE') return;
overlay.classList.add('hidden');
statusEl.textContent = 'Connecting...';
statusEl.style.color = 'var(--accent-color)';
connectWebSocket();
}
function connectWebSocket() {
frameBuffer.length = 0;
frameCount = 0;
currentFps = 0;
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${location.host}/ws`);
ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
if (event.data.startsWith('Error:')) {
statusEl.textContent = event.data;
statusEl.style.color = '#ff0000';
if (ws) ws.close();
setTimeout(() => finishStream(), 3000);
return;
}
if (event.data.startsWith('INIT:')) {
const p = event.data.split(':');
targetFps = parseFloat(p[1]);
frameInterval = 1000 / targetFps;
renderMode = parseInt(p[2]);
pixelMode = (p.length > 5 && parseInt(p[5]) === 1);
buildCanvas(parseInt(p[3]), parseInt(p[4]));
readyToRender = false;
state = 'PLAYING';
const beginRendering = () => {
readyToRender = true;
streamStartTime = performance.now();
lastRenderTime = performance.now();
lastFpsUpdate = lastRenderTime;
requestAnimationFrame(renderFrame);
};
if (audioEl) {
audioEl.pause();
audioEl.src = '/audio?' + Date.now();
audioEl.volume = volumeSlider ? volumeSlider.value : 1.0;
audioEl.load();
audioEl.play().catch(() => {});
if (audioEl.readyState >= 3) {
beginRendering();
} else {
audioEl.addEventListener('playing', beginRendering, { once: true });
setTimeout(() => {
if (!readyToRender) beginRendering();
}, 500);
}
} else {
beginRendering();
}
return;
}
// Mode 1: Text Frame with Timestamp
const text = event.data;
const newlineIdx = text.indexOf('\n');
const frameIndex = parseInt(text.substring(0, newlineIdx));
const frameTime = frameIndex / targetFps;
const frameData = text.substring(newlineIdx + 1);
frameBuffer.push({ data: frameData, time: frameTime });
} else {
// Binary Frames with 4-byte header
const buffer = event.data;
const view = new DataView(buffer);
const frameIndex = view.getUint32(0, false); // Big-endian
const frameTime = frameIndex / targetFps;
const frameData = new Uint8Array(buffer, 4);
frameBuffer.push({ data: frameData, time: frameTime });
}
while (frameBuffer.length > BUFFER_SIZE * 5) frameBuffer.shift();
};
ws.onopen = () => { statusEl.textContent = 'Buffering...'; };
ws.onclose = () => {
if (state === 'PLAYING') {
statusEl.textContent = 'Stream Ended.';
statusEl.style.color = '#888';
if (audioEl) audioEl.pause();
setTimeout(() => finishStream(), 800);
}
};
ws.onerror = () => {
statusEl.textContent = 'Connection Error!';
statusEl.style.color = '#ff0000';
setTimeout(() => finishStream(), 2000);
};
}
// ═══════════════════════════════════════
// RENDER LOOP
// ═══════════════════════════════════════
function renderFrame(now) {
if (state !== 'PLAYING' || !readyToRender) return;
requestAnimationFrame(renderFrame);
// ── MASTER CLOCK LOGIC ──
let masterClock;
if (audioEl && audioEl.readyState >= 1 && !audioEl.paused) {
masterClock = audioEl.currentTime;
} else {
masterClock = (now - streamStartTime) / 1000.0;
}
if (frameBuffer.length === 0) return;
// A/V Sync: Drop frames that are too far behind the master clock (catch up)
while (frameBuffer.length > 1 && frameBuffer[0].time < masterClock - 0.1) {
frameBuffer.shift();
}
// A/V Sync: Wait if the frame is in the future
if (frameBuffer[0].time > masterClock + 0.05) {
return;
}
const frameObj = frameBuffer.shift();
const frame = frameObj.data;
frameCount++;
if (now - lastFpsUpdate >= 1000) {
currentFps = frameCount;
frameCount = 0;
lastFpsUpdate = now;
const modes = { 2: '512 Color', 3: '32K Color', 4: '262K Color', 5: '16M Ultra' };
const label = (modes[renderMode] || 'B&W') + (pixelMode ? ' PIXEL' : '');
statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${label}`;
}
lastRenderTime = now;
if (renderMode === 1) {
player.style.display = 'block';
player.style.color = '#fff';
player.textContent = frame;
} else if (pixelMode) {
const view = frame;
const data = dotImageData.data;
for (let src = 0, dst = 0; src < view.length; src += 3, dst += 4) {
data[dst] = view[src + 2]; // R (from BGR)
data[dst + 1] = view[src + 1]; // G
data[dst + 2] = view[src]; // B
}
ctx.putImageData(dotImageData, 0, 0);
} else {
// ── STANDARD COLOR MODES (2-5): fillText per character ──
const view = frame;
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 8px Courier New';
ctx.textBaseline = 'top';
let col = 0, row = 0, prevPacked = -1;
for (let idx = 0; idx < view.length; idx += 4) {
const packed = (view[idx+1] << 16) | (view[idx+2] << 8) | view[idx+3];
if (packed !== prevPacked) {
ctx.fillStyle = `rgb(${view[idx+1]},${view[idx+2]},${view[idx+3]})`;
prevPacked = packed;
}
ctx.fillText(CHAR_LUT[view[idx]], xPos[col], yPos[row]);
selectionBuffer[row * (gridCols + 1) + col] = view[idx];
col++;
if (col >= gridCols) { col = 0; row++; }
}
player.style.display = 'block';
player.style.color = 'transparent';
player.textContent = textDecoder.decode(selectionBuffer);
}
}
// ═══════════════════════════════════════
// CLEANUP
// ═══════════════════════════════════════
function finishStream() {
state = 'IDLE';
if (ws) { ws.onclose = null; ws.close(); ws = null; }
if (audioEl) { audioEl.pause(); audioEl.src = ''; }
ctx.clearRect(0, 0, canvas.width, canvas.height);
player.textContent = '';
player.style.display = 'none';
overlay.classList.remove('hidden');
statusEl.textContent = 'Ready';
statusEl.style.color = 'rgba(255,255,255,0.6)';
readyToRender = false;
frameBuffer.length = 0;
}
// ── EVENT LISTENERS ──
overlay.addEventListener('click', (e) => {
e.stopPropagation();
startStream();
});
if (volumeSlider) {
volumeSlider.addEventListener('input', () => {
if (audioEl) audioEl.volume = volumeSlider.value;
});
}
window.addEventListener('resize', () => {
const syncSize = (el) => {
if (!el) return;
el.style.width = container.clientWidth + 'px';
el.style.height = container.clientHeight + 'px';
};
syncSize(canvas);
syncSize(player);
});

334
vendor/asciline/ascii_video_player2.py vendored Normal file
View File

@@ -0,0 +1,334 @@
"""
ascii_video_player.py
=====================
Modular, True Color (24-bit ANSI), zero-flicker ASCII video player.
- VideoDecoder : Produces (gray, color) frame pairs from video.
- AsciiMapper : Gray matrix -> ASCII character + ANSI True Color code -> String.
- TerminalRenderer: Main loop, FPS control, orientation detection, rendering.
Dependencies:
pip install opencv-python numpy
"""
import sys
import time
import shutil
import numpy as np
import cv2
import os
# Enable ANSI color codes on PowerShell/CMD (Windows):
os.system("")
# ─────────────────────────────────────────────
# MODULE 1 ─ VideoDecoder
# ─────────────────────────────────────────────
class VideoDecoder:
"""
Opens the video file and yields (gray, bgr) pair for each frame.
For color rendering, both gray (for character selection) and
original BGR (for color sampling) matrices are needed.
Both undergo the same resize operation -> size consistency guaranteed.
"""
def __init__(self, path: str, cols: int, rows: int, skip_gray: bool = False) -> None:
self._cap = cv2.VideoCapture(path)
if not self._cap.isOpened():
raise FileNotFoundError(f"Could not open video file: {path!r}")
self.fps : float = self._cap.get(cv2.CAP_PROP_FPS) or 24.0
self.frame_count : int = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))
self.vid_w : int = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
self.vid_h : int = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
self._size : tuple = (cols, rows)
self._skip_gray : bool = skip_gray
def __iter__(self):
return self
def __next__(self) -> tuple[np.ndarray, np.ndarray]:
"""
:return: (gray[H,W] uint8, bgr[H,W,3] uint8)
gray is None when skip_gray=True (pixel mode optimization)
"""
ok, frame = self._cap.read()
if not ok:
raise StopIteration
small = cv2.resize(frame, self._size, interpolation=cv2.INTER_LINEAR)
if self._skip_gray:
return None, small
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
return gray, small # small = downscaled BGR frame
def release(self):
self._cap.release()
def grab(self) -> bool:
"""Advance the video by one frame WITHOUT decoding (nearly free).
Used by stream_server for FPS decimation of high-FPS sources."""
return self._cap.grab()
def __del__(self):
self.release()
# ─────────────────────────────────────────────
# MODULE 2 ─ AsciiMapper
# ─────────────────────────────────────────────
class AsciiMapper:
"""
Converts Gray + BGR matrix into a string of ASCII characters
colored with ANSI True Color codes.
── True Color ANSI Format ─────────────────────────────────────────────
\033[38;2;R;G;Bm{character}\033[0m
└─ foreground color ───────┘
── Color Quantization (Performance Optimization) ───────────────────────
Instead of generating a separate escape code for every pixel, color values
are downsampled to 6-bit (>> 2 << 2, 64 levels/channel).
This allows consecutive pixels with the same color to share a single escape code
-> reduces string size and stdout.write overhead.
There is no visually perceptible loss of color (16M -> ~262K colors).
── RLE (Run-Length Encoding) ───────────────────────────────────────────
The escape code is not repeated for consecutive characters of the same color;
a new code is appended only when the color changes.
This provides a 40-60% reduction in string size for a typical frame.
"""
DEFAULT_PALETTE = list(
" `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@"
)
# ANSI reset + carriage return
_RESET = "\033[0m"
def __init__(self, palette: list[str] | None = None, quantize_bits: int = 0) -> None:
"""
:param palette: Character list (None -> 93 level default)
:param quantize_bits: Right bit shift amount for color quantization.
2 -> 64 levels/channel (fast),
0 -> full 8-bit (highest quality, default).
"""
p = palette or self.DEFAULT_PALETTE
self._n = len(p)
self._lut = np.array(p, dtype='U1')
self._qb = quantize_bits # quantization bit shift amount
def convert(self, gray: np.ndarray, bgr: np.ndarray) -> str:
"""
For each pixel:
1. Gray value -> ASCII character (intensity LUT)
2. BGR color -> ANSI True Color escape code (quantized + RLE)
:param gray: shape=(H,W) uint8 gray matrix
:param bgr: shape=(H,W,3) uint8 BGR color matrix
:return: Colored ASCII string ready to be written directly to the terminal
"""
H, W = gray.shape
# ── Step 1: Pixel intensity -> character index ──────────────────
indices = np.floor_divide(gray, max(1, 256 // self._n))
np.clip(indices, 0, self._n - 1, out=indices)
char_matrix = self._lut[indices] # shape=(H,W), dtype='U1'
# ── Step 2: Color quantization ────────────────────────────────────
# BGR -> RGB order (ANSI code is in R,G,B order)
rgb = bgr[:, :, ::-1] # BGR -> RGB view, no copy
if self._qb > 0:
# Zero out the lower bits -> reduce color precision, increase speed
qb = self._qb
rgb = (rgb >> qb) << qb # e.g., qb=2: 0b11111100 masking
# ── Step 3: RLE and colored string construction ─────────────────────
# Since RLE cannot be done with pure NumPy, this part uses a Python loop.
# However, the escape code is only written when the color changes per row;
# loop overhead is minimized for repeated colors.
lines = []
prev_r = prev_g = prev_b = -1 # previous color (first pixel is always different)
for row_idx in range(H):
row_chars = char_matrix[row_idx] # shape=(W,) char array
row_colors = rgb[row_idx] # shape=(W,3) uint8 array
buf = []
for col_idx in range(W):
r, g, b = int(row_colors[col_idx, 0]), \
int(row_colors[col_idx, 1]), \
int(row_colors[col_idx, 2])
# RLE: only add a new escape code if the color changes
if r != prev_r or g != prev_g or b != prev_b:
buf.append(f"\033[38;2;{r};{g};{b}m")
prev_r, prev_g, prev_b = r, g, b
buf.append(row_chars[col_idx])
lines.append("".join(buf))
return self._RESET + "\n".join(lines) + self._RESET
# ─────────────────────────────────────────────
# MODULE 3 ─ TerminalRenderer
# ─────────────────────────────────────────────
class TerminalRenderer:
"""
Manages the flow: VideoDecoder -> AsciiMapper -> stdout.
Additional features (colored version):
- Sets terminal background to black initially (\033[40m)
-> colored characters appear more prominent.
- Resets color with \033[0m at the end of each frame
-> prevents affecting subsequent terminal commands.
"""
_CURSOR_HOME = "\033[H"
_HIDE_CURSOR = "\033[?25l"
_SHOW_CURSOR = "\033[?25h"
_DISABLE_WRAP = "\033[?7l" # prevent line wrapping
_ENABLE_WRAP = "\033[?7h" # restore line wrapping
_BLACK_BG = "\033[40m" # black background — for contrast
_RESET_ALL = "\033[0m"
_CLEAR_SCREEN = "\033[2J"
CHAR_RATIO = 0.45 # terminal character aspect ratio correction
def __init__(
self,
path : str,
palette : list[str] | None = None,
quantize_bits: int = 0,
cols : int = 0,
) -> None:
"""
:param path: Path to video file
:param palette: Custom character palette (None -> 93 levels)
:param quantize_bits: Color quantization (0=full quality, 2=fast)
:param cols: Fixed columns. If 0, auto-fit to terminal.
"""
# ── Video metadata ────────────────────────────────────────────
_probe = VideoDecoder(path, 2, 2)
vid_w, vid_h = _probe.vid_w, _probe.vid_h
src_fps = _probe.fps
_probe.release()
# ── Terminal dimensions ────────────────────────────────────────────
term = shutil.get_terminal_size(fallback=(220, 50))
t_cols = term.columns
t_lines = term.lines - 2
# ── Orientation detection & aspect-ratio-preserving resizing ─────────────
orientation = "portrait" if vid_h > vid_w else "landscape"
aspect = vid_h / vid_w
if cols > 0:
rows = max(1, int(cols * aspect * self.CHAR_RATIO))
else:
safe_cols = min(t_cols, 160)
if orientation == "landscape":
cols = safe_cols
rows = max(1, int(cols * aspect * self.CHAR_RATIO))
if rows > t_lines:
rows = t_lines
cols = max(1, int(rows / (aspect * self.CHAR_RATIO)))
else:
rows = t_lines
cols = max(1, int(rows / (aspect * self.CHAR_RATIO)))
if cols > safe_cols:
cols = safe_cols
rows = max(1, int(cols * aspect * self.CHAR_RATIO))
self._pad_y = max(0, (t_lines - rows) // 2)
self._pad_x = " " * max(0, (t_cols - cols) // 2)
print(self._CLEAR_SCREEN)
print(
f"\033[1m[ASCII Player — True Color]\033[0m\n"
f" Orientation : {orientation.upper()}\n"
f" Video : {vid_w}x{vid_h}\n"
f" ASCII : {cols}x{rows} characters\n"
f" FPS : {src_fps:.1f}\n"
f" Quantization: {2**(8-quantize_bits)} levels/channel\n"
f" Exit : Ctrl+C\n"
)
time.sleep(2.0)
self._decoder = VideoDecoder(path, cols, rows)
self._mapper = AsciiMapper(palette, quantize_bits)
self._fps = self._decoder.fps
self._frame_t = 1.0 / self._fps
def play(self) -> None:
"""Main playback loop."""
stdout = sys.stdout
stdout.write(self._DISABLE_WRAP + self._HIDE_CURSOR + self._BLACK_BG)
stdout.flush()
try:
for gray_frame, bgr_frame in self._decoder:
t0 = time.perf_counter()
ascii_frame = self._mapper.convert(gray_frame, bgr_frame)
if self._pad_x:
ascii_frame = self._pad_x + ascii_frame.replace('\n', '\n' + self._pad_x)
if self._pad_y > 0:
ascii_frame = ('\n' * self._pad_y) + ascii_frame
stdout.write(self._CURSOR_HOME + ascii_frame)
stdout.flush()
wait = self._frame_t - (time.perf_counter() - t0)
if wait > 0:
time.sleep(wait)
except KeyboardInterrupt:
pass
finally:
stdout.write(self._ENABLE_WRAP + self._SHOW_CURSOR + self._RESET_ALL + "\n")
stdout.flush()
self._decoder.release()
# ─────────────────────────────────────────────
# ENTRY POINT
# ─────────────────────────────────────────────
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="True Color ANSI ASCII video player — zero flicker"
)
parser.add_argument("video",
help="Path to video file (MP4, AVI, MKV ...)")
parser.add_argument("--palette", default=None,
help="Custom character palette, space-separated")
parser.add_argument("-q", "--quality", type=int, choices=[0, 1, 2, 3], default=0,
help="Color quality: 0=max quality, 3=max speed (default: 0)")
parser.add_argument("-c", "--cols", type=int, default=0,
help="Fixed grid width. If 0, auto-fits to terminal (default: 0)")
args = parser.parse_args()
custom_palette = args.palette.split() if args.palette else None
try:
renderer = TerminalRenderer(
path = args.video,
palette = custom_palette,
quantize_bits = args.quality,
cols = args.cols,
)
renderer.play()
except FileNotFoundError as e:
print(f"\n[Error] {e}")
sys.exit(1)

313
vendor/asciline/stream_server.py vendored Normal file
View File

@@ -0,0 +1,313 @@
"""
stream_server.py
================
Streams the core Video-to-ASCII engine to the web via HTTP/WebSocket.
Dependencies: pip install fastapi uvicorn websockets
Priority Order:
1. --playlist playlist.json → JSON file (per-video vol, mode, path)
2. --folder ./videos → folder scan (filesystem order, not alphabetical)
3. positional video arg → single video (legacy behavior)
VENDORED — do not modify. Used as reference for protocol only.
See vendor/asciline/PROTOCOL-NOTES.md for the wire format documentation.
"""
import asyncio
import subprocess
import json
import numpy as np
import cv2
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
import uvicorn
import os
from websockets.exceptions import ConnectionClosed
from ascii_video_player2 import VideoDecoder, AsciiMapper
app = FastAPI()
def get_video_dimensions(path: str) -> tuple[int, int]:
cap = cv2.VideoCapture(path)
if not cap.isOpened():
raise FileNotFoundError(f"Could not open video file: {path!r}")
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
cap.release()
return w, h
def calc_auto_rows(cols: int, vid_w: int, vid_h: int, pixel_mode: bool) -> int:
ratio = vid_w / max(vid_h, 1)
if pixel_mode:
return max(1, round(cols / ratio))
else:
return max(1, round(cols / ratio / 2))
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app.mount("/static", StaticFiles(directory=BASE_DIR), name="static")
def get_html_content():
html_path = os.path.join(os.path.dirname(__file__), "index.html")
with open(html_path, "r", encoding="utf-8") as f:
return f.read()
def resolve_video_path(video: str) -> str:
candidates = [
video,
os.path.join(BASE_DIR, video),
os.path.join(BASE_DIR, "videos", os.path.basename(video)),
]
for path in candidates:
if os.path.exists(path):
return path
return video
def load_playlist(playlist_path: str) -> list[dict]:
with open(playlist_path, "r", encoding="utf-8") as f:
items = json.load(f)
for item in items:
item["video"] = resolve_video_path(item["video"])
return items
def load_folder(folder_path: str, default_mode: int, default_vol: int) -> list[dict]:
supported = (".mp4", ".mkv", ".avi", ".mov", ".webm")
entries = []
with os.scandir(folder_path) as it:
for entry in it:
if entry.is_file() and entry.name.lower().endswith(supported):
entries.append({
"video": entry.path,
"mode": default_mode,
"vol": default_vol
})
return entries
def build_queue(args) -> list[dict]:
if args.playlist:
items = load_playlist(args.playlist)
for item in items:
item.setdefault("mode", args.mode)
item.setdefault("vol", args.vol)
item.setdefault("pixel", args.pixel)
is_pixel = item.get("pixel", False)
default_cols = args.cols if args.cols is not None else (450 if is_pixel else 200)
item.setdefault("cols", default_cols)
item.setdefault("rows", args.rows)
return items
if args.folder:
items = load_folder(args.folder, args.mode, args.vol)
default_cols = args.cols if args.cols is not None else (450 if args.pixel else 200)
for item in items:
item["pixel"] = args.pixel
item["cols"] = default_cols
item["rows"] = args.rows
return items
default_cols = args.cols if args.cols is not None else (450 if args.pixel else 200)
return [{"video": resolve_video_path(args.video), "mode": args.mode, "vol": args.vol, "pixel": args.pixel, "cols": default_cols, "rows": args.rows}]
@app.get("/")
async def root():
return HTMLResponse(get_html_content())
@app.get("/audio")
async def audio_stream():
queue = getattr(app.state, "queue", [])
idx = getattr(app.state, "current_index", 0)
entry = queue[idx] if queue else {}
vol_level = entry.get("vol", 1)
video_path = entry.get("video", "video.mp4")
if vol_level <= 0:
from fastapi import Response
return Response(status_code=204)
if not os.path.exists(video_path):
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Video file not found")
ffmpeg_vol = 1.0 + (vol_level - 1) * 0.25
def audio_generator():
process = subprocess.Popen(
[
"ffmpeg",
"-i", video_path,
"-vn",
"-filter:a", f"volume={ffmpeg_vol}",
"-acodec", "libmp3lame",
"-ab", "128k",
"-ar", "44100",
"-f", "mp3",
"-loglevel", "quiet",
"pipe:1"
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
try:
while True:
chunk = process.stdout.read(4096)
if not chunk:
break
yield chunk
finally:
process.stdout.close()
process.wait()
return StreamingResponse(
audio_generator(),
media_type="audio/mpeg",
headers={"Accept-Ranges": "bytes"}
)
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
queue = getattr(app.state, "queue", [])
loop = getattr(app.state, "loop", False)
if not queue:
await websocket.send_text("Error: No video in queue!")
await websocket.close()
return
queue_index = 0
try:
while True:
entry = queue[queue_index]
video_path = entry["video"]
render_mode= entry["mode"]
pixel_mode = entry.get("pixel", False)
cols = entry.get("cols", 200)
rows_cfg = entry.get("rows", 0)
app.state.current_index = queue_index
try:
vid_w, vid_h = get_video_dimensions(video_path)
except FileNotFoundError:
await websocket.send_text(f"Error: '{video_path}' not found!")
queue_index += 1
if queue_index >= len(queue):
if loop:
queue_index = 0
else:
break
continue
if rows_cfg == 0:
rows = calc_auto_rows(cols, vid_w, vid_h, pixel_mode)
else:
rows = rows_cfg
try:
decoder = VideoDecoder(video_path, cols, rows, skip_gray=pixel_mode)
except FileNotFoundError:
await websocket.send_text(f"Error: '{video_path}' not found!")
queue_index += 1
if queue_index >= len(queue):
if loop:
queue_index = 0
else:
break
continue
mapper = AsciiMapper()
source_fps = decoder.fps
MAX_FPS = 30
char_byte_lut= np.array([ord(c) for c in mapper._lut], dtype=np.uint8)
qb = {5: 0, 4: 2, 3: 3, 2: 5}.get(render_mode, 0)
if source_fps > MAX_FPS:
skip_n = round(source_fps / MAX_FPS)
effective_fps = source_fps / skip_n
else:
skip_n = 1
effective_fps = source_fps
frame_t = 1.0 / effective_fps
await websocket.send_text(f"INIT:{effective_fps}:{render_mode}:{cols}:{rows}:{int(pixel_mode)}")
frame_buf = np.empty((rows, cols, 4), dtype=np.uint8) if render_mode > 1 else None
import struct
start_time = asyncio.get_event_loop().time()
frame_index = 0
if pixel_mode:
pixel_send_buf = bytearray(4 + rows * cols * 3)
elif render_mode > 1:
ascii_send_buf = bytearray(4 + rows * cols * 4)
try:
while True:
for _ in range(skip_n - 1):
if not decoder.grab():
break
try:
gray_frame, bgr_frame = next(decoder)
except StopIteration:
break
if pixel_mode:
bgr_bytes = bgr_frame.tobytes()
struct.pack_into(">I", pixel_send_buf, 0, frame_index)
pixel_send_buf[4:] = bgr_bytes
await websocket.send_bytes(bytes(pixel_send_buf))
else:
indices = np.floor_divide(gray_frame, max(1, 256 // mapper._n))
np.clip(indices, 0, mapper._n - 1, out=indices)
if render_mode == 1:
char_matrix = mapper._lut[indices]
lines = [''.join(row) for row in char_matrix]
await websocket.send_text(f"{frame_index}\n" + '\n'.join(lines))
else:
char_codes = char_byte_lut[indices]
rgb = bgr_frame[:, :, ::-1]
if qb > 0:
rgb = (rgb >> qb) << qb
frame_buf[:, :, 0] = char_codes
frame_buf[:, :, 1:] = rgb
struct.pack_into(">I", ascii_send_buf, 0, frame_index)
ascii_send_buf[4:] = frame_buf.tobytes()
await websocket.send_bytes(bytes(ascii_send_buf))
elapsed = asyncio.get_event_loop().time() - start_time
wait = (frame_index * frame_t) - elapsed
if wait > 0:
await asyncio.sleep(wait)
frame_index += 1
finally:
decoder.release()
queue_index += 1
if queue_index >= len(queue):
if loop:
queue_index = 0
else:
break
except (WebSocketDisconnect, ConnectionClosed):
pass