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:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
160
README.md
Normal 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 | 4–8 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
|
||||
20–40 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 ~150–300 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
162
auth.py
Normal 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
33
config.py
Normal 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
172
docs/PROJECT-REPORT.html
Normal 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 4–8 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 <target_fps></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 ~150–300 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 <4-8 digits></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.9–7.3 ms per frame (≈140 fps of headroom). The earlier "13/24" figure was the server's irregular delivery, not paint cost. A glyph atlas was tried; it regressed performance because mode-3 (32K colors) thrashes any reasonably-sized LRU. The atlas was reverted. <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://<host>: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 20–40 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
354
pipeline.py
Normal 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
7
requirements.txt
Normal 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
203
resolver.py
Normal 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
55
run.sh
Executable 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
352
server.py
Normal 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="4–8 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
273
session.py
Normal 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
527
static/app.js
Normal 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
84
static/index.html
Normal 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
285
static/pin.html
Normal 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">✓</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
412
static/style.css
Normal 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
0
vendor/__init__.py
vendored
Normal file
26
vendor/asciline/LICENSE
vendored
Normal file
26
vendor/asciline/LICENSE
vendored
Normal 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
0
vendor/asciline/__init__.py
vendored
Normal file
354
vendor/asciline/app.js
vendored
Normal file
354
vendor/asciline/app.js
vendored
Normal 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
334
vendor/asciline/ascii_video_player2.py
vendored
Normal 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
313
vendor/asciline/stream_server.py
vendored
Normal 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
|
||||
Reference in New Issue
Block a user