Implement high framerate, HDR mode, and latency improvements

Added `--max-fps` flag to explicitly control max frame rate up to 120 FPS.
Introduced 1440p resolution support in `get_cols_from_res`.
Added Mode 6 to support 32M Colors with experimental HDR (display-p3).
Optimized CV2 capture and javascript buffer logic for live latency reduction.
Added yielding to Python async loop for high FPS encoding stability.
Updated README.md to represent these fork enhancements.
This commit is contained in:
taisrisk 2026-06-15 15:39:15 +00:00
parent 720dccb149
commit 9b6e379ee3
6 changed files with 55 additions and 40 deletions

View file

@ -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.

23
app.js
View file

@ -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}`;
}

View file

@ -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

View file

@ -1 +0,0 @@
from multiprocessing import Process; import uvicorn; uvicorn.run('stream_server:app', host='127.0.0.1', port=8000, log_level='info')

View file

@ -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)

View file