diff --git a/src/telescope_routes.py b/src/telescope_routes.py index 957f900..e2072a2 100644 --- a/src/telescope_routes.py +++ b/src/telescope_routes.py @@ -2708,6 +2708,74 @@ def isolate_transit_route(): return handle_error(exc) +def video_fps_route(): + """GET /api/video/fps?path=captures/... + + Probe an MP4's video-stream fps via ffprobe. Returns {"fps": } on + success, or 404 {"error": "..."} on any failure (missing file, ffprobe + absent, parse error). The frontend falls back to 30 fps on non-200. + """ + rel_path = request.args.get("path", "") + if not rel_path: + return jsonify({"error": "Missing path"}), 400 + + captures_abs = os.path.abspath("static/captures") + abs_path = os.path.abspath(os.path.join("static", rel_path)) + if not abs_path.startswith(captures_abs): + return jsonify({"error": "Invalid file path"}), 403 + if not os.path.exists(abs_path): + return jsonify({"error": "File not found"}), 404 + if not abs_path.lower().endswith(".mp4"): + return jsonify({"error": "Only MP4 files supported"}), 400 + + ffprobe = os.getenv("FFPROBE_PATH", "") or "ffprobe" + # Derive ffprobe from ffmpeg path when FFMPEG is an absolute path and + # ffprobe sits next to it (common for bundled builds). + if FFMPEG and os.path.isabs(FFMPEG): + candidate = os.path.join(os.path.dirname(FFMPEG), "ffprobe") + if os.path.isfile(candidate): + ffprobe = candidate + + try: + r = subprocess.run( + [ + ffprobe, + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=r_frame_rate", + "-of", + "csv=p=0", + abs_path, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=3, + ) + if r.returncode != 0: + return jsonify({"error": "ffprobe failed"}), 404 + raw = r.stdout.decode("utf-8", errors="replace").strip() + if not raw: + return jsonify({"error": "empty ffprobe output"}), 404 + if "/" in raw: + num, den = raw.split("/", 1) + fps = float(num) / float(den) if float(den) != 0 else 0.0 + else: + fps = float(raw) + if fps <= 0 or fps > 1000: + return jsonify({"error": "implausible fps"}), 404 + return jsonify({"fps": fps}), 200 + except FileNotFoundError: + return jsonify({"error": "ffprobe not installed"}), 404 + except subprocess.TimeoutExpired: + return jsonify({"error": "ffprobe timeout"}), 404 + except Exception as exc: + logger.warning(f"[Telescope] video fps probe error: {exc}") + return jsonify({"error": str(exc)}), 404 + + def composite_from_frames_route(): """POST /telescope/files/composite-from-frames @@ -4079,6 +4147,13 @@ def register_routes(app): methods=["POST"], ) + app.add_url_rule( + "/api/video/fps", + "api_video_fps", + video_fps_route, + methods=["GET"], + ) + app.add_url_rule( "/telescope/composite", "telescope_composite_viewer", diff --git a/static/telescope.js b/static/telescope.js index 58332f8..ba68daf 100644 --- a/static/telescope.js +++ b/static/telescope.js @@ -3912,22 +3912,26 @@ function viewFile(path, name, opts) { const slider = document.getElementById('frameScrubSlider'); slider.addEventListener('input', () => { const f = parseInt(slider.value, 10); - _currentFrame = f; - vid.currentTime = f / _videoFps; + _seekToFrame(vid, f); }); + // Do NOT overwrite _currentFrame from vid.currentTime here when a + // programmatic seek is in flight: floating-point rounding can push + // the value to frame+1, which then made the play-loop advance by two. + // Only trust vid.currentTime when the delta is large — i.e. something + // external (scrubber drag past target, loop-wrap) moved the playhead. const updateAfterSeek = () => { - _currentFrame = Math.round(vid.currentTime * _videoFps); - _updateScrubPosition(vid); - _updateFivePanel(); + const fromVid = Math.round(vid.currentTime * _videoFps); + if (!_programmaticSeekInProgress && Math.abs(fromVid - _currentFrame) > 1) { + _currentFrame = fromVid; + } + _scheduleFivePanelRender(); }; + // timeupdate only handles loop wrap-around; render is driven by seeked. vid.addEventListener('timeupdate', () => { if (_loopSegment && _loopSegment.start != null && vid.currentTime >= _loopSegment.end) { vid.currentTime = _loopSegment.start; } - _currentFrame = Math.round(vid.currentTime * _videoFps); - _updateScrubPosition(vid); - _updateFivePanel(); }); vid.addEventListener('seeked', updateAfterSeek); const _isDetClip = /\/det_[^/]+\.mp4$/i.test(path); @@ -3951,10 +3955,22 @@ function viewFile(path, name, opts) { _runIsolateTransit(apiPath, null); }); }; - vid.addEventListener('loadedmetadata', () => { _initFrameScrubber(vid); updateAfterSeek(); _maybeAutoIsolate(); }); + const _initAfterMeta = async () => { + _videoFps = await _probeVideoFps(vid, path); + _initFrameScrubber(vid); + // Ensure the playhead is settled at frame 0 and a real frame is + // decoded before we size the thumb canvas / draw anything. + await _seekToFrame(vid, 0); + _extractFrameThumbs(vid); + // Prime the pump: guarantee the centre panel has content. + _captureLandedThumb(vid, 0); + _updateFivePanel(); + updateAfterSeek(); + _maybeAutoIsolate(); + }; + vid.addEventListener('loadedmetadata', _initAfterMeta, { once: true }); vid.addEventListener('loadeddata', () => { updateAfterSeek(); }); - if (vid.readyState >= 1) { _initFrameScrubber(vid); updateAfterSeek(); } - _extractFrameThumbs(vid); + if (vid.readyState >= 1) { _initAfterMeta(); } } else { const isDiff = name.includes('_diff'); const isFrame = name.includes('_frame'); @@ -4046,14 +4062,11 @@ function closeFileViewer() { _frameThumbs = {}; _thumbExtractionQueue = []; _thumbExtractionPending = new Set(); - _thumbExtractionBusy = false; - _thumbExtractorCanvas = null; - _thumbExtractorCtx = null; + _thumbCanvas = null; + _thumbCtx = null; + _thumbGeneration++; + _thumbChain = Promise.resolve(); _currentFrame = 0; - if (_thumbExtractorVid) { - try { document.body.removeChild(_thumbExtractorVid); } catch (e) {} - _thumbExtractorVid = null; - } if (viewer._filesModalWasOpen) { const filesModal = document.getElementById('filesModal'); @@ -4171,29 +4184,41 @@ function frameStepStop() { if (_frameStepTimer) { clearTimeout(_frameStepTimer); _frameStepTimer = null; } } -// Scrubber playback (frame-by-frame at ~_videoFps using rAF) -var _scrubPlayRAF = null; +// Scrubber playback. Forward uses native