diff --git a/README.md b/README.md index cf90974..5f59eee 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# 🌌 ASCILINE Engine +# 🌌 ASCILINE Engine (Upgraded Fork) + +> **Note:** This is an upgraded fork of the original ASCILINE project, adding high FPS support (60/120fps), low-latency live streaming, 1440p resolution presets, and an experimental 32M Ultra HDR rendering mode. If you love the core engine, please support the [original creator](https://github.com/YusufB5/ASCILINE). **ASCILINE** is a high-performance, cross-platform real-time ASCII video rendering engine. **Our core objective is to transform the web into a highly dynamic and interactive typographic canvas.** By mapping pixels to text-based representations, we unlock new possibilities for web media delivery. @@ -18,9 +20,9 @@ ## 🚀 Technical Features - **Cross-Platform**: Runs seamlessly on Windows, macOS, and Linux. -- **Real-Time ASCII Streaming**: Low-latency video-to-ASCII conversion. -- **Real-Time Pixel Streaming**: Replaces characters with colored blocks, approaching 360p video quality. -- **High Performance**: Uses **HTML5 Canvas** for rendering, optimized for cinematic 24-30 FPS playback. High-FPS sources are automatically decimated for stability. +- **Real-Time ASCII Streaming**: Ultra low-latency video-to-ASCII conversion, fully compatible with live RTMP/HTTP streams. +- **Real-Time Pixel Streaming**: Replaces characters with colored blocks, approaching true HD video quality with high resolutions. +- **High Performance**: Uses **HTML5 Canvas** for rendering. Supports high-framerate playback (60 FPS, 120 FPS) with the new `--max-fps` flag! - **Master Clock Sync**: The audio track acts as the absolute master clock, guaranteeing perfect A/V synchronization. - **Low-Overhead Binary Protocol*: Frames are streamed as raw binary (`Uint8Array`) directly to the canvas, saving bandwidth and CPU. - **Multiple Color Modes**: Supports everything from classic B&W to 16M color ultra-fidelity. @@ -157,15 +159,17 @@ The engine supports different fidelity levels via the `--mode` flag: - `3`: 32K Colors - `4`: 262K Colors - `5`: 16M Colors (Ultra) +- `6`: 32M Colors (Experimental HDR display-p3) ```bash -python stream_server.py --mode 5 --cols 240 --rows 100 +python stream_server.py --mode 6 --res 1080p ``` ### 📐 Resolution & Auto-Scaling You can easily control the output quality by using the `--res` flag, which provides convenient presets that automatically set the optimal width for higher quality/density output: - `--res 480p` (Maps to 854 columns) - `--res 720p` (Maps to 1280 columns) - `--res 1080p` (Maps to 1920 columns) +- `--res 1440p` (Maps to 2560 columns) Alternatively, you can manually specify the width (`--cols`). ASCILINE will automatically calculate the correct `--rows` based on the source video's aspect ratio to prevent stretching. diff --git a/app.js b/app.js index 1262927..ca929ab 100644 --- a/app.js +++ b/app.js @@ -8,7 +8,7 @@ const player = document.getElementById('ascii-player'); const canvas = document.getElementById('ascii-canvas'); -const ctx = canvas.getContext('2d'); +let ctx = canvas.getContext('2d'); const statusEl = document.getElementById('status'); const container = document.getElementById('player-container'); const overlay = document.getElementById('play-overlay'); @@ -19,7 +19,7 @@ const volumeSlider = document.getElementById('volume-slider'); let state = 'IDLE'; // IDLE | PLAYING | PAUSED let ws = null; const frameBuffer = []; -const BUFFER_SIZE = 4; +const BUFFER_SIZE = 2; // Reduced buffer size for lower latency let codecDecoder = null; // Adaptive codec decoder (codec.js) let targetFps = 24; let frameInterval = 1000 / targetFps; @@ -166,6 +166,16 @@ function connectWebSocket() { renderMode = parseInt(p[2]); pixelMode = (p.length > 5 && parseInt(p[5]) === 1); const currentQueueIndex = (p.length > 6) ? parseInt(p[6]) : null; + + if (renderMode === 6) { + try { + // Re-initialize context for HDR + ctx = canvas.getContext('2d', { colorSpace: 'display-p3' }); + } catch (e) { + console.warn('display-p3 not supported by browser, falling back to sRGB'); + } + } + buildCanvas(parseInt(p[3]), parseInt(p[4])); // Initialize adaptive codec decoder (pixel=3 bytes, ASCII color=4 bytes) @@ -239,7 +249,7 @@ function connectWebSocket() { } } - while (frameBuffer.length > BUFFER_SIZE * 5) frameBuffer.shift(); + while (frameBuffer.length > BUFFER_SIZE * 3) frameBuffer.shift(); }; ws.onopen = () => { statusEl.textContent = 'Buffering...'; }; @@ -279,12 +289,13 @@ function renderFrame(now) { if (frameBuffer.length === 0) return; // A/V Sync: Drop frames that are too far behind the master clock (catch up) - while (frameBuffer.length > 1 && frameBuffer[0].time < masterClock - 0.1) { + // Made more aggressive for lower latency live playback + while (frameBuffer.length > 1 && frameBuffer[0].time < masterClock - 0.05) { frameBuffer.shift(); } // A/V Sync: Wait if the frame is in the future - if (frameBuffer[0].time > masterClock + 0.05) { + if (frameBuffer[0].time > masterClock + 0.02) { return; } @@ -296,7 +307,7 @@ function renderFrame(now) { currentFps = frameCount; frameCount = 0; lastFpsUpdate = now; - const modes = { 2: '512 Color', 3: '32K Color', 4: '262K Color', 5: '16M Ultra' }; + const modes = { 2: '512 Color', 3: '32K Color', 4: '262K Color', 5: '16M Ultra', 6: '32M HDR' }; const label = (modes[renderMode] || 'B&W') + (pixelMode ? ' PIXEL' : ''); statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${label}`; } diff --git a/ascii_video_player2.py b/ascii_video_player2.py index 673ad9f..e700fce 100644 --- a/ascii_video_player2.py +++ b/ascii_video_player2.py @@ -36,6 +36,11 @@ class VideoDecoder: def __init__(self, path: str, cols: int, rows: int, skip_gray: bool = False) -> None: self._cap = cv2.VideoCapture(path) + + # Optimize latency for live streams (HTTP/RTMP/RTSP) by reducing the buffer + if path.startswith(("http://", "https://", "rtmp://", "rtsp://", "udp://")): + self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + if not self._cap.isOpened(): raise FileNotFoundError(f"Could not open video file: {path!r}") @@ -322,7 +327,7 @@ if __name__ == "__main__": help="Color quality: 0=max quality, 3=max speed (default: 0)") parser.add_argument("-c", "--cols", type=int, default=0, help="Fixed grid width. If 0, auto-fits to terminal (default: 0)") - parser.add_argument("--res", type=str, choices=["480p", "720p", "1080p"], default=None, + parser.add_argument("--res", type=str, choices=["480p", "720p", "1080p", "1440p"], default=None, help="Resolution preset (overrides --cols)") args = parser.parse_args() @@ -331,7 +336,7 @@ if __name__ == "__main__": # Map resolution to cols res_cols = None if args.res: - res_map = {"480p": 854, "720p": 1280, "1080p": 1920} + res_map = {"480p": 854, "720p": 1280, "1080p": 1920, "1440p": 2560} res_cols = res_map.get(args.res.lower()) final_cols = res_cols if res_cols is not None else args.cols diff --git a/run_server.py b/run_server.py deleted file mode 100644 index 15306c0..0000000 --- a/run_server.py +++ /dev/null @@ -1 +0,0 @@ -from multiprocessing import Process; import uvicorn; uvicorn.run('stream_server:app', host='127.0.0.1', port=8000, log_level='info') diff --git a/stream_server.py b/stream_server.py index d3d4885..d9e160a 100644 --- a/stream_server.py +++ b/stream_server.py @@ -46,7 +46,8 @@ def get_cols_from_res(res: str) -> int | None: res_map = { "480p": 854, "720p": 1280, - "1080p": 1920 + "1080p": 1920, + "1440p": 2560 } return res_map.get(res.lower() if res else None) @@ -356,15 +357,15 @@ async def websocket_endpoint(websocket: WebSocket): mapper = AsciiMapper() source_fps = decoder.fps - MAX_FPS = 30 + MAX_FPS = getattr(app.state, "max_fps", 30) 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) + qb = {6: 0, 5: 0, 4: 2, 3: 3, 2: 5}.get(render_mode, 0) # ── FPS DECIMATION ── - # If source > 30 FPS, skip every Nth frame using grab() (no decode). - # This halves CPU load for 60 FPS sources. + # If source > MAX_FPS, skip every Nth frame using grab() (no decode). + # This halves CPU load for high FPS sources. if source_fps > MAX_FPS: - skip_n = round(source_fps / MAX_FPS) # e.g. 60/30 = 2 + skip_n = round(source_fps / MAX_FPS) effective_fps = source_fps / skip_n else: skip_n = 1 @@ -475,6 +476,9 @@ async def websocket_endpoint(websocket: WebSocket): wait = (frame_index * frame_t) - elapsed if wait > 0: await asyncio.sleep(wait) + else: + # Yield control to prevent event loop blocking on high-FPS or fast encodes + await asyncio.sleep(0) frame_index += 1 @@ -527,7 +531,7 @@ HELP_TEXT = "\033[1;37m" + """ ║ \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[32m--res\033[1;37m \033[35mR\033[1;37m Resolution preset (480p, 720p, 1080p)║ +║ \033[32m--res\033[1;37m \033[35mR\033[1;37m Resolution preset (480p, 720p, 1080p, 1440p)║ ║ ║ ║ \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) ║ @@ -626,17 +630,17 @@ if __name__ == "__main__": render = parser.add_argument_group('\033[33mRender\033[0m') render.add_argument( "--mode", - type=int, choices=[1, 2, 3, 4, 5], default=1, - help="Color quality: 1=B&W 2=512c 3=32Kc 4=262Kc 5=16M Ultra" + type=int, choices=[1, 2, 3, 4, 5, 6], default=1, + help="Color quality: 1=B&W 2=512c 3=32Kc 4=262Kc 5=16M Ultra 6=32M HDR" ) render.add_argument( "--pixel", action="store_true", default=False, - help="Pixel mode: replaces ASCII characters with colored blocks (combine with --mode 2-5)" + help="Pixel mode: replaces ASCII characters with colored blocks (combine with --mode 2-6)" ) render.add_argument("--cols", type=int, default=None, help="Grid columns (default: 200 for text, 450 for pixel)") render.add_argument("--rows", type=int, default=0, help="Grid rows (default: auto from video aspect ratio)") - render.add_argument("--res", type=str, choices=["480p", "720p", "1080p"], default=None, help="Resolution preset (overrides --cols)") + render.add_argument("--res", type=str, choices=["480p", "720p", "1080p", "1440p"], default=None, help="Resolution preset (overrides --cols)") # ── Playback ── playback = parser.add_argument_group('\033[33mPlayback\033[0m') @@ -652,6 +656,7 @@ if __name__ == "__main__": help="Adaptive-codec colour fidelity (lossless = bit-exact; lower = " "smaller stream via lossy temporal delta). Chars always exact." ) + playback.add_argument("--max-fps", type=int, default=30, help="Max FPS cap (e.g. 30, 60, 120)") # ── Server ── srv = parser.add_argument_group('\033[33mServer\033[0m') @@ -661,9 +666,9 @@ if __name__ == "__main__": args = parser.parse_args() - # Validate: --pixel requires color mode (2-5) + # Validate: --pixel requires color mode (2-6) if args.pixel and args.mode == 1: - print("[ERROR] --pixel requires a color mode (--mode 2-5). B&W mode is text-only.") + print("[ERROR] --pixel requires a color mode (--mode 2-6). B&W mode is text-only.") exit(1) # Build the queue @@ -679,6 +684,7 @@ if __name__ == "__main__": app.state.loop = args.loop app.state.tolerance = {"lossless": 0, "high": 4, "balanced": 8, "low": 16}[args.quality] app.state.debug = args.debug + app.state.max_fps = args.max_fps res_cols = get_cols_from_res(args.res) if res_cols is not None: @@ -695,7 +701,7 @@ if __name__ == "__main__": cap = cv2.VideoCapture(entry['video']) if cap.isOpened(): fps = cap.get(cv2.CAP_PROP_FPS) - if fps > 35: # Consider > 35 as high FPS + if fps > args.max_fps + 5: high_fps_videos.append((entry['video'], fps)) cap.release() @@ -703,18 +709,8 @@ if __name__ == "__main__": print("\n\033[1;33m[WARNING] High FPS Source(s) Detected:\033[0m") for vid, fps in high_fps_videos: print(f" - \033[36m{vid}\033[0m is \033[1;31m{fps:.1f} FPS\033[0m") - print("\033[33mASCILINE is optimized for 24-30 FPS cinematic playback.") - print("High FPS videos will automatically be decimated to ~30 FPS,") - print("but performance may still drop depending on the system's CPU.") - print("For optimal performance, we recommend using 30 FPS source videos.\033[0m\n") - - while True: - choice = input("\033[1mDo you want to continue anyway? (y/n): \033[0m").strip().lower() - if choice == 'y': - break - elif choice == 'n': - print("Exiting...") - exit(0) + print(f"\033[33mASCILINE is set to max {args.max_fps} FPS.") + print(f"High FPS videos will automatically be decimated to ~{args.max_fps} FPS.\033[0m\n") # ── Startup Banner ── print(ASCII_LOGO) diff --git a/test.txt b/test.txt deleted file mode 100644 index e69de29..0000000