2026-05-02 14:36:22 +03:00
|
|
|
"""
|
|
|
|
|
stream_server.py
|
|
|
|
|
================
|
2026-05-03 13:49:22 +03:00
|
|
|
Streams the core Video-to-ASCII engine to the web via HTTP/WebSocket.
|
|
|
|
|
Dependencies: pip install fastapi uvicorn websockets
|
2026-05-02 14:36:22 +03:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2026-05-05 13:51:27 +03:00
|
|
|
import subprocess
|
2026-05-02 14:36:22 +03:00
|
|
|
import numpy as np
|
|
|
|
|
import cv2
|
|
|
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
2026-05-05 13:51:27 +03:00
|
|
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
2026-05-02 14:36:22 +03:00
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
|
import uvicorn
|
|
|
|
|
import os
|
|
|
|
|
from websockets.exceptions import ConnectionClosed
|
|
|
|
|
|
2026-05-03 13:49:22 +03:00
|
|
|
# Import the existing engine (ascii_video_player2.py)
|
2026-05-02 14:36:22 +03:00
|
|
|
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():
|
2026-05-03 13:49:22 +03:00
|
|
|
"""Serves the Frontend (HTML/JS/CSS) file to the client."""
|
2026-05-02 14:36:22 +03:00
|
|
|
return HTMLResponse(get_html_content())
|
|
|
|
|
|
2026-05-05 13:51:27 +03:00
|
|
|
@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"}
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-02 14:36:22 +03:00
|
|
|
@app.websocket("/ws")
|
|
|
|
|
async def websocket_endpoint(websocket: WebSocket):
|
|
|
|
|
"""
|
2026-05-03 13:49:22 +03:00
|
|
|
Starts decoding the video when a client connects,
|
|
|
|
|
converts to pure ASCII using AsciiMapper and sends via WebSockets.
|
2026-05-02 14:36:22 +03:00
|
|
|
"""
|
|
|
|
|
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:
|
2026-05-03 13:49:22 +03:00
|
|
|
await websocket.send_text("Error: Video file not found!")
|
2026-05-02 14:36:22 +03:00
|
|
|
await websocket.close()
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
mapper = AsciiMapper()
|
|
|
|
|
fps = decoder.fps
|
|
|
|
|
frame_t = 1.0 / fps
|
|
|
|
|
|
2026-05-03 13:49:22 +03:00
|
|
|
# Character -> byte code lookup table (for binary format)
|
2026-05-02 14:36:22 +03:00
|
|
|
char_byte_lut = np.array([ord(c) for c in mapper._lut], dtype=np.uint8)
|
|
|
|
|
|
2026-05-03 13:49:22 +03:00
|
|
|
# Set the quantization level once (render_mode is fixed)
|
2026-05-02 14:36:22 +03:00
|
|
|
qb = {5: 0, 4: 2, 3: 3, 2: 5}.get(render_mode, 0)
|
|
|
|
|
|
2026-05-03 13:49:22 +03:00
|
|
|
# Send meta information to the client (to create cols/rows grid)
|
2026-05-02 14:36:22 +03:00
|
|
|
await websocket.send_text(f"INIT:{fps}:{render_mode}:{cols}:{rows}")
|
|
|
|
|
|
|
|
|
|
try:
|
2026-05-03 13:49:22 +03:00
|
|
|
# Decoder iterator yields (gray, bgr) for each frame
|
|
|
|
|
# Pre-allocate binary frame buffer (reduces GC pressure)
|
2026-05-02 14:36:22 +03:00
|
|
|
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()
|
|
|
|
|
|
2026-05-03 13:49:22 +03:00
|
|
|
# Common: intensity -> character index
|
2026-05-02 14:36:22 +03:00
|
|
|
indices = np.floor_divide(gray_frame, max(1, 256 // mapper._n))
|
|
|
|
|
np.clip(indices, 0, mapper._n - 1, out=indices)
|
|
|
|
|
|
|
|
|
|
if render_mode == 1:
|
2026-05-03 13:49:22 +03:00
|
|
|
# --- PURE ASCII CONVERSION (text) ---
|
2026-05-02 14:36:22 +03:00
|
|
|
char_matrix = mapper._lut[indices]
|
|
|
|
|
lines = [''.join(row) for row in char_matrix]
|
|
|
|
|
await websocket.send_text('\n'.join(lines))
|
|
|
|
|
else:
|
2026-05-03 13:49:22 +03:00
|
|
|
# --- COLOR BINARY CONVERSION (numpy, zero Python loops) ---
|
2026-05-02 14:36:22 +03:00
|
|
|
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):
|
2026-05-03 13:49:22 +03:00
|
|
|
print("Client disconnected from the stream.")
|
2026-05-02 14:36:22 +03:00
|
|
|
finally:
|
|
|
|
|
decoder.release()
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
import argparse
|
2026-05-03 13:49:22 +03:00
|
|
|
parser = argparse.ArgumentParser(description="Real-Time ASCII Web Server")
|
|
|
|
|
parser.add_argument("video", help="Video file to be streamed", default="video.mp4", nargs='?')
|
|
|
|
|
parser.add_argument("--port", type=int, default=8000, help="Server port")
|
|
|
|
|
parser.add_argument("--mode", type=int, choices=[1, 2, 3, 4, 5], default=1, help="Render Mode: 1=B&W, 2=512colors, 3=32K, 4=262K, 5=16M Ultra")
|
|
|
|
|
parser.add_argument("--cols", type=int, default=200, help="Terminal column width")
|
|
|
|
|
parser.add_argument("--rows", type=int, default=80, help="Terminal row height")
|
2026-05-02 14:36:22 +03:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
2026-05-03 13:49:22 +03:00
|
|
|
# Save arguments globally into the state
|
2026-05-02 14:36:22 +03:00
|
|
|
app.state.video_path = args.video
|
|
|
|
|
app.state.render_mode = args.mode
|
|
|
|
|
app.state.cols = args.cols
|
|
|
|
|
app.state.rows = args.rows
|
|
|
|
|
|
2026-05-03 21:36:14 +03:00
|
|
|
if not os.path.exists(args.video):
|
|
|
|
|
print(f"\n[WARNING] Video file '{args.video}' not found!")
|
|
|
|
|
print("The server will start, but streaming will fail until the file is provided.\n")
|
|
|
|
|
else:
|
|
|
|
|
print(f"[{args.video}] ready to stream. Mode: {args.mode}, Res: {args.cols}x{args.rows}")
|
|
|
|
|
|
2026-05-03 13:49:22 +03:00
|
|
|
print(f"Starting server... Please go to http://localhost:{args.port} in your browser.")
|
2026-05-02 14:36:22 +03:00
|
|
|
|
|
|
|
|
uvicorn.run(app, host="0.0.0.0", port=args.port, ws_ping_interval=None, ws_ping_timeout=None)
|