diff --git a/README.md b/README.md
index d8d5d71..7c38d4b 100644
--- a/README.md
+++ b/README.md
@@ -46,10 +46,27 @@ pip install fastapi uvicorn opencv-python numpy websockets
```
### 3. Run the Web Server
-Place a `video.mp4` in the root directory and start the server:
+
+**Single video:**
```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.
### 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
```
+### Server-Side Volume Control
+Volume is controlled at the server level via the `--vol` flag (scale 0–5).
+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
**MIT License (with Anti-Ad Restriction)**
diff --git a/app.js b/app.js
index 633f499..999fc12 100644
--- a/app.js
+++ b/app.js
@@ -99,7 +99,7 @@ function connectWebSocket() {
if (audioEl) {
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:';
@@ -121,8 +121,18 @@ function connectWebSocket() {
frameInterval = 1000 / targetFps;
renderMode = parseInt(p[2]);
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;
state = 'PLAYING';
lastRenderTime = performance.now();
diff --git a/index.html b/index.html
index 662b34d..46cfaf2 100644
--- a/index.html
+++ b/index.html
@@ -57,7 +57,7 @@
VOL_
-
+
diff --git a/playlist.json b/playlist.json
new file mode 100644
index 0000000..c587857
--- /dev/null
+++ b/playlist.json
@@ -0,0 +1,12 @@
+[
+ {
+ "video": "first_video.mp4",
+ "mode": 1, //stream mode
+ "vol": 1 //sream volume
+ },
+ {
+ "video": "second_video.mp4",
+ "mode": 5,
+ "vol": 2
+ }
+]
\ No newline at end of file
diff --git a/stream_server.py b/stream_server.py
index 53ec25e..f510080 100644
--- a/stream_server.py
+++ b/stream_server.py
@@ -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)
diff --git a/videos/.gitkeep b/videos/.gitkeep
new file mode 100644
index 0000000..0e85e99
--- /dev/null
+++ b/videos/.gitkeep
@@ -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