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:
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