mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-17 22:35:13 +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>
|
||||
<img src="https://github.com/user-attachments/assets/6bd7f5c0-81de-49fe-ba0d-9a8872ec8ae3" width="600" alt="Animation-after" />
|
||||
<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>
|
||||
|
||||
## 🎯 Strategic Vision & Core Capabilities
|
||||
|
|
@ -45,13 +45,19 @@ cd ASCILINE
|
|||
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:
|
||||
```bash
|
||||
python stream_server.py
|
||||
```
|
||||
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
|
||||
|
||||
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,
|
||||
* and dual-mode rendering (Canvas/DOM).
|
||||
* No decorative animations. Pure WebSocket streaming
|
||||
* and high-performance canvas rendering.
|
||||
* Includes an "Invisible Selection Layer" for text selection.
|
||||
*/
|
||||
|
||||
const player = document.getElementById('ascii-player');
|
||||
|
|
@ -11,100 +12,122 @@ 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 | DISSOLVING
|
||||
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 readyToRender = false;
|
||||
|
||||
// Grid & Dimensions
|
||||
let gridCols = 0, gridRows = 0;
|
||||
let charWidth = 0, charHeight = 0;
|
||||
let xPos = null, yPos = null;
|
||||
|
||||
// Selection Layer optimization
|
||||
const textDecoder = new TextDecoder();
|
||||
let selectionBuffer = 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.
|
||||
*/
|
||||
// ═══════════════════════════════════════
|
||||
// CANVAS SETUP
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
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';
|
||||
// Selection Layer Buffer
|
||||
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.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.
|
||||
*/
|
||||
// ═══════════════════════════════════════
|
||||
// STREAM CONTROL
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
function startStream() {
|
||||
if (state !== 'IDLE') return;
|
||||
state = 'PLAYING';
|
||||
overlay.classList.add('hidden');
|
||||
statusEl.textContent = 'Connecting...';
|
||||
statusEl.style.color = 'var(--accent-color)';
|
||||
connectWebSocket();
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
frameBuffer.length = 0;
|
||||
lastFrameView = null;
|
||||
frameCount = 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.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (state !== 'PLAYING') return;
|
||||
|
||||
if (typeof event.data === 'string') {
|
||||
if (event.data.startsWith('Error:')) {
|
||||
statusEl.textContent = event.data;
|
||||
statusEl.style.color = '#ff0000';
|
||||
state = 'IDLE';
|
||||
if (ws) ws.close();
|
||||
setTimeout(() => resetToIdle(), 3000);
|
||||
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]);
|
||||
if (renderMode > 1) {
|
||||
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
||||
} else {
|
||||
player.style.display = 'block';
|
||||
canvas.style.display = 'none';
|
||||
}
|
||||
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
||||
|
||||
if (audioEl) audioEl.play().catch(() => {});
|
||||
readyToRender = true;
|
||||
state = 'PLAYING';
|
||||
lastRenderTime = performance.now();
|
||||
lastFpsUpdate = lastRenderTime;
|
||||
requestAnimationFrame(renderFrame);
|
||||
return;
|
||||
}
|
||||
frameBuffer.push(event.data);
|
||||
|
|
@ -112,39 +135,31 @@ function startStream() {
|
|||
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.onopen = () => { statusEl.textContent = 'Buffering...'; };
|
||||
|
||||
ws.onclose = () => {
|
||||
if (state === 'PLAYING') {
|
||||
statusEl.textContent = 'Stream Ended.';
|
||||
statusEl.style.color = '#888';
|
||||
setTimeout(() => resetToIdle(), 1500);
|
||||
if (audioEl) audioEl.pause();
|
||||
setTimeout(() => finishStream(), 800);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
statusEl.textContent = 'Connection Error!';
|
||||
statusEl.style.color = '#ff0000';
|
||||
setTimeout(() => resetToIdle(), 2000);
|
||||
setTimeout(() => finishStream(), 2000);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main render loop using requestAnimationFrame.
|
||||
*/
|
||||
// ═══════════════════════════════════════
|
||||
// RENDER LOOP
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
function renderFrame(now) {
|
||||
if (state !== 'PLAYING') return;
|
||||
requestAnimationFrame(renderFrame);
|
||||
|
|
@ -152,29 +167,27 @@ function renderFrame(now) {
|
|||
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}`;
|
||||
statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${modes[renderMode] || 'B&W'}`;
|
||||
}
|
||||
|
||||
if (frameBuffer.length === 0) return;
|
||||
lastRenderTime = now;
|
||||
|
||||
const frame = frameBuffer.shift();
|
||||
|
||||
if (renderMode === 1) {
|
||||
player.style.display = 'block';
|
||||
player.style.color = '#fff';
|
||||
player.textContent = frame;
|
||||
} else {
|
||||
const view = new Uint8Array(frame);
|
||||
lastFrameView = view;
|
||||
|
||||
|
||||
// 1. Draw Canvas (Background)
|
||||
ctx.fillStyle = '#050505';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = 'bold 8px Courier New';
|
||||
|
|
@ -188,94 +201,37 @@ function renderFrame(now) {
|
|||
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);
|
||||
}
|
||||
|
||||
// Fill Selection Buffer (char code is at view[idx])
|
||||
selectionBuffer[row * (gridCols + 1) + col] = view[idx];
|
||||
|
||||
col++;
|
||||
if (col >= gridCols) { col = 0; row++; }
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
requestAnimationFrame(animateRipple);
|
||||
// 2. Update Selection Layer (Foreground)
|
||||
player.style.display = 'block';
|
||||
player.style.color = 'transparent';
|
||||
player.textContent = textDecoder.decode(selectionBuffer);
|
||||
}
|
||||
|
||||
requestAnimationFrame(animateRipple);
|
||||
}
|
||||
|
||||
function resetToIdle() {
|
||||
// ═══════════════════════════════════════
|
||||
// CLEANUP
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
function finishStream() {
|
||||
state = 'IDLE';
|
||||
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
||||
frameBuffer.length = 0;
|
||||
lastRenderTime = 0;
|
||||
lastFrameView = 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 ──
|
||||
|
|
@ -284,14 +240,18 @@ overlay.addEventListener('click', (e) => {
|
|||
startStream();
|
||||
});
|
||||
|
||||
container.addEventListener('click', (e) => {
|
||||
if (state !== 'PLAYING') return;
|
||||
if (volumeSlider) {
|
||||
volumeSlider.addEventListener('input', () => {
|
||||
if (audioEl) audioEl.volume = volumeSlider.value;
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
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>
|
||||
<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>
|
||||
|
||||
<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 -->
|
||||
<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>
|
||||
<header class="blog-header">
|
||||
<h1>ASCILINE</h1>
|
||||
<p>Turning the web into a living, breathing typographic canvas.</p>
|
||||
</header>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Main Content -->
|
||||
<main class="blog-container">
|
||||
<article class="blog-post">
|
||||
<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>
|
||||
|
||||
<!-- Tap to Play Interaction Overlay -->
|
||||
<div id="play-overlay">
|
||||
<div class="play-btn"></div>
|
||||
<span class="play-label">Tap to Play</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="post-content">
|
||||
Every character on this page is potential energy. The web was built on text — HTML,
|
||||
the very skeleton of every site you visit, is nothing but structured characters. Yet
|
||||
somewhere along the way, we forgot that text itself can be the medium, not just the
|
||||
message. ASCILINE exists to remind us: every glyph is a pixel waiting to move.
|
||||
</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 -->
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -6,10 +6,11 @@ Dependencies: pip install fastapi uvicorn websockets
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import numpy as np
|
||||
import cv2
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import uvicorn
|
||||
import os
|
||||
|
|
@ -34,6 +35,51 @@ async def root():
|
|||
"""Serves the Frontend (HTML/JS/CSS) file to the client."""
|
||||
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")
|
||||
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 {
|
||||
--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;
|
||||
--bg-color: #0a0a0c;
|
||||
--text-color: #e0e0e0;
|
||||
--accent-color: #00f3ff;
|
||||
--accent-secondary: #39ff14;
|
||||
--post-bg: #121217;
|
||||
--font-main: 'Outfit', sans-serif;
|
||||
--font-tech: 'Courier New', monospace;
|
||||
--player-bg: #050505;
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -21,120 +23,165 @@
|
|||
|
||||
body {
|
||||
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);
|
||||
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;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-main);
|
||||
overflow: hidden;
|
||||
margin: 50px 0;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 12px;
|
||||
font-family: var(--font-tech);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* ── PLAYER CONTAINER ─────────────────────────── */
|
||||
|
||||
/* ── 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;
|
||||
border-radius: 4px;
|
||||
width: 860px;
|
||||
height: 560px;
|
||||
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);
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
#ascii-player {
|
||||
font-family: var(--font-main);
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
font-size: 8px;
|
||||
line-height: 8px;
|
||||
letter-spacing: 0;
|
||||
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;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
#ascii-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── OVERLAYS ───────────────────────── */
|
||||
|
||||
#play-overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
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);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 10;
|
||||
transition: opacity 0.5s ease;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#play-overlay.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 2px solid rgba(0, 255, 65, 0.5);
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 3px solid var(--accent-color);
|
||||
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);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.play-btn::after {
|
||||
|
|
@ -143,25 +190,74 @@ h1 {
|
|||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 12px 0 12px 22px;
|
||||
border-width: 10px 0 10px 18px;
|
||||
border-color: transparent transparent transparent var(--accent-color);
|
||||
margin-left: 4px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.play-label {
|
||||
color: rgba(0, 255, 65, 0.5);
|
||||
font-size: 11px;
|
||||
letter-spacing: 5px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
/* ── PLAYER CONTROLS BAR ───────────────── */
|
||||
.player-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
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