Fix thumbnailer

This commit is contained in:
Jan Hamal Dvořák 2025-03-09 19:27:05 +01:00
parent 85950648b9
commit fe00f49b5d
3 changed files with 136 additions and 57 deletions

View file

@ -15,7 +15,7 @@ gi.require_version("Gdk", "4.0")
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("Gst", "1.0") gi.require_version("Gst", "1.0")
gi.require_version("Pango", "1.0") gi.require_version("Pango", "1.0")
from gi.repository import Gdk, Gst, Gtk, Pango # NOQA: E402 from gi.repository import Gdk, GLib, Gst, Gtk, Pango # NOQA: E402
class MainWindow(Gtk.ApplicationWindow): class MainWindow(Gtk.ApplicationWindow):
@ -101,6 +101,15 @@ class MainWindow(Gtk.ApplicationWindow):
left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
left_box.set_valign(Gtk.Align.CENTER) left_box.set_valign(Gtk.Align.CENTER)
left_box.set_halign(Gtk.Align.FILL) left_box.set_halign(Gtk.Align.FILL)
# Create image widget for thumbnail with fixed 16:9 aspect
self.thumbnail_image = Gtk.Picture()
self.thumbnail_image.set_size_request(384, 216) # 16:9 aspect ratio
self.thumbnail_image.set_can_shrink(True)
self.thumbnail_image.set_keep_aspect_ratio(True)
self.thumbnail_image.set_resource(None)
left_box.append(self.thumbnail_image)
self.file_info_label = Gtk.Label(label="") self.file_info_label = Gtk.Label(label="")
self.file_info_label.set_wrap(True) self.file_info_label.set_wrap(True)
left_box.append(self.file_info_label) left_box.append(self.file_info_label)
@ -268,10 +277,17 @@ class MainWindow(Gtk.ApplicationWindow):
if selection_model.get_selected() == Gtk.INVALID_LIST_POSITION: if selection_model.get_selected() == Gtk.INVALID_LIST_POSITION:
self.file_info_label.set_text("") self.file_info_label.set_text("")
else: else:
selected_item = selection_model.get_selected_item() file_item = self.selection
if selected_item: self.file_info_label.set_text(file_item.name)
file_item = cast(FileItem, selected_item)
self.file_info_label.set_text(file_item.name) # Update thumbnail if available
if file_item.thumbnail:
gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail))
texture = Gdk.Texture.new_from_bytes(gbytes)
self.thumbnail_image.set_paintable(texture)
else:
self.thumbnailer.generate_thumbnail(file_item)
self.thumbnail_image.set_paintable(None)
def _toggle_play_pause(self) -> None: def _toggle_play_pause(self) -> None:
"""Toggle between play and pause states""" """Toggle between play and pause states"""
@ -426,6 +442,13 @@ class MainWindow(Gtk.ApplicationWindow):
self._save_position() self._save_position()
self.last_position_save = frame_time self.last_position_save = frame_time
# Update thumbnail if available
file_item = self.selection
if file_item.thumbnail and not self.thumbnail_image.get_paintable():
gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail))
texture = Gdk.Texture.new_from_bytes(gbytes)
self.thumbnail_image.set_paintable(texture)
return True return True
def _populate_file_list(self) -> None: def _populate_file_list(self) -> None:
@ -444,8 +467,6 @@ class MainWindow(Gtk.ApplicationWindow):
items.append(FileItem(path.name, FileType.DIRECTORY, path.resolve())) items.append(FileItem(path.name, FileType.DIRECTORY, path.resolve()))
elif path.suffix in (".mkv", ".mp4", ".avi"): elif path.suffix in (".mkv", ".mp4", ".avi"):
file_item = FileItem(path.name, FileType.VIDEO, path.resolve()) file_item = FileItem(path.name, FileType.VIDEO, path.resolve())
if not file_item.saved_thumbnail:
self.thumbnailer.generate_thumbnail(file_item)
items.append(file_item) items.append(file_item)
# Sort directories first, then files, both alphabetically # Sort directories first, then files, both alphabetically

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys
from enum import Enum, auto from enum import Enum, auto
from pathlib import Path from pathlib import Path
from typing import Optional, overload from typing import Optional, overload
@ -19,6 +20,8 @@ class FileType(Enum):
class FileItem(GObject.Object): class FileItem(GObject.Object):
file_type: FileType file_type: FileType
full_path: Path full_path: Path
thumbnail: bytes
_has_thumbnail: bool
__gtype_name__ = "FileItem" __gtype_name__ = "FileItem"
@ -27,6 +30,8 @@ class FileItem(GObject.Object):
self.name = name self.name = name
self.file_type = file_type self.file_type = file_type
self.full_path = full_path self.full_path = full_path
self.thumbnail = b""
self._has_thumbnail = False
@GObject.Property(type=GObject.TYPE_UINT64) @GObject.Property(type=GObject.TYPE_UINT64)
def saved_position(self) -> int: def saved_position(self) -> int:
@ -55,36 +60,44 @@ class FileItem(GObject.Object):
self._save_attribute("subtitle_track", value if value >= -1 else None) self._save_attribute("subtitle_track", value if value >= -1 else None)
self.notify("saved-subtitle-track") self.notify("saved-subtitle-track")
@GObject.Property(type=str) @GObject.Property(type=bool, default=False)
def saved_thumbnail(self) -> str: def has_thumbnail(self):
return self._load_attribute("thumbnail", "") return self._has_thumbnail
@saved_thumbnail.setter @has_thumbnail.setter
def set_saved_thumbnail(self, value: str) -> None: def set_has_thumbnail(self, value: bool):
self._save_attribute("thumbnail", value) self._has_thumbnail = value
self.notify("saved-thumbnail") self.notify("has-thumbnail")
@overload @overload
def _load_attribute(self, name: str, dfl: str) -> str: ... def _load_attribute(self, name: str, dfl: str) -> str: ...
@overload
def _load_attribute(self, name: str, dfl: bytes) -> bytes: ...
@overload @overload
def _load_attribute(self, name: str, dfl: int) -> int: ... def _load_attribute(self, name: str, dfl: int) -> int: ...
def _load_attribute(self, name: str, dfl: str | int) -> str | int: def _load_attribute(self, name: str, dfl: str | bytes | int) -> str | bytes | int:
try: try:
strval = os.getxattr(self.full_path, f"user.lazy_player.{name}") strval = os.getxattr(self.full_path, f"user.lazy_player.{name}")
return type(dfl)(strval) return type(dfl)(strval)
except OSError: except OSError:
return dfl return dfl
def _save_attribute(self, name: str, value: str | float | int | None) -> None: def _save_attribute(self, name: str, value: str | bytes | float | int | None) -> None:
try: try:
if value is None: if value is None:
os.removexattr(self.full_path, f"user.lazy_player.{name}") os.removexattr(self.full_path, f"user.lazy_player.{name}")
else: else:
os.setxattr(self.full_path, f"user.lazy_player.{name}", str(value).encode("utf8")) if isinstance(value, bytes):
except OSError: os.setxattr(self.full_path, f"user.lazy_player.{name}", value)
pass else:
os.setxattr(
self.full_path, f"user.lazy_player.{name}", str(value).encode("utf8")
)
except OSError as err:
print(err, file=sys.stderr)
class FileListModel(GObject.Object, Gio.ListModel): class FileListModel(GObject.Object, Gio.ListModel):

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import base64
import sys import sys
from enum import Enum, auto
import gi import gi
@ -11,23 +11,34 @@ gi.require_version("Gst", "1.0")
from gi.repository import Gst # NOQA: E402 from gi.repository import Gst # NOQA: E402
class State(Enum):
IDLE = auto()
INITIALIZING = auto()
SEEKING = auto()
CAPTURING = auto()
CLEANING_UP = auto()
class Thumbnailer: class Thumbnailer:
pipeline: Gst.Pipeline pipeline: Gst.Pipeline
uridecodebin: Gst.Element uridecodebin: Gst.Element
sink: Gst.Element sink: Gst.Element
queue: list[FileItem] queue: list[FileItem]
current_item: FileItem | None current_item: FileItem | None
state: State
def __init__(self): def __init__(self):
self.queue = [] self.queue = []
self.current_item = None self.current_item = None
self.state = State.IDLE
pipeline_str = ( pipeline_str = (
"uridecodebin name=uridecodebin ! " "uridecodebin name=uridecodebin ! "
"videoconvert ! " "videoconvert ! "
"videoscale ! video/x-raw,width=480,height=270 ! " "videoscale ! "
"videobox name=box ! " "videobox name=box,autocrop=true ! "
"jpegenc ! " "video/x-raw,width=384,height=216 ! "
"jpegenc quality=85 ! "
"appsink name=sink" "appsink name=sink"
) )
@ -40,14 +51,16 @@ class Thumbnailer:
assert uridecodebin is not None assert uridecodebin is not None
self.uridecodebin = uridecodebin self.uridecodebin = uridecodebin
# Get elements
box = self.pipeline.get_by_name("box")
assert box is not None
sink = self.pipeline.get_by_name("sink") sink = self.pipeline.get_by_name("sink")
assert sink is not None assert sink is not None
self.sink = sink self.sink = sink
# Configure appsink for better reliability
self.sink.set_property("emit-signals", True)
self.sink.set_property("max-buffers", 1)
self.sink.set_property("drop", True)
self.sink.connect("new-sample", self._on_new_sample)
# Set up bus message handler # Set up bus message handler
bus = self.pipeline.get_bus() bus = self.pipeline.get_bus()
bus.add_signal_watch() bus.add_signal_watch()
@ -72,15 +85,28 @@ class Thumbnailer:
return return
if message.type == Gst.MessageType.STATE_CHANGED: if message.type == Gst.MessageType.STATE_CHANGED:
if self.pipeline and message.src == self.pipeline: if message.src != self.pipeline:
_, new_state, _ = message.parse_state_changed() return
if new_state == Gst.State.PAUSED:
self._on_pipeline_ready() _, new_state, _ = message.parse_state_changed()
if new_state == Gst.State.PAUSED and self.state == State.INITIALIZING:
self.state = State.SEEKING
self._on_pipeline_ready()
elif new_state == Gst.State.PLAYING:
self.state = State.CAPTURING
elif new_state == Gst.State.NULL:
if self.state == State.CAPTURING:
self._on_capture_complete()
self.state = State.IDLE
return return
if message.type == Gst.MessageType.ASYNC_DONE: if message.type == Gst.MessageType.ASYNC_DONE:
self._on_seek_complete() if self.state == State.SEEKING:
return # Let the pipeline run to capture the frame
self.pipeline.set_state(Gst.State.PLAYING)
return
if message.type == Gst.MessageType.EOS: if message.type == Gst.MessageType.EOS:
self._on_capture_complete() self._on_capture_complete()
@ -95,56 +121,75 @@ class Thumbnailer:
return return
seek_pos = duration // 3 seek_pos = duration // 3
self.pipeline.seek_simple( success = self.pipeline.seek_simple(
Gst.Format.TIME, Gst.Format.TIME,
Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE,
seek_pos, seek_pos,
) )
def _on_seek_complete(self) -> None: if not success:
"""Called when seek operation completes"""
# Let the pipeline run to capture the frame
self.pipeline.set_state(Gst.State.PLAYING)
def _on_capture_complete(self) -> None:
"""Called when capture is complete"""
if not (self.sink and self.current_item):
self._cleanup() self._cleanup()
return return
sample = self.sink.emit("pull-sample") def _on_new_sample(self, sink: Gst.Element) -> Gst.FlowReturn:
if sample: """Handle new sample from appsink"""
buffer = sample.get_buffer()
success, map_info = buffer.map(Gst.MapFlags.READ)
if success:
try:
jpeg_bytes = bytes(map_info.data)
base64_data = base64.b64encode(jpeg_bytes).decode("utf-8")
data_url = f"data:image/jpeg;base64,{base64_data}"
self.current_item.saved_thumbnail = data_url
finally:
buffer.unmap(map_info)
if self.state != State.CAPTURING or not self.current_item:
return Gst.FlowReturn.OK
sample = sink.emit("pull-sample")
if not sample:
self._cleanup()
return Gst.FlowReturn.ERROR
buffer = sample.get_buffer()
if not buffer:
self._cleanup()
return Gst.FlowReturn.ERROR
success, map_info = buffer.map(Gst.MapFlags.READ)
if not success:
self._cleanup()
return Gst.FlowReturn.ERROR
try:
self.current_item.thumbnail = bytes(map_info.data)
self.current_item.has_thumbnail = True
finally:
buffer.unmap(map_info)
# We got our sample, clean up
self._cleanup()
return Gst.FlowReturn.OK
def _on_capture_complete(self) -> None:
"""Called when capture is complete"""
self._cleanup() self._cleanup()
def _cleanup(self) -> None: def _cleanup(self) -> None:
"""Clean up resources and process next item""" """Clean up resources and process next item"""
# Ensure pipeline is stopped
self.pipeline.set_state(Gst.State.NULL) self.pipeline.set_state(Gst.State.NULL)
# Reset state
self.state = State.IDLE
self.current_item = None self.current_item = None
# Process next item
self._process_next() self._process_next()
def _process_next(self) -> None: def _process_next(self) -> None:
"""Start processing the next item in the queue""" """Start processing the next item in the queue"""
if not self.queue: if not self.queue:
self.state = State.IDLE
return return
self.state = State.INITIALIZING
self.current_item = self.queue.pop(0) self.current_item = self.queue.pop(0)
# Update URI and start pipeline # Update URI and start pipeline
video_uri = Gst.filename_to_uri(str(self.current_item.full_path.resolve())) video_uri = Gst.filename_to_uri(str(self.current_item.full_path))
self.uridecodebin.set_property("uri", video_uri) self.uridecodebin.set_property("uri", video_uri)
self.pipeline.set_state(Gst.State.PLAYING) self.pipeline.set_state(Gst.State.PLAYING)