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:
parent
b1ce736abd
commit
660abe42e7
11
README.md
11
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
|
||||
|
||||
|
||||
@ -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,11 +218,13 @@ 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(
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user