mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-14 22:25:13 +02:00
Initial commit: ASCILINE Engine - Modular & Optimized
This commit is contained in:
commit
7cd84b657b
7 changed files with 1016 additions and 0 deletions
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Video files (too large for GitHub, user provides their own)
|
||||
*.mp4
|
||||
*.avi
|
||||
*.mov
|
||||
*.mkv
|
||||
*.webm
|
||||
|
||||
# Environment & IDE
|
||||
.env
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Personal notes
|
||||
mynotes.txt
|
||||
|
||||
# Old versions
|
||||
*-previous-ver-*
|
||||
67
README.md
Normal file
67
README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# 🌌 ASCILINE Engine
|
||||
|
||||
**ASCILINE** is a high-performance, real-time ASCII video rendering engine for the web. It streams video frames from a Python backend directly into a web browser at **60 FPS** using binary WebSockets and HTML5 Canvas.
|
||||
|
||||
 <!-- Replace with your actual GIF -->
|
||||
|
||||
## 🚀 Key Features
|
||||
|
||||
- **Real-Time Streaming**: Low-latency video-to-ASCII conversion.
|
||||
- **High Performance**: Uses **HTML5 Canvas** for rendering instead of heavy DOM elements, enabling 60 FPS playback.
|
||||
- **Binary Protocol**: Frames are encoded into `Uint8Array` (binary) for efficient bandwidth usage.
|
||||
- **Multiple Color Modes**: Supports everything from classic B&W to 16M color ultra-fidelity.
|
||||
- **Modern Aesthetic**: Premium dark-mode UI with interactive ripple dissolve effects.
|
||||
|
||||
## 🛠️ Architecture
|
||||
|
||||
1. **Backend (Python/FastAPI)**: Decodes video using OpenCV, maps pixels to ASCII characters via NumPy, and streams binary data.
|
||||
2. **Frontend (Vanilla JS)**: Receives binary frames via WebSockets, manages a jitter buffer, and renders to a Canvas grid.
|
||||
3. **Communication**: Optimized WebSocket protocol with a custom `INIT` handshake for dynamic resolution/FPS adjustment.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### 1. Clone the repository
|
||||
```bash
|
||||
git clone https://github.com/yourusername/ASCILINE.git
|
||||
cd ASCILINE
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
```bash
|
||||
pip install fastapi uvicorn opencv-python numpy websockets
|
||||
```
|
||||
|
||||
### 3. Run the engine
|
||||
Place a `video.mp4` in the root directory and start the server:
|
||||
```bash
|
||||
python stream_server.py
|
||||
```
|
||||
Open `http://localhost:8000` in your browser.
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
You can easily customize the look and feel of the engine:
|
||||
|
||||
### Styling
|
||||
Edit `style.css` to change the accent colors and typography using CSS variables:
|
||||
```css
|
||||
:root {
|
||||
--accent-color: #00ff41; /* Classic Matrix Green */
|
||||
--bg-color: #050505;
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering Modes
|
||||
The engine supports different fidelity levels via the `--mode` flag:
|
||||
- `1`: Black & White (DOM mode)
|
||||
- `2`: 512 Colors
|
||||
- `3`: 32K Colors
|
||||
- `4`: 262K Colors
|
||||
- `5`: 16M Colors (Ultra)
|
||||
|
||||
```bash
|
||||
python stream_server.py --mode 5 --cols 240 --rows 100
|
||||
```
|
||||
|
||||
## 📜 License
|
||||
MIT License. Feel free to use and abuse for your own ASCII adventures!
|
||||
288
app.js
Normal file
288
app.js
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* ASCILINE ENGINE - Core Logic
|
||||
* =========================================
|
||||
* Handles WebSocket communication, frame buffering,
|
||||
* and dual-mode rendering (Canvas/DOM).
|
||||
*/
|
||||
|
||||
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');
|
||||
|
||||
// ── STATE ──
|
||||
let state = 'IDLE'; // IDLE | PLAYING | DISSOLVING
|
||||
let ws = null;
|
||||
const frameBuffer = [];
|
||||
const BUFFER_SIZE = 4;
|
||||
let targetFps = 24;
|
||||
let frameInterval = 1000 / targetFps;
|
||||
let renderMode = 1;
|
||||
|
||||
// Grid & Dimensions
|
||||
let gridCols = 0, gridRows = 0;
|
||||
let charWidth = 0, charHeight = 0;
|
||||
let xPos = null, yPos = null;
|
||||
|
||||
// Timing & Metrics
|
||||
let lastRenderTime = 0;
|
||||
let frameCount = 0, currentFps = 0, lastFpsUpdate = 0;
|
||||
let lastFrameView = null; // Stored for the ripple effect
|
||||
|
||||
// Character Lookup Table (optimization)
|
||||
const CHAR_LUT = new Array(128);
|
||||
for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i);
|
||||
|
||||
/**
|
||||
* Pre-calculates positions and scales canvas for high-performance rendering.
|
||||
*/
|
||||
function buildCanvas(cols, rows) {
|
||||
gridCols = cols;
|
||||
gridRows = rows;
|
||||
ctx.font = 'bold 8px Courier New';
|
||||
charWidth = Math.ceil(ctx.measureText('M').width);
|
||||
charHeight = 8;
|
||||
|
||||
canvas.width = cols * charWidth;
|
||||
canvas.height = rows * charHeight;
|
||||
canvas.style.display = 'block';
|
||||
player.style.display = 'none';
|
||||
|
||||
container.style.minWidth = canvas.width + 'px';
|
||||
container.style.minHeight = canvas.height + 'px';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates WebSocket connection and stream handling.
|
||||
*/
|
||||
function startStream() {
|
||||
if (state !== 'IDLE') return;
|
||||
state = 'PLAYING';
|
||||
overlay.classList.add('hidden');
|
||||
statusEl.textContent = 'Connecting...';
|
||||
statusEl.style.color = 'var(--accent-color)';
|
||||
|
||||
frameBuffer.length = 0;
|
||||
lastFrameView = null;
|
||||
frameCount = 0;
|
||||
currentFps = 0;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (state !== 'PLAYING') return;
|
||||
|
||||
if (typeof event.data === 'string') {
|
||||
if (event.data.startsWith('INIT:')) {
|
||||
const p = event.data.split(':');
|
||||
targetFps = parseFloat(p[1]);
|
||||
frameInterval = 1000 / targetFps;
|
||||
renderMode = parseInt(p[2]);
|
||||
if (renderMode > 1) {
|
||||
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
||||
} else {
|
||||
player.style.display = 'block';
|
||||
canvas.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
frameBuffer.push(event.data);
|
||||
} else {
|
||||
frameBuffer.push(event.data);
|
||||
}
|
||||
|
||||
// Buffer Overflow Protection
|
||||
while (frameBuffer.length > BUFFER_SIZE * 3) frameBuffer.shift();
|
||||
|
||||
// Start render loop once buffered
|
||||
if (frameBuffer.length >= BUFFER_SIZE && lastRenderTime === 0) {
|
||||
lastRenderTime = performance.now();
|
||||
lastFpsUpdate = lastRenderTime;
|
||||
requestAnimationFrame(renderFrame);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
statusEl.textContent = 'Buffering...';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (state === 'PLAYING') {
|
||||
statusEl.textContent = 'Stream Ended.';
|
||||
statusEl.style.color = '#888';
|
||||
setTimeout(() => resetToIdle(), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
statusEl.textContent = 'Connection Error!';
|
||||
statusEl.style.color = '#ff0000';
|
||||
setTimeout(() => resetToIdle(), 2000);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main render loop using requestAnimationFrame.
|
||||
*/
|
||||
function renderFrame(now) {
|
||||
if (state !== 'PLAYING') return;
|
||||
requestAnimationFrame(renderFrame);
|
||||
|
||||
const elapsed = now - lastRenderTime;
|
||||
if (elapsed < frameInterval) return;
|
||||
|
||||
// FPS Counter
|
||||
frameCount++;
|
||||
if (now - lastFpsUpdate >= 1000) {
|
||||
currentFps = frameCount;
|
||||
frameCount = 0;
|
||||
lastFpsUpdate = now;
|
||||
let modeText = 'B&W';
|
||||
const modes = { 2: '512 Color', 3: '32K Color', 4: '262K Color', 5: '16M Ultra' };
|
||||
modeText = modes[renderMode] || 'B&W';
|
||||
statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${modeText}`;
|
||||
}
|
||||
|
||||
if (frameBuffer.length === 0) return;
|
||||
lastRenderTime = now;
|
||||
|
||||
const frame = frameBuffer.shift();
|
||||
|
||||
if (renderMode === 1) {
|
||||
player.textContent = frame;
|
||||
} else {
|
||||
const view = new Uint8Array(frame);
|
||||
lastFrameView = view;
|
||||
|
||||
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]);
|
||||
col++;
|
||||
if (col >= gridCols) { col = 0; row++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual Effect: Ripple Dissolve
|
||||
* Triggered on click during playback.
|
||||
*/
|
||||
function triggerRipple(clickX, clickY) {
|
||||
if (!lastFrameView || renderMode === 1) {
|
||||
resetToIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
state = 'DISSOLVING';
|
||||
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
||||
|
||||
const fv = lastFrameView;
|
||||
const rippleSpeed = 400;
|
||||
const waveFront = 70;
|
||||
const maxShake = 6;
|
||||
const maxDist = Math.sqrt(canvas.width * canvas.width + canvas.height * canvas.height);
|
||||
const startTime = performance.now();
|
||||
|
||||
statusEl.textContent = '';
|
||||
|
||||
function animateRipple(now) {
|
||||
const elapsed = (now - startTime) / 1000;
|
||||
const radius = elapsed * rippleSpeed;
|
||||
|
||||
if (radius > maxDist + waveFront + 20) {
|
||||
resetToIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = 'bold 8px Courier New';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
let col = 0, row = 0;
|
||||
|
||||
for (let idx = 0; idx < fv.length; idx += 4) {
|
||||
const px = xPos[col];
|
||||
const py = yPos[row];
|
||||
const dx = px - clickX;
|
||||
const dy = py - clickY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist > radius) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = `rgb(${fv[idx+1]},${fv[idx+2]},${fv[idx+3]})`;
|
||||
ctx.fillText(CHAR_LUT[fv[idx]], px, py);
|
||||
} else if (dist > radius - waveFront) {
|
||||
const progress = (radius - dist) / waveFront;
|
||||
const shake = Math.sin(progress * Math.PI) * maxShake;
|
||||
const ox = (Math.random() - 0.5) * shake * 2;
|
||||
const oy = (Math.random() - 0.5) * shake * 2;
|
||||
|
||||
ctx.globalAlpha = 1 - progress;
|
||||
ctx.fillStyle = `rgb(${fv[idx+1]},${fv[idx+2]},${fv[idx+3]})`;
|
||||
ctx.fillText(CHAR_LUT[fv[idx]], px + ox, py + oy);
|
||||
}
|
||||
|
||||
col++;
|
||||
if (col >= gridCols) { col = 0; row++; }
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
requestAnimationFrame(animateRipple);
|
||||
}
|
||||
|
||||
requestAnimationFrame(animateRipple);
|
||||
}
|
||||
|
||||
function resetToIdle() {
|
||||
state = 'IDLE';
|
||||
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
||||
frameBuffer.length = 0;
|
||||
lastRenderTime = 0;
|
||||
lastFrameView = null;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
player.textContent = '';
|
||||
overlay.classList.remove('hidden');
|
||||
statusEl.textContent = 'Ready';
|
||||
statusEl.style.color = 'rgba(255,255,255,0.6)';
|
||||
}
|
||||
|
||||
// ── EVENT LISTENERS ──
|
||||
overlay.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
startStream();
|
||||
});
|
||||
|
||||
container.addEventListener('click', (e) => {
|
||||
if (state !== 'PLAYING') return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const clickX = (e.clientX - rect.left) * scaleX;
|
||||
const clickY = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
triggerRipple(clickX, clickY);
|
||||
});
|
||||
302
ascii_video_player2.py
Normal file
302
ascii_video_player2.py
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
"""
|
||||
ascii_video_player.py
|
||||
=====================
|
||||
Modüler, renkli (True Color / 24-bit ANSI), sıfır titremeli ASCII video oynatıcı.
|
||||
|
||||
- VideoDecoder : Video → (gray, color) kare çifti üretir.
|
||||
- AsciiMapper : Gri matris → ASCII karakter + ANSI True Color kodu → String.
|
||||
- TerminalRenderer: Ana döngü, FPS kontrolü, yön tespiti, render.
|
||||
|
||||
Bağımlılıklar:
|
||||
pip install opencv-python numpy
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import shutil
|
||||
import numpy as np
|
||||
import cv2
|
||||
import os
|
||||
|
||||
# PowerShell/CMD (Windows) üzerinde ANSI renk kodlarını aktif etmek için:
|
||||
os.system("")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# MODÜL 1 ─ VideoDecoder
|
||||
# ─────────────────────────────────────────────
|
||||
class VideoDecoder:
|
||||
"""
|
||||
Video dosyasını açar ve her kare için (gray, bgr) çifti üretir.
|
||||
|
||||
Renkli render için hem gri (karakter seçimi) hem de
|
||||
orijinal BGR (renk örnekleme) matrisine ihtiyaç var.
|
||||
İkisi de aynı resize işleminden geçer → boyut tutarlılığı garantili.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, cols: int, rows: int) -> None:
|
||||
self._cap = cv2.VideoCapture(path)
|
||||
if not self._cap.isOpened():
|
||||
raise FileNotFoundError(f"Video açılamadı: {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)
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
:return: (gray[H,W] uint8, bgr[H,W,3] uint8)
|
||||
"""
|
||||
ok, frame = self._cap.read()
|
||||
if not ok:
|
||||
raise StopIteration
|
||||
|
||||
small = cv2.resize(frame, self._size, interpolation=cv2.INTER_LINEAR)
|
||||
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
|
||||
return gray, small # small = küçültülmüş BGR karesi
|
||||
|
||||
def release(self):
|
||||
self._cap.release()
|
||||
|
||||
def __del__(self):
|
||||
self.release()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# MODÜL 2 ─ AsciiMapper
|
||||
# ─────────────────────────────────────────────
|
||||
class AsciiMapper:
|
||||
"""
|
||||
Gri + BGR matrisini ANSI True Color kodlarıyla renklendirilmiş
|
||||
ASCII çerçeve dizisine dönüştürür.
|
||||
|
||||
── True Color ANSI Formatı ─────────────────────────────────────────────
|
||||
\033[38;2;R;G;Bm{karakter}\033[0m
|
||||
└─ ön plan rengi ─────────┘
|
||||
|
||||
── Renk Kuantizasyonu (Performans Optimizasyonu) ───────────────────────
|
||||
Her piksel için ayrı bir escape kodu üretmek yerine renk değerleri
|
||||
6-bit'e indirilir (>> 2 << 2, 64 seviye/kanal).
|
||||
Bu sayede ardışık aynı renkli pikseller tek bir escape koduyla
|
||||
temsil edilir → string boyutu ve stdout.write yükü azalır.
|
||||
Gözle algılanabilir renk kaybı olmaz (16M → ~262K renk).
|
||||
|
||||
── RLE (Run-Length Encoding) ───────────────────────────────────────────
|
||||
Aynı renkteki ardışık karakterler için escape kodu tekrar yazılmaz;
|
||||
yalnızca renk değiştiğinde yeni kod eklenir.
|
||||
Tipik bir karede %40-60 oranında string küçülmesi sağlar.
|
||||
"""
|
||||
|
||||
DEFAULT_PALETTE = list(
|
||||
" `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@"
|
||||
)
|
||||
|
||||
# ANSI sıfırlama + satır başı
|
||||
_RESET = "\033[0m"
|
||||
|
||||
def __init__(self, palette: list[str] | None = None, quantize_bits: int = 0) -> None:
|
||||
"""
|
||||
:param palette: Karakter listesi (None → 93 seviyeli varsayılan)
|
||||
:param quantize_bits: Renk kuantizasyonu için sağdan kaydırma miktarı.
|
||||
2 → 64 seviye/kanal (hızlı),
|
||||
0 → tam 8-bit (en yüksek kalite, varsayılan).
|
||||
"""
|
||||
p = palette or self.DEFAULT_PALETTE
|
||||
self._n = len(p)
|
||||
self._lut = np.array(p, dtype='U1')
|
||||
self._qb = quantize_bits # kuantizasyon bit kaydırma miktarı
|
||||
|
||||
def convert(self, gray: np.ndarray, bgr: np.ndarray) -> str:
|
||||
"""
|
||||
Her piksel için:
|
||||
1. Gri değeri → ASCII karakter (yoğunluk LUT)
|
||||
2. BGR rengi → ANSI True Color escape kodu (kuantize + RLE)
|
||||
|
||||
:param gray: shape=(H,W) uint8 gri matris
|
||||
:param bgr: shape=(H,W,3) uint8 BGR renk matrisi
|
||||
:return: Terminale doğrudan yazılabilecek renkli ASCII dizesi
|
||||
"""
|
||||
H, W = gray.shape
|
||||
|
||||
# ── Adım 1: Piksel yoğunluğu → karakter indeksi ──────────────────
|
||||
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'
|
||||
|
||||
# ── Adım 2: Renk kuantizasyonu ────────────────────────────────────
|
||||
# BGR → RGB sıralaması (ANSI kodu R,G,B sırasında)
|
||||
rgb = bgr[:, :, ::-1] # BGR → RGB view, kopyasız
|
||||
|
||||
if self._qb > 0:
|
||||
# Düşük bitleri sıfırla → renk hassasiyetini düşür, hızı artır
|
||||
qb = self._qb
|
||||
rgb = (rgb >> qb) << qb # örn. qb=2: 0b11111100 maskeleme
|
||||
|
||||
# ── Adım 3: RLE ile renkli string birleştirme ─────────────────────
|
||||
# Saf NumPy ile RLE yapılamadığından bu kısım Python döngüsüdür.
|
||||
# Ancak satır başına yalnızca renk değişimlerinde escape kodu yazılır;
|
||||
# tekrarlanan renkler için döngü yükü minimize edilir.
|
||||
lines = []
|
||||
prev_r = prev_g = prev_b = -1 # önceki renk (ilk piksel hep farklı)
|
||||
|
||||
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: sadece renk değişince yeni escape kodu ekle
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# MODÜL 3 ─ TerminalRenderer
|
||||
# ─────────────────────────────────────────────
|
||||
class TerminalRenderer:
|
||||
"""
|
||||
VideoDecoder → AsciiMapper → stdout akışını yönetir.
|
||||
|
||||
Ek özellikler (renkli sürüm):
|
||||
- Başlangıçta terminal arka planını siyaha alır (\033[40m)
|
||||
→ renkli karakterler daha belirgin görünür.
|
||||
- Her kare sonunda \033[0m ile renk sıfırlanır
|
||||
→ sonraki terminal komutları etkilenmez.
|
||||
"""
|
||||
|
||||
_CURSOR_HOME = "\033[H"
|
||||
_HIDE_CURSOR = "\033[?25l"
|
||||
_SHOW_CURSOR = "\033[?25h"
|
||||
_BLACK_BG = "\033[40m" # siyah arka plan — kontrast için
|
||||
_RESET_ALL = "\033[0m"
|
||||
_CLEAR_SCREEN = "\033[2J"
|
||||
|
||||
CHAR_RATIO = 0.45 # terminal karakter en-boy oranı düzeltmesi
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path : str,
|
||||
palette : list[str] | None = None,
|
||||
quantize_bits: int = 0,
|
||||
) -> None:
|
||||
"""
|
||||
:param path: Video dosyası yolu
|
||||
:param palette: Özel karakter paleti (None → 93 seviyeli)
|
||||
:param quantize_bits: Renk kuantizasyonu (0=tam kalite, 2=hızlı)
|
||||
"""
|
||||
# ── Video meta bilgisi ────────────────────────────────────────────
|
||||
_probe = VideoDecoder(path, 2, 2)
|
||||
vid_w, vid_h = _probe.vid_w, _probe.vid_h
|
||||
src_fps = _probe.fps
|
||||
_probe.release()
|
||||
|
||||
# ── Terminal boyutları ────────────────────────────────────────────
|
||||
term = shutil.get_terminal_size(fallback=(220, 50))
|
||||
t_cols = term.columns
|
||||
t_lines = term.lines - 2
|
||||
|
||||
# ── Yön tespiti & en-boy oranı korumalı boyutlandırma ─────────────
|
||||
orientation = "portrait" if vid_h > vid_w else "landscape"
|
||||
aspect = vid_h / vid_w
|
||||
|
||||
if orientation == "landscape":
|
||||
cols = t_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 > t_cols:
|
||||
cols = t_cols
|
||||
rows = max(1, int(cols * aspect * self.CHAR_RATIO))
|
||||
|
||||
# ── Bilgi ekranı ──────────────────────────────────────────────────
|
||||
print(self._CLEAR_SCREEN)
|
||||
print(
|
||||
f"\033[1m[ASCII Player — True Color]\033[0m\n"
|
||||
f" Yön : {orientation.upper()}\n"
|
||||
f" Video : {vid_w}×{vid_h}\n"
|
||||
f" ASCII : {cols}×{rows} karakter\n"
|
||||
f" FPS : {src_fps:.1f}\n"
|
||||
f" Kuantizasyon: {2**(8-quantize_bits)} seviye/kanal\n"
|
||||
f" Çıkış : 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:
|
||||
"""Ana oynatma döngüsü."""
|
||||
stdout = sys.stdout
|
||||
|
||||
stdout.write(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)
|
||||
|
||||
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._SHOW_CURSOR + self._RESET_ALL + "\n")
|
||||
stdout.flush()
|
||||
self._decoder.release()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# GİRİŞ NOKTASI
|
||||
# ─────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="True Color ANSI ASCII video oynatıcı — sıfır titreme"
|
||||
)
|
||||
parser.add_argument("video",
|
||||
help="Video dosyası yolu (MP4, AVI, MKV …)")
|
||||
parser.add_argument("--palette", default=None,
|
||||
help="Özel karakter paleti, boşlukla ayrılmış")
|
||||
parser.add_argument("--quality", type=int, choices=[0, 1, 2, 3], default=0,
|
||||
help="Renk kalitesi: 0=maksimum kalite, 3=maksimum hız (varsayılan: 0)")
|
||||
args = parser.parse_args()
|
||||
|
||||
custom_palette = args.palette.split() if args.palette else None
|
||||
|
||||
renderer = TerminalRenderer(
|
||||
path = args.video,
|
||||
palette = custom_palette,
|
||||
quantize_bits = args.quality,
|
||||
)
|
||||
renderer.play()
|
||||
38
index.html
Normal file
38
index.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ASCILINE | Real-Time ASCII Engine</title>
|
||||
|
||||
<!-- Core Styles -->
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Live Status Indicator -->
|
||||
<div id="status" class="status">Ready</div>
|
||||
|
||||
<!-- Header Section -->
|
||||
<h1>ASCILINE</h1>
|
||||
<p class="subtitle">real-time ascii streaming engine</p>
|
||||
|
||||
<!-- Main Player Container -->
|
||||
<div id="player-container">
|
||||
<!-- Text-based rendering (B&W Mode) -->
|
||||
<pre id="ascii-player"></pre>
|
||||
|
||||
<!-- Canvas-based rendering (Color Modes) -->
|
||||
<canvas id="ascii-canvas"></canvas>
|
||||
|
||||
<!-- Tap to Play Interaction Overlay -->
|
||||
<div id="play-overlay">
|
||||
<div class="play-btn"></div>
|
||||
<span class="play-label">Tap to Play</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Engine Logic -->
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
131
stream_server.py
Normal file
131
stream_server.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""
|
||||
stream_server.py
|
||||
================
|
||||
Video to ASCII çekirdek motorunu HTTP/WebSocket üzerinden web'e yayınlar.
|
||||
Bağımlılıklar: pip install fastapi uvicorn websockets
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import numpy as np
|
||||
import cv2
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import uvicorn
|
||||
import os
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
# Mevcut motoru import ediyoruz (ascii_video_player2.py)
|
||||
from ascii_video_player2 import VideoDecoder, AsciiMapper
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Serve static files (style.css, app.js) from the project directory
|
||||
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()
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""İstemciye Frontend (HTML/JS/CSS) dosyasını sunar."""
|
||||
return HTMLResponse(get_html_content())
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""
|
||||
İstemci bağlandığında videoyu decode etmeye başlar,
|
||||
AsciiMapper ile saf ASCII'ye çevirir ve WebSockets üzerinden gönderir.
|
||||
"""
|
||||
await websocket.accept()
|
||||
|
||||
video_path = getattr(app.state, "video_path", "video.mp4")
|
||||
render_mode = getattr(app.state, "render_mode", 1)
|
||||
cols = getattr(app.state, "cols", 200)
|
||||
rows = getattr(app.state, "rows", 80)
|
||||
|
||||
try:
|
||||
decoder = VideoDecoder(video_path, cols, rows)
|
||||
except FileNotFoundError:
|
||||
await websocket.send_text("Hata: Video dosyası bulunamadı!")
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
mapper = AsciiMapper()
|
||||
fps = decoder.fps
|
||||
frame_t = 1.0 / fps
|
||||
|
||||
# Karakter → byte kodu lookup tablosu (binary format için)
|
||||
char_byte_lut = np.array([ord(c) for c in mapper._lut], dtype=np.uint8)
|
||||
|
||||
# Kuantizasyon seviyesini bir kere belirle (render_mode sabit)
|
||||
qb = {5: 0, 4: 2, 3: 3, 2: 5}.get(render_mode, 0)
|
||||
|
||||
# İstemciye meta bilgilerini yolla (cols/rows grid oluşturmak için)
|
||||
await websocket.send_text(f"INIT:{fps}:{render_mode}:{cols}:{rows}")
|
||||
|
||||
try:
|
||||
# Decoder iteratörü her kare için (gray, bgr) döndürüyor
|
||||
# Binary frame buffer'ı önceden ayır (GC baskısını azalt)
|
||||
frame_buf = np.empty((rows, cols, 4), dtype=np.uint8) if render_mode > 1 else None
|
||||
|
||||
for gray_frame, bgr_frame in decoder:
|
||||
t0 = asyncio.get_event_loop().time()
|
||||
|
||||
# Ortak: yoğunluk → karakter indeksi
|
||||
indices = np.floor_divide(gray_frame, max(1, 256 // mapper._n))
|
||||
np.clip(indices, 0, mapper._n - 1, out=indices)
|
||||
|
||||
if render_mode == 1:
|
||||
# --- SAF ASCII DÖNÜŞÜMÜ (text) ---
|
||||
char_matrix = mapper._lut[indices]
|
||||
lines = [''.join(row) for row in char_matrix]
|
||||
await websocket.send_text('\n'.join(lines))
|
||||
else:
|
||||
# --- RENKLİ BINARY DÖNÜŞÜMÜ (numpy, sıfır Python döngüsü) ---
|
||||
H, W = gray_frame.shape
|
||||
char_codes = char_byte_lut[indices] # (H,W) uint8
|
||||
|
||||
rgb = bgr_frame[:, :, ::-1] # BGR → RGB
|
||||
if qb > 0:
|
||||
rgb = (rgb >> qb) << qb
|
||||
|
||||
# [char, R, G, B] interleaved binary frame
|
||||
frame_buf[:, :, 0] = char_codes
|
||||
frame_buf[:, :, 1:] = rgb
|
||||
|
||||
await websocket.send_bytes(frame_buf.tobytes())
|
||||
|
||||
elapsed = asyncio.get_event_loop().time() - t0
|
||||
wait = frame_t - elapsed
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
except (WebSocketDisconnect, ConnectionClosed):
|
||||
print("İstemci yayından ayrıldı.")
|
||||
finally:
|
||||
decoder.release()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Gerçek Zamanlı ASCII Web Sunucusu")
|
||||
parser.add_argument("video", help="Yayınlanacak video dosyası", default="video.mp4", nargs='?')
|
||||
parser.add_argument("--port", type=int, default=8000, help="Sunucu portu")
|
||||
parser.add_argument("--mode", type=int, choices=[1, 2, 3, 4, 5], default=1, help="Render Modu: 1=B&W, 2=512renk, 3=32K, 4=262K, 5=16M Ultra")
|
||||
parser.add_argument("--cols", type=int, default=200, help="Terminal kolon genişliği")
|
||||
parser.add_argument("--rows", type=int, default=80, help="Terminal satır yüksekliği")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Argümanları global olarak state içine kaydet
|
||||
app.state.video_path = args.video
|
||||
app.state.render_mode = args.mode
|
||||
app.state.cols = args.cols
|
||||
app.state.rows = args.rows
|
||||
|
||||
print(f"[{args.video}] yayınlanmaya hazır. Mod: {args.mode}, Çöz: {args.cols}x{args.rows}")
|
||||
print(f"Sunucu başlatılıyor... Lütfen tarayıcınızdan http://localhost:{args.port} adresine gidin.")
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=args.port, ws_ping_interval=None, ws_ping_timeout=None)
|
||||
167
style.css
Normal file
167
style.css
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
ASCILINE ENGINE - Core Styles
|
||||
=========================================
|
||||
Feel free to customize the look using the variables below.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg-color: #050505;
|
||||
--accent-color: #00ff41;
|
||||
--accent-glow: rgba(0, 255, 65, 0.3);
|
||||
--text-muted: rgba(255, 255, 255, 0.4);
|
||||
--player-bg: #030303;
|
||||
--font-main: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--accent-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-main);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── UI ELEMENTS ─────────────────────────── */
|
||||
|
||||
.status {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--accent-color);
|
||||
font-size: 18px;
|
||||
letter-spacing: 6px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 3px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ── PLAYER CONTAINER ─────────────────────────── */
|
||||
|
||||
#player-container {
|
||||
position: relative;
|
||||
border: 1px solid rgba(0, 255, 65, 0.2);
|
||||
box-shadow: 0 0 30px rgba(0, 255, 65, 0.06),
|
||||
inset 0 0 30px rgba(0, 0, 0, 0.5);
|
||||
background: var(--player-bg);
|
||||
cursor: pointer;
|
||||
min-width: 500px;
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.4s ease, box-shadow 0.4s ease;
|
||||
}
|
||||
|
||||
#player-container:hover {
|
||||
border-color: rgba(0, 255, 65, 0.4);
|
||||
box-shadow: 0 0 40px rgba(0, 255, 65, 0.1),
|
||||
inset 0 0 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#ascii-player {
|
||||
font-family: var(--font-main);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
font-size: 8px;
|
||||
line-height: 8px;
|
||||
letter-spacing: 0;
|
||||
font-weight: bold;
|
||||
contain: content;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#ascii-canvas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── OVERLAYS ───────────────────────── */
|
||||
|
||||
#play-overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(3, 3, 3, 0.97);
|
||||
z-index: 10;
|
||||
transition: opacity 0.5s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#play-overlay.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 2px solid rgba(0, 255, 65, 0.5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
animation: pulse-glow 2.5s ease-in-out infinite;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
transform: scale(1.08);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.play-btn::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 12px 0 12px 22px;
|
||||
border-color: transparent transparent transparent var(--accent-color);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.play-label {
|
||||
color: rgba(0, 255, 65, 0.5);
|
||||
font-size: 11px;
|
||||
letter-spacing: 5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(0, 255, 65, 0.3),
|
||||
0 0 15px rgba(0, 255, 65, 0.05);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 12px rgba(0, 255, 65, 0),
|
||||
0 0 25px rgba(0, 255, 65, 0.12);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue