From 660abe42e7168ec53f09a18fad42a00c0188845b Mon Sep 17 00:00:00 2001 From: pax Date: Sun, 5 Apr 2026 04:16:38 -0500 Subject: [PATCH] Replace ffmpeg with Qt-native video thumbnails Use QMediaPlayer + QVideoSink to grab the first frame instead of shelling out to ffmpeg. Removes ffmpeg as a dependency entirely. --- README.md | 11 ++---- booru_viewer/gui/library.py | 68 +++++++++++++++++++++++++------------ 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 4597b67..f894972 100644 --- a/README.md +++ b/README.md @@ -91,18 +91,15 @@ Requires Python 3.11+ and pip. Most distros ship Python but you may need to inst **Arch / CachyOS:** ```sh -sudo pacman -S python python-pip qt6-base qt6-multimedia qt6-multimedia-ffmpeg ffmpeg -``` +sudo pacman -S python python-pip qt6-base qt6-multimedia qt6-multimedia-ffmpeg``` **Ubuntu / Debian (24.04+):** ```sh -sudo apt install python3 python3-pip python3-venv libqt6multimedia6 gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav ffmpeg -``` +sudo apt install python3 python3-pip python3-venv libqt6multimedia6 gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav``` **Fedora:** ```sh -sudo dnf install python3 python3-pip qt6-qtbase qt6-qtmultimedia gstreamer1-plugins-good gstreamer1-plugins-bad-free gstreamer1-libav ffmpeg -``` +sudo dnf install python3 python3-pip qt6-qtbase qt6-qtmultimedia gstreamer1-plugins-good gstreamer1-plugins-bad-free gstreamer1-libav``` Then clone and install: ```sh @@ -120,7 +117,6 @@ booru-viewer Or without installing: `python3 -m booru_viewer.main_gui` -**Optional:** `ffmpeg` is recommended for video thumbnail generation in the Library tab. The app works without it but video files won't have thumbnails. **Desktop entry:** To add booru-viewer to your app launcher, create `~/.local/share/applications/booru-viewer.desktop`: ```ini @@ -138,7 +134,6 @@ Categories=Graphics; - PySide6 (Qt6) - httpx - Pillow -- ffmpeg (optional, for video thumbnails in Library) ## Keybinds diff --git a/booru_viewer/gui/library.py b/booru_viewer/gui/library.py index 89c9294..f5beab8 100644 --- a/booru_viewer/gui/library.py +++ b/booru_viewer/gui/library.py @@ -7,8 +7,10 @@ import os import threading from pathlib import Path -from PySide6.QtCore import Qt, Signal, QObject +from PySide6.QtCore import Qt, Signal, QObject, QUrl, QTimer from PySide6.QtGui import QPixmap +from PySide6.QtMultimedia import QMediaPlayer, QVideoSink, QVideoFrame +from PySide6.QtMultimediaWidgets import QVideoWidget from PySide6.QtWidgets import ( QWidget, QVBoxLayout, @@ -31,6 +33,7 @@ LIBRARY_THUMB_SIZE = 180 class _LibThumbSignals(QObject): thumb_ready = Signal(int, str) + video_thumb_request = Signal(int, str, str) # index, source, dest class LibraryView(QWidget): @@ -47,6 +50,9 @@ class LibraryView(QWidget): self._signals.thumb_ready.connect( self._on_thumb_ready, Qt.ConnectionType.QueuedConnection ) + self._signals.video_thumb_request.connect( + self._capture_video_thumb, Qt.ConnectionType.QueuedConnection + ) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -212,19 +218,21 @@ class LibraryView(QWidget): def _generate_thumb_async( self, index: int, source: Path, dest: Path ) -> None: + if source.suffix.lower() in self._VIDEO_EXTS: + # Video thumbnails must run on main thread (Qt requirement) + self._signals.video_thumb_request.emit(index, str(source), str(dest)) + return + def _work() -> None: try: - if source.suffix.lower() in self._VIDEO_EXTS: - self._generate_video_thumb(source, dest) - else: - from PIL import Image - with Image.open(source) as img: - img.thumbnail( - (LIBRARY_THUMB_SIZE, LIBRARY_THUMB_SIZE), Image.LANCZOS - ) - if img.mode in ("RGBA", "P"): - img = img.convert("RGB") - img.save(str(dest), "JPEG", quality=85) + from PIL import Image + with Image.open(source) as img: + img.thumbnail( + (LIBRARY_THUMB_SIZE, LIBRARY_THUMB_SIZE), Image.LANCZOS + ) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + img.save(str(dest), "JPEG", quality=85) if dest.exists(): self._signals.thumb_ready.emit(index, str(dest)) except Exception as e: @@ -232,16 +240,32 @@ class LibraryView(QWidget): threading.Thread(target=_work, daemon=True).start() - @staticmethod - def _generate_video_thumb(source: Path, dest: Path) -> None: - """Extract first frame from video using ffmpeg.""" - import subprocess - subprocess.run( - ["ffmpeg", "-y", "-i", str(source), "-vframes", "1", - "-vf", f"scale={LIBRARY_THUMB_SIZE}:{LIBRARY_THUMB_SIZE}:force_original_aspect_ratio=decrease", - "-q:v", "5", str(dest)], - capture_output=True, timeout=10, - ) + def _capture_video_thumb(self, index: int, source: str, dest: str) -> None: + """Grab first frame from video using Qt's QMediaPlayer + QVideoSink.""" + player = QMediaPlayer() + sink = QVideoSink() + player.setVideoSink(sink) + + def _on_frame(frame: QVideoFrame): + if frame.isValid(): + img = frame.toImage() + if not img.isNull(): + scaled = img.scaled( + LIBRARY_THUMB_SIZE, LIBRARY_THUMB_SIZE, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + scaled.save(dest, "JPEG", 85) + self._signals.thumb_ready.emit(index, dest) + sink.videoFrameChanged.disconnect(_on_frame) + player.stop() + player.deleteLater() + + sink.videoFrameChanged.connect(_on_frame) + player.setSource(QUrl.fromLocalFile(source)) + player.play() + # Pause immediately — we just need one frame + QTimer.singleShot(100, player.pause) def _on_thumb_ready(self, index: int, path: str) -> None: thumbs = self._grid._thumbs