Merge pull request #24 from Shaku-Med/feat/player-ui and thumbnails

Live player UI: control bar with hover thumbnails, skip, and responsive layout
This commit is contained in:
SteadyW 2026-06-19 10:27:07 +03:00 committed by GitHub
commit 0dd557dce8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 518 additions and 69 deletions

View file

@ -130,6 +130,28 @@ Use `playlist.json` when you need different `--mode` or `--vol` settings for eac
Open `http://localhost:8000` in your browser. Open `http://localhost:8000` in your browser.
### Player Controls
Once a video is playing, the page has a full control bar under it:
* Play and pause with the button, by clicking the video, or with the space bar.
* Skip back and forward ten seconds with the `«10` and `10»` buttons.
* Drag the seek bar to jump anywhere. The filled part shows how far along you are.
* Hover over the seek bar and a small picture of that moment pops up, so you can
find the spot you want before you let go.
* A volume slider on the right.
The bar wraps and the whole player scales down so it still works on small screens
and phones.
The hover previews come from a light preview sprite. The server builds it once per
video the first time you hover, in a single quick ffmpeg pass, and keeps it in
memory so nothing is written to disk. If you already have your own sprite, point
the `/scrub` route at it instead.
Hover previews are on by default. If you would rather not build them at all, start
the server with `--no-thumbnails` and the rest of the player keeps working.
### 4. Run directly in Terminal (Standalone) ### 4. Run directly in Terminal (Standalone)
If you prefer to bypass the web interface, you can render the video directly inside an ANSI-supported terminal (zero-flicker, true color): If you prefer to bypass the web interface, you can render the video directly inside an ANSI-supported terminal (zero-flicker, true color):
```bash ```bash

155
app.js
View file

@ -20,6 +20,16 @@ const seekBar = document.getElementById('seek-slider');
const timeCurrent = document.getElementById('time-current'); const timeCurrent = document.getElementById('time-current');
const timeTotal = document.getElementById('time-total'); const timeTotal = document.getElementById('time-total');
// Added controls: skip buttons, played fill, and the hover scrub preview
const btnBack = document.getElementById('btn-back');
const btnFwd = document.getElementById('btn-fwd');
const seekPlayed = document.getElementById('seek-played');
const seekWrap = document.querySelector('.seek-wrap');
const seekPreview = document.getElementById('seek-preview');
const seekPreviewImg = document.getElementById('seek-preview-img');
const seekPreviewTime = document.getElementById('seek-preview-time');
let scrubMeta = null; // hover sprite layout from /scrub
function formatTime(seconds) { function formatTime(seconds) {
if (isNaN(seconds) || seconds < 0) return "00:00"; if (isNaN(seconds) || seconds < 0) return "00:00";
const m = Math.floor(seconds / 60).toString().padStart(2, '0'); const m = Math.floor(seconds / 60).toString().padStart(2, '0');
@ -193,8 +203,10 @@ function connectWebSocket() {
} }
if (timeTotal) timeTotal.textContent = formatTime(duration); if (timeTotal) timeTotal.textContent = formatTime(duration);
if (timeCurrent) timeCurrent.textContent = "00:00"; if (timeCurrent) timeCurrent.textContent = "00:00";
if (seekPlayed) seekPlayed.style.width = '0%';
audioOffset = 0; audioOffset = 0;
setupScrub(currentQueueIdx); // load hover thumbnails for this video
buildCanvas(parseInt(p[3]), parseInt(p[4])); buildCanvas(parseInt(p[3]), parseInt(p[4]));
@ -308,6 +320,7 @@ function renderFrame(now) {
if (!isSeeking && seekBar) { if (!isSeeking && seekBar) {
if (now - lastUiUpdateTime >= 100) { if (now - lastUiUpdateTime >= 100) {
seekBar.value = masterClock; seekBar.value = masterClock;
if (seekPlayed && duration) seekPlayed.style.width = Math.min(100, (masterClock / duration) * 100) + '%';
lastUiUpdateTime = now; lastUiUpdateTime = now;
} }
const formattedTime = formatTime(masterClock); const formattedTime = formatTime(masterClock);
@ -477,62 +490,110 @@ if (playPauseBtn) {
}); });
} }
// Seek to an absolute time. Reuses the live seek (tell the server, then reload
// the audio from that point). Shared by the slider and the skip buttons.
function doSeek(targetSec) {
if (duration) targetSec = Math.max(0, Math.min(targetSec, duration));
if (seekBar) seekBar.value = targetSec;
if (seekPlayed && duration) seekPlayed.style.width = Math.min(100, (targetSec / duration) * 100) + '%';
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'seek', time: targetSec }));
}
// Drop stale frames, then restart audio from the seek point
frameBuffer.length = 0;
audioOffset = targetSec;
if (audioEl) {
audioEl.pause();
audioEl.src = `/audio?v=${currentQueueIdx}&start=${targetSec}&t=${Date.now()}`;
audioEl.load();
if (state === 'PLAYING') {
readyToRender = false;
audioEl.play().catch(() => {});
const onAudioStart = () => {
if (!readyToRender) {
readyToRender = true;
streamStartTime = performance.now() - (targetSec * 1000.0);
lastRenderTime = performance.now();
lastFpsUpdate = performance.now();
frameCount = 0;
requestAnimationFrame(renderFrame);
}
};
if (audioEl.readyState >= 3) onAudioStart();
else {
audioEl.addEventListener('playing', onAudioStart, { once: true });
setTimeout(onAudioStart, 500);
}
} else {
streamStartTime = performance.now() - (targetSec * 1000.0);
}
} else {
streamStartTime = performance.now() - (targetSec * 1000.0);
}
}
function getMasterClock() {
if (audioEl && audioEl.readyState >= 1 && !audioEl.paused) return audioEl.currentTime + audioOffset;
return (performance.now() - streamStartTime) / 1000.0;
}
function skip(delta) {
if (state !== 'PLAYING' && state !== 'PAUSED') return;
if (!duration) return;
doSeek(getMasterClock() + delta);
}
// Pull the hover thumbnail sprite for this video (built lazily by the server).
function setupScrub(v) {
scrubMeta = null;
if (seekPreviewImg) seekPreviewImg.style.backgroundImage = '';
fetch('/scrub?v=' + (v || 0)).then(r => r.json()).then(m => {
if (!m || !m.available || !seekPreviewImg) return;
scrubMeta = m;
seekPreviewImg.style.width = m.cellW + 'px';
seekPreviewImg.style.height = m.cellH + 'px';
seekPreviewImg.style.backgroundImage = `url(${m.sprite})`;
seekPreviewImg.style.backgroundSize = (m.gridCols * m.cellW) + 'px ' + (m.gridRows * m.cellH) + 'px';
}).catch(() => {});
}
function onSeekHover(e) {
if (!scrubMeta || !duration || !seekWrap) return;
const rect = seekWrap.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const time = (x / rect.width) * duration;
const idx = Math.max(0, Math.min(Math.floor(time / scrubMeta.interval), scrubMeta.count - 1));
const col = idx % scrubMeta.gridCols, row = Math.floor(idx / scrubMeta.gridCols);
seekPreviewImg.style.backgroundPosition = `-${col * scrubMeta.cellW}px -${row * scrubMeta.cellH}px`;
seekPreviewTime.textContent = formatTime(time);
const half = scrubMeta.cellW / 2;
seekPreview.style.left = Math.max(half, Math.min(x, rect.width - half)) + 'px';
seekPreview.classList.add('show');
}
if (seekBar) { if (seekBar) {
seekBar.addEventListener('input', () => { seekBar.addEventListener('input', () => {
isSeeking = true; isSeeking = true;
if (timeCurrent) timeCurrent.textContent = formatTime(seekBar.value); if (timeCurrent) timeCurrent.textContent = formatTime(seekBar.value);
}); });
seekBar.addEventListener('change', () => { seekBar.addEventListener('change', () => {
const targetSec = parseFloat(seekBar.value); doSeek(parseFloat(seekBar.value));
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'seek', time: targetSec }));
}
// Clear buffer so we don't render stale frames
frameBuffer.length = 0;
audioOffset = targetSec;
// Reload audio with correct start offset
if (audioEl) {
audioEl.pause();
const qs = `?v=${currentQueueIdx}&start=${targetSec}&`;
audioEl.src = `/audio${qs}t=${Date.now()}`;
audioEl.load();
if (state === 'PLAYING') {
readyToRender = false;
audioEl.play().catch(() => {});
const onAudioStart = () => {
if (!readyToRender) {
readyToRender = true;
streamStartTime = performance.now() - (targetSec * 1000.0);
lastRenderTime = performance.now();
lastFpsUpdate = performance.now();
frameCount = 0;
requestAnimationFrame(renderFrame);
}
};
if (audioEl.readyState >= 3) {
onAudioStart();
} else {
audioEl.addEventListener('playing', onAudioStart, { once: true });
// Fallback in case audio is muted or fails to play
setTimeout(onAudioStart, 500);
}
} else {
streamStartTime = performance.now() - (targetSec * 1000.0);
}
} else {
streamStartTime = performance.now() - (targetSec * 1000.0);
}
isSeeking = false; isSeeking = false;
}); });
} }
if (btnBack) btnBack.addEventListener('click', (e) => { e.stopPropagation(); skip(-10); });
if (btnFwd) btnFwd.addEventListener('click', (e) => { e.stopPropagation(); skip(10); });
if (seekWrap) {
seekWrap.addEventListener('mousemove', onSeekHover);
seekWrap.addEventListener('mouseleave', () => { if (seekPreview) seekPreview.classList.remove('show'); });
}
// ── EVENT LISTENERS ── // ── EVENT LISTENERS ──
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();

View file

@ -53,21 +53,24 @@
</div> </div>
<!-- Player Controls Bar --> <!-- Player Controls Bar -->
<div class="player-controls" style="display: flex; align-items: center; justify-content: center; gap: 20px; padding: 10px 0; border-top: 1px solid #333; margin-top: 10px; width: 100%;"> <div class="player-controls">
<!-- Play/Pause --> <button id="play-pause-btn" class="ctrl-btn" title="Play / Pause (Space)">❚❚</button>
<div class="ctrl-group" style="flex: 0 0 auto;"> <button id="btn-back" class="ctrl-btn" title="Back 10s">«10</button>
<button id="play-pause-btn" style="background: none; border: none; color: var(--accent-color, #00ff41); font-family: monospace; font-size: 16px; cursor: pointer;">❚❚</button> <span id="time-current" class="ctrl-time">00:00</span>
<div class="seek-wrap">
<div class="seek-track"></div>
<div class="seek-played" id="seek-played"></div>
<input id="seek-slider" class="seek-slider" type="range" min="0" max="100" step="0.1" value="0" title="Seek">
<div class="seek-preview" id="seek-preview">
<div class="seek-preview-img" id="seek-preview-img"></div>
<div class="seek-preview-time" id="seek-preview-time">0:00</div>
</div>
</div> </div>
<!-- Seek Bar --> <span id="time-total" class="ctrl-time">00:00</span>
<div class="ctrl-group seek-group" style="flex: 1; max-width: 500px; display: flex; align-items: center;"> <button id="btn-fwd" class="ctrl-btn" title="Forward 10s">10»</button>
<span id="time-current" style="margin-right: 10px; font-size: 12px; color: #888; flex-shrink: 0; width: 35px; text-align: right;">00:00</span> <div class="ctrl-group">
<input id="seek-slider" type="range" min="0" max="100" step="0.1" value="0" style="flex: 1; cursor: pointer; accent-color: var(--accent-color, #00ff41);"> <span class="ctrl-icon">VOL_</span>
<span id="time-total" style="margin-left: 10px; font-size: 12px; color: #888; flex-shrink: 0; width: 35px;">00:00</span> <input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
</div>
<!-- Volume Control -->
<div class="ctrl-group" style="display: flex; align-items: center; flex: 0 0 auto;">
<span class="ctrl-icon" style="margin-right: 5px; color: #888;">VOL_</span>
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1" style="cursor: pointer; width: 60px; accent-color: var(--accent-color, #00ff41);">
</div> </div>
</div> </div>

View file

@ -244,6 +244,98 @@ async def audio_stream(v: int | None = None, start: float = 0.0):
) )
# ── Scrub-preview sprite (powers the hover thumbnails on the seek bar) ──
# A grid of small frames sampled across the video, like a YouTube preview strip.
# Built once per video on first request and kept in memory only (no disk cache).
# If you'd rather serve a sprite from the static compiler, just point /scrub at it.
_scrub_cache: dict = {} # video_path -> {"meta": {...}, "jpeg": bytes} or None
def _build_scrub_sprite(video_path: str, max_count: int = 64, cell_w: int = 160):
import math
# Probe size + duration quickly (metadata only, no frame decoding).
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
return None
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
w0 = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h0 = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
cap.release()
duration = (total / fps) if fps else 0
if duration <= 0 or w0 <= 0 or h0 <= 0:
return None
cell_h = max(1, round(cell_w * h0 / w0))
n = max(1, min(max_count, int(duration))) # roughly one frame per second
cols = max(1, math.ceil(math.sqrt(n)))
rows = max(1, math.ceil(n / cols))
interval = duration / n
# One ffmpeg pass: sample frames, scale them, tile into a single grid image.
# Sequential decode (no per-frame seeking), so this is fast even on long clips.
vf = f"fps={n}/{duration:.3f},scale={cell_w}:{cell_h},tile={cols}x{rows}"
try:
proc = subprocess.run(
["ffmpeg", "-nostdin", "-i", video_path, "-vf", vf,
"-frames:v", "1", "-q:v", "4", "-f", "image2", "-c:v", "mjpeg",
"-loglevel", "error", "pipe:1"],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, timeout=120,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if proc.returncode != 0 or not proc.stdout:
return None
return {
"meta": {"available": True, "count": n, "gridCols": cols, "gridRows": rows,
"cellW": cell_w, "cellH": cell_h, "interval": interval, "duration": duration},
"jpeg": proc.stdout,
}
def _scrub_video_path(v: int | None) -> str:
queue = getattr(app.state, "queue", [])
idx = getattr(app.state, "current_index", 0)
if v is not None and 0 <= v < len(queue):
idx = v
entry = queue[idx] if queue and 0 <= idx < len(queue) else {}
return entry.get("video", "")
@app.get("/scrub")
async def scrub_meta(v: int | None = None):
"""Layout for the hover thumbnails. Builds the sprite lazily (off the event
loop) the first time it's asked for, then reuses it from memory."""
from fastapi import Response
import json as _json
# Thumbnails are on by default; --no-thumbnails turns the whole thing off.
if not getattr(app.state, "thumbnails", True):
return Response(content='{"available": false}', media_type="application/json")
video_path = _scrub_video_path(v)
if not video_path or not os.path.exists(video_path):
return Response(content='{"available": false}', media_type="application/json")
if video_path not in _scrub_cache:
loop = asyncio.get_event_loop()
_scrub_cache[video_path] = await loop.run_in_executor(None, _build_scrub_sprite, video_path)
built = _scrub_cache.get(video_path)
if not built:
return Response(content='{"available": false}', media_type="application/json")
meta = dict(built["meta"])
meta["sprite"] = f"/scrub_sprite?v={v if v is not None else 0}"
return Response(content=_json.dumps(meta), media_type="application/json")
@app.get("/scrub_sprite")
async def scrub_sprite(v: int | None = None):
from fastapi import Response, HTTPException
built = _scrub_cache.get(_scrub_video_path(v))
if not built:
raise HTTPException(status_code=404, detail="Not found")
return Response(content=built["jpeg"], media_type="image/jpeg")
def _origin_allowed(origin: str | None, host_header: str | None = None) -> bool: def _origin_allowed(origin: str | None, host_header: str | None = None) -> bool:
"""Reject cross-site WebSocket hijacking while allowing localhost and LAN same-origin.""" """Reject cross-site WebSocket hijacking while allowing localhost and LAN same-origin."""
if not origin: if not origin:
@ -668,6 +760,12 @@ if __name__ == "__main__":
help="Adaptive-codec colour fidelity (lossless = bit-exact; lower = " help="Adaptive-codec colour fidelity (lossless = bit-exact; lower = "
"smaller stream via lossy temporal delta). Chars always exact." "smaller stream via lossy temporal delta). Chars always exact."
) )
playback.add_argument(
"--no-thumbnails",
action="store_true", default=False,
help="Turn off the hover thumbnails on the seek bar (skips building the "
"preview sprite). The rest of the player still works."
)
# ── Server ── # ── Server ──
srv = parser.add_argument_group('\033[33mServer\033[0m') srv = parser.add_argument_group('\033[33mServer\033[0m')
@ -695,6 +793,7 @@ if __name__ == "__main__":
app.state.loop = args.loop app.state.loop = args.loop
app.state.tolerance = {"lossless": 0, "high": 4, "balanced": 8, "low": 16}[args.quality] app.state.tolerance = {"lossless": 0, "high": 4, "balanced": 8, "low": 16}[args.quality]
app.state.debug = args.debug app.state.debug = args.debug
app.state.thumbnails = not args.no_thumbnails
global_default_cols = args.cols if args.cols is not None else (450 if args.pixel else 200) global_default_cols = args.cols if args.cols is not None else (450 if args.pixel else 200)
app.state.cols = global_default_cols app.state.cols = global_default_cols
app.state.rows = args.rows app.state.rows = args.rows

154
style.css
View file

@ -115,8 +115,9 @@ body {
position: relative; position: relative;
background: var(--player-bg); background: var(--player-bg);
border-radius: 4px; border-radius: 4px;
width: 860px; width: 100%;
height: 560px; max-width: 860px;
aspect-ratio: 860 / 560; /* scales with width, keeps shape */
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -207,7 +208,8 @@ body {
.player-controls { .player-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; flex-wrap: wrap;
gap: 10px;
margin-top: 10px; margin-top: 10px;
padding: 8px 12px; padding: 8px 12px;
background: #1a1a1a; background: #1a1a1a;
@ -222,7 +224,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex: 1; flex-shrink: 0;
} }
.ctrl-icon { .ctrl-icon {
@ -231,18 +233,133 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
/* Styled range slider */ /* Buttons (play / pause / skip) */
.ctrl-btn {
background: #050505;
color: var(--accent-color);
border: 1px solid #333;
border-radius: 4px;
font-family: var(--font-tech);
font-size: 12px;
font-weight: bold;
min-width: 34px;
height: 28px;
padding: 0 8px;
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.15s, color 0.15s;
}
.ctrl-btn:hover { border-color: var(--accent-color); }
.ctrl-btn:disabled { opacity: 0.3; cursor: not-allowed; border-color: #333; }
/* Time readouts */
.ctrl-time {
font-family: var(--font-tech);
font-size: 11px;
color: #aaa;
flex-shrink: 0;
white-space: nowrap;
min-width: 42px;
text-align: center;
}
/* Seek bar (track + played fill + transparent slider on top) */
.seek-wrap {
position: relative;
flex: 1;
min-width: 80px;
height: 14px;
display: flex;
align-items: center;
}
.seek-track {
position: absolute;
left: 0; right: 0;
height: 4px;
background: #444;
border-radius: 2px;
pointer-events: none;
}
.seek-played {
position: absolute;
left: 0;
height: 4px;
width: 0;
background: var(--accent-color);
border-radius: 2px;
pointer-events: none;
}
.seek-slider {
-webkit-appearance: none;
appearance: none;
position: relative;
width: 100%;
height: 14px;
background: transparent;
outline: none;
cursor: pointer;
margin: 0;
}
.seek-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: var(--accent-color);
border-radius: 50%;
cursor: pointer;
}
.seek-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: var(--accent-color);
border-radius: 50%;
border: none;
cursor: pointer;
}
/* Hover scrub-preview thumbnail */
.seek-preview {
position: absolute;
bottom: 20px;
left: 0;
transform: translateX(-50%);
display: none;
flex-direction: column;
align-items: center;
pointer-events: none;
z-index: 60;
}
.seek-preview.show { display: flex; }
.seek-preview-img {
background-repeat: no-repeat;
border: 1px solid var(--accent-color);
border-radius: 3px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
}
.seek-preview-time {
margin-top: 4px;
font-family: var(--font-tech);
font-size: 10px;
color: #fff;
background: rgba(0, 0, 0, 0.85);
padding: 1px 6px;
border-radius: 3px;
white-space: nowrap;
}
/* Volume */
#volume-slider { #volume-slider {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
flex: 1; width: 80px;
flex-shrink: 0;
height: 4px; height: 4px;
background: #444; background: #444;
border-radius: 2px; border-radius: 2px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
} }
#volume-slider::-webkit-slider-thumb { #volume-slider::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
@ -252,7 +369,6 @@ body {
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
} }
#volume-slider::-moz-range-thumb { #volume-slider::-moz-range-thumb {
width: 14px; width: 14px;
height: 14px; height: 14px;
@ -282,4 +398,26 @@ body {
#player-container.paused #ascii-player { #player-container.paused #ascii-player {
pointer-events: none; pointer-events: none;
}
/* ── RESPONSIVE ─────────────────────────── */
@media (max-width: 920px) {
.blog-container { margin: 30px auto; }
.blog-post { padding: 24px; }
.blog-header { padding: 50px 16px; }
.blog-header h1 { font-size: 32px; }
}
@media (max-width: 560px) {
.blog-post { padding: 16px; }
.blog-header { padding: 36px 12px; }
.blog-header h1 { font-size: 26px; letter-spacing: 2px; }
.post-title { font-size: 19px; }
.post-content { font-size: 15px; }
.player-controls { gap: 8px; padding: 8px; }
.ctrl-btn { min-width: 30px; height: 26px; padding: 0 6px; font-size: 11px; }
.ctrl-time { min-width: 0; font-size: 10px; }
.seek-wrap { order: 5; flex-basis: 100%; } /* seek bar gets its own row */
#volume-slider { width: 64px; }
} }

126
test/test_scrub.py Normal file
View file

@ -0,0 +1,126 @@
"""
Tests for the live player UI backend bit we added.
Just the new stuff: the scrub sprite endpoint that powers the hover thumbnails.
The control bar itself is frontend, so it isn't covered here. Makes its own tiny
video and never touches your real files. The ffmpeg parts skip themselves if
ffmpeg isn't around.
python -m unittest discover -s test
pytest test/
"""
import os
import sys
import json
import shutil
import asyncio
import tempfile
import unittest
import numpy as np
import cv2
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import stream_server as ss
def _has_ffmpeg():
return shutil.which("ffmpeg") is not None
def _make_video(path, frames=40, w=64, h=48, fps=10.0):
vw = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"MJPG"), fps, (w, h))
if not vw.isOpened():
return False
for i in range(frames):
img = np.zeros((h, w, 3), np.uint8)
img[:, : w // 2] = (40, 80, 120)
img[:, w // 2 :] = (120, 80, 40)
x = (i * 2) % max(1, w - 8)
img[h // 2 : h // 2 + 8, x : x + 8] = (255, 255, 255)
vw.write(img)
vw.release()
return os.path.exists(path) and os.path.getsize(path) > 0
def _entry(video):
return {"video": video, "mode": 5, "pixel": False, "cols": 80, "rows": 0, "vol": 1}
class ScrubTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp(prefix="asciline_ui_")
cls.video = os.path.join(cls.tmp, "clip.avi")
if not _make_video(cls.video):
raise unittest.SkipTest("OpenCV could not write a test video here.")
ss.app.state.queue = [_entry(cls.video)]
ss.app.state.current_index = 0
ss._scrub_cache.clear()
@classmethod
def tearDownClass(cls):
ss._scrub_cache.clear()
shutil.rmtree(cls.tmp, ignore_errors=True)
def test_video_path_lookup(self):
self.assertEqual(ss._scrub_video_path(0), self.video)
# an out of range index just falls back to the current entry
self.assertEqual(ss._scrub_video_path(99), self.video)
def test_missing_video_says_unavailable(self):
ss.app.state.queue = [_entry(os.path.join(self.tmp, "nope.mp4"))]
try:
body = json.loads(asyncio.run(ss.scrub_meta(0)).body)
self.assertFalse(body["available"])
finally:
ss.app.state.queue = [_entry(self.video)]
def test_thumbnails_can_be_disabled(self):
ss.app.state.thumbnails = False
try:
body = json.loads(asyncio.run(ss.scrub_meta(0)).body)
self.assertFalse(body["available"])
finally:
ss.app.state.thumbnails = True
def test_sprite_404_before_it_is_built(self):
from fastapi import HTTPException
ss._scrub_cache.clear()
with self.assertRaises(HTTPException):
asyncio.run(ss.scrub_sprite(0))
@unittest.skipUnless(_has_ffmpeg(), "ffmpeg not installed")
def test_sprite_grid_and_image(self):
import math
built = ss._build_scrub_sprite(self.video, max_count=16, cell_w=80)
self.assertIsNotNone(built)
m = built["meta"]
self.assertTrue(m["available"])
self.assertEqual(m["gridCols"], math.ceil(math.sqrt(m["count"])))
self.assertGreaterEqual(m["gridCols"] * m["gridRows"], m["count"])
# the bytes really are a JPEG, and it decodes to the full grid size
arr = cv2.imdecode(np.frombuffer(built["jpeg"], np.uint8), cv2.IMREAD_COLOR)
self.assertIsNotNone(arr)
self.assertEqual(arr.shape[0], m["gridRows"] * m["cellH"])
self.assertEqual(arr.shape[1], m["gridCols"] * m["cellW"])
@unittest.skipUnless(_has_ffmpeg(), "ffmpeg not installed")
def test_endpoint_builds_then_serves(self):
ss._scrub_cache.clear()
body = json.loads(asyncio.run(ss.scrub_meta(0)).body)
self.assertTrue(body["available"])
self.assertIn("sprite", body)
# it's cached now, so the sprite serves as jpeg bytes
resp = asyncio.run(ss.scrub_sprite(0))
self.assertEqual(resp.media_type, "image/jpeg")
self.assertGreater(len(resp.body), 0)
if __name__ == "__main__":
unittest.main(verbosity=2)