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

@ -46,10 +46,27 @@ pip install fastapi uvicorn opencv-python numpy websockets
``` ```
### 3. Run the Web Server ### 3. Run the Web Server
Place a `video.mp4` in the root directory and start the server:
**Single video:**
```bash ```bash
python stream_server.py python stream_server.py video.mp4
``` ```
**Folder mode — drop your videos into `videos/` and run:**
```bash
python stream_server.py --folder videos
python stream_server.py --folder videos --loop # infinite loop
python stream_server.py --folder videos --mode 5 --vol 2 # all videos same settings
```
Videos play in **filesystem order** (top to bottom as they appear in the folder, not alphabetically). Just add/remove files from the `videos/` folder to control the queue.
**JSON Playlist — full control per video:**
```bash
python stream_server.py --playlist playlist.json
python stream_server.py --playlist playlist.json --loop
```
Use `playlist.json` when you need different `--mode` or `--vol` settings for each video.
Open `http://localhost:8000` in your browser. Open `http://localhost:8000` in your browser.
### 4. Run directly in Terminal (Standalone) ### 4. Run directly in Terminal (Standalone)
@ -83,6 +100,33 @@ The engine supports different fidelity levels via the `--mode` flag:
python stream_server.py --mode 5 --cols 240 --rows 100 python stream_server.py --mode 5 --cols 240 --rows 100
``` ```
### Server-Side Volume Control
Volume is controlled at the server level via the `--vol` flag (scale 05).
When set to `0`, the audio engine (FFmpeg) **never runs**, saving CPU and bandwidth.
| `--vol` | FFmpeg Multiplier | Description |
|---------|------------------|-------------|
| `0` | — | Muted (no processing) |
| `1` | 1.0× | Normal (default) |
| `3` | 1.5× | Loud |
| `5` | 2.0× | Double volume |
```bash
python stream_server.py video.mp4 --vol 0 # Silent
python stream_server.py video.mp4 --vol 3 # Loud
```
### Playlist Format (`playlist.json`)
Each entry can override the global `--mode` and `--vol` defaults:
```json
[
{ "video": "intro.mp4", "mode": 1, "vol": 1 },
{ "video": "main.mp4", "mode": 5, "vol": 3 },
{ "video": "outro.mp4", "mode": 3, "vol": 2 }
]
```
Video paths are resolved automatically — the engine checks the project root and the `videos/` subfolder, so you can write just the filename.
## 📜 License & Ethical Guardrails ## 📜 License & Ethical Guardrails
**MIT License (with Anti-Ad Restriction)** **MIT License (with Anti-Ad Restriction)**

16
app.js
View file

@ -99,7 +99,7 @@ function connectWebSocket() {
if (audioEl) { if (audioEl) {
audioEl.src = '/audio?' + Date.now(); audioEl.src = '/audio?' + Date.now();
audioEl.volume = volumeSlider ? volumeSlider.value : 0.8; audioEl.volume = volumeSlider ? volumeSlider.value : 1.0;
} }
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
@ -121,8 +121,18 @@ function connectWebSocket() {
frameInterval = 1000 / targetFps; frameInterval = 1000 / targetFps;
renderMode = parseInt(p[2]); renderMode = parseInt(p[2]);
buildCanvas(parseInt(p[3]), parseInt(p[4])); buildCanvas(parseInt(p[3]), parseInt(p[4]));
if (audioEl) audioEl.play().catch(() => {}); // Reload audio on every INIT so each video's audio plays correctly.
// The server updates current_index BEFORE sending INIT, so /audio
// will already serve the new video's audio when we request it here.
if (audioEl) {
audioEl.pause();
audioEl.src = '/audio?' + Date.now();
audioEl.volume = volumeSlider ? volumeSlider.value : 1.0;
audioEl.load();
audioEl.play().catch(() => {});
}
readyToRender = true; readyToRender = true;
state = 'PLAYING'; state = 'PLAYING';
lastRenderTime = performance.now(); lastRenderTime = performance.now();

View file

@ -57,7 +57,7 @@
<!-- Volume Control --> <!-- Volume Control -->
<div class="ctrl-group"> <div class="ctrl-group">
<span class="ctrl-icon">VOL_</span> <span class="ctrl-icon">VOL_</span>
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="0.8"> <input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
</div> </div>
</div> </div>

12
playlist.json Normal file
View file

@ -0,0 +1,12 @@
[
{
"video": "first_video.mp4",
"mode": 1, //stream mode
"vol": 1 //sream volume
},
{
"video": "second_video.mp4",
"mode": 5,
"vol": 2
}
]

View file

@ -3,10 +3,16 @@ stream_server.py
================ ================
Streams the core Video-to-ASCII engine to the web via HTTP/WebSocket. Streams the core Video-to-ASCII engine to the web via HTTP/WebSocket.
Dependencies: pip install fastapi uvicorn websockets 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 asyncio
import subprocess import subprocess
import json
import numpy as np import numpy as np
import cv2 import cv2
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
@ -30,36 +36,127 @@ def get_html_content():
with open(html_path, "r", encoding="utf-8") as f: with open(html_path, "r", encoding="utf-8") as f:
return f.read() 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("/") @app.get("/")
async def root(): 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") @app.get("/audio")
async def audio_stream(): async def audio_stream():
""" """
Extracts and streams audio from the video file using ffmpeg. Extracts and streams audio from the currently active video entry.
Returns an MP3 audio stream that the browser can play natively. 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): if not os.path.exists(video_path):
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Video file not found") 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(): def audio_generator():
# Use ffmpeg to extract audio as MP3 stream
process = subprocess.Popen( process = subprocess.Popen(
[ [
"ffmpeg", "ffmpeg",
"-i", video_path, "-i", video_path,
"-vn", # No video "-vn",
"-filter:a", f"volume={ffmpeg_vol}",
"-acodec", "libmp3lame", "-acodec", "libmp3lame",
"-ab", "128k", # 128kbps bitrate "-ab", "128k",
"-ar", "44100", # Sample rate "-ar", "44100",
"-f", "mp3", # Output format "-f", "mp3",
"-loglevel", "quiet", "-loglevel", "quiet",
"pipe:1" # Output to stdout "pipe:1"
], ],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL stderr=subprocess.DEVNULL
@ -73,110 +170,187 @@ async def audio_stream():
finally: finally:
process.stdout.close() process.stdout.close()
process.wait() process.wait()
return StreamingResponse( return StreamingResponse(
audio_generator(), audio_generator(),
media_type="audio/mpeg", media_type="audio/mpeg",
headers={"Accept-Ranges": "bytes"} headers={"Accept-Ranges": "bytes"}
) )
@app.websocket("/ws") @app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
""" """
Starts decoding the video when a client connects, Streams ASCII frames for every video in the queue.
converts to pure ASCII using AsciiMapper and sends via WebSockets. Advances to the next entry automatically when a video ends.
Loops back to the start if --loop is set.
""" """
await websocket.accept() await websocket.accept()
video_path = getattr(app.state, "video_path", "video.mp4") queue = getattr(app.state, "queue", [])
render_mode = getattr(app.state, "render_mode", 1) loop = getattr(app.state, "loop", False)
cols = getattr(app.state, "cols", 200) cols = getattr(app.state, "cols", 200)
rows = getattr(app.state, "rows", 80) rows = getattr(app.state, "rows", 80)
try: if not queue:
decoder = VideoDecoder(video_path, cols, rows) await websocket.send_text("Error: No video in queue!")
except FileNotFoundError:
await websocket.send_text("Error: Video file not found!")
await websocket.close() await websocket.close()
return return
mapper = AsciiMapper() queue_index = 0 # local index; advances through the queue
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}")
try: try:
# Decoder iterator yields (gray, bgr) for each frame while True:
# Pre-allocate binary frame buffer (reduces GC pressure) entry = queue[queue_index]
frame_buf = np.empty((rows, cols, 4), dtype=np.uint8) if render_mode > 1 else None video_path = entry["video"]
render_mode= entry["mode"]
for gray_frame, bgr_frame in decoder:
t0 = asyncio.get_event_loop().time() # IMPORTANT: Update current_index BEFORE sending INIT so that
# when the client reloads /audio in response to INIT, the endpoint
# Common: intensity -> character index # already serves the correct video's audio.
indices = np.floor_divide(gray_frame, max(1, 256 // mapper._n)) app.state.current_index = queue_index
np.clip(indices, 0, mapper._n - 1, out=indices)
print(f"[PLAYING] ({queue_index + 1}/{len(queue)}) {video_path} "
if render_mode == 1: f"mode={render_mode} vol={entry['vol']}")
# --- PURE ASCII CONVERSION (text) ---
char_matrix = mapper._lut[indices] try:
lines = [''.join(row) for row in char_matrix] decoder = VideoDecoder(video_path, cols, rows)
await websocket.send_text('\n'.join(lines)) except FileNotFoundError:
else: await websocket.send_text(f"Error: '{video_path}' not found!")
# --- COLOR BINARY CONVERSION (numpy, zero Python loops) --- queue_index += 1
H, W = gray_frame.shape if queue_index >= len(queue):
char_codes = char_byte_lut[indices] # (H,W) uint8 if loop:
queue_index = 0
rgb = bgr_frame[:, :, ::-1] # BGR → RGB else:
if qb > 0: break
rgb = (rgb >> qb) << qb continue
# [char, R, G, B] interleaved binary frame mapper = AsciiMapper()
frame_buf[:, :, 0] = char_codes fps = decoder.fps
frame_buf[:, :, 1:] = rgb frame_t = 1.0 / fps
char_byte_lut= np.array([ord(c) for c in mapper._lut], dtype=np.uint8)
await websocket.send_bytes(frame_buf.tobytes()) qb = {5: 0, 4: 2, 3: 3, 2: 5}.get(render_mode, 0)
elapsed = asyncio.get_event_loop().time() - t0 await websocket.send_text(f"INIT:{fps}:{render_mode}:{cols}:{rows}")
wait = frame_t - elapsed
if wait > 0: frame_buf = np.empty((rows, cols, 4), dtype=np.uint8) if render_mode > 1 else None
await asyncio.sleep(wait)
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): except (WebSocketDisconnect, ConnectionClosed):
print("Client disconnected from the stream.") print("Client disconnected from the stream.")
finally:
decoder.release()
if __name__ == "__main__": if __name__ == "__main__":
import argparse 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 = argparse.ArgumentParser(
parser.add_argument("--port", type=int, default=8000, help="Server port") description="Real-Time ASCII Web Server",
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") formatter_class=argparse.RawTextHelpFormatter
parser.add_argument("--cols", type=int, default=200, help="Terminal column width") )
parser.add_argument("--rows", type=int, default=80, help="Terminal row height")
# ── 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() args = parser.parse_args()
# Save arguments globally into the state # Build the queue
app.state.video_path = args.video queue = build_queue(args)
app.state.render_mode = args.mode
app.state.cols = args.cols if not queue:
app.state.rows = args.rows print("[ERROR] No videos found. Check your --playlist / --folder / video argument.")
exit(1)
if not os.path.exists(args.video):
print(f"\n[WARNING] Video file '{args.video}' not found!") # Save state
print("The server will start, but streaming will fail until the file is provided.\n") app.state.queue = queue
else: app.state.current_index = 0
print(f"[{args.video}] ready to stream. Mode: {args.mode}, Res: {args.cols}x{args.rows}") app.state.loop = args.loop
app.state.cols = args.cols
print(f"Starting server... Please go to http://localhost:{args.port} in your browser.") 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) uvicorn.run(app, host="0.0.0.0", port=args.port, ws_ping_interval=None, ws_ping_timeout=None)

3
videos/.gitkeep Normal file
View file

@ -0,0 +1,3 @@
# This file ensures the videos/ directory is tracked by Git.
# Place your video files (.mp4, .mkv, .avi, .mov, .webm) here.
# Run the server with: python stream_server.py --folder ./videos