diff --git a/app.js b/app.js index f8e22b2..3ac7e81 100644 --- a/app.js +++ b/app.js @@ -203,16 +203,23 @@ function connectWebSocket() { } if (timeTotal) timeTotal.textContent = formatTime(duration); if (timeCurrent) timeCurrent.textContent = "00:00"; - if (seekPlayed) seekPlayed.style.width = '0%'; + if (seekPlayed) seekPlayed.style.transform = 'scaleX(0)'; audioOffset = 0; - setupScrub(currentQueueIdx); // load hover thumbnails for this video + // Lazy-load hover thumbnails: only fetch on first hover + const qIdx = currentQueueIdx; + if (seekWrap && !scrubMeta) { + seekWrap.addEventListener('mouseenter', () => { + if (!scrubMeta) setupScrub(qIdx); + }, { once: true }); + } buildCanvas(parseInt(p[3]), parseInt(p[4])); // Initialize adaptive codec decoder (pixel=3 bytes, ASCII color=4 bytes) - if (typeof AscilineCodec !== 'undefined' && renderMode > 1) { - codecDecoder = AscilineCodec.makeDecoder(pixelMode ? 3 : 4); + // Pixel mode explicitly bypasses the codec for maximum raw throughput + if (typeof AscilineCodec !== 'undefined' && renderMode > 1 && !pixelMode) { + codecDecoder = AscilineCodec.makeDecoder(4); } else { codecDecoder = null; } @@ -320,7 +327,7 @@ function renderFrame(now) { if (!isSeeking && seekBar) { if (now - lastUiUpdateTime >= 100) { seekBar.value = masterClock; - if (seekPlayed && duration) seekPlayed.style.width = Math.min(100, (masterClock / duration) * 100) + '%'; + if (seekPlayed && duration) seekPlayed.style.transform = `scaleX(${Math.min(1, masterClock / duration)})`; lastUiUpdateTime = now; } const formattedTime = formatTime(masterClock); @@ -495,7 +502,7 @@ if (playPauseBtn) { 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 (seekPlayed && duration) seekPlayed.style.transform = `scaleX(${Math.min(1, targetSec / duration)})`; if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'seek', time: targetSec })); diff --git a/stream_server.py b/stream_server.py index 0ddb5c3..7e56479 100644 --- a/stream_server.py +++ b/stream_server.py @@ -486,6 +486,58 @@ async def websocket_endpoint(websocket: WebSocket): receive_task = asyncio.create_task(receive_commands()) raw_frame_num = 0 + + # ── THREAD-OFFLOADED FRAME PRODUCER ── + # Bundles ALL CPU work (decode + process + encode) into one + # closure that runs in a thread pool, keeping the asyncio + # event loop 100% free for I/O (WebSocket send) and timing. + def produce(pf, fi): + """Decode, process, encode one frame. Returns None on EOF. + pf = prev_frame, fi = frame_index.""" + for _ in range(skip_n - 1): + if not decoder.grab(): + return None + try: + gray_frame, bgr_frame = next(decoder) + except StopIteration: + return None + + if pixel_mode: + raw_sz = 4 + rows * cols * 3 + struct.pack_into(">I", pixel_send_buf, 0, fi) + pixel_send_buf[4:] = bgr_frame.tobytes() + buf = bytes(pixel_send_buf) + return ('bytes', buf, pf, raw_sz, len(buf)) + 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] + payload = f"{fi}\n" + '\n'.join(lines) + sz = len(payload.encode('utf-8')) + return ('text', payload, pf, sz, sz) + 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 + raw_sz = 4 + rows * cols * 4 + if adaptive: + msg, npf = encode_frame( + frame_buf.copy(), pf, fi, 3, tolerance) + return ('bytes', msg, npf, raw_sz, len(msg)) + else: + struct.pack_into(">I", ascii_send_buf, 0, fi) + ascii_send_buf[4:] = frame_buf.tobytes() + buf = bytes(ascii_send_buf) + return ('bytes', buf, pf, raw_sz, len(buf)) + + _loop = asyncio.get_event_loop() + try: while True: while not cmd_queue.empty(): @@ -493,82 +545,36 @@ async def websocket_endpoint(websocket: WebSocket): if msg.get("type") == "pause": is_paused = msg.get("paused", False) if not is_paused: - start_time = asyncio.get_event_loop().time() - (frame_index * frame_t) + start_time = _loop.time() - (frame_index * frame_t) bw_start_time = time.time() elif msg.get("type") == "seek": target_sec = float(msg.get("time", 0)) decoder.seek(target_sec) prev_frame = None frame_index = int(target_sec * effective_fps) - start_time = asyncio.get_event_loop().time() - (frame_index * frame_t) + start_time = _loop.time() - (frame_index * frame_t) bw_start_time = time.time() if is_paused: await asyncio.sleep(0.1) continue - # ── 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 + # ALL CPU work in thread pool — event loop stays 100% free + result = await _loop.run_in_executor( + None, produce, prev_frame, frame_index) - try: - gray_frame, bgr_frame = next(decoder) - except StopIteration: + if result is None: break - if pixel_mode: - # ── PIXEL MODE: raw BGR (3 bytes/cell) ── - raw_size = 4 + rows * cols * 3 - if adaptive: - msg, prev_frame = encode_frame( - np.ascontiguousarray(bgr_frame), - prev_frame, frame_index, tolerance=tolerance) - await websocket.send_bytes(msg) - bw_bytes_sent += len(msg) - bw_raw_bytes += raw_size - 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)) - bw_bytes_sent += len(pixel_send_buf) - bw_raw_bytes += len(pixel_send_buf) - else: - indices = np.floor_divide(gray_frame, max(1, 256 // mapper._n)) - np.clip(indices, 0, mapper._n - 1, out=indices) + send_type, data, prev_frame, raw_size, wire_size = result - if render_mode == 1: - char_matrix = mapper._lut[indices] - lines = [''.join(row) for row in char_matrix] - payload = f"{frame_index}\n" + '\n'.join(lines) - await websocket.send_text(payload) - payload_size = len(payload.encode('utf-8')) - bw_bytes_sent += payload_size - bw_raw_bytes += payload_size - 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 - raw_size = 4 + rows * cols * 4 - if adaptive: - msg, prev_frame = encode_frame( - frame_buf, prev_frame, frame_index, - tolerance=tolerance) - await websocket.send_bytes(msg) - bw_bytes_sent += len(msg) - bw_raw_bytes += raw_size - 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)) - bw_bytes_sent += len(ascii_send_buf) - bw_raw_bytes += len(ascii_send_buf) + if send_type == 'text': + await websocket.send_text(data) + else: + await websocket.send_bytes(data) + + bw_bytes_sent += wire_size + bw_raw_bytes += raw_size current_time = time.time() if debug_mode and current_time - bw_start_time >= 1.0: @@ -580,11 +586,11 @@ async def websocket_endpoint(websocket: WebSocket): bw_bytes_sent = 0 bw_raw_bytes = 0 - elapsed = asyncio.get_event_loop().time() - start_time + elapsed = _loop.time() - start_time wait = (frame_index * frame_t) - elapsed if wait > 0: await asyncio.sleep(wait) - + frame_index += 1 finally: @@ -780,6 +786,11 @@ if __name__ == "__main__": print("[ERROR] --pixel requires a color mode (--mode 2-5). B&W mode is text-only.") exit(1) + # Validate: --pixel does not support adaptive codec quality flags + if args.pixel and args.quality != "lossless": + print("[ERROR] --pixel mode sends raw data and does not support the adaptive codec. Remove the --quality flag.") + exit(1) + # Build the queue queue = build_queue(args) diff --git a/style.css b/style.css index 20bc27f..429496a 100644 --- a/style.css +++ b/style.css @@ -284,10 +284,13 @@ body { position: absolute; left: 0; height: 4px; - width: 0; + width: 100%; background: var(--accent-color); border-radius: 2px; pointer-events: none; + transform-origin: left; + transform: scaleX(0); + will-change: transform; } .seek-slider { -webkit-appearance: none;