2026-05-02 14:36:22 +03:00
|
|
|
"""
|
|
|
|
|
stream_server.py
|
|
|
|
|
================
|
2026-05-03 13:49:22 +03:00
|
|
|
Streams the core Video-to-ASCII engine to the web via HTTP/WebSocket.
|
|
|
|
|
Dependencies: pip install fastapi uvicorn websockets
|
2026-06-04 16:14:23 +03:00
|
|
|
|
|
|
|
|
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)
|
2026-05-02 14:36:22 +03:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2026-05-05 13:51:27 +03:00
|
|
|
import subprocess
|
2026-06-04 16:14:23 +03:00
|
|
|
import json
|
2026-05-02 14:36:22 +03:00
|
|
|
import numpy as np
|
|
|
|
|
import cv2
|
|
|
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
2026-05-05 13:51:27 +03:00
|
|
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
2026-05-02 14:36:22 +03:00
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
|
import uvicorn
|
|
|
|
|
import os
|
|
|
|
|
from websockets.exceptions import ConnectionClosed
|
|
|
|
|
|
2026-05-03 13:49:22 +03:00
|
|
|
# Import the existing engine (ascii_video_player2.py)
|
2026-05-02 14:36:22 +03:00
|
|
|
from ascii_video_player2 import VideoDecoder, AsciiMapper
|
feat: adaptive raw/zlib/delta frame codec (opt-in, backward compatible)
The binary protocol re-sent the full grid every frame. This adds an opt-in
per-frame codec that picks the smallest of three encodings and tags it in a
1-byte header, without changing the rendered output:
0 RAW framebuffer as-is (legacy)
1 ZLIB zlib(framebuffer)
2 DELTA only the cells changed since the previous frame, patched on top
Clients opt in via /ws?codec=adaptive; omitting it yields the original protocol
byte-for-byte, so existing clients are unaffected. A keyframe is forced
periodically for resync. codec.js is shared by the browser and the Node test,
so the shipped decode path is the tested one.
Optional --quality {lossless,high,balanced,low} enables lossy temporal delta
(conditional replenishment): a colour cell is only re-sent once it drifts past a
tolerance from what the viewer already sees; the character plane stays exact.
Default lossless = bit-exact.
Measured wire savings (mode 5, 200x80): static screen 0.3% of legacy (~375x),
pixel mode 11.6%, high-motion 63% (never worse). Encoder tuned (zlib level 3,
smart candidate selection) to stay well under the frame budget.
Verified bit-exact two independent ways: Python->Node vectors and a live
adaptive-vs-legacy WebSocket diff. (A fuller mutation + Autobahn conformance
harness exists on request.)
2026-06-13 02:14:42 -04:00
|
|
|
from codec import encode_frame
|
2026-05-02 14:36:22 +03:00
|
|
|
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
|
2026-06-05 23:08:45 +03:00
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
2026-05-02 14:36:22 +03:00
|
|
|
# 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")
|
|
|
|
|
|
|
|
|
|
def get_html_content():
|
|
|
|
|
html_path = os.path.join(os.path.dirname(__file__), "index.html")
|
|
|
|
|
with open(html_path, "r", encoding="utf-8") as f:
|
|
|
|
|
return f.read()
|
|
|
|
|
|
2026-06-04 16:14:23 +03:00
|
|
|
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)
|
2026-06-05 23:08:45 +03:00
|
|
|
item.setdefault("pixel", args.pixel)
|
2026-06-11 14:25:24 +03:00
|
|
|
|
|
|
|
|
is_pixel = item.get("pixel", False)
|
|
|
|
|
default_cols = args.cols if args.cols is not None else (450 if is_pixel else 200)
|
|
|
|
|
item.setdefault("cols", default_cols)
|
|
|
|
|
item.setdefault("rows", args.rows)
|
2026-06-04 16:14:23 +03:00
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
if args.folder:
|
|
|
|
|
print(f"[FOLDER] Scanning: {args.folder}")
|
2026-06-05 23:08:45 +03:00
|
|
|
items = load_folder(args.folder, args.mode, args.vol)
|
2026-06-11 14:25:24 +03:00
|
|
|
default_cols = args.cols if args.cols is not None else (450 if args.pixel else 200)
|
2026-06-05 23:08:45 +03:00
|
|
|
for item in items:
|
|
|
|
|
item["pixel"] = args.pixel
|
2026-06-11 14:25:24 +03:00
|
|
|
item["cols"] = default_cols
|
|
|
|
|
item["rows"] = args.rows
|
2026-06-05 23:08:45 +03:00
|
|
|
return items
|
2026-06-04 16:14:23 +03:00
|
|
|
|
|
|
|
|
# Legacy: single video argument
|
2026-06-11 14:25:24 +03:00
|
|
|
default_cols = args.cols if args.cols is not None else (450 if args.pixel else 200)
|
|
|
|
|
return [{"video": resolve_video_path(args.video), "mode": args.mode, "vol": args.vol, "pixel": args.pixel, "cols": default_cols, "rows": args.rows}]
|
2026-06-04 16:14:23 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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.
|
|
|
|
|
# ──────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-02 14:36:22 +03:00
|
|
|
@app.get("/")
|
|
|
|
|
async def root():
|
2026-05-03 13:49:22 +03:00
|
|
|
"""Serves the Frontend (HTML/JS/CSS) file to the client."""
|
2026-05-02 14:36:22 +03:00
|
|
|
return HTMLResponse(get_html_content())
|
|
|
|
|
|
2026-06-04 16:14:23 +03:00
|
|
|
|
2026-05-05 13:51:27 +03:00
|
|
|
@app.get("/audio")
|
|
|
|
|
async def audio_stream():
|
|
|
|
|
"""
|
2026-06-04 16:14:23 +03:00
|
|
|
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)
|
2026-05-05 13:51:27 +03:00
|
|
|
"""
|
2026-06-04 16:14:23 +03:00
|
|
|
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)
|
|
|
|
|
|
2026-05-05 13:51:27 +03:00
|
|
|
if not os.path.exists(video_path):
|
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
raise HTTPException(status_code=404, detail="Video file not found")
|
2026-06-04 16:14:23 +03:00
|
|
|
|
|
|
|
|
# Map 1-5 → 1.0x-2.0x FFmpeg volume
|
|
|
|
|
ffmpeg_vol = 1.0 + (vol_level - 1) * 0.25
|
|
|
|
|
|
2026-05-05 13:51:27 +03:00
|
|
|
def audio_generator():
|
|
|
|
|
process = subprocess.Popen(
|
|
|
|
|
[
|
|
|
|
|
"ffmpeg",
|
|
|
|
|
"-i", video_path,
|
2026-06-04 16:14:23 +03:00
|
|
|
"-vn",
|
|
|
|
|
"-filter:a", f"volume={ffmpeg_vol}",
|
2026-05-05 13:51:27 +03:00
|
|
|
"-acodec", "libmp3lame",
|
2026-06-04 16:14:23 +03:00
|
|
|
"-ab", "128k",
|
|
|
|
|
"-ar", "44100",
|
|
|
|
|
"-f", "mp3",
|
2026-05-05 13:51:27 +03:00
|
|
|
"-loglevel", "quiet",
|
2026-06-04 16:14:23 +03:00
|
|
|
"pipe:1"
|
2026-05-05 13:51:27 +03:00
|
|
|
],
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.DEVNULL
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
chunk = process.stdout.read(4096)
|
|
|
|
|
if not chunk:
|
|
|
|
|
break
|
|
|
|
|
yield chunk
|
|
|
|
|
finally:
|
|
|
|
|
process.stdout.close()
|
|
|
|
|
process.wait()
|
2026-06-04 16:14:23 +03:00
|
|
|
|
2026-05-05 13:51:27 +03:00
|
|
|
return StreamingResponse(
|
|
|
|
|
audio_generator(),
|
|
|
|
|
media_type="audio/mpeg",
|
|
|
|
|
headers={"Accept-Ranges": "bytes"}
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-04 16:14:23 +03:00
|
|
|
|
2026-05-02 14:36:22 +03:00
|
|
|
@app.websocket("/ws")
|
|
|
|
|
async def websocket_endpoint(websocket: WebSocket):
|
|
|
|
|
"""
|
2026-06-04 16:14:23 +03:00
|
|
|
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.
|
2026-05-02 14:36:22 +03:00
|
|
|
"""
|
|
|
|
|
await websocket.accept()
|
2026-06-04 16:14:23 +03:00
|
|
|
|
feat: adaptive raw/zlib/delta frame codec (opt-in, backward compatible)
The binary protocol re-sent the full grid every frame. This adds an opt-in
per-frame codec that picks the smallest of three encodings and tags it in a
1-byte header, without changing the rendered output:
0 RAW framebuffer as-is (legacy)
1 ZLIB zlib(framebuffer)
2 DELTA only the cells changed since the previous frame, patched on top
Clients opt in via /ws?codec=adaptive; omitting it yields the original protocol
byte-for-byte, so existing clients are unaffected. A keyframe is forced
periodically for resync. codec.js is shared by the browser and the Node test,
so the shipped decode path is the tested one.
Optional --quality {lossless,high,balanced,low} enables lossy temporal delta
(conditional replenishment): a colour cell is only re-sent once it drifts past a
tolerance from what the viewer already sees; the character plane stays exact.
Default lossless = bit-exact.
Measured wire savings (mode 5, 200x80): static screen 0.3% of legacy (~375x),
pixel mode 11.6%, high-motion 63% (never worse). Encoder tuned (zlib level 3,
smart candidate selection) to stay well under the frame budget.
Verified bit-exact two independent ways: Python->Node vectors and a live
adaptive-vs-legacy WebSocket diff. (A fuller mutation + Autobahn conformance
harness exists on request.)
2026-06-13 02:14:42 -04:00
|
|
|
# Opt-in adaptive codec (raw/zlib/delta). Legacy clients omit it and get
|
|
|
|
|
# the original uncompressed binary protocol, byte-for-byte unchanged.
|
|
|
|
|
adaptive = websocket.query_params.get("codec") == "adaptive"
|
|
|
|
|
tolerance = getattr(app.state, "tolerance", 0) # lossy colour drift budget
|
|
|
|
|
|
2026-06-04 16:14:23 +03:00
|
|
|
queue = getattr(app.state, "queue", [])
|
|
|
|
|
loop = getattr(app.state, "loop", False)
|
|
|
|
|
|
|
|
|
|
if not queue:
|
|
|
|
|
await websocket.send_text("Error: No video in queue!")
|
2026-05-02 14:36:22 +03:00
|
|
|
await websocket.close()
|
|
|
|
|
return
|
|
|
|
|
|
2026-06-04 16:14:23 +03:00
|
|
|
queue_index = 0 # local index; advances through the queue
|
2026-05-02 14:36:22 +03:00
|
|
|
|
|
|
|
|
try:
|
2026-06-04 16:14:23 +03:00
|
|
|
while True:
|
|
|
|
|
entry = queue[queue_index]
|
|
|
|
|
video_path = entry["video"]
|
|
|
|
|
render_mode= entry["mode"]
|
2026-06-05 23:08:45 +03:00
|
|
|
pixel_mode = entry.get("pixel", False)
|
2026-06-11 14:25:24 +03:00
|
|
|
cols = entry.get("cols", 200)
|
|
|
|
|
rows_cfg = entry.get("rows", 0)
|
2026-06-04 16:14:23 +03:00
|
|
|
|
|
|
|
|
# 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} "
|
2026-06-05 23:08:45 +03:00
|
|
|
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
|
2026-06-04 16:14:23 +03:00
|
|
|
|
|
|
|
|
try:
|
2026-06-07 23:16:25 +03:00
|
|
|
decoder = VideoDecoder(video_path, cols, rows, skip_gray=pixel_mode)
|
2026-06-04 16:14:23 +03:00
|
|
|
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()
|
2026-06-07 23:16:25 +03:00
|
|
|
source_fps = decoder.fps
|
|
|
|
|
MAX_FPS = 30
|
2026-06-04 16:14:23 +03:00
|
|
|
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)
|
|
|
|
|
|
2026-06-07 23:16:25 +03:00
|
|
|
# ── FPS DECIMATION ──
|
|
|
|
|
# If source > 30 FPS, skip every Nth frame using grab() (no decode).
|
|
|
|
|
# This halves CPU load for 60 FPS sources.
|
|
|
|
|
if source_fps > MAX_FPS:
|
|
|
|
|
skip_n = round(source_fps / MAX_FPS) # e.g. 60/30 = 2
|
|
|
|
|
effective_fps = source_fps / skip_n
|
|
|
|
|
else:
|
|
|
|
|
skip_n = 1
|
|
|
|
|
effective_fps = source_fps
|
|
|
|
|
frame_t = 1.0 / effective_fps
|
|
|
|
|
|
|
|
|
|
await websocket.send_text(f"INIT:{effective_fps}:{render_mode}:{cols}:{rows}:{int(pixel_mode)}")
|
|
|
|
|
if skip_n > 1:
|
|
|
|
|
print(f"[FPS CAP] {source_fps} FPS → {effective_fps} FPS (skip every {skip_n} frames)")
|
2026-06-04 16:14:23 +03:00
|
|
|
|
|
|
|
|
frame_buf = np.empty((rows, cols, 4), dtype=np.uint8) if render_mode > 1 else None
|
|
|
|
|
|
2026-06-07 23:16:25 +03:00
|
|
|
import struct
|
|
|
|
|
start_time = asyncio.get_event_loop().time()
|
|
|
|
|
frame_index = 0
|
feat: adaptive raw/zlib/delta frame codec (opt-in, backward compatible)
The binary protocol re-sent the full grid every frame. This adds an opt-in
per-frame codec that picks the smallest of three encodings and tags it in a
1-byte header, without changing the rendered output:
0 RAW framebuffer as-is (legacy)
1 ZLIB zlib(framebuffer)
2 DELTA only the cells changed since the previous frame, patched on top
Clients opt in via /ws?codec=adaptive; omitting it yields the original protocol
byte-for-byte, so existing clients are unaffected. A keyframe is forced
periodically for resync. codec.js is shared by the browser and the Node test,
so the shipped decode path is the tested one.
Optional --quality {lossless,high,balanced,low} enables lossy temporal delta
(conditional replenishment): a colour cell is only re-sent once it drifts past a
tolerance from what the viewer already sees; the character plane stays exact.
Default lossless = bit-exact.
Measured wire savings (mode 5, 200x80): static screen 0.3% of legacy (~375x),
pixel mode 11.6%, high-motion 63% (never worse). Encoder tuned (zlib level 3,
smart candidate selection) to stay well under the frame budget.
Verified bit-exact two independent ways: Python->Node vectors and a live
adaptive-vs-legacy WebSocket diff. (A fuller mutation + Autobahn conformance
harness exists on request.)
2026-06-13 02:14:42 -04:00
|
|
|
prev_frame = None # previous framebuffer snapshot for delta coding
|
2026-06-07 23:16:25 +03:00
|
|
|
|
|
|
|
|
# Pre-allocate send buffer WITH header space to avoid per-frame concat
|
|
|
|
|
if pixel_mode:
|
|
|
|
|
# Zero-Copy Pixel: 4-byte header + raw BGR (3 bytes per pixel)
|
|
|
|
|
pixel_send_buf = bytearray(4 + rows * cols * 3)
|
|
|
|
|
elif render_mode > 1:
|
|
|
|
|
# ASCII Color: 4-byte header + [char,R,G,B] per pixel
|
|
|
|
|
ascii_send_buf = bytearray(4 + rows * cols * 4)
|
|
|
|
|
|
|
|
|
|
raw_frame_num = 0
|
2026-06-04 16:14:23 +03:00
|
|
|
try:
|
2026-06-07 23:16:25 +03:00
|
|
|
while True:
|
|
|
|
|
# ── FPS DECIMATION via grab() ──
|
|
|
|
|
# For 60→30 fps: grab (skip) 1 frame, then decode 1 frame.
|
|
|
|
|
# grab() is ~10x faster than read() because it skips decoding.
|
|
|
|
|
for _ in range(skip_n - 1):
|
|
|
|
|
if not decoder.grab():
|
|
|
|
|
break # EOF reached during skip
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
gray_frame, bgr_frame = next(decoder)
|
|
|
|
|
except StopIteration:
|
|
|
|
|
break
|
2026-06-04 16:14:23 +03:00
|
|
|
|
2026-06-05 23:08:45 +03:00
|
|
|
if pixel_mode:
|
feat: adaptive raw/zlib/delta frame codec (opt-in, backward compatible)
The binary protocol re-sent the full grid every frame. This adds an opt-in
per-frame codec that picks the smallest of three encodings and tags it in a
1-byte header, without changing the rendered output:
0 RAW framebuffer as-is (legacy)
1 ZLIB zlib(framebuffer)
2 DELTA only the cells changed since the previous frame, patched on top
Clients opt in via /ws?codec=adaptive; omitting it yields the original protocol
byte-for-byte, so existing clients are unaffected. A keyframe is forced
periodically for resync. codec.js is shared by the browser and the Node test,
so the shipped decode path is the tested one.
Optional --quality {lossless,high,balanced,low} enables lossy temporal delta
(conditional replenishment): a colour cell is only re-sent once it drifts past a
tolerance from what the viewer already sees; the character plane stays exact.
Default lossless = bit-exact.
Measured wire savings (mode 5, 200x80): static screen 0.3% of legacy (~375x),
pixel mode 11.6%, high-motion 63% (never worse). Encoder tuned (zlib level 3,
smart candidate selection) to stay well under the frame budget.
Verified bit-exact two independent ways: Python->Node vectors and a live
adaptive-vs-legacy WebSocket diff. (A fuller mutation + Autobahn conformance
harness exists on request.)
2026-06-13 02:14:42 -04:00
|
|
|
# ── PIXEL MODE: raw BGR (3 bytes/cell) ──
|
|
|
|
|
if adaptive:
|
|
|
|
|
msg, prev_frame = encode_frame(
|
|
|
|
|
np.ascontiguousarray(bgr_frame),
|
|
|
|
|
prev_frame, frame_index, tolerance=tolerance)
|
|
|
|
|
await websocket.send_bytes(msg)
|
|
|
|
|
else:
|
|
|
|
|
# ── ZERO-COPY PIXEL MODE (legacy) ──
|
|
|
|
|
struct.pack_into(">I", pixel_send_buf, 0, frame_index)
|
|
|
|
|
pixel_send_buf[4:] = bgr_frame.tobytes()
|
|
|
|
|
await websocket.send_bytes(bytes(pixel_send_buf))
|
2026-06-05 23:08:45 +03:00
|
|
|
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]
|
2026-06-07 23:16:25 +03:00
|
|
|
await websocket.send_text(f"{frame_index}\n" + '\n'.join(lines))
|
2026-06-05 23:08:45 +03:00
|
|
|
else:
|
|
|
|
|
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
|
feat: adaptive raw/zlib/delta frame codec (opt-in, backward compatible)
The binary protocol re-sent the full grid every frame. This adds an opt-in
per-frame codec that picks the smallest of three encodings and tags it in a
1-byte header, without changing the rendered output:
0 RAW framebuffer as-is (legacy)
1 ZLIB zlib(framebuffer)
2 DELTA only the cells changed since the previous frame, patched on top
Clients opt in via /ws?codec=adaptive; omitting it yields the original protocol
byte-for-byte, so existing clients are unaffected. A keyframe is forced
periodically for resync. codec.js is shared by the browser and the Node test,
so the shipped decode path is the tested one.
Optional --quality {lossless,high,balanced,low} enables lossy temporal delta
(conditional replenishment): a colour cell is only re-sent once it drifts past a
tolerance from what the viewer already sees; the character plane stays exact.
Default lossless = bit-exact.
Measured wire savings (mode 5, 200x80): static screen 0.3% of legacy (~375x),
pixel mode 11.6%, high-motion 63% (never worse). Encoder tuned (zlib level 3,
smart candidate selection) to stay well under the frame budget.
Verified bit-exact two independent ways: Python->Node vectors and a live
adaptive-vs-legacy WebSocket diff. (A fuller mutation + Autobahn conformance
harness exists on request.)
2026-06-13 02:14:42 -04:00
|
|
|
if adaptive:
|
|
|
|
|
msg, prev_frame = encode_frame(
|
|
|
|
|
frame_buf, prev_frame, frame_index,
|
|
|
|
|
tolerance=tolerance)
|
|
|
|
|
await websocket.send_bytes(msg)
|
|
|
|
|
else:
|
|
|
|
|
struct.pack_into(">I", ascii_send_buf, 0, frame_index)
|
|
|
|
|
ascii_send_buf[4:] = frame_buf.tobytes()
|
|
|
|
|
await websocket.send_bytes(bytes(ascii_send_buf))
|
2026-06-04 16:14:23 +03:00
|
|
|
|
2026-06-07 23:16:25 +03:00
|
|
|
elapsed = asyncio.get_event_loop().time() - start_time
|
|
|
|
|
wait = (frame_index * frame_t) - elapsed
|
2026-06-04 16:14:23 +03:00
|
|
|
if wait > 0:
|
|
|
|
|
await asyncio.sleep(wait)
|
2026-06-07 23:16:25 +03:00
|
|
|
|
|
|
|
|
frame_index += 1
|
2026-06-04 16:14:23 +03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-05-02 14:36:22 +03:00
|
|
|
except (WebSocketDisconnect, ConnectionClosed):
|
2026-05-03 13:49:22 +03:00
|
|
|
print("Client disconnected from the stream.")
|
2026-06-04 16:14:23 +03:00
|
|
|
|
2026-05-02 14:36:22 +03:00
|
|
|
|
2026-06-05 23:08:45 +03:00
|
|
|
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 ''
|
2026-06-11 14:25:24 +03:00
|
|
|
cols = entry.get('cols', cols)
|
|
|
|
|
rows = entry.get('rows', rows)
|
2026-06-05 23:08:45 +03:00
|
|
|
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']}")
|
2026-06-11 14:25:24 +03:00
|
|
|
res_str = f"{cols}x{rows}" if rows > 0 else f"{cols}x(auto)"
|
|
|
|
|
print(f" \033[32m▶\033[0m \033[1mResolution\033[0m : {res_str}")
|
2026-06-05 23:08:45 +03:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-05-02 14:36:22 +03:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
import argparse
|
2026-06-05 23:08:45 +03:00
|
|
|
import os
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
# Enable ANSI escape sequences on Windows
|
|
|
|
|
os.system("")
|
2026-06-04 16:14:23 +03:00
|
|
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
2026-06-05 23:08:45 +03:00
|
|
|
description=f"{ASCII_LOGO}\nReal-Time ASCII Web Server\n"
|
|
|
|
|
"Stream local videos to your browser with high performance ASCII and Pixel rendering.",
|
2026-06-04 16:14:23 +03:00
|
|
|
formatter_class=argparse.RawTextHelpFormatter
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-05 23:08:45 +03:00
|
|
|
# ── Source ──
|
|
|
|
|
src = parser.add_argument_group('\033[33mSource\033[0m')
|
|
|
|
|
src.add_argument(
|
2026-06-04 16:14:23 +03:00
|
|
|
"video",
|
|
|
|
|
nargs="?",
|
|
|
|
|
default="video.mp4",
|
2026-06-05 23:08:45 +03:00
|
|
|
help="Single video file to stream"
|
2026-06-04 16:14:23 +03:00
|
|
|
)
|
2026-06-05 23:08:45 +03:00
|
|
|
src.add_argument(
|
2026-06-04 16:14:23 +03:00
|
|
|
"--playlist",
|
|
|
|
|
metavar="FILE",
|
|
|
|
|
default=None,
|
|
|
|
|
help="Path to a playlist JSON file\n"
|
|
|
|
|
" Format: [{\"video\": \"a.mp4\", \"mode\": 5, \"vol\": 3}, ...]"
|
|
|
|
|
)
|
2026-06-05 23:08:45 +03:00
|
|
|
src.add_argument(
|
2026-06-04 16:14:23 +03:00
|
|
|
"--folder",
|
|
|
|
|
metavar="DIR",
|
|
|
|
|
default=None,
|
|
|
|
|
help="Path to a folder; plays all videos in filesystem order"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-05 23:08:45 +03:00
|
|
|
# ── Render ──
|
|
|
|
|
render = parser.add_argument_group('\033[33mRender\033[0m')
|
|
|
|
|
render.add_argument(
|
2026-06-04 16:14:23 +03:00
|
|
|
"--mode",
|
|
|
|
|
type=int, choices=[1, 2, 3, 4, 5], default=1,
|
2026-06-05 23:08:45 +03:00
|
|
|
help="Color quality: 1=B&W 2=512c 3=32Kc 4=262Kc 5=16M Ultra"
|
|
|
|
|
)
|
|
|
|
|
render.add_argument(
|
|
|
|
|
"--pixel",
|
|
|
|
|
action="store_true", default=False,
|
|
|
|
|
help="Pixel mode: replaces ASCII characters with colored blocks (combine with --mode 2-5)"
|
2026-06-04 16:14:23 +03:00
|
|
|
)
|
2026-06-11 14:25:24 +03:00
|
|
|
render.add_argument("--cols", type=int, default=None, help="Grid columns (default: 200 for text, 450 for pixel)")
|
2026-06-05 23:08:45 +03:00
|
|
|
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(
|
2026-06-04 16:14:23 +03:00
|
|
|
"--vol",
|
|
|
|
|
type=int, default=1,
|
2026-06-05 23:08:45 +03:00
|
|
|
help="Volume 0-5 (0=muted, 1=normal, 5=double)"
|
2026-06-04 16:14:23 +03:00
|
|
|
)
|
2026-06-05 23:08:45 +03:00
|
|
|
playback.add_argument("--loop", action="store_true", default=False, help="Loop the queue infinitely")
|
feat: adaptive raw/zlib/delta frame codec (opt-in, backward compatible)
The binary protocol re-sent the full grid every frame. This adds an opt-in
per-frame codec that picks the smallest of three encodings and tags it in a
1-byte header, without changing the rendered output:
0 RAW framebuffer as-is (legacy)
1 ZLIB zlib(framebuffer)
2 DELTA only the cells changed since the previous frame, patched on top
Clients opt in via /ws?codec=adaptive; omitting it yields the original protocol
byte-for-byte, so existing clients are unaffected. A keyframe is forced
periodically for resync. codec.js is shared by the browser and the Node test,
so the shipped decode path is the tested one.
Optional --quality {lossless,high,balanced,low} enables lossy temporal delta
(conditional replenishment): a colour cell is only re-sent once it drifts past a
tolerance from what the viewer already sees; the character plane stays exact.
Default lossless = bit-exact.
Measured wire savings (mode 5, 200x80): static screen 0.3% of legacy (~375x),
pixel mode 11.6%, high-motion 63% (never worse). Encoder tuned (zlib level 3,
smart candidate selection) to stay well under the frame budget.
Verified bit-exact two independent ways: Python->Node vectors and a live
adaptive-vs-legacy WebSocket diff. (A fuller mutation + Autobahn conformance
harness exists on request.)
2026-06-13 02:14:42 -04:00
|
|
|
playback.add_argument(
|
|
|
|
|
"--quality",
|
|
|
|
|
choices=["lossless", "high", "balanced", "low"], default="lossless",
|
|
|
|
|
help="Adaptive-codec colour fidelity (lossless = bit-exact; lower = "
|
|
|
|
|
"smaller stream via lossy temporal delta). Chars always exact."
|
|
|
|
|
)
|
2026-06-05 23:08:45 +03:00
|
|
|
|
|
|
|
|
# ── Server ──
|
|
|
|
|
srv = parser.add_argument_group('\033[33mServer\033[0m')
|
|
|
|
|
srv.add_argument("--port", type=int, default=8000, help="Server port (default: 8000)")
|
2026-06-04 16:14:23 +03:00
|
|
|
|
2026-05-02 14:36:22 +03:00
|
|
|
args = parser.parse_args()
|
2026-06-04 16:14:23 +03:00
|
|
|
|
2026-06-05 23:08:45 +03:00
|
|
|
# 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)
|
|
|
|
|
|
2026-06-04 16:14:23 +03:00
|
|
|
# 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
|
feat: adaptive raw/zlib/delta frame codec (opt-in, backward compatible)
The binary protocol re-sent the full grid every frame. This adds an opt-in
per-frame codec that picks the smallest of three encodings and tags it in a
1-byte header, without changing the rendered output:
0 RAW framebuffer as-is (legacy)
1 ZLIB zlib(framebuffer)
2 DELTA only the cells changed since the previous frame, patched on top
Clients opt in via /ws?codec=adaptive; omitting it yields the original protocol
byte-for-byte, so existing clients are unaffected. A keyframe is forced
periodically for resync. codec.js is shared by the browser and the Node test,
so the shipped decode path is the tested one.
Optional --quality {lossless,high,balanced,low} enables lossy temporal delta
(conditional replenishment): a colour cell is only re-sent once it drifts past a
tolerance from what the viewer already sees; the character plane stays exact.
Default lossless = bit-exact.
Measured wire savings (mode 5, 200x80): static screen 0.3% of legacy (~375x),
pixel mode 11.6%, high-motion 63% (never worse). Encoder tuned (zlib level 3,
smart candidate selection) to stay well under the frame budget.
Verified bit-exact two independent ways: Python->Node vectors and a live
adaptive-vs-legacy WebSocket diff. (A fuller mutation + Autobahn conformance
harness exists on request.)
2026-06-13 02:14:42 -04:00
|
|
|
app.state.tolerance = {"lossless": 0, "high": 4, "balanced": 8, "low": 16}[args.quality]
|
2026-06-11 14:25:24 +03:00
|
|
|
global_default_cols = args.cols if args.cols is not None else (450 if args.pixel else 200)
|
|
|
|
|
app.state.cols = global_default_cols
|
2026-06-04 16:14:23 +03:00
|
|
|
app.state.rows = args.rows
|
|
|
|
|
|
2026-06-07 23:16:25 +03:00
|
|
|
# ── High FPS Warning ──
|
|
|
|
|
high_fps_videos = []
|
|
|
|
|
for entry in queue:
|
|
|
|
|
cap = cv2.VideoCapture(entry['video'])
|
|
|
|
|
if cap.isOpened():
|
|
|
|
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
|
|
|
|
if fps > 35: # Consider > 35 as high FPS
|
|
|
|
|
high_fps_videos.append((entry['video'], fps))
|
|
|
|
|
cap.release()
|
|
|
|
|
|
|
|
|
|
if high_fps_videos:
|
|
|
|
|
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)
|
|
|
|
|
|
2026-06-05 23:08:45 +03:00
|
|
|
# ── 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'}")
|
2026-06-11 14:25:24 +03:00
|
|
|
res_str = f"{global_default_cols}x{args.rows}" if args.rows > 0 else f"{global_default_cols}x(auto)"
|
2026-06-05 23:08:45 +03:00
|
|
|
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")
|
2026-06-04 16:14:23 +03:00
|
|
|
for i, entry in enumerate(queue, 1):
|
2026-06-05 23:08:45 +03:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
# ── 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()
|