mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-20 22:38:06 +02:00
feat: add invisible selection layer, audio streaming, and updated manifesto for pure performance mode
This commit is contained in:
parent
e7002173c2
commit
2043a7bb37
6 changed files with 396 additions and 244 deletions
10
README.md
10
README.md
|
|
@ -8,7 +8,7 @@
|
||||||
<br>
|
<br>
|
||||||
<img src="https://github.com/user-attachments/assets/6bd7f5c0-81de-49fe-ba0d-9a8872ec8ae3" width="600" alt="Animation-after" />
|
<img src="https://github.com/user-attachments/assets/6bd7f5c0-81de-49fe-ba0d-9a8872ec8ae3" width="600" alt="Animation-after" />
|
||||||
<br>
|
<br>
|
||||||
<sub><i>* Showcases rendered using Mode 3 (32K Colors)</i></sub>
|
<sub><i>* Showcases rendered using Mode 3 (32K Colors) from a 30 FPS source video. The engine naturally synchronizes up to 60+ FPS depending on the source material.</i></sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 🎯 Strategic Vision & Core Capabilities
|
## 🎯 Strategic Vision & Core Capabilities
|
||||||
|
|
@ -45,13 +45,19 @@ cd ASCILINE
|
||||||
pip install fastapi uvicorn opencv-python numpy websockets
|
pip install fastapi uvicorn opencv-python numpy websockets
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Run the engine
|
### 3. Run the Web Server
|
||||||
Place a `video.mp4` in the root directory and start the server:
|
Place a `video.mp4` in the root directory and start the server:
|
||||||
```bash
|
```bash
|
||||||
python stream_server.py
|
python stream_server.py
|
||||||
```
|
```
|
||||||
Open `http://localhost:8000` in your browser.
|
Open `http://localhost:8000` in your browser.
|
||||||
|
|
||||||
|
### 4. Run directly in Terminal (Standalone)
|
||||||
|
If you prefer to bypass the web interface, you can render the video directly inside an ANSI-supported terminal (zero-flicker, true color):
|
||||||
|
```bash
|
||||||
|
python ascii_video_player2.py video.mp4 --quality 0
|
||||||
|
```
|
||||||
|
|
||||||
## 🎨 Customization
|
## 🎨 Customization
|
||||||
|
|
||||||
You can easily customize the look and feel of the engine:
|
You can easily customize the look and feel of the engine:
|
||||||
|
|
|
||||||
232
app.js
232
app.js
|
|
@ -1,8 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* ASCILINE ENGINE - Core Logic
|
* ASCILINE ENGINE - Pure & Performant Logic
|
||||||
* =========================================
|
* =========================================
|
||||||
* Handles WebSocket communication, frame buffering,
|
* No decorative animations. Pure WebSocket streaming
|
||||||
* and dual-mode rendering (Canvas/DOM).
|
* and high-performance canvas rendering.
|
||||||
|
* Includes an "Invisible Selection Layer" for text selection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const player = document.getElementById('ascii-player');
|
const player = document.getElementById('ascii-player');
|
||||||
|
|
@ -11,100 +12,122 @@ const ctx = canvas.getContext('2d');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const container = document.getElementById('player-container');
|
const container = document.getElementById('player-container');
|
||||||
const overlay = document.getElementById('play-overlay');
|
const overlay = document.getElementById('play-overlay');
|
||||||
|
const audioEl = document.getElementById('ascii-audio');
|
||||||
|
const volumeSlider = document.getElementById('volume-slider');
|
||||||
|
|
||||||
// ── STATE ──
|
// ── STATE ──
|
||||||
let state = 'IDLE'; // IDLE | PLAYING | DISSOLVING
|
let state = 'IDLE'; // IDLE | PLAYING
|
||||||
let ws = null;
|
let ws = null;
|
||||||
const frameBuffer = [];
|
const frameBuffer = [];
|
||||||
const BUFFER_SIZE = 4;
|
const BUFFER_SIZE = 4;
|
||||||
let targetFps = 24;
|
let targetFps = 24;
|
||||||
let frameInterval = 1000 / targetFps;
|
let frameInterval = 1000 / targetFps;
|
||||||
let renderMode = 1;
|
let renderMode = 1;
|
||||||
|
let readyToRender = false;
|
||||||
|
|
||||||
// Grid & Dimensions
|
// Grid & Dimensions
|
||||||
let gridCols = 0, gridRows = 0;
|
let gridCols = 0, gridRows = 0;
|
||||||
let charWidth = 0, charHeight = 0;
|
let charWidth = 0, charHeight = 0;
|
||||||
let xPos = null, yPos = null;
|
let xPos = null, yPos = null;
|
||||||
|
|
||||||
|
// Selection Layer optimization
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
let selectionBuffer = null;
|
||||||
|
|
||||||
// Timing & Metrics
|
// Timing & Metrics
|
||||||
let lastRenderTime = 0;
|
let lastRenderTime = 0;
|
||||||
let frameCount = 0, currentFps = 0, lastFpsUpdate = 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);
|
const CHAR_LUT = new Array(128);
|
||||||
for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i);
|
for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i);
|
||||||
|
|
||||||
/**
|
// ═══════════════════════════════════════
|
||||||
* Pre-calculates positions and scales canvas for high-performance rendering.
|
// CANVAS SETUP
|
||||||
*/
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
function buildCanvas(cols, rows) {
|
function buildCanvas(cols, rows) {
|
||||||
gridCols = cols;
|
gridCols = cols;
|
||||||
gridRows = rows;
|
gridRows = rows;
|
||||||
ctx.font = 'bold 8px Courier New';
|
ctx.font = 'bold 8px Courier New';
|
||||||
charWidth = Math.ceil(ctx.measureText('M').width);
|
charWidth = Math.ceil(ctx.measureText('M').width);
|
||||||
charHeight = 8;
|
charHeight = 8;
|
||||||
|
|
||||||
canvas.width = cols * charWidth;
|
canvas.width = cols * charWidth;
|
||||||
canvas.height = rows * charHeight;
|
canvas.height = rows * charHeight;
|
||||||
canvas.style.display = 'block';
|
canvas.style.display = 'block';
|
||||||
player.style.display = 'none';
|
|
||||||
|
|
||||||
container.style.minWidth = canvas.width + 'px';
|
// Selection Layer Buffer
|
||||||
container.style.minHeight = canvas.height + 'px';
|
selectionBuffer = new Uint8Array((cols + 1) * rows);
|
||||||
|
for (let r = 0; r < rows; r++) selectionBuffer[r * (cols + 1) + cols] = 10; // Newline (\n)
|
||||||
|
|
||||||
|
// Sizing and positioning for both layers
|
||||||
|
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';
|
||||||
|
};
|
||||||
|
|
||||||
|
syncSize(canvas);
|
||||||
|
syncSize(player);
|
||||||
|
|
||||||
ctx.font = 'bold 8px Courier New';
|
ctx.font = 'bold 8px Courier New';
|
||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
xPos = new Float32Array(cols);
|
xPos = new Float32Array(cols);
|
||||||
yPos = new Float32Array(rows);
|
yPos = new Float32Array(rows);
|
||||||
for (let c = 0; c < cols; c++) xPos[c] = c * charWidth;
|
for (let c = 0; c < cols; c++) xPos[c] = c * charWidth;
|
||||||
for (let r = 0; r < rows; r++) yPos[r] = r * charHeight;
|
for (let r = 0; r < rows; r++) yPos[r] = r * charHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ═══════════════════════════════════════
|
||||||
* Initiates WebSocket connection and stream handling.
|
// STREAM CONTROL
|
||||||
*/
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
function startStream() {
|
function startStream() {
|
||||||
if (state !== 'IDLE') return;
|
if (state !== 'IDLE') return;
|
||||||
state = 'PLAYING';
|
|
||||||
overlay.classList.add('hidden');
|
overlay.classList.add('hidden');
|
||||||
statusEl.textContent = 'Connecting...';
|
statusEl.textContent = 'Connecting...';
|
||||||
statusEl.style.color = 'var(--accent-color)';
|
statusEl.style.color = 'var(--accent-color)';
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWebSocket() {
|
||||||
frameBuffer.length = 0;
|
frameBuffer.length = 0;
|
||||||
lastFrameView = null;
|
|
||||||
frameCount = 0;
|
frameCount = 0;
|
||||||
currentFps = 0;
|
currentFps = 0;
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
if (audioEl) {
|
||||||
|
audioEl.src = '/audio?' + Date.now();
|
||||||
|
audioEl.volume = volumeSlider ? volumeSlider.value : 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||||
ws.binaryType = 'arraybuffer';
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
if (state !== 'PLAYING') return;
|
|
||||||
|
|
||||||
if (typeof event.data === 'string') {
|
if (typeof event.data === 'string') {
|
||||||
if (event.data.startsWith('Error:')) {
|
if (event.data.startsWith('Error:')) {
|
||||||
statusEl.textContent = event.data;
|
statusEl.textContent = event.data;
|
||||||
statusEl.style.color = '#ff0000';
|
statusEl.style.color = '#ff0000';
|
||||||
state = 'IDLE';
|
|
||||||
if (ws) ws.close();
|
if (ws) ws.close();
|
||||||
setTimeout(() => resetToIdle(), 3000);
|
setTimeout(() => finishStream(), 3000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.data.startsWith('INIT:')) {
|
if (event.data.startsWith('INIT:')) {
|
||||||
const p = event.data.split(':');
|
const p = event.data.split(':');
|
||||||
targetFps = parseFloat(p[1]);
|
targetFps = parseFloat(p[1]);
|
||||||
frameInterval = 1000 / targetFps;
|
frameInterval = 1000 / targetFps;
|
||||||
renderMode = parseInt(p[2]);
|
renderMode = parseInt(p[2]);
|
||||||
if (renderMode > 1) {
|
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
||||||
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
|
||||||
} else {
|
if (audioEl) audioEl.play().catch(() => {});
|
||||||
player.style.display = 'block';
|
readyToRender = true;
|
||||||
canvas.style.display = 'none';
|
state = 'PLAYING';
|
||||||
}
|
lastRenderTime = performance.now();
|
||||||
|
lastFpsUpdate = lastRenderTime;
|
||||||
|
requestAnimationFrame(renderFrame);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
frameBuffer.push(event.data);
|
frameBuffer.push(event.data);
|
||||||
|
|
@ -112,39 +135,31 @@ function startStream() {
|
||||||
frameBuffer.push(event.data);
|
frameBuffer.push(event.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buffer Overflow Protection
|
|
||||||
while (frameBuffer.length > BUFFER_SIZE * 3) frameBuffer.shift();
|
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 = () => {
|
ws.onopen = () => { statusEl.textContent = 'Buffering...'; };
|
||||||
statusEl.textContent = 'Buffering...';
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (state === 'PLAYING') {
|
if (state === 'PLAYING') {
|
||||||
statusEl.textContent = 'Stream Ended.';
|
statusEl.textContent = 'Stream Ended.';
|
||||||
statusEl.style.color = '#888';
|
statusEl.style.color = '#888';
|
||||||
setTimeout(() => resetToIdle(), 1500);
|
if (audioEl) audioEl.pause();
|
||||||
|
setTimeout(() => finishStream(), 800);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
statusEl.textContent = 'Connection Error!';
|
statusEl.textContent = 'Connection Error!';
|
||||||
statusEl.style.color = '#ff0000';
|
statusEl.style.color = '#ff0000';
|
||||||
setTimeout(() => resetToIdle(), 2000);
|
setTimeout(() => finishStream(), 2000);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ═══════════════════════════════════════
|
||||||
* Main render loop using requestAnimationFrame.
|
// RENDER LOOP
|
||||||
*/
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
function renderFrame(now) {
|
function renderFrame(now) {
|
||||||
if (state !== 'PLAYING') return;
|
if (state !== 'PLAYING') return;
|
||||||
requestAnimationFrame(renderFrame);
|
requestAnimationFrame(renderFrame);
|
||||||
|
|
@ -152,29 +167,27 @@ function renderFrame(now) {
|
||||||
const elapsed = now - lastRenderTime;
|
const elapsed = now - lastRenderTime;
|
||||||
if (elapsed < frameInterval) return;
|
if (elapsed < frameInterval) return;
|
||||||
|
|
||||||
// FPS Counter
|
|
||||||
frameCount++;
|
frameCount++;
|
||||||
if (now - lastFpsUpdate >= 1000) {
|
if (now - lastFpsUpdate >= 1000) {
|
||||||
currentFps = frameCount;
|
currentFps = frameCount;
|
||||||
frameCount = 0;
|
frameCount = 0;
|
||||||
lastFpsUpdate = now;
|
lastFpsUpdate = now;
|
||||||
let modeText = 'B&W';
|
|
||||||
const modes = { 2: '512 Color', 3: '32K Color', 4: '262K Color', 5: '16M Ultra' };
|
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} | ${modes[renderMode] || 'B&W'}`;
|
||||||
statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${modeText}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frameBuffer.length === 0) return;
|
if (frameBuffer.length === 0) return;
|
||||||
lastRenderTime = now;
|
lastRenderTime = now;
|
||||||
|
|
||||||
const frame = frameBuffer.shift();
|
const frame = frameBuffer.shift();
|
||||||
|
|
||||||
if (renderMode === 1) {
|
if (renderMode === 1) {
|
||||||
|
player.style.display = 'block';
|
||||||
|
player.style.color = '#fff';
|
||||||
player.textContent = frame;
|
player.textContent = frame;
|
||||||
} else {
|
} else {
|
||||||
const view = new Uint8Array(frame);
|
const view = new Uint8Array(frame);
|
||||||
lastFrameView = view;
|
|
||||||
|
// 1. Draw Canvas (Background)
|
||||||
ctx.fillStyle = '#050505';
|
ctx.fillStyle = '#050505';
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.font = 'bold 8px Courier New';
|
ctx.font = 'bold 8px Courier New';
|
||||||
|
|
@ -188,94 +201,37 @@ function renderFrame(now) {
|
||||||
prevPacked = packed;
|
prevPacked = packed;
|
||||||
}
|
}
|
||||||
ctx.fillText(CHAR_LUT[view[idx]], xPos[col], yPos[row]);
|
ctx.fillText(CHAR_LUT[view[idx]], xPos[col], yPos[row]);
|
||||||
col++;
|
|
||||||
if (col >= gridCols) { col = 0; row++; }
|
// Fill Selection Buffer (char code is at view[idx])
|
||||||
}
|
selectionBuffer[row * (gridCols + 1) + col] = view[idx];
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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++;
|
col++;
|
||||||
if (col >= gridCols) { col = 0; row++; }
|
if (col >= gridCols) { col = 0; row++; }
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.globalAlpha = 1;
|
// 2. Update Selection Layer (Foreground)
|
||||||
requestAnimationFrame(animateRipple);
|
player.style.display = 'block';
|
||||||
|
player.style.color = 'transparent';
|
||||||
|
player.textContent = textDecoder.decode(selectionBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(animateRipple);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetToIdle() {
|
// ═══════════════════════════════════════
|
||||||
|
// CLEANUP
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
function finishStream() {
|
||||||
state = 'IDLE';
|
state = 'IDLE';
|
||||||
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
||||||
frameBuffer.length = 0;
|
if (audioEl) { audioEl.pause(); audioEl.src = ''; }
|
||||||
lastRenderTime = 0;
|
|
||||||
lastFrameView = null;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
player.textContent = '';
|
player.textContent = '';
|
||||||
|
player.style.display = 'none';
|
||||||
overlay.classList.remove('hidden');
|
overlay.classList.remove('hidden');
|
||||||
statusEl.textContent = 'Ready';
|
statusEl.textContent = 'Ready';
|
||||||
statusEl.style.color = 'rgba(255,255,255,0.6)';
|
statusEl.style.color = 'rgba(255,255,255,0.6)';
|
||||||
|
readyToRender = false;
|
||||||
|
frameBuffer.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── EVENT LISTENERS ──
|
// ── EVENT LISTENERS ──
|
||||||
|
|
@ -284,14 +240,18 @@ overlay.addEventListener('click', (e) => {
|
||||||
startStream();
|
startStream();
|
||||||
});
|
});
|
||||||
|
|
||||||
container.addEventListener('click', (e) => {
|
if (volumeSlider) {
|
||||||
if (state !== 'PLAYING') return;
|
volumeSlider.addEventListener('input', () => {
|
||||||
|
if (audioEl) audioEl.volume = volumeSlider.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
window.addEventListener('resize', () => {
|
||||||
const scaleX = canvas.width / rect.width;
|
const syncSize = (el) => {
|
||||||
const scaleY = canvas.height / rect.height;
|
if (!el) return;
|
||||||
const clickX = (e.clientX - rect.left) * scaleX;
|
el.style.width = container.clientWidth + 'px';
|
||||||
const clickY = (e.clientY - rect.top) * scaleY;
|
el.style.height = container.clientHeight + 'px';
|
||||||
|
};
|
||||||
triggerRipple(clickX, clickY);
|
syncSize(canvas);
|
||||||
|
syncSize(player);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
BIN
bg.png
Normal file
BIN
bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 MiB |
84
index.html
84
index.html
|
|
@ -1,38 +1,82 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ASCILINE | Real-Time ASCII Engine</title>
|
<title>ASCILINE - Dynamic Typography Engine</title>
|
||||||
|
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<!-- Core Styles -->
|
<!-- Core Styles -->
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- Live Status Indicator -->
|
|
||||||
<div id="status" class="status">Ready</div>
|
|
||||||
|
|
||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<h1>ASCILINE</h1>
|
<header class="blog-header">
|
||||||
<p class="subtitle">real-time ascii streaming engine</p>
|
<h1>ASCILINE</h1>
|
||||||
|
<p>Turning the web into a living, breathing typographic canvas.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- Main Player Container -->
|
<!-- Main Content -->
|
||||||
<div id="player-container">
|
<main class="blog-container">
|
||||||
<!-- Text-based rendering (B&W Mode) -->
|
<article class="blog-post">
|
||||||
<pre id="ascii-player"></pre>
|
<h2 class="post-title">The Web Was Never Meant to Be Static</h2>
|
||||||
|
<div class="post-meta">Manifesto // May 4, 2026 // ASCILINE Core Team</div>
|
||||||
<!-- Canvas-based rendering (Color Modes) -->
|
|
||||||
<canvas id="ascii-canvas"></canvas>
|
|
||||||
|
|
||||||
<!-- Tap to Play Interaction Overlay -->
|
<p class="post-content">
|
||||||
<div id="play-overlay">
|
Every character on this page is potential energy. The web was built on text — HTML,
|
||||||
<div class="play-btn"></div>
|
the very skeleton of every site you visit, is nothing but structured characters. Yet
|
||||||
<span class="play-label">Tap to Play</span>
|
somewhere along the way, we forgot that text itself can be the medium, not just the
|
||||||
</div>
|
message. ASCILINE exists to remind us: every glyph is a pixel waiting to move.
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
|
<!-- Video Wrapper -->
|
||||||
|
<div class="video-wrapper">
|
||||||
|
<div id="status" class="status">System Ready</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Interaction Overlay -->
|
||||||
|
<div id="play-overlay">
|
||||||
|
<div class="play-btn"></div>
|
||||||
|
<span class="play-label">Initialize Uplink</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Controls Bar -->
|
||||||
|
<div class="player-controls">
|
||||||
|
<!-- Volume Control -->
|
||||||
|
<div class="ctrl-group">
|
||||||
|
<span class="ctrl-icon">VOL_</span>
|
||||||
|
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="0.8">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden Audio Element -->
|
||||||
|
<audio id="ascii-audio" preload="none"></audio>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="post-content">
|
||||||
|
This is not a video player in the traditional sense. There is no <video> tag here,
|
||||||
|
no compressed binary stream decoded by your GPU. Instead, a WebSocket pushes raw
|
||||||
|
character data at 24+ frames per second, and your browser renders it as pure text —
|
||||||
|
the same primitive that built the entire internet. The web becomes dynamic not through
|
||||||
|
heavier payloads, but by rethinking what was already there.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- Core Engine Logic -->
|
<!-- Core Engine Logic -->
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -6,10 +6,11 @@ Dependencies: pip install fastapi uvicorn websockets
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import subprocess
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import cv2
|
import cv2
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import os
|
import os
|
||||||
|
|
@ -34,6 +35,51 @@ async def root():
|
||||||
"""Serves the Frontend (HTML/JS/CSS) file to the client."""
|
"""Serves the Frontend (HTML/JS/CSS) file to the client."""
|
||||||
return HTMLResponse(get_html_content())
|
return HTMLResponse(get_html_content())
|
||||||
|
|
||||||
|
@app.get("/audio")
|
||||||
|
async def audio_stream():
|
||||||
|
"""
|
||||||
|
Extracts and streams audio from the video file using ffmpeg.
|
||||||
|
Returns an MP3 audio stream that the browser can play natively.
|
||||||
|
"""
|
||||||
|
video_path = getattr(app.state, "video_path", "video.mp4")
|
||||||
|
|
||||||
|
if not os.path.exists(video_path):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="Video file not found")
|
||||||
|
|
||||||
|
def audio_generator():
|
||||||
|
# Use ffmpeg to extract audio as MP3 stream
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-i", video_path,
|
||||||
|
"-vn", # No video
|
||||||
|
"-acodec", "libmp3lame",
|
||||||
|
"-ab", "128k", # 128kbps bitrate
|
||||||
|
"-ar", "44100", # Sample rate
|
||||||
|
"-f", "mp3", # Output format
|
||||||
|
"-loglevel", "quiet",
|
||||||
|
"pipe:1" # Output to stdout
|
||||||
|
],
|
||||||
|
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")
|
@app.websocket("/ws")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
266
style.css
266
style.css
|
|
@ -1,16 +1,18 @@
|
||||||
/*
|
/*
|
||||||
ASCILINE ENGINE - Core Styles
|
ASCILINE ENGINE - Minimal Blog Theme
|
||||||
=========================================
|
=========================================
|
||||||
Feel free to customize the look using the variables below.
|
Pure, performant, zero-decoration.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color: #050505;
|
--bg-color: #0a0a0c;
|
||||||
--accent-color: #00ff41;
|
--text-color: #e0e0e0;
|
||||||
--accent-glow: rgba(0, 255, 65, 0.3);
|
--accent-color: #00f3ff;
|
||||||
--text-muted: rgba(255, 255, 255, 0.4);
|
--accent-secondary: #39ff14;
|
||||||
--player-bg: #030303;
|
--post-bg: #121217;
|
||||||
--font-main: 'Courier New', monospace;
|
--font-main: 'Outfit', sans-serif;
|
||||||
|
--font-tech: 'Courier New', monospace;
|
||||||
|
--player-bg: #050505;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
@ -21,120 +23,165 @@
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: var(--font-main);
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HEADER ───────────────────────── */
|
||||||
|
.blog-header {
|
||||||
|
background-color: #000;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
|
padding: 80px 20px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-header h1 {
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-header p {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 300;
|
||||||
|
opacity: 0.7;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── MAIN CONTENT ─────────────────── */
|
||||||
|
.blog-container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 60px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post {
|
||||||
|
background: var(--post-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 40px;
|
||||||
|
border: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--accent-secondary);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
border-left: 4px solid var(--accent-secondary);
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #bbb;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── VIDEO WRAPPER ────────────────── */
|
||||||
|
.video-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
margin: 50px 0;
|
||||||
min-height: 100vh;
|
|
||||||
font-family: var(--font-main);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── UI ELEMENTS ─────────────────────────── */
|
|
||||||
|
|
||||||
.status {
|
.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;
|
font-size: 11px;
|
||||||
letter-spacing: 3px;
|
color: var(--accent-color);
|
||||||
margin-bottom: 24px;
|
margin-bottom: 12px;
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── PLAYER CONTAINER ─────────────────────────── */
|
/* ── PLAYER CONTAINER ──────────────── */
|
||||||
|
|
||||||
#player-container {
|
#player-container {
|
||||||
position: relative;
|
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);
|
background: var(--player-bg);
|
||||||
cursor: pointer;
|
border-radius: 4px;
|
||||||
min-width: 500px;
|
width: 860px;
|
||||||
min-height: 280px;
|
height: 560px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: border-color 0.4s ease, box-shadow 0.4s ease;
|
border: 1px solid #333;
|
||||||
}
|
|
||||||
|
|
||||||
#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 {
|
#ascii-player {
|
||||||
font-family: var(--font-main);
|
font-family: 'Courier New', monospace;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
line-height: 8px;
|
line-height: 8px;
|
||||||
letter-spacing: 0;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
contain: content;
|
color: transparent;
|
||||||
|
/* Selection text is transparent */
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: auto;
|
||||||
display: none;
|
display: none;
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ascii-canvas {
|
#ascii-canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── OVERLAYS ───────────────────────── */
|
/* ── OVERLAYS ───────────────────────── */
|
||||||
|
|
||||||
#play-overlay {
|
#play-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(3, 3, 3, 0.97);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
transition: opacity 0.5s ease;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#play-overlay.hidden {
|
#play-overlay.hidden {
|
||||||
opacity: 0;
|
display: none;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn {
|
.play-btn {
|
||||||
width: 72px;
|
width: 60px;
|
||||||
height: 72px;
|
height: 60px;
|
||||||
border: 2px solid rgba(0, 255, 65, 0.5);
|
border: 3px solid var(--accent-color);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 15px;
|
||||||
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 {
|
.play-btn::after {
|
||||||
|
|
@ -143,25 +190,74 @@ h1 {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 12px 0 12px 22px;
|
border-width: 10px 0 10px 18px;
|
||||||
border-color: transparent transparent transparent var(--accent-color);
|
border-color: transparent transparent transparent var(--accent-color);
|
||||||
margin-left: 4px;
|
margin-left: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-label {
|
.play-label {
|
||||||
color: rgba(0, 255, 65, 0.5);
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
letter-spacing: 5px;
|
letter-spacing: 2px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-glow {
|
/* ── PLAYER CONTROLS BAR ───────────────── */
|
||||||
0%, 100% {
|
.player-controls {
|
||||||
box-shadow: 0 0 0 0 rgba(0, 255, 65, 0.3),
|
display: flex;
|
||||||
0 0 15px rgba(0, 255, 65, 0.05);
|
align-items: center;
|
||||||
}
|
gap: 12px;
|
||||||
50% {
|
margin-top: 10px;
|
||||||
box-shadow: 0 0 0 12px rgba(0, 255, 65, 0),
|
padding: 8px 12px;
|
||||||
0 0 25px rgba(0, 255, 65, 0.12);
|
background: #1a1a1a;
|
||||||
}
|
border-radius: 8px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ctrl-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styled range slider */
|
||||||
|
#volume-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: #444;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#volume-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#volume-slider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue