mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-07-02 23:01:00 +02:00
feat: server-side volume control feature added, playlist & folder video management
This commit is contained in:
parent
fd422b5100
commit
5fad7c5aa9
6 changed files with 344 additions and 101 deletions
48
README.md
48
README.md
|
|
@ -46,10 +46,27 @@ pip install fastapi uvicorn opencv-python numpy websockets
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Run the Web Server
|
### 3. Run the Web Server
|
||||||
Place a `video.mp4` in the root directory and start the server:
|
|
||||||
|
**Single video:**
|
||||||
```bash
|
```bash
|
||||||
python stream_server.py
|
python stream_server.py video.mp4
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Folder mode — drop your videos into `videos/` and run:**
|
||||||
|
```bash
|
||||||
|
python stream_server.py --folder videos
|
||||||
|
python stream_server.py --folder videos --loop # infinite loop
|
||||||
|
python stream_server.py --folder videos --mode 5 --vol 2 # all videos same settings
|
||||||
|
```
|
||||||
|
Videos play in **filesystem order** (top to bottom as they appear in the folder, not alphabetically). Just add/remove files from the `videos/` folder to control the queue.
|
||||||
|
|
||||||
|
**JSON Playlist — full control per video:**
|
||||||
|
```bash
|
||||||
|
python stream_server.py --playlist playlist.json
|
||||||
|
python stream_server.py --playlist playlist.json --loop
|
||||||
|
```
|
||||||
|
Use `playlist.json` when you need different `--mode` or `--vol` settings for each video.
|
||||||
|
|
||||||
Open `http://localhost:8000` in your browser.
|
Open `http://localhost:8000` in your browser.
|
||||||
|
|
||||||
### 4. Run directly in Terminal (Standalone)
|
### 4. Run directly in Terminal (Standalone)
|
||||||
|
|
@ -83,6 +100,33 @@ The engine supports different fidelity levels via the `--mode` flag:
|
||||||
python stream_server.py --mode 5 --cols 240 --rows 100
|
python stream_server.py --mode 5 --cols 240 --rows 100
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Server-Side Volume Control
|
||||||
|
Volume is controlled at the server level via the `--vol` flag (scale 0–5).
|
||||||
|
When set to `0`, the audio engine (FFmpeg) **never runs**, saving CPU and bandwidth.
|
||||||
|
|
||||||
|
| `--vol` | FFmpeg Multiplier | Description |
|
||||||
|
|---------|------------------|-------------|
|
||||||
|
| `0` | — | Muted (no processing) |
|
||||||
|
| `1` | 1.0× | Normal (default) |
|
||||||
|
| `3` | 1.5× | Loud |
|
||||||
|
| `5` | 2.0× | Double volume |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python stream_server.py video.mp4 --vol 0 # Silent
|
||||||
|
python stream_server.py video.mp4 --vol 3 # Loud
|
||||||
|
```
|
||||||
|
|
||||||
|
### Playlist Format (`playlist.json`)
|
||||||
|
Each entry can override the global `--mode` and `--vol` defaults:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "video": "intro.mp4", "mode": 1, "vol": 1 },
|
||||||
|
{ "video": "main.mp4", "mode": 5, "vol": 3 },
|
||||||
|
{ "video": "outro.mp4", "mode": 3, "vol": 2 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
Video paths are resolved automatically — the engine checks the project root and the `videos/` subfolder, so you can write just the filename.
|
||||||
|
|
||||||
## 📜 License & Ethical Guardrails
|
## 📜 License & Ethical Guardrails
|
||||||
|
|
||||||
**MIT License (with Anti-Ad Restriction)**
|
**MIT License (with Anti-Ad Restriction)**
|
||||||
|
|
|
||||||
16
app.js
16
app.js
|
|
@ -99,7 +99,7 @@ function connectWebSocket() {
|
||||||
|
|
||||||
if (audioEl) {
|
if (audioEl) {
|
||||||
audioEl.src = '/audio?' + Date.now();
|
audioEl.src = '/audio?' + Date.now();
|
||||||
audioEl.volume = volumeSlider ? volumeSlider.value : 0.8;
|
audioEl.volume = volumeSlider ? volumeSlider.value : 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
|
@ -121,8 +121,18 @@ function connectWebSocket() {
|
||||||
frameInterval = 1000 / targetFps;
|
frameInterval = 1000 / targetFps;
|
||||||
renderMode = parseInt(p[2]);
|
renderMode = parseInt(p[2]);
|
||||||
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
||||||
|
|
||||||
if (audioEl) audioEl.play().catch(() => {});
|
// Reload audio on every INIT so each video's audio plays correctly.
|
||||||
|
// The server updates current_index BEFORE sending INIT, so /audio
|
||||||
|
// will already serve the new video's audio when we request it here.
|
||||||
|
if (audioEl) {
|
||||||
|
audioEl.pause();
|
||||||
|
audioEl.src = '/audio?' + Date.now();
|
||||||
|
audioEl.volume = volumeSlider ? volumeSlider.value : 1.0;
|
||||||
|
audioEl.load();
|
||||||
|
audioEl.play().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
readyToRender = true;
|
readyToRender = true;
|
||||||
state = 'PLAYING';
|
state = 'PLAYING';
|
||||||
lastRenderTime = performance.now();
|
lastRenderTime = performance.now();
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
<!-- Volume Control -->
|
<!-- Volume Control -->
|
||||||
<div class="ctrl-group">
|
<div class="ctrl-group">
|
||||||
<span class="ctrl-icon">VOL_</span>
|
<span class="ctrl-icon">VOL_</span>
|
||||||
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="0.8">
|
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
12
playlist.json
Normal file
12
playlist.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"video": "first_video.mp4",
|
||||||
|
"mode": 1, //stream mode
|
||||||
|
"vol": 1 //sream volume
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"video": "second_video.mp4",
|
||||||
|
"mode": 5,
|
||||||
|
"vol": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
364
stream_server.py
364
stream_server.py
|
|
@ -3,10 +3,16 @@ stream_server.py
|
||||||
================
|
================
|
||||||
Streams the core Video-to-ASCII engine to the web via HTTP/WebSocket.
|
Streams the core Video-to-ASCII engine to the web via HTTP/WebSocket.
|
||||||
Dependencies: pip install fastapi uvicorn websockets
|
Dependencies: pip install fastapi uvicorn websockets
|
||||||
|
|
||||||
|
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)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import json
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import cv2
|
import cv2
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
|
@ -30,36 +36,127 @@ def get_html_content():
|
||||||
with open(html_path, "r", encoding="utf-8") as f:
|
with open(html_path, "r", encoding="utf-8") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
|
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)
|
||||||
|
return items
|
||||||
|
|
||||||
|
if args.folder:
|
||||||
|
print(f"[FOLDER] Scanning: {args.folder}")
|
||||||
|
return load_folder(args.folder, args.mode, args.vol)
|
||||||
|
|
||||||
|
# Legacy: single video argument
|
||||||
|
return [{"video": args.video, "mode": args.mode, "vol": args.vol}]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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.
|
||||||
|
# ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""Serves the Frontend (HTML/JS/CSS) file to the client."""
|
"""Serves the Frontend (HTML/JS/CSS) file to the client."""
|
||||||
return HTMLResponse(get_html_content())
|
return HTMLResponse(get_html_content())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/audio")
|
@app.get("/audio")
|
||||||
async def audio_stream():
|
async def audio_stream():
|
||||||
"""
|
"""
|
||||||
Extracts and streams audio from the video file using ffmpeg.
|
Extracts and streams audio from the currently active video entry.
|
||||||
Returns an MP3 audio stream that the browser can play natively.
|
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)
|
||||||
"""
|
"""
|
||||||
video_path = getattr(app.state, "video_path", "video.mp4")
|
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)
|
||||||
|
|
||||||
if not os.path.exists(video_path):
|
if not os.path.exists(video_path):
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
raise HTTPException(status_code=404, detail="Video file not found")
|
raise HTTPException(status_code=404, detail="Video file not found")
|
||||||
|
|
||||||
|
# Map 1-5 → 1.0x-2.0x FFmpeg volume
|
||||||
|
ffmpeg_vol = 1.0 + (vol_level - 1) * 0.25
|
||||||
|
|
||||||
def audio_generator():
|
def audio_generator():
|
||||||
# Use ffmpeg to extract audio as MP3 stream
|
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
[
|
[
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-i", video_path,
|
"-i", video_path,
|
||||||
"-vn", # No video
|
"-vn",
|
||||||
|
"-filter:a", f"volume={ffmpeg_vol}",
|
||||||
"-acodec", "libmp3lame",
|
"-acodec", "libmp3lame",
|
||||||
"-ab", "128k", # 128kbps bitrate
|
"-ab", "128k",
|
||||||
"-ar", "44100", # Sample rate
|
"-ar", "44100",
|
||||||
"-f", "mp3", # Output format
|
"-f", "mp3",
|
||||||
"-loglevel", "quiet",
|
"-loglevel", "quiet",
|
||||||
"pipe:1" # Output to stdout
|
"pipe:1"
|
||||||
],
|
],
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL
|
stderr=subprocess.DEVNULL
|
||||||
|
|
@ -73,110 +170,187 @@ async def audio_stream():
|
||||||
finally:
|
finally:
|
||||||
process.stdout.close()
|
process.stdout.close()
|
||||||
process.wait()
|
process.wait()
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
audio_generator(),
|
audio_generator(),
|
||||||
media_type="audio/mpeg",
|
media_type="audio/mpeg",
|
||||||
headers={"Accept-Ranges": "bytes"}
|
headers={"Accept-Ranges": "bytes"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
"""
|
"""
|
||||||
Starts decoding the video when a client connects,
|
Streams ASCII frames for every video in the queue.
|
||||||
converts to pure ASCII using AsciiMapper and sends via WebSockets.
|
Advances to the next entry automatically when a video ends.
|
||||||
|
Loops back to the start if --loop is set.
|
||||||
"""
|
"""
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
||||||
video_path = getattr(app.state, "video_path", "video.mp4")
|
queue = getattr(app.state, "queue", [])
|
||||||
render_mode = getattr(app.state, "render_mode", 1)
|
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 = getattr(app.state, "rows", 80)
|
||||||
|
|
||||||
try:
|
if not queue:
|
||||||
decoder = VideoDecoder(video_path, cols, rows)
|
await websocket.send_text("Error: No video in queue!")
|
||||||
except FileNotFoundError:
|
|
||||||
await websocket.send_text("Error: Video file not found!")
|
|
||||||
await websocket.close()
|
await websocket.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
mapper = AsciiMapper()
|
queue_index = 0 # local index; advances through the queue
|
||||||
fps = decoder.fps
|
|
||||||
frame_t = 1.0 / fps
|
|
||||||
|
|
||||||
# Character -> byte code lookup table (for binary format)
|
|
||||||
char_byte_lut = np.array([ord(c) for c in mapper._lut], dtype=np.uint8)
|
|
||||||
|
|
||||||
# Set the quantization level once (render_mode is fixed)
|
|
||||||
qb = {5: 0, 4: 2, 3: 3, 2: 5}.get(render_mode, 0)
|
|
||||||
|
|
||||||
# Send meta information to the client (to create cols/rows grid)
|
|
||||||
await websocket.send_text(f"INIT:{fps}:{render_mode}:{cols}:{rows}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Decoder iterator yields (gray, bgr) for each frame
|
while True:
|
||||||
# Pre-allocate binary frame buffer (reduces GC pressure)
|
entry = queue[queue_index]
|
||||||
frame_buf = np.empty((rows, cols, 4), dtype=np.uint8) if render_mode > 1 else None
|
video_path = entry["video"]
|
||||||
|
render_mode= entry["mode"]
|
||||||
for gray_frame, bgr_frame in decoder:
|
|
||||||
t0 = asyncio.get_event_loop().time()
|
# IMPORTANT: Update current_index BEFORE sending INIT so that
|
||||||
|
# when the client reloads /audio in response to INIT, the endpoint
|
||||||
# Common: intensity -> character index
|
# already serves the correct video's audio.
|
||||||
indices = np.floor_divide(gray_frame, max(1, 256 // mapper._n))
|
app.state.current_index = queue_index
|
||||||
np.clip(indices, 0, mapper._n - 1, out=indices)
|
|
||||||
|
print(f"[PLAYING] ({queue_index + 1}/{len(queue)}) {video_path} "
|
||||||
if render_mode == 1:
|
f"mode={render_mode} vol={entry['vol']}")
|
||||||
# --- PURE ASCII CONVERSION (text) ---
|
|
||||||
char_matrix = mapper._lut[indices]
|
try:
|
||||||
lines = [''.join(row) for row in char_matrix]
|
decoder = VideoDecoder(video_path, cols, rows)
|
||||||
await websocket.send_text('\n'.join(lines))
|
except FileNotFoundError:
|
||||||
else:
|
await websocket.send_text(f"Error: '{video_path}' not found!")
|
||||||
# --- COLOR BINARY CONVERSION (numpy, zero Python loops) ---
|
queue_index += 1
|
||||||
H, W = gray_frame.shape
|
if queue_index >= len(queue):
|
||||||
char_codes = char_byte_lut[indices] # (H,W) uint8
|
if loop:
|
||||||
|
queue_index = 0
|
||||||
rgb = bgr_frame[:, :, ::-1] # BGR → RGB
|
else:
|
||||||
if qb > 0:
|
break
|
||||||
rgb = (rgb >> qb) << qb
|
continue
|
||||||
|
|
||||||
# [char, R, G, B] interleaved binary frame
|
mapper = AsciiMapper()
|
||||||
frame_buf[:, :, 0] = char_codes
|
fps = decoder.fps
|
||||||
frame_buf[:, :, 1:] = rgb
|
frame_t = 1.0 / fps
|
||||||
|
char_byte_lut= np.array([ord(c) for c in mapper._lut], dtype=np.uint8)
|
||||||
await websocket.send_bytes(frame_buf.tobytes())
|
qb = {5: 0, 4: 2, 3: 3, 2: 5}.get(render_mode, 0)
|
||||||
|
|
||||||
elapsed = asyncio.get_event_loop().time() - t0
|
await websocket.send_text(f"INIT:{fps}:{render_mode}:{cols}:{rows}")
|
||||||
wait = frame_t - elapsed
|
|
||||||
if wait > 0:
|
frame_buf = np.empty((rows, cols, 4), dtype=np.uint8) if render_mode > 1 else None
|
||||||
await asyncio.sleep(wait)
|
|
||||||
|
try:
|
||||||
|
for gray_frame, bgr_frame in decoder:
|
||||||
|
t0 = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
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
|
||||||
|
wait = frame_t - elapsed
|
||||||
|
if wait > 0:
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
except (WebSocketDisconnect, ConnectionClosed):
|
except (WebSocketDisconnect, ConnectionClosed):
|
||||||
print("Client disconnected from the stream.")
|
print("Client disconnected from the stream.")
|
||||||
finally:
|
|
||||||
decoder.release()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description="Real-Time ASCII Web Server")
|
|
||||||
parser.add_argument("video", help="Video file to be streamed", default="video.mp4", nargs='?')
|
parser = argparse.ArgumentParser(
|
||||||
parser.add_argument("--port", type=int, default=8000, help="Server port")
|
description="Real-Time ASCII Web Server",
|
||||||
parser.add_argument("--mode", type=int, choices=[1, 2, 3, 4, 5], default=1, help="Render Mode: 1=B&W, 2=512colors, 3=32K, 4=262K, 5=16M Ultra")
|
formatter_class=argparse.RawTextHelpFormatter
|
||||||
parser.add_argument("--cols", type=int, default=200, help="Terminal column width")
|
)
|
||||||
parser.add_argument("--rows", type=int, default=80, help="Terminal row height")
|
|
||||||
|
# ── Source (mutually exclusive priority: playlist > folder > video) ──
|
||||||
|
parser.add_argument(
|
||||||
|
"video",
|
||||||
|
nargs="?",
|
||||||
|
default="video.mp4",
|
||||||
|
help="Single video file to stream (legacy mode)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--playlist",
|
||||||
|
metavar="FILE",
|
||||||
|
default=None,
|
||||||
|
help="Path to a playlist JSON file\n"
|
||||||
|
" Format: [{\"video\": \"a.mp4\", \"mode\": 5, \"vol\": 3}, ...]"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--folder",
|
||||||
|
metavar="DIR",
|
||||||
|
default=None,
|
||||||
|
help="Path to a folder; plays all videos in filesystem order"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Playback ──
|
||||||
|
parser.add_argument("--loop", action="store_true", default=False, help="Loop the queue infinitely")
|
||||||
|
parser.add_argument("--port", type=int, default=8000, help="Server port (default: 8000)")
|
||||||
|
|
||||||
|
# ── Global defaults (overridden per-entry in JSON) ──
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
parser.add_argument("--cols", type=int, default=200, help="Column count (default: 200)")
|
||||||
|
parser.add_argument("--rows", type=int, default=80, help="Row count (default: 80)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--vol",
|
||||||
|
type=int, default=1,
|
||||||
|
help="Volume 0-5 (0=muted, 1=normal, 5=double) — global default"
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Save arguments globally into the state
|
# Build the queue
|
||||||
app.state.video_path = args.video
|
queue = build_queue(args)
|
||||||
app.state.render_mode = args.mode
|
|
||||||
app.state.cols = args.cols
|
if not queue:
|
||||||
app.state.rows = args.rows
|
print("[ERROR] No videos found. Check your --playlist / --folder / video argument.")
|
||||||
|
exit(1)
|
||||||
if not os.path.exists(args.video):
|
|
||||||
print(f"\n[WARNING] Video file '{args.video}' not found!")
|
# Save state
|
||||||
print("The server will start, but streaming will fail until the file is provided.\n")
|
app.state.queue = queue
|
||||||
else:
|
app.state.current_index = 0
|
||||||
print(f"[{args.video}] ready to stream. Mode: {args.mode}, Res: {args.cols}x{args.rows}")
|
app.state.loop = args.loop
|
||||||
|
app.state.cols = args.cols
|
||||||
print(f"Starting server... Please go to http://localhost:{args.port} in your browser.")
|
app.state.rows = args.rows
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f" ASCILINE | {len(queue)} video(s) in queue")
|
||||||
|
print(f" Loop : {'ON' if args.loop else 'OFF'}")
|
||||||
|
print(f" Res : {args.cols}x{args.rows}")
|
||||||
|
print(f" Default : mode={args.mode} vol={args.vol}")
|
||||||
|
print(f"{'='*50}")
|
||||||
|
for i, entry in enumerate(queue, 1):
|
||||||
|
print(f" {i:2}. {entry['video']} [mode={entry['mode']} vol={entry['vol']}]")
|
||||||
|
print(f"{'='*50}\n")
|
||||||
|
print(f"Starting server → http://localhost:{args.port}\n")
|
||||||
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=args.port, ws_ping_interval=None, ws_ping_timeout=None)
|
uvicorn.run(app, host="0.0.0.0", port=args.port, ws_ping_interval=None, ws_ping_timeout=None)
|
||||||
|
|
|
||||||
3
videos/.gitkeep
Normal file
3
videos/.gitkeep
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# This file ensures the videos/ directory is tracked by Git.
|
||||||
|
# Place your video files (.mp4, .mkv, .avi, .mov, .webm) here.
|
||||||
|
# Run the server with: python stream_server.py --folder ./videos
|
||||||
Loading…
Add table
Add a link
Reference in a new issue