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("Gst", "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):
@ -101,6 +101,15 @@ class MainWindow(Gtk.ApplicationWindow):
left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
left_box.set_valign(Gtk.Align.CENTER)
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.set_wrap(True)
left_box.append(self.file_info_label)
@ -268,10 +277,17 @@ class MainWindow(Gtk.ApplicationWindow):
if selection_model.get_selected() == Gtk.INVALID_LIST_POSITION:
self.file_info_label.set_text("")
else:
selected_item = selection_model.get_selected_item()
if selected_item:
file_item = cast(FileItem, selected_item)
self.file_info_label.set_text(file_item.name)
file_item = self.selection
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:
"""Toggle between play and pause states"""
@ -426,6 +442,13 @@ class MainWindow(Gtk.ApplicationWindow):
self._save_position()
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
def _populate_file_list(self) -> None:
@ -444,8 +467,6 @@ class MainWindow(Gtk.ApplicationWindow):
items.append(FileItem(path.name, FileType.DIRECTORY, path.resolve()))
elif path.suffix in (".mkv", ".mp4", ".avi"):
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)
# Sort directories first, then files, both alphabetically

View file

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

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import base64
import sys
from enum import Enum, auto
import gi
@ -11,23 +11,34 @@ gi.require_version("Gst", "1.0")
from gi.repository import Gst # NOQA: E402
class State(Enum):
IDLE = auto()
INITIALIZING = auto()
SEEKING = auto()
CAPTURING = auto()
CLEANING_UP = auto()
class Thumbnailer:
pipeline: Gst.Pipeline
uridecodebin: Gst.Element
sink: Gst.Element
queue: list[FileItem]
current_item: FileItem | None
state: State
def __init__(self):
self.queue = []
self.current_item = None
self.state = State.IDLE
pipeline_str = (
"uridecodebin name=uridecodebin ! "
"videoconvert ! "
"videoscale ! video/x-raw,width=480,height=270 ! "
"videobox name=box ! "
"jpegenc ! "
"videoscale ! "
"videobox name=box,autocrop=true ! "
"video/x-raw,width=384,height=216 ! "
"jpegenc quality=85 ! "
"appsink name=sink"
)
@ -40,14 +51,16 @@ class Thumbnailer:
assert uridecodebin is not None
self.uridecodebin = uridecodebin
# Get elements
box = self.pipeline.get_by_name("box")
assert box is not None
sink = self.pipeline.get_by_name("sink")
assert sink is not None
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
bus = self.pipeline.get_bus()
bus.add_signal_watch()
@ -72,15 +85,28 @@ class Thumbnailer:
return
if message.type == Gst.MessageType.STATE_CHANGED:
if self.pipeline and message.src == self.pipeline:
_, new_state, _ = message.parse_state_changed()
if new_state == Gst.State.PAUSED:
self._on_pipeline_ready()
if message.src != self.pipeline:
return
_, 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
if message.type == Gst.MessageType.ASYNC_DONE:
self._on_seek_complete()
return
if self.state == State.SEEKING:
# Let the pipeline run to capture the frame
self.pipeline.set_state(Gst.State.PLAYING)
return
if message.type == Gst.MessageType.EOS:
self._on_capture_complete()
@ -95,56 +121,75 @@ class Thumbnailer:
return
seek_pos = duration // 3
self.pipeline.seek_simple(
success = self.pipeline.seek_simple(
Gst.Format.TIME,
Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE,
seek_pos,
)
def _on_seek_complete(self) -> None:
"""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):
if not success:
self._cleanup()
return
sample = self.sink.emit("pull-sample")
if sample:
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)
def _on_new_sample(self, sink: Gst.Element) -> Gst.FlowReturn:
"""Handle new sample from appsink"""
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()
def _cleanup(self) -> None:
"""Clean up resources and process next item"""
# Ensure pipeline is stopped
self.pipeline.set_state(Gst.State.NULL)
# Reset state
self.state = State.IDLE
self.current_item = None
# Process next item
self._process_next()
def _process_next(self) -> None:
"""Start processing the next item in the queue"""
if not self.queue:
self.state = State.IDLE
return
self.state = State.INITIALIZING
self.current_item = self.queue.pop(0)
# 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.pipeline.set_state(Gst.State.PLAYING)