Video thumbnails via ffmpeg first-frame extraction
Library now generates video thumbnails by extracting the first frame with ffmpeg. Cached alongside image thumbnails. Falls back gracefully if ffmpeg is not available.
This commit is contained in:
parent
189e44db1b
commit
c9fe8fa8a0
@ -39,8 +39,9 @@ class LibraryView(QWidget):
|
|||||||
file_selected = Signal(str)
|
file_selected = Signal(str)
|
||||||
file_activated = Signal(str)
|
file_activated = Signal(str)
|
||||||
|
|
||||||
def __init__(self, parent: QWidget | None = None) -> None:
|
def __init__(self, db=None, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self._db = db
|
||||||
self._files: list[Path] = []
|
self._files: list[Path] = []
|
||||||
self._signals = _LibThumbSignals()
|
self._signals = _LibThumbSignals()
|
||||||
self._signals.thumb_ready.connect(
|
self._signals.thumb_ready.connect(
|
||||||
@ -110,26 +111,7 @@ class LibraryView(QWidget):
|
|||||||
if not pix.isNull():
|
if not pix.isNull():
|
||||||
thumb.set_pixmap(pix)
|
thumb.set_pixmap(pix)
|
||||||
continue
|
continue
|
||||||
if filepath.suffix.lower() not in self._VIDEO_EXTS:
|
self._generate_thumb_async(i, filepath, cached_thumb)
|
||||||
self._generate_thumb_async(i, filepath, cached_thumb)
|
|
||||||
else:
|
|
||||||
# Video placeholder with play triangle
|
|
||||||
from PySide6.QtGui import QPainter, QColor, QFont
|
|
||||||
pix = QPixmap(LIBRARY_THUMB_SIZE - 4, LIBRARY_THUMB_SIZE - 4)
|
|
||||||
pix.fill(QColor(40, 40, 40))
|
|
||||||
painter = QPainter(pix)
|
|
||||||
painter.setPen(QColor(180, 180, 180))
|
|
||||||
painter.setFont(QFont(painter.font().family(), 9))
|
|
||||||
painter.drawText(pix.rect(), Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, filepath.suffix.upper().lstrip("."))
|
|
||||||
# Draw play triangle
|
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
|
||||||
painter.setBrush(QColor(180, 180, 180, 150))
|
|
||||||
cx, cy = pix.width() // 2, pix.height() // 2 - 10
|
|
||||||
from PySide6.QtGui import QPolygon
|
|
||||||
from PySide6.QtCore import QPoint as QP
|
|
||||||
painter.drawPolygon(QPolygon([QP(cx - 15, cy - 20), QP(cx - 15, cy + 20), QP(cx + 20, cy)]))
|
|
||||||
painter.end()
|
|
||||||
thumb.set_pixmap(pix)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Folder list
|
# Folder list
|
||||||
@ -214,28 +196,37 @@ 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:
|
|
||||||
# Can't thumbnail videos with PIL — just show the file directly
|
|
||||||
# and let QPixmap try (it won't work for video, but that's OK)
|
|
||||||
return
|
|
||||||
|
|
||||||
def _work() -> None:
|
def _work() -> None:
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
if source.suffix.lower() in self._VIDEO_EXTS:
|
||||||
|
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"):
|
||||||
self._signals.thumb_ready.emit(index, str(dest))
|
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:
|
except Exception as e:
|
||||||
log.warning("Library thumb %d (%s) failed: %s", index, source.name, e)
|
log.warning("Library thumb %d (%s) failed: %s", index, source.name, e)
|
||||||
|
|
||||||
threading.Thread(target=_work, daemon=True).start()
|
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 _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
|
||||||
if 0 <= index < len(thumbs):
|
if 0 <= index < len(thumbs):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user