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.
This commit is contained in:
pax 2026-04-05 04:16:38 -05:00
parent b1ce736abd
commit 660abe42e7
2 changed files with 49 additions and 30 deletions

View File

@ -91,18 +91,15 @@ Requires Python 3.11+ and pip. Most distros ship Python but you may need to inst
**Arch / CachyOS:** **Arch / CachyOS:**
```sh ```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+):** **Ubuntu / Debian (24.04+):**
```sh ```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:** **Fedora:**
```sh ```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: Then clone and install:
```sh ```sh
@ -120,7 +117,6 @@ booru-viewer
Or without installing: `python3 -m booru_viewer.main_gui` 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`: **Desktop entry:** To add booru-viewer to your app launcher, create `~/.local/share/applications/booru-viewer.desktop`:
```ini ```ini
@ -138,7 +134,6 @@ Categories=Graphics;
- PySide6 (Qt6) - PySide6 (Qt6)
- httpx - httpx
- Pillow - Pillow
- ffmpeg (optional, for video thumbnails in Library)
## Keybinds ## Keybinds

View File

@ -7,8 +7,10 @@ import os
import threading import threading
from pathlib import Path 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.QtGui import QPixmap
from PySide6.QtMultimedia import QMediaPlayer, QVideoSink, QVideoFrame
from PySide6.QtMultimediaWidgets import QVideoWidget
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QWidget,
QVBoxLayout, QVBoxLayout,
@ -31,6 +33,7 @@ LIBRARY_THUMB_SIZE = 180
class _LibThumbSignals(QObject): class _LibThumbSignals(QObject):
thumb_ready = Signal(int, str) thumb_ready = Signal(int, str)
video_thumb_request = Signal(int, str, str) # index, source, dest
class LibraryView(QWidget): class LibraryView(QWidget):
@ -47,6 +50,9 @@ class LibraryView(QWidget):
self._signals.thumb_ready.connect( self._signals.thumb_ready.connect(
self._on_thumb_ready, Qt.ConnectionType.QueuedConnection self._on_thumb_ready, Qt.ConnectionType.QueuedConnection
) )
self._signals.video_thumb_request.connect(
self._capture_video_thumb, Qt.ConnectionType.QueuedConnection
)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
@ -212,19 +218,21 @@ class LibraryView(QWidget):
def _generate_thumb_async( def _generate_thumb_async(
self, index: int, source: Path, dest: Path self, index: int, source: Path, dest: Path
) -> None: ) -> 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: def _work() -> None:
try: try:
if source.suffix.lower() in self._VIDEO_EXTS: from PIL import Image
self._generate_video_thumb(source, dest) with Image.open(source) as img:
else: img.thumbnail(
from PIL import Image (LIBRARY_THUMB_SIZE, LIBRARY_THUMB_SIZE), Image.LANCZOS
with Image.open(source) as img: )
img.thumbnail( if img.mode in ("RGBA", "P"):
(LIBRARY_THUMB_SIZE, LIBRARY_THUMB_SIZE), Image.LANCZOS img = img.convert("RGB")
) img.save(str(dest), "JPEG", quality=85)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(str(dest), "JPEG", quality=85)
if dest.exists(): if dest.exists():
self._signals.thumb_ready.emit(index, str(dest)) self._signals.thumb_ready.emit(index, str(dest))
except Exception as e: except Exception as e:
@ -232,16 +240,32 @@ class LibraryView(QWidget):
threading.Thread(target=_work, daemon=True).start() threading.Thread(target=_work, daemon=True).start()
@staticmethod def _capture_video_thumb(self, index: int, source: str, dest: str) -> None:
def _generate_video_thumb(source: Path, dest: Path) -> None: """Grab first frame from video using Qt's QMediaPlayer + QVideoSink."""
"""Extract first frame from video using ffmpeg.""" player = QMediaPlayer()
import subprocess sink = QVideoSink()
subprocess.run( player.setVideoSink(sink)
["ffmpeg", "-y", "-i", str(source), "-vframes", "1",
"-vf", f"scale={LIBRARY_THUMB_SIZE}:{LIBRARY_THUMB_SIZE}:force_original_aspect_ratio=decrease", def _on_frame(frame: QVideoFrame):
"-q:v", "5", str(dest)], if frame.isValid():
capture_output=True, timeout=10, 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: def _on_thumb_ready(self, index: int, path: str) -> None:
thumbs = self._grid._thumbs thumbs = self._grid._thumbs