mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-23 22:48:06 +02:00
fix: add verification and playback safeguards
This commit is contained in:
parent
312d5d6df0
commit
50a954c585
16 changed files with 1556 additions and 43 deletions
158
stream_server.py
158
stream_server.py
|
|
@ -15,8 +15,8 @@ import subprocess
|
|||
import json
|
||||
import numpy as np
|
||||
import cv2
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import uvicorn
|
||||
import os
|
||||
|
|
@ -27,6 +27,90 @@ from ascii_video_player2 import VideoDecoder, AsciiMapper
|
|||
|
||||
app = FastAPI()
|
||||
|
||||
MIN_COLS = 1
|
||||
MAX_COLS = 1200
|
||||
MIN_ROWS = 0
|
||||
MAX_ROWS = 800
|
||||
MAX_CELLS = 500_000
|
||||
MIN_VOL = 0
|
||||
MAX_VOL = 5
|
||||
VALID_MODES = {1, 2, 3, 4, 5}
|
||||
SUPPORTED_VIDEO_EXTENSIONS = (".mp4", ".mkv", ".avi", ".mov", ".webm")
|
||||
|
||||
|
||||
def field_label(context: str, name: str) -> str:
|
||||
return f"{context}: {name}" if context else name
|
||||
|
||||
|
||||
def validate_int_range(name: str, value: object, min_value: int, max_value: int, context: str = "") -> int:
|
||||
"""Validate plain integers, excluding bool which is an int subclass."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{field_label(context, name)} must be an integer")
|
||||
if value < min_value or value > max_value:
|
||||
raise ValueError(f"{field_label(context, name)} must be between {min_value} and {max_value}")
|
||||
return value
|
||||
|
||||
|
||||
def validate_bool(name: str, value: object, context: str = "") -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{field_label(context, name)} must be true or false")
|
||||
return value
|
||||
|
||||
|
||||
def validate_dimensions(cols: int, rows: int, context: str = "") -> None:
|
||||
validate_int_range("cols", cols, MIN_COLS, MAX_COLS, context)
|
||||
validate_int_range("rows", rows, MIN_ROWS, MAX_ROWS, context)
|
||||
if rows > 0 and cols * rows > MAX_CELLS:
|
||||
prefix = f"{context}: " if context else ""
|
||||
raise ValueError(f"{prefix}grid size {cols}x{rows} exceeds the {MAX_CELLS} cell limit")
|
||||
|
||||
|
||||
def validate_queue_entry(entry: dict, index: int | None = None) -> dict:
|
||||
context = f"playlist entry {index + 1}" if index is not None else "queue entry"
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError(f"{context} must be an object")
|
||||
|
||||
normalized = dict(entry)
|
||||
video = normalized.get("video")
|
||||
if not isinstance(video, str) or not video.strip():
|
||||
raise ValueError(f"{field_label(context, 'video')} must be a non-empty string")
|
||||
normalized["video"] = video
|
||||
|
||||
normalized["mode"] = validate_int_range("mode", normalized.get("mode"), 1, 5, context)
|
||||
if normalized["mode"] not in VALID_MODES:
|
||||
raise ValueError(f"{field_label(context, 'mode')} must be one of 1, 2, 3, 4, 5")
|
||||
|
||||
normalized["pixel"] = validate_bool("pixel", normalized.get("pixel"), context)
|
||||
if normalized["pixel"] and normalized["mode"] == 1:
|
||||
raise ValueError(f"{context}: pixel mode requires color mode 2-5")
|
||||
|
||||
normalized["vol"] = validate_int_range("vol", normalized.get("vol"), MIN_VOL, MAX_VOL, context)
|
||||
normalized["cols"] = validate_int_range("cols", normalized.get("cols"), MIN_COLS, MAX_COLS, context)
|
||||
normalized["rows"] = validate_int_range("rows", normalized.get("rows"), MIN_ROWS, MAX_ROWS, context)
|
||||
validate_dimensions(normalized["cols"], normalized["rows"], context)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_queue_entry(raw_entry: dict, args, index: int | None = None) -> dict:
|
||||
context = f"playlist entry {index + 1}" if index is not None else "queue entry"
|
||||
if not isinstance(raw_entry, dict):
|
||||
raise ValueError(f"{context} must be an object")
|
||||
|
||||
entry = dict(raw_entry)
|
||||
entry.setdefault("mode", args.mode)
|
||||
entry.setdefault("vol", args.vol)
|
||||
entry.setdefault("pixel", args.pixel)
|
||||
|
||||
is_pixel = entry.get("pixel") is True
|
||||
default_cols = args.cols if args.cols is not None else (450 if is_pixel else 200)
|
||||
entry.setdefault("cols", default_cols)
|
||||
entry.setdefault("rows", args.rows)
|
||||
|
||||
if isinstance(entry.get("video"), str):
|
||||
entry["video"] = resolve_video_path(entry["video"])
|
||||
|
||||
return validate_queue_entry(entry, index)
|
||||
|
||||
|
||||
def get_video_dimensions(path: str) -> tuple[int, int]:
|
||||
"""Quickly probe a video file to get (width, height) without decoding frames."""
|
||||
|
|
@ -82,8 +166,8 @@ 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"])
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("playlist must be a JSON array")
|
||||
return items
|
||||
|
||||
def load_folder(folder_path: str, default_mode: int, default_vol: int) -> list[dict]:
|
||||
|
|
@ -91,11 +175,10 @@ def load_folder(folder_path: str, default_mode: int, default_vol: int) -> list[d
|
|||
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):
|
||||
if entry.is_file() and entry.name.lower().endswith(SUPPORTED_VIDEO_EXTENSIONS):
|
||||
entries.append({
|
||||
"video": entry.path,
|
||||
"mode": default_mode,
|
||||
|
|
@ -114,31 +197,15 @@ def build_queue(args) -> list[dict]:
|
|||
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)
|
||||
item.setdefault("pixel", args.pixel)
|
||||
|
||||
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)
|
||||
return items
|
||||
return [normalize_queue_entry(item, args, index=i) for i, item in enumerate(items)]
|
||||
|
||||
if args.folder:
|
||||
print(f"[FOLDER] Scanning: {args.folder}")
|
||||
items = load_folder(args.folder, args.mode, args.vol)
|
||||
default_cols = args.cols if args.cols is not None else (450 if args.pixel else 200)
|
||||
for item in items:
|
||||
item["pixel"] = args.pixel
|
||||
item["cols"] = default_cols
|
||||
item["rows"] = args.rows
|
||||
return items
|
||||
return [normalize_queue_entry(item, args, index=i) for i, item in enumerate(items)]
|
||||
|
||||
# Legacy: single video argument
|
||||
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}]
|
||||
return [normalize_queue_entry({"video": args.video}, args)]
|
||||
|
||||
|
||||
# ── APP STATE ──────────────────────────────────────────────
|
||||
|
|
@ -155,6 +222,16 @@ async def root():
|
|||
|
||||
@app.get("/audio")
|
||||
async def audio_stream():
|
||||
idx = getattr(app.state, "current_index", 0)
|
||||
return audio_response_for_index(idx)
|
||||
|
||||
|
||||
@app.get("/audio/{queue_index}")
|
||||
async def audio_stream_for_index(queue_index: int):
|
||||
return audio_response_for_index(queue_index)
|
||||
|
||||
|
||||
def audio_response_for_index(queue_index: int):
|
||||
"""
|
||||
Extracts and streams audio from the currently active video entry.
|
||||
Server-side volume control via the entry's 'vol' field (0-5 scale).
|
||||
|
|
@ -163,19 +240,18 @@ async def audio_stream():
|
|||
5 = Double (2.0x)
|
||||
"""
|
||||
queue = getattr(app.state, "queue", [])
|
||||
idx = getattr(app.state, "current_index", 0)
|
||||
entry = queue[idx] if queue else {}
|
||||
if queue_index < 0 or queue_index >= len(queue):
|
||||
raise HTTPException(status_code=404, detail="Audio entry not found")
|
||||
entry = queue[queue_index]
|
||||
|
||||
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):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Video file not found")
|
||||
|
||||
# Map 1-5 → 1.0x-2.0x FFmpeg volume
|
||||
|
|
@ -244,8 +320,8 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||
rows_cfg = entry.get("rows", 0)
|
||||
|
||||
# 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.
|
||||
# /status can report progress. Audio uses the per-client queue index
|
||||
# sent in INIT rather than this global display value.
|
||||
app.state.current_index = queue_index
|
||||
|
||||
print(f"[PLAYING] ({queue_index + 1}/{len(queue)}) {video_path} "
|
||||
|
|
@ -270,6 +346,18 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||
else:
|
||||
rows = rows_cfg
|
||||
|
||||
try:
|
||||
validate_dimensions(cols, rows)
|
||||
except ValueError as exc:
|
||||
await websocket.send_text(f"Error: {exc}")
|
||||
queue_index += 1
|
||||
if queue_index >= len(queue):
|
||||
if loop:
|
||||
queue_index = 0
|
||||
else:
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
decoder = VideoDecoder(video_path, cols, rows, skip_gray=pixel_mode)
|
||||
except FileNotFoundError:
|
||||
|
|
@ -299,7 +387,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||
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)}")
|
||||
await websocket.send_text(f"INIT:{effective_fps}:{render_mode}:{cols}:{rows}:{int(pixel_mode)}:{queue_index}")
|
||||
if skip_n > 1:
|
||||
print(f"[FPS CAP] {source_fps} FPS → {effective_fps} FPS (skip every {skip_n} frames)")
|
||||
|
||||
|
|
@ -543,7 +631,11 @@ if __name__ == "__main__":
|
|||
exit(1)
|
||||
|
||||
# Build the queue
|
||||
queue = build_queue(args)
|
||||
try:
|
||||
queue = build_queue(args)
|
||||
except ValueError as exc:
|
||||
print(f"[ERROR] {exc}")
|
||||
exit(1)
|
||||
|
||||
if not queue:
|
||||
print("[ERROR] No videos found. Check your --playlist / --folder / video argument.")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue