feat: server-side volume control feature added, playlist & folder video management

This commit is contained in:
YusufB5 2026-06-04 16:14:23 +03:00
parent fd422b5100
commit 5fad7c5aa9
6 changed files with 344 additions and 101 deletions

View file

@ -3,10 +3,16 @@ stream_server.py
================
Streams the core Video-to-ASCII engine to the web via HTTP/WebSocket.
Dependencies: pip install fastapi uvicorn websockets
Priority Order:
1. --playlist playlist.json JSON file (per-video vol, mode, path)
2. --folder ./videos folder scan (filesystem order, not alphabetical)
3. positional video arg single video (legacy behavior)
"""
import asyncio
import subprocess
import json
import numpy as np
import cv2
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
@ -30,36 +36,127 @@ def get_html_content():
with open(html_path, "r", encoding="utf-8") as f:
return f.read()
def resolve_video_path(video: str) -> str:
"""
Resolves a video path by checking multiple locations in order:
1. As-is (absolute or relative to CWD)
2. Inside the project root (BASE_DIR)
3. Inside BASE_DIR/videos/ subfolder
Returns the first path that exists, or the original string if none found.
"""
candidates = [
video,
os.path.join(BASE_DIR, video),
os.path.join(BASE_DIR, "videos", os.path.basename(video)),
]
for path in candidates:
if os.path.exists(path):
return path
return video # Return original; error will be caught during playback
def load_playlist(playlist_path: str) -> list[dict]:
"""Loads playlist from a JSON file and resolves all video paths."""
with open(playlist_path, "r", encoding="utf-8") as f:
items = json.load(f)
for item in items:
item["video"] = resolve_video_path(item["video"])
return items
def load_folder(folder_path: str, default_mode: int, default_vol: int) -> list[dict]:
"""
Scans a folder for video files in filesystem order (top to bottom,
as they appear in the directory not alphabetically sorted).
"""
supported = (".mp4", ".mkv", ".avi", ".mov", ".webm")
entries = []
with os.scandir(folder_path) as it:
for entry in it:
if entry.is_file() and entry.name.lower().endswith(supported):
entries.append({
"video": entry.path,
"mode": default_mode,
"vol": default_vol
})
# Filesystem order (no sort applied)
return entries
def build_queue(args) -> list[dict]:
"""
Builds the video queue based on argument priority:
1. --playlist JSON file
2. --folder directory
3. Single positional video argument
"""
if args.playlist:
print(f"[PLAYLIST] Loading: {args.playlist}")
items = load_playlist(args.playlist)
# Fill missing fields with global defaults
for item in items:
item.setdefault("mode", args.mode)
item.setdefault("vol", args.vol)
return items
if args.folder:
print(f"[FOLDER] Scanning: {args.folder}")
return load_folder(args.folder, args.mode, args.vol)
# Legacy: single video argument
return [{"video": args.video, "mode": args.mode, "vol": args.vol}]
# ── APP STATE ──────────────────────────────────────────────
# Queue is stored in app.state so the WebSocket endpoint can read it.
# current_index tracks which video is playing.
# loop flag controls infinite playback.
# ──────────────────────────────────────────────────────────
@app.get("/")
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.
Extracts and streams audio from the currently active video entry.
Server-side volume control via the entry's 'vol' field (0-5 scale).
0 = Muted (FFmpeg never runs)
1 = Normal (1.0x)
5 = Double (2.0x)
"""
video_path = getattr(app.state, "video_path", "video.mp4")
queue = getattr(app.state, "queue", [])
idx = getattr(app.state, "current_index", 0)
entry = queue[idx] if queue else {}
vol_level = entry.get("vol", 1)
video_path = entry.get("video", "video.mp4")
# vol 0 → skip audio entirely, no FFmpeg process
if vol_level <= 0:
from fastapi import Response
return Response(status_code=204)
if not os.path.exists(video_path):
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Video file not found")
# Map 1-5 → 1.0x-2.0x FFmpeg volume
ffmpeg_vol = 1.0 + (vol_level - 1) * 0.25
def audio_generator():
# Use ffmpeg to extract audio as MP3 stream
process = subprocess.Popen(
[
"ffmpeg",
"-i", video_path,
"-vn", # No video
"-vn",
"-filter:a", f"volume={ffmpeg_vol}",
"-acodec", "libmp3lame",
"-ab", "128k", # 128kbps bitrate
"-ar", "44100", # Sample rate
"-f", "mp3", # Output format
"-ab", "128k",
"-ar", "44100",
"-f", "mp3",
"-loglevel", "quiet",
"pipe:1" # Output to stdout
"pipe:1"
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
@ -73,110 +170,187 @@ async def audio_stream():
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):
"""
Starts decoding the video when a client connects,
converts to pure ASCII using AsciiMapper and sends via WebSockets.
Streams ASCII frames for every video in the queue.
Advances to the next entry automatically when a video ends.
Loops back to the start if --loop is set.
"""
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("Error: Video file not found!")
queue = getattr(app.state, "queue", [])
loop = getattr(app.state, "loop", False)
cols = getattr(app.state, "cols", 200)
rows = getattr(app.state, "rows", 80)
if not queue:
await websocket.send_text("Error: No video in queue!")
await websocket.close()
return
mapper = AsciiMapper()
fps = decoder.fps
frame_t = 1.0 / fps
# Character -> byte code lookup table (for binary format)
char_byte_lut = np.array([ord(c) for c in mapper._lut], dtype=np.uint8)
# Set the quantization level once (render_mode is fixed)
qb = {5: 0, 4: 2, 3: 3, 2: 5}.get(render_mode, 0)
# Send meta information to the client (to create cols/rows grid)
await websocket.send_text(f"INIT:{fps}:{render_mode}:{cols}:{rows}")
queue_index = 0 # local index; advances through the queue
try:
# Decoder iterator yields (gray, bgr) for each frame
# Pre-allocate binary frame buffer (reduces GC pressure)
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()
# Common: intensity -> character index
indices = np.floor_divide(gray_frame, max(1, 256 // mapper._n))
np.clip(indices, 0, mapper._n - 1, out=indices)
if render_mode == 1:
# --- PURE ASCII CONVERSION (text) ---
char_matrix = mapper._lut[indices]
lines = [''.join(row) for row in char_matrix]
await websocket.send_text('\n'.join(lines))
else:
# --- COLOR BINARY CONVERSION (numpy, zero Python loops) ---
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)
while True:
entry = queue[queue_index]
video_path = entry["video"]
render_mode= entry["mode"]
# IMPORTANT: Update current_index BEFORE sending INIT so that
# when the client reloads /audio in response to INIT, the endpoint
# already serves the correct video's audio.
app.state.current_index = queue_index
print(f"[PLAYING] ({queue_index + 1}/{len(queue)}) {video_path} "
f"mode={render_mode} vol={entry['vol']}")
try:
decoder = VideoDecoder(video_path, cols, rows)
except FileNotFoundError:
await websocket.send_text(f"Error: '{video_path}' not found!")
queue_index += 1
if queue_index >= len(queue):
if loop:
queue_index = 0
else:
break
continue
mapper = AsciiMapper()
fps = decoder.fps
frame_t = 1.0 / fps
char_byte_lut= np.array([ord(c) for c in mapper._lut], dtype=np.uint8)
qb = {5: 0, 4: 2, 3: 3, 2: 5}.get(render_mode, 0)
await websocket.send_text(f"INIT:{fps}:{render_mode}:{cols}:{rows}")
frame_buf = np.empty((rows, cols, 4), dtype=np.uint8) if render_mode > 1 else None
try:
for gray_frame, bgr_frame in decoder:
t0 = asyncio.get_event_loop().time()
indices = np.floor_divide(gray_frame, max(1, 256 // mapper._n))
np.clip(indices, 0, mapper._n - 1, out=indices)
if render_mode == 1:
char_matrix = mapper._lut[indices]
lines = [''.join(row) for row in char_matrix]
await websocket.send_text('\n'.join(lines))
else:
H, W = gray_frame.shape
char_codes = char_byte_lut[indices]
rgb = bgr_frame[:, :, ::-1]
if qb > 0:
rgb = (rgb >> qb) << qb
frame_buf[:, :, 0] = char_codes
frame_buf[:, :, 1:] = rgb
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)
finally:
decoder.release()
# Video finished → advance queue
queue_index += 1
if queue_index >= len(queue):
if loop:
print("[LOOP] Restarting queue from the beginning.")
queue_index = 0
else:
print("[DONE] All videos finished.")
break
except (WebSocketDisconnect, ConnectionClosed):
print("Client disconnected from the stream.")
finally:
decoder.release()
if __name__ == "__main__":
import argparse
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")
parser = argparse.ArgumentParser(
description="Real-Time ASCII Web Server",
formatter_class=argparse.RawTextHelpFormatter
)
# ── Source (mutually exclusive priority: playlist > folder > video) ──
parser.add_argument(
"video",
nargs="?",
default="video.mp4",
help="Single video file to stream (legacy mode)"
)
parser.add_argument(
"--playlist",
metavar="FILE",
default=None,
help="Path to a playlist JSON file\n"
" Format: [{\"video\": \"a.mp4\", \"mode\": 5, \"vol\": 3}, ...]"
)
parser.add_argument(
"--folder",
metavar="DIR",
default=None,
help="Path to a folder; plays all videos in filesystem order"
)
# ── Playback ──
parser.add_argument("--loop", action="store_true", default=False, help="Loop the queue infinitely")
parser.add_argument("--port", type=int, default=8000, help="Server port (default: 8000)")
# ── Global defaults (overridden per-entry in JSON) ──
parser.add_argument(
"--mode",
type=int, choices=[1, 2, 3, 4, 5], default=1,
help="Render mode: 1=B&W 2=512c 3=32Kc 4=262Kc 5=16M Ultra"
)
parser.add_argument("--cols", type=int, default=200, help="Column count (default: 200)")
parser.add_argument("--rows", type=int, default=80, help="Row count (default: 80)")
parser.add_argument(
"--vol",
type=int, default=1,
help="Volume 0-5 (0=muted, 1=normal, 5=double) — global default"
)
args = parser.parse_args()
# Save arguments globally into the state
app.state.video_path = args.video
app.state.render_mode = args.mode
app.state.cols = args.cols
app.state.rows = args.rows
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}")
print(f"Starting server... Please go to http://localhost:{args.port} in your browser.")
# Build the queue
queue = build_queue(args)
if not queue:
print("[ERROR] No videos found. Check your --playlist / --folder / video argument.")
exit(1)
# Save state
app.state.queue = queue
app.state.current_index = 0
app.state.loop = args.loop
app.state.cols = args.cols
app.state.rows = args.rows
# Summary
print(f"\n{'='*50}")
print(f" ASCILINE | {len(queue)} video(s) in queue")
print(f" Loop : {'ON' if args.loop else 'OFF'}")
print(f" Res : {args.cols}x{args.rows}")
print(f" Default : mode={args.mode} vol={args.vol}")
print(f"{'='*50}")
for i, entry in enumerate(queue, 1):
print(f" {i:2}. {entry['video']} [mode={entry['mode']} vol={entry['vol']}]")
print(f"{'='*50}\n")
print(f"Starting server → http://localhost:{args.port}\n")
uvicorn.run(app, host="0.0.0.0", port=args.port, ws_ping_interval=None, ws_ping_timeout=None)