Initial commit: ASCILINE YouTube Streamer

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

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

0
vendor/__init__.py vendored Normal file
View File

26
vendor/asciline/LICENSE vendored Normal file
View File

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

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

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

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

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

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

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

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