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:**
|
**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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user