mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-07-11 23:22:12 +02:00
feat: Add auto-scaling rows, pixel mode flag, and command loop
This commit is contained in:
parent
265d1b2e57
commit
8b31a7450a
2 changed files with 303 additions and 64 deletions
96
app.js
96
app.js
|
|
@ -23,6 +23,7 @@ const BUFFER_SIZE = 4;
|
||||||
let targetFps = 24;
|
let targetFps = 24;
|
||||||
let frameInterval = 1000 / targetFps;
|
let frameInterval = 1000 / targetFps;
|
||||||
let renderMode = 1;
|
let renderMode = 1;
|
||||||
|
let pixelMode = false;
|
||||||
let readyToRender = false;
|
let readyToRender = false;
|
||||||
|
|
||||||
// Grid & Dimensions
|
// Grid & Dimensions
|
||||||
|
|
@ -30,6 +31,9 @@ let gridCols = 0, gridRows = 0;
|
||||||
let charWidth = 0, charHeight = 0;
|
let charWidth = 0, charHeight = 0;
|
||||||
let xPos = null, yPos = null;
|
let xPos = null, yPos = null;
|
||||||
|
|
||||||
|
// Pixel Mode (--pixel) — ImageData pixel buffer
|
||||||
|
let dotImageData = null;
|
||||||
|
|
||||||
// Selection Layer optimization
|
// Selection Layer optimization
|
||||||
const textDecoder = new TextDecoder();
|
const textDecoder = new TextDecoder();
|
||||||
let selectionBuffer = null;
|
let selectionBuffer = null;
|
||||||
|
|
@ -48,16 +52,6 @@ for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i);
|
||||||
function buildCanvas(cols, rows) {
|
function buildCanvas(cols, rows) {
|
||||||
gridCols = cols;
|
gridCols = cols;
|
||||||
gridRows = rows;
|
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
|
// Sizing and positioning for both layers
|
||||||
const syncSize = (el) => {
|
const syncSize = (el) => {
|
||||||
|
|
@ -69,15 +63,64 @@ function buildCanvas(cols, rows) {
|
||||||
el.style.left = '0';
|
el.style.left = '0';
|
||||||
};
|
};
|
||||||
|
|
||||||
syncSize(canvas);
|
if (pixelMode) {
|
||||||
syncSize(player);
|
// ── 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';
|
// Selection Layer Buffer
|
||||||
ctx.textBaseline = 'top';
|
selectionBuffer = new Uint8Array((cols + 1) * rows);
|
||||||
xPos = new Float32Array(cols);
|
for (let r = 0; r < rows; r++) selectionBuffer[r * (cols + 1) + cols] = 10;
|
||||||
yPos = new Float32Array(rows);
|
|
||||||
for (let c = 0; c < cols; c++) xPos[c] = c * charWidth;
|
syncSize(canvas);
|
||||||
for (let r = 0; r < rows; r++) yPos[r] = r * charHeight;
|
|
||||||
|
// 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]);
|
targetFps = parseFloat(p[1]);
|
||||||
frameInterval = 1000 / targetFps;
|
frameInterval = 1000 / targetFps;
|
||||||
renderMode = parseInt(p[2]);
|
renderMode = parseInt(p[2]);
|
||||||
|
pixelMode = (p.length > 5 && parseInt(p[5]) === 1);
|
||||||
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
||||||
|
|
||||||
// Reload audio on every INIT so each video's audio plays correctly.
|
// Reload audio on every INIT so each video's audio plays correctly.
|
||||||
|
|
@ -183,7 +227,8 @@ function renderFrame(now) {
|
||||||
frameCount = 0;
|
frameCount = 0;
|
||||||
lastFpsUpdate = now;
|
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' };
|
||||||
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;
|
if (frameBuffer.length === 0) return;
|
||||||
|
|
@ -194,7 +239,20 @@ function renderFrame(now) {
|
||||||
player.style.display = 'block';
|
player.style.display = 'block';
|
||||||
player.style.color = '#fff';
|
player.style.color = '#fff';
|
||||||
player.textContent = frame;
|
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 {
|
} else {
|
||||||
|
// ── STANDARD COLOR MODES (2-5): fillText per character ──
|
||||||
const view = new Uint8Array(frame);
|
const view = new Uint8Array(frame);
|
||||||
|
|
||||||
// 1. Draw Canvas (Background)
|
// 1. Draw Canvas (Background)
|
||||||
|
|
|
||||||
271
stream_server.py
271
stream_server.py
|
|
@ -27,6 +27,30 @@ from ascii_video_player2 import VideoDecoder, AsciiMapper
|
||||||
|
|
||||||
app = FastAPI()
|
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
|
# Serve static files (style.css, app.js) from the project directory
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
app.mount("/static", StaticFiles(directory=BASE_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=BASE_DIR), name="static")
|
||||||
|
|
@ -94,14 +118,18 @@ def build_queue(args) -> list[dict]:
|
||||||
for item in items:
|
for item in items:
|
||||||
item.setdefault("mode", args.mode)
|
item.setdefault("mode", args.mode)
|
||||||
item.setdefault("vol", args.vol)
|
item.setdefault("vol", args.vol)
|
||||||
|
item.setdefault("pixel", args.pixel)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
if args.folder:
|
if args.folder:
|
||||||
print(f"[FOLDER] Scanning: {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
|
# 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 ──────────────────────────────────────────────
|
# ── APP STATE ──────────────────────────────────────────────
|
||||||
|
|
@ -189,8 +217,8 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
|
||||||
queue = getattr(app.state, "queue", [])
|
queue = getattr(app.state, "queue", [])
|
||||||
loop = getattr(app.state, "loop", False)
|
loop = getattr(app.state, "loop", False)
|
||||||
cols = getattr(app.state, "cols", 200)
|
cols = getattr(app.state, "cols", 200)
|
||||||
rows = getattr(app.state, "rows", 80)
|
rows_cfg = getattr(app.state, "rows", 0) # 0 = auto
|
||||||
|
|
||||||
if not queue:
|
if not queue:
|
||||||
await websocket.send_text("Error: No video in queue!")
|
await websocket.send_text("Error: No video in queue!")
|
||||||
|
|
@ -204,6 +232,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
entry = queue[queue_index]
|
entry = queue[queue_index]
|
||||||
video_path = entry["video"]
|
video_path = entry["video"]
|
||||||
render_mode= entry["mode"]
|
render_mode= entry["mode"]
|
||||||
|
pixel_mode = entry.get("pixel", False)
|
||||||
|
|
||||||
# IMPORTANT: Update current_index BEFORE sending INIT so that
|
# IMPORTANT: Update current_index BEFORE sending INIT so that
|
||||||
# when the client reloads /audio in response to INIT, the endpoint
|
# 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
|
app.state.current_index = queue_index
|
||||||
|
|
||||||
print(f"[PLAYING] ({queue_index + 1}/{len(queue)}) {video_path} "
|
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:
|
try:
|
||||||
decoder = VideoDecoder(video_path, cols, rows)
|
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)
|
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 = {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
|
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:
|
for gray_frame, bgr_frame in decoder:
|
||||||
t0 = asyncio.get_event_loop().time()
|
t0 = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
indices = np.floor_divide(gray_frame, max(1, 256 // mapper._n))
|
if pixel_mode:
|
||||||
np.clip(indices, 0, mapper._n - 1, out=indices)
|
# Pixel Mode: skip character mapping, send raw RGB
|
||||||
|
|
||||||
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]
|
rgb = bgr_frame[:, :, ::-1]
|
||||||
if qb > 0:
|
if qb > 0:
|
||||||
rgb = (rgb >> qb) << qb
|
rgb = (rgb >> qb) << qb
|
||||||
frame_buf[:, :, 0] = char_codes
|
frame_buf[:, :, 0] = 0xDB
|
||||||
frame_buf[:, :, 1:] = rgb
|
frame_buf[:, :, 1:] = rgb
|
||||||
await websocket.send_bytes(frame_buf.tobytes())
|
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
|
elapsed = asyncio.get_event_loop().time() - t0
|
||||||
wait = frame_t - elapsed
|
wait = frame_t - elapsed
|
||||||
|
|
@ -278,55 +335,162 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
print("Client disconnected from the stream.")
|
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__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
# Enable ANSI escape sequences on Windows
|
||||||
|
os.system("")
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
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
|
formatter_class=argparse.RawTextHelpFormatter
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Source (mutually exclusive priority: playlist > folder > video) ──
|
# ── Source ──
|
||||||
parser.add_argument(
|
src = parser.add_argument_group('\033[33mSource\033[0m')
|
||||||
|
src.add_argument(
|
||||||
"video",
|
"video",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
default="video.mp4",
|
default="video.mp4",
|
||||||
help="Single video file to stream (legacy mode)"
|
help="Single video file to stream"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
src.add_argument(
|
||||||
"--playlist",
|
"--playlist",
|
||||||
metavar="FILE",
|
metavar="FILE",
|
||||||
default=None,
|
default=None,
|
||||||
help="Path to a playlist JSON file\n"
|
help="Path to a playlist JSON file\n"
|
||||||
" Format: [{\"video\": \"a.mp4\", \"mode\": 5, \"vol\": 3}, ...]"
|
" Format: [{\"video\": \"a.mp4\", \"mode\": 5, \"vol\": 3}, ...]"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
src.add_argument(
|
||||||
"--folder",
|
"--folder",
|
||||||
metavar="DIR",
|
metavar="DIR",
|
||||||
default=None,
|
default=None,
|
||||||
help="Path to a folder; plays all videos in filesystem order"
|
help="Path to a folder; plays all videos in filesystem order"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Playback ──
|
# ── Render ──
|
||||||
parser.add_argument("--loop", action="store_true", default=False, help="Loop the queue infinitely")
|
render = parser.add_argument_group('\033[33mRender\033[0m')
|
||||||
parser.add_argument("--port", type=int, default=8000, help="Server port (default: 8000)")
|
render.add_argument(
|
||||||
|
|
||||||
# ── Global defaults (overridden per-entry in JSON) ──
|
|
||||||
parser.add_argument(
|
|
||||||
"--mode",
|
"--mode",
|
||||||
type=int, choices=[1, 2, 3, 4, 5], default=1,
|
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)")
|
render.add_argument(
|
||||||
parser.add_argument("--rows", type=int, default=80, help="Row count (default: 80)")
|
"--pixel",
|
||||||
parser.add_argument(
|
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",
|
"--vol",
|
||||||
type=int, default=1,
|
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()
|
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
|
# Build the queue
|
||||||
queue = build_queue(args)
|
queue = build_queue(args)
|
||||||
|
|
||||||
|
|
@ -341,16 +505,33 @@ if __name__ == "__main__":
|
||||||
app.state.cols = args.cols
|
app.state.cols = args.cols
|
||||||
app.state.rows = args.rows
|
app.state.rows = args.rows
|
||||||
|
|
||||||
# Summary
|
# ── Startup Banner ──
|
||||||
print(f"\n{'='*50}")
|
print(ASCII_LOGO)
|
||||||
print(f" ASCILINE | {len(queue)} video(s) in queue")
|
print(f"\033[1;37m{'═'*55}\033[0m")
|
||||||
print(f" Loop : {'ON' if args.loop else 'OFF'}")
|
print(f" \033[32m▶\033[0m \033[1mQueue\033[0m : {len(queue)} video(s)")
|
||||||
print(f" Res : {args.cols}x{args.rows}")
|
print(f" \033[32m▶\033[0m \033[1mLoop\033[0m : {'ON' if args.loop else 'OFF'}")
|
||||||
print(f" Default : mode={args.mode} vol={args.vol}")
|
res_str = f"{args.cols}x{args.rows}" if args.rows > 0 else f"{args.cols}x(auto)"
|
||||||
print(f"{'='*50}")
|
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):
|
for i, entry in enumerate(queue, 1):
|
||||||
print(f" {i:2}. {entry['video']} [mode={entry['mode']} vol={entry['vol']}]")
|
px = ' \033[35m[PIXEL]\033[0m' if entry.get('pixel') else ''
|
||||||
print(f"{'='*50}\n")
|
print(f" {i:2}. \033[36m{entry['video']}\033[0m (mode={entry['mode']}{px} vol={entry['vol']})")
|
||||||
print(f"Starting server → http://localhost:{args.port}\n")
|
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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue