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