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