mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-17 22:35:13 +02:00
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:
parent
720dccb149
commit
9b6e379ee3
6 changed files with 55 additions and 40 deletions
14
README.md
14
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.
|
||||
|
||||
|
|
|
|||
23
app.js
23
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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
from multiprocessing import Process; import uvicorn; uvicorn.run('stream_server:app', host='127.0.0.1', port=8000, log_level='info')
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
0
test.txt
0
test.txt
Loading…
Add table
Add a link
Reference in a new issue