diff --git a/app.js b/app.js index 999fc12..abee249 100644 --- a/app.js +++ b/app.js @@ -23,6 +23,7 @@ const BUFFER_SIZE = 4; let targetFps = 24; let frameInterval = 1000 / targetFps; let renderMode = 1; +let pixelMode = false; let readyToRender = false; // Grid & Dimensions @@ -30,6 +31,9 @@ let gridCols = 0, gridRows = 0; let charWidth = 0, charHeight = 0; let xPos = null, yPos = null; +// Pixel Mode (--pixel) — ImageData pixel buffer +let dotImageData = null; + // Selection Layer optimization const textDecoder = new TextDecoder(); let selectionBuffer = null; @@ -48,16 +52,6 @@ for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i); function buildCanvas(cols, rows) { gridCols = cols; gridRows = rows; - ctx.font = 'bold 8px Courier New'; - charWidth = Math.ceil(ctx.measureText('M').width); - charHeight = 8; - canvas.width = cols * charWidth; - canvas.height = rows * charHeight; - canvas.style.display = 'block'; - - // Selection Layer Buffer - selectionBuffer = new Uint8Array((cols + 1) * rows); - for (let r = 0; r < rows; r++) selectionBuffer[r * (cols + 1) + cols] = 10; // Newline (\n) // Sizing and positioning for both layers const syncSize = (el) => { @@ -69,15 +63,64 @@ function buildCanvas(cols, rows) { el.style.left = '0'; }; - syncSize(canvas); - syncSize(player); + if (pixelMode) { + // ── DOT MODE: 1 canvas pixel = 1 grid cell ── + canvas.width = cols; + canvas.height = rows; + canvas.style.display = 'block'; + canvas.style.imageRendering = 'pixelated'; + dotImageData = ctx.createImageData(cols, rows); + // Pre-fill alpha channel to 255 (fully opaque) + const d = dotImageData.data; + for (let i = 3; i < d.length; i += 4) d[i] = 255; + syncSize(canvas); + // Hide selection layer — no text to select in dot mode + player.style.display = 'none'; + } else { + // ── STANDARD ASCII MODES (1-5) ── + canvas.style.imageRendering = ''; + dotImageData = null; + ctx.font = 'bold 8px Courier New'; + charWidth = ctx.measureText('M').width; + charHeight = 8; + canvas.width = cols * charWidth; + canvas.height = rows * charHeight; + canvas.style.display = 'block'; - ctx.font = 'bold 8px Courier New'; - ctx.textBaseline = 'top'; - xPos = new Float32Array(cols); - yPos = new Float32Array(rows); - for (let c = 0; c < cols; c++) xPos[c] = c * charWidth; - for (let r = 0; r < rows; r++) yPos[r] = r * charHeight; + // Selection Layer Buffer + selectionBuffer = new Uint8Array((cols + 1) * rows); + for (let r = 0; r < rows; r++) selectionBuffer[r * (cols + 1) + cols] = 10; + + syncSize(canvas); + + // Selection layer: match canvas object-fit:contain position exactly + const containerW = container.clientWidth; + const containerH = container.clientHeight; + const fitScaleX = containerW / canvas.width; + const fitScaleY = containerH / canvas.height; + const fitScale = Math.min(fitScaleX, fitScaleY); + const renderedW = canvas.width * fitScale; + const renderedH = canvas.height * fitScale; + const offsetX = (containerW - renderedW) / 2; + const offsetY = (containerH - renderedH) / 2; + + player.style.width = canvas.width + 'px'; + player.style.height = canvas.height + 'px'; + player.style.position = 'absolute'; + player.style.top = '0'; + player.style.left = '0'; + player.style.transformOrigin = 'top left'; + player.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${fitScale})`; + player.style.fontSize = '8px'; + player.style.lineHeight = '8px'; + + ctx.font = 'bold 8px Courier New'; + ctx.textBaseline = 'top'; + xPos = new Float32Array(cols); + yPos = new Float32Array(rows); + for (let c = 0; c < cols; c++) xPos[c] = c * charWidth; + for (let r = 0; r < rows; r++) yPos[r] = r * charHeight; + } } // ═══════════════════════════════════════ @@ -120,6 +163,7 @@ function connectWebSocket() { targetFps = parseFloat(p[1]); frameInterval = 1000 / targetFps; renderMode = parseInt(p[2]); + pixelMode = (p.length > 5 && parseInt(p[5]) === 1); buildCanvas(parseInt(p[3]), parseInt(p[4])); // Reload audio on every INIT so each video's audio plays correctly. @@ -183,7 +227,8 @@ function renderFrame(now) { frameCount = 0; lastFpsUpdate = now; const modes = { 2: '512 Color', 3: '32K Color', 4: '262K Color', 5: '16M Ultra' }; - statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${modes[renderMode] || 'B&W'}`; + const label = (modes[renderMode] || 'B&W') + (pixelMode ? ' PIXEL' : ''); + statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${label}`; } if (frameBuffer.length === 0) return; @@ -194,7 +239,20 @@ function renderFrame(now) { player.style.display = 'block'; player.style.color = '#fff'; player.textContent = frame; + } else if (pixelMode) { + // ── DOT MODE: ImageData pixel blast (1 draw call) ── + const view = new Uint8Array(frame); + const data = dotImageData.data; + // view: [char,R,G,B, char,R,G,B, ...] → data: [R,G,B,A, R,G,B,A, ...] + for (let src = 0, dst = 0; src < view.length; src += 4, dst += 4) { + data[dst] = view[src + 1]; // R + data[dst + 1] = view[src + 2]; // G + data[dst + 2] = view[src + 3]; // B + // Alpha already set to 255 in buildCanvas + } + ctx.putImageData(dotImageData, 0, 0); } else { + // ── STANDARD COLOR MODES (2-5): fillText per character ── const view = new Uint8Array(frame); // 1. Draw Canvas (Background) diff --git a/stream_server.py b/stream_server.py index cbacc52..4571305 100644 --- a/stream_server.py +++ b/stream_server.py @@ -27,6 +27,30 @@ from ascii_video_player2 import VideoDecoder, AsciiMapper app = FastAPI() + +def get_video_dimensions(path: str) -> tuple[int, int]: + """Quickly probe a video file to get (width, height) without decoding frames.""" + cap = cv2.VideoCapture(path) + if not cap.isOpened(): + raise FileNotFoundError(f"Could not open video file: {path!r}") + w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + cap.release() + return w, h + + +def calc_auto_rows(cols: int, vid_w: int, vid_h: int, pixel_mode: bool) -> int: + """ + Calculate rows from video aspect ratio. + ASCII mode: characters are ~2x taller than wide, so divide by 2. + Pixel mode: cells are square (CSS stretches), no correction needed. + """ + ratio = vid_w / max(vid_h, 1) + if pixel_mode: + return max(1, round(cols / ratio)) + else: + return max(1, round(cols / ratio / 2)) + # 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") @@ -94,14 +118,18 @@ def build_queue(args) -> list[dict]: for item in items: item.setdefault("mode", args.mode) item.setdefault("vol", args.vol) + item.setdefault("pixel", args.pixel) return items if args.folder: print(f"[FOLDER] Scanning: {args.folder}") - return load_folder(args.folder, args.mode, args.vol) + items = load_folder(args.folder, args.mode, args.vol) + for item in items: + item["pixel"] = args.pixel + return items # Legacy: single video argument - return [{"video": resolve_video_path(args.video), "mode": args.mode, "vol": args.vol}] + return [{"video": resolve_video_path(args.video), "mode": args.mode, "vol": args.vol, "pixel": args.pixel}] # ── APP STATE ────────────────────────────────────────────── @@ -189,8 +217,8 @@ async def websocket_endpoint(websocket: WebSocket): queue = getattr(app.state, "queue", []) loop = getattr(app.state, "loop", False) - cols = getattr(app.state, "cols", 200) - rows = getattr(app.state, "rows", 80) + cols = getattr(app.state, "cols", 200) + rows_cfg = getattr(app.state, "rows", 0) # 0 = auto if not queue: await websocket.send_text("Error: No video in queue!") @@ -204,6 +232,7 @@ async def websocket_endpoint(websocket: WebSocket): entry = queue[queue_index] video_path = entry["video"] render_mode= entry["mode"] + pixel_mode = entry.get("pixel", False) # IMPORTANT: Update current_index BEFORE sending INIT so that # when the client reloads /audio in response to INIT, the endpoint @@ -211,7 +240,26 @@ async def websocket_endpoint(websocket: WebSocket): app.state.current_index = queue_index print(f"[PLAYING] ({queue_index + 1}/{len(queue)}) {video_path} " - f"mode={render_mode} vol={entry['vol']}") + f"mode={render_mode} pixel={pixel_mode} vol={entry['vol']}") + + # ── Auto-calculate rows if not specified ── + try: + vid_w, vid_h = get_video_dimensions(video_path) + 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 + + if rows_cfg == 0: + rows = calc_auto_rows(cols, vid_w, vid_h, pixel_mode) + print(f"[AUTO] {vid_w}x{vid_h} → grid {cols}x{rows}") + else: + rows = rows_cfg try: decoder = VideoDecoder(video_path, cols, rows) @@ -231,7 +279,7 @@ async def websocket_endpoint(websocket: WebSocket): 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}") + await websocket.send_text(f"INIT:{fps}:{render_mode}:{cols}:{rows}:{int(pixel_mode)}") frame_buf = np.empty((rows, cols, 4), dtype=np.uint8) if render_mode > 1 else None @@ -239,22 +287,31 @@ async def websocket_endpoint(websocket: WebSocket): 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] + if pixel_mode: + # Pixel Mode: skip character mapping, send raw RGB rgb = bgr_frame[:, :, ::-1] if qb > 0: rgb = (rgb >> qb) << qb - frame_buf[:, :, 0] = char_codes + frame_buf[:, :, 0] = 0xDB frame_buf[:, :, 1:] = rgb await websocket.send_bytes(frame_buf.tobytes()) + else: + 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 @@ -278,55 +335,162 @@ async def websocket_endpoint(websocket: WebSocket): print("Client disconnected from the stream.") +ASCII_LOGO = "\033[36m" + r""" + _ ____ ____ ___ _ ___ _ _ _____ + / \ / ___| / ___|_ _| | |_ _| \ | | ____| + / _ \ \___ \| | | || | | || \| | _| + / ___ \ ___) | |___ | || |___ | || |\ | |___ +/_/ \_\____/ \____|___|_____|___|_| \_|_____| +""" + "\033[0m" + +HELP_TEXT = "\033[1;37m" + """ +╔═══════════════════════════════════════════════════╗ +║ ASCILINE — COMMANDS ║ +╠═══════════════════════════════════════════════════╣ +║ ║ +║ \033[36m/help\033[1;37m Show this help message ║ +║ \033[36m/status\033[1;37m Show current server & playback info ║ +║ \033[36m/quit\033[1;37m Stop the server and exit ║ +║ ║ +╠═══════════════════════════════════════════════════╣ +║ CLI LAUNCH OPTIONS ║ +╠═══════════════════════════════════════════════════╣ +║ ║ +║ \033[33m─── Source ───\033[1;37m ║ +║ \033[32mvideo\033[1;37m Path to a single video file ║ +║ \033[32m--playlist\033[1;37m JSON playlist file ║ +║ \033[32m--folder\033[1;37m Play all videos in a folder ║ +║ ║ +║ \033[33m─── Render ───\033[1;37m ║ +║ \033[32m--mode\033[1;37m \033[35m1-5\033[1;37m Color quality ║ +║ 1=B&W 2=512c 3=32Kc 4=262Kc 5=16M ║ +║ \033[32m--pixel\033[1;37m Pixel block mode (with mode 2-5) ║ +║ \033[32m--cols\033[1;37m \033[35mN\033[1;37m Grid columns (default: 200) ║ +║ \033[32m--rows\033[1;37m \033[35mN\033[1;37m Grid rows (default: auto) ║ +║ ║ +║ \033[33m─── Playback ───\033[1;37m ║ +║ \033[32m--vol\033[1;37m \033[35m0-5\033[1;37m Volume (0=mute, 1=normal, 5=2x) ║ +║ \033[32m--loop\033[1;37m Loop the playlist infinitely ║ +║ ║ +║ \033[33m─── Server ───\033[1;37m ║ +║ \033[32m--port\033[1;37m \033[35mN\033[1;37m Server port (default: 8000) ║ +║ ║ +╚═══════════════════════════════════════════════════╝ +""" + "\033[0m" + + +def print_status(): + """Prints current server status.""" + queue = getattr(app.state, "queue", []) + idx = getattr(app.state, "current_index", 0) + loop = getattr(app.state, "loop", False) + cols = getattr(app.state, "cols", 0) + rows = getattr(app.state, "rows", 0) + + print(f"\n\033[1;37m{'═'*55}\033[0m") + print(f" \033[32m▶\033[0m \033[1mQueue\033[0m : {len(queue)} video(s)") + print(f" \033[32m▶\033[0m \033[1mNow Playing\033[0m: {idx + 1}/{len(queue)}") + if queue and idx < len(queue): + entry = queue[idx] + px = ' \033[35m[PIXEL]\033[0m' if entry.get('pixel') else '' + print(f" \033[32m▶\033[0m \033[1mVideo\033[0m : \033[36m{entry['video']}\033[0m") + print(f" \033[32m▶\033[0m \033[1mSettings\033[0m : mode={entry['mode']}{px} vol={entry['vol']}") + print(f" \033[32m▶\033[0m \033[1mResolution\033[0m : {cols}x{rows}") + print(f" \033[32m▶\033[0m \033[1mLoop\033[0m : {'ON' if loop else 'OFF'}") + print(f"\033[1;37m{'═'*55}\033[0m\n") + + +def command_loop(): + """Interactive command listener — runs in main thread alongside uvicorn.""" + print(f" \033[90mType \033[36m/help\033[90m for available commands.\033[0m\n") + while True: + try: + cmd = input().strip().lower() + if cmd in ('/help', 'help'): + print(HELP_TEXT) + elif cmd in ('/status', 'status'): + print_status() + elif cmd in ('/quit', 'quit', 'exit'): + print("\n \033[33m⏹ Shutting down ASCILINE...\033[0m\n") + os._exit(0) + elif cmd: + print(f" \033[90mUnknown command: '{cmd}'. Type \033[36m/help\033[90m for options.\033[0m") + except (EOFError, KeyboardInterrupt): + print("\n \033[33m⏹ Shutting down ASCILINE...\033[0m\n") + os._exit(0) + + if __name__ == "__main__": import argparse + import os + import threading + + # Enable ANSI escape sequences on Windows + os.system("") parser = argparse.ArgumentParser( - description="Real-Time ASCII Web Server", + description=f"{ASCII_LOGO}\nReal-Time ASCII Web Server\n" + "Stream local videos to your browser with high performance ASCII and Pixel rendering.", formatter_class=argparse.RawTextHelpFormatter ) - # ── Source (mutually exclusive priority: playlist > folder > video) ── - parser.add_argument( + # ── Source ── + src = parser.add_argument_group('\033[33mSource\033[0m') + src.add_argument( "video", nargs="?", default="video.mp4", - help="Single video file to stream (legacy mode)" + help="Single video file to stream" ) - parser.add_argument( + src.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( + src.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( + # ── Render ── + render = parser.add_argument_group('\033[33mRender\033[0m') + render.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" + help="Color quality: 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( + render.add_argument( + "--pixel", + action="store_true", default=False, + help="Pixel mode: replaces ASCII characters with colored blocks (combine with --mode 2-5)" + ) + render.add_argument("--cols", type=int, default=200, help="Grid columns (default: 200)") + render.add_argument("--rows", type=int, default=0, help="Grid rows (default: auto from video aspect ratio)") + + # ── Playback ── + playback = parser.add_argument_group('\033[33mPlayback\033[0m') + playback.add_argument( "--vol", type=int, default=1, - help="Volume 0-5 (0=muted, 1=normal, 5=double) — global default" + help="Volume 0-5 (0=muted, 1=normal, 5=double)" ) + playback.add_argument("--loop", action="store_true", default=False, help="Loop the queue infinitely") + + # ── Server ── + srv = parser.add_argument_group('\033[33mServer\033[0m') + srv.add_argument("--port", type=int, default=8000, help="Server port (default: 8000)") args = parser.parse_args() + # Validate: --pixel requires color mode (2-5) + if args.pixel and args.mode == 1: + print("[ERROR] --pixel requires a color mode (--mode 2-5). B&W mode is text-only.") + exit(1) + # Build the queue queue = build_queue(args) @@ -341,16 +505,33 @@ if __name__ == "__main__": 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}") + # ── Startup Banner ── + print(ASCII_LOGO) + print(f"\033[1;37m{'═'*55}\033[0m") + print(f" \033[32m▶\033[0m \033[1mQueue\033[0m : {len(queue)} video(s)") + print(f" \033[32m▶\033[0m \033[1mLoop\033[0m : {'ON' if args.loop else 'OFF'}") + res_str = f"{args.cols}x{args.rows}" if args.rows > 0 else f"{args.cols}x(auto)" + print(f" \033[32m▶\033[0m \033[1mResolution\033[0m: {res_str}") + print(f" \033[32m▶\033[0m \033[1mDefault\033[0m : mode={args.mode} | pixel={'ON' if args.pixel else 'OFF'} | vol={args.vol}") + print(f"\033[1;37m{'─'*55}\033[0m") 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") + px = ' \033[35m[PIXEL]\033[0m' if entry.get('pixel') else '' + print(f" {i:2}. \033[36m{entry['video']}\033[0m (mode={entry['mode']}{px} vol={entry['vol']})") + print(f"\033[1;37m{'═'*55}\033[0m\n") + print(f" \033[1;32m🚀 Server live →\033[0m \033[4;36mhttp://localhost:{args.port}\033[0m\n") - uvicorn.run(app, host="0.0.0.0", port=args.port, ws_ping_interval=None, ws_ping_timeout=None) + # ── Run server in background thread, command loop in main thread ── + server_thread = threading.Thread( + target=uvicorn.run, + args=(app,), + kwargs={ + "host": "0.0.0.0", + "port": args.port, + "ws_ping_interval": None, + "ws_ping_timeout": None, + "log_level": "warning", + }, + daemon=True + ) + server_thread.start() + command_loop()