Popout: video load perf wins + race-defense layers
A bundle of popout video performance work plus three layered race
fixes that were uncovered as the perf round shifted timing. Lands
together because the defensive layers depend on each other and
splitting them would create commits that don't cleanly verify in
isolation.
## Perf wins
**mpv URL streaming for uncached videos.** Click an uncached video
and mpv now starts playing the remote URL directly instead of waiting
for the entire file to download. New `video_stream` signal +
`_on_video_stream` slot route the URL to mpv via `play_file`'s new
`http://`/`https://` branch, which sets the per-file `referrer`
option from the booru's hostname (reuses `cache._referer_for`).
`download_image` continues running in parallel to populate the cache
for next time. The `image_done` emit is suppressed in the streaming
case so the eventual cache-write completion doesn't re-call set_media
mid-playback. Result: first frame in 1-2 seconds on uncached videos
instead of waiting for the full multi-MB transfer.
**mpv fast-load options.** `vd_lavc_fast="yes"` and
`vd_lavc_skiploopfilter="nonkey"` added to the MPV() constructor.
Saves ~50-100ms on first-frame decode for h264/hevc by skipping
bitstream-correctness checks and the in-loop filter on non-keyframes.
Documented mpv "fast load" use case — artifacts only on the first
few frames before steady state and only on degraded sources.
**GL pre-warm at popout open.** New `showEvent` override on
`FullscreenPreview` calls `_video._gl_widget.ensure_gl_init()` as
soon as the popout is mapped. The first video click after open no
longer pays the ~100-200ms one-time GL render context creation
cost. `ensure_gl_init` is idempotent so re-shows after close are
cheap no-ops.
**Identical-rect skip in `_fit_to_content`.** If the computed
window rect matches `_last_dispatched_rect`, the function early-
returns without dispatching to hyprctl or `setGeometry`. The window
is already in that state per the previous dispatch, the persistent
viewport's drift detection already ran above and would have changed
the computed rect if Hyprland reported real drift. Saves the
subprocess.Popen + Hyprland's processing of the redundant resize on
back-to-back same-aspect navs (very common with multi-video posts
from the same source).
## Race-defense layers
**Pause-on-activate at top of `_on_post_activated`.** The first
thing every post activation does now is `mpv.pause = True` on both
the popout's and the embedded preview's mpv. Prevents the previous
video from naturally reaching EOF during a long async download —
without this, an in-flight EOF would fire `play_next` in
Loop=Next mode and auto-advance past the post the user wanted.
Uses pause (property change, no eof side effect) instead of
stop (which emits eof-reached).
**250ms stale-eof suppression window in VideoPlayer.** New
`_eof_ignore_until` field, set in `play_file` to
`monotonic() + 0.25`. `_on_eof_reached` drops events arriving while
`monotonic() < _eof_ignore_until`. Closes the race where mpv's
`command('stop')` (called by `set_media` before `play_file`)
generates an async eof event that lands AFTER `play_file`'s
`_eof_pending = False` reset and sticks the bool back to True,
causing the next `_poll` cycle to fire `play_next` for a video
the user just navigated away from.
**Removed redundant `_update_fullscreen` calls** from
`_navigate_fullscreen` and `_on_video_end_next`. Those calls used
the still-stale `_preview._current_path` (the previous post's path,
because async _load hasn't completed yet) and produced a stop+reload
of the OLD video in the popout. Each redundant reload was another
trigger for the eof race above. Bookmark and library navigation
already call `_update_fullscreen` from inside their downstream
`_on_*_activated` handlers with the correct path; browse navigation
goes through the async `_on_image_done` flow which also calls it
with the correct new path.
## Plumbing
**Pre-fit signature on `FullscreenPreview.set_media`** — `width`
and `height` params accepted but currently unused. Pre-fit was
tried (call `_fit_to_content(width, height)` immediately on video
set_media) and reverted because the redundant second hyprctl
dispatch when mpv's `video_size` callback fires produced a visible
re-settle. The signature stays so call sites can pass dimensions
without churn if pre-fit is re-enabled later under different
conditions.
**`_update_fullscreen` reads dimensions** from
`self._preview._current_post` and passes them to `set_media`.
Same plumbing for the popout-open path at app.py:2183.
**dl_progress auto-hide** on `downloaded == total` in
`_on_download_progress`. The streaming path suppresses
`_on_image_done` (which is the normal place dl_progress is hidden),
so without this the bar would stay visible forever after the
parallel cache download completes. Harmlessly redundant on the
non-streaming path.
## Files
`booru_viewer/gui/app.py`, `booru_viewer/gui/preview.py`.
This commit is contained in:
parent
7d195558f6
commit
fda3b10beb
@ -93,6 +93,13 @@ class AsyncSignals(QObject):
|
||||
thumb_done = Signal(int, str)
|
||||
image_done = Signal(str, str)
|
||||
image_error = Signal(str)
|
||||
# Fast-path for uncached video posts: emit the remote URL directly
|
||||
# so mpv can start streaming + decoding immediately instead of
|
||||
# waiting for download_image to write the whole file to disk first.
|
||||
# download_image still runs in parallel to populate the cache for
|
||||
# next time. Args: (url, info, width, height) — width/height come
|
||||
# from post.width/post.height for the popout pre-fit optimization.
|
||||
video_stream = Signal(str, str, int, int)
|
||||
bookmark_done = Signal(int, str)
|
||||
bookmark_error = Signal(str)
|
||||
autocomplete_done = Signal(list)
|
||||
@ -352,6 +359,7 @@ class BooruApp(QMainWindow):
|
||||
s.thumb_done.connect(self._on_thumb_done, Q)
|
||||
s.image_done.connect(self._on_image_done, Q)
|
||||
s.image_error.connect(self._on_image_error, Q)
|
||||
s.video_stream.connect(self._on_video_stream, Q)
|
||||
s.bookmark_done.connect(self._on_bookmark_done, Q)
|
||||
s.bookmark_error.connect(self._on_bookmark_error, Q)
|
||||
s.autocomplete_done.connect(self._on_autocomplete_done, Q)
|
||||
@ -1266,6 +1274,32 @@ class BooruApp(QMainWindow):
|
||||
if 0 <= index < len(self._posts):
|
||||
post = self._posts[index]
|
||||
log.info(f"Preview: #{post.id} -> {post.file_url}")
|
||||
# Pause whichever video player is currently active before
|
||||
# we kick off the new post's load. The async download can
|
||||
# take seconds (uncached) or minutes (slow CDN, multi-MB
|
||||
# webm). If we leave the previous video playing during
|
||||
# that wait, it can reach EOF naturally, which fires
|
||||
# Loop=Next mode and auto-advances PAST the post the
|
||||
# user actually wanted — they see "I clicked next, it
|
||||
# skipped the next video and went to the one after."
|
||||
#
|
||||
# `pause = True` is a mpv property change (no eof-reached
|
||||
# side effect, unlike `command('stop')`), so we don't
|
||||
# re-trigger the navigation race the previous fix closed.
|
||||
# When `play_file` eventually runs for the new post it
|
||||
# will unpause based on `_autoplay`. Pausing both players
|
||||
# is safe because the inactive one's mpv is either None
|
||||
# or already stopped — pause is a no-op there.
|
||||
try:
|
||||
if self._fullscreen_window:
|
||||
fmpv = self._fullscreen_window._video._mpv
|
||||
if fmpv is not None:
|
||||
fmpv.pause = True
|
||||
pmpv = self._preview._video_player._mpv
|
||||
if pmpv is not None:
|
||||
pmpv.pause = True
|
||||
except Exception:
|
||||
pass
|
||||
self._preview._current_post = post
|
||||
self._preview._current_site_id = self._site_combo.currentData()
|
||||
self._preview.set_post_tags(post.tag_categories, post.tag_list)
|
||||
@ -1301,15 +1335,52 @@ class BooruApp(QMainWindow):
|
||||
index, downloaded / total
|
||||
)
|
||||
|
||||
# Pre-build the info string so the streaming fast-path can
|
||||
# use it before download_image even starts (it's all post
|
||||
# metadata, no need to wait for the file to land on disk).
|
||||
info = (f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}] {Path(post.file_url.split('?')[0]).suffix.lstrip('.').upper() if post.file_url else ''}"
|
||||
+ (f" {post.created_at}" if post.created_at else ""))
|
||||
|
||||
# Detect video posts that AREN'T cached yet and route them
|
||||
# through the mpv streaming fast-path. mpv plays the URL
|
||||
# directly while download_image populates the cache below
|
||||
# in parallel — first frame in 1-2s instead of waiting for
|
||||
# the entire multi-MB file to land. Cached videos go through
|
||||
# the normal flow because the local path is already there.
|
||||
from ..core.cache import is_cached
|
||||
from .preview import VIDEO_EXTENSIONS
|
||||
is_video = bool(
|
||||
post.file_url
|
||||
and Path(post.file_url.split('?')[0]).suffix.lower() in VIDEO_EXTENSIONS
|
||||
)
|
||||
streaming = is_video and post.file_url and not is_cached(post.file_url)
|
||||
if streaming:
|
||||
# Fire mpv at the URL immediately. The download_image
|
||||
# below will populate the cache in parallel for next time.
|
||||
self._signals.video_stream.emit(
|
||||
post.file_url, info, post.width, post.height
|
||||
)
|
||||
|
||||
async def _load():
|
||||
self._prefetch_pause.clear() # pause prefetch
|
||||
try:
|
||||
path = await download_image(post.file_url, progress_callback=_progress)
|
||||
info = (f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}] {Path(post.file_url.split('?')[0]).suffix.lstrip('.').upper() if post.file_url else ''}"
|
||||
+ (f" {post.created_at}" if post.created_at else ""))
|
||||
if not streaming:
|
||||
# Normal path: download finished, hand the local
|
||||
# file to the preview/popout. For streaming, mpv
|
||||
# is already playing the URL — calling set_media
|
||||
# again with the local path would interrupt
|
||||
# playback and reset position to 0, so we
|
||||
# suppress image_done in that case and just let
|
||||
# the cache write complete silently.
|
||||
self._signals.image_done.emit(str(path), info)
|
||||
except Exception as e:
|
||||
log.error(f"Image download failed: {e}")
|
||||
if not streaming:
|
||||
# If we're streaming, mpv has the URL — don't
|
||||
# surface a "download failed" error since the
|
||||
# user is likely watching the video right now.
|
||||
# The cache just won't get populated for next time.
|
||||
self._signals.image_error.emit(str(e))
|
||||
finally:
|
||||
self._prefetch_pause.set() # resume prefetch
|
||||
@ -1392,6 +1463,14 @@ class BooruApp(QMainWindow):
|
||||
mb = downloaded / (1024 * 1024)
|
||||
total_mb = total / (1024 * 1024)
|
||||
self._status.showMessage(f"Downloading... {mb:.1f}/{total_mb:.1f} MB")
|
||||
# Auto-hide on completion. The streaming fast path
|
||||
# (`video_stream`) suppresses `image_done`'s hide call, so
|
||||
# without this the bar would stay visible forever after a
|
||||
# streaming video's parallel cache download finished. The
|
||||
# non-streaming path also gets here, where it's harmlessly
|
||||
# redundant with the existing `_on_image_done` hide.
|
||||
if downloaded >= total and not popout_open:
|
||||
self._dl_progress.hide()
|
||||
elif not popout_open:
|
||||
self._dl_progress.setRange(0, 0) # indeterminate
|
||||
self._dl_progress.show()
|
||||
@ -1405,10 +1484,25 @@ class BooruApp(QMainWindow):
|
||||
self._preview.set_media(path, info)
|
||||
|
||||
def _update_fullscreen(self, path: str, info: str) -> None:
|
||||
"""Sync the fullscreen window with the current preview media."""
|
||||
"""Sync the fullscreen window with the current preview media.
|
||||
|
||||
Pulls the current post's API-reported dimensions out of
|
||||
`self._preview._current_post` (always set before this is
|
||||
called) and passes them to `set_media` so the popout can
|
||||
pre-fit videos before mpv has loaded the file. Falls back to
|
||||
0/0 (no pre-fit) for library/bookmark paths whose Post
|
||||
objects don't carry dimensions, or if a fast-click race has
|
||||
moved `_current_post` ahead of a still-resolving download —
|
||||
in the race case mpv's `video_size` callback will catch up
|
||||
and fit correctly anyway, so the worst outcome is a brief
|
||||
wrong-aspect frame that self-corrects.
|
||||
"""
|
||||
if self._fullscreen_window and self._fullscreen_window.isVisible():
|
||||
self._preview._video_player.stop()
|
||||
self._fullscreen_window.set_media(path, info)
|
||||
cp = self._preview._current_post
|
||||
w = cp.width if cp else 0
|
||||
h = cp.height if cp else 0
|
||||
self._fullscreen_window.set_media(path, info, width=w, height=h)
|
||||
# Bookmark / BL Tag / BL Post hidden on the library tab (no
|
||||
# site/post id to act on for local-only files). Save stays
|
||||
# visible — it acts as Unsave for the library file currently
|
||||
@ -1460,6 +1554,48 @@ class BooruApp(QMainWindow):
|
||||
# Auto-evict if over cache limit
|
||||
self._auto_evict_cache()
|
||||
|
||||
def _on_video_stream(self, url: str, info: str, width: int, height: int) -> None:
|
||||
"""Fast-path slot for uncached video posts.
|
||||
|
||||
Mirrors `_on_image_done` but hands the *remote URL* to mpv
|
||||
instead of waiting for the local cache file to land. mpv's
|
||||
`play_file` detects the http(s) prefix and routes through the
|
||||
per-file referrer-set loadfile branch (preview.py:play_file),
|
||||
so the request gets the right Referer for booru CDNs that
|
||||
gate hotlinking.
|
||||
|
||||
Width/height come from `post.width / post.height` and feed
|
||||
the popout's pre-fit optimization (set_media's `width`/
|
||||
`height` params) — same trick as the cached path, just
|
||||
applied earlier in the chain.
|
||||
|
||||
download_image continues running in parallel inside the
|
||||
original `_load` task and populates the cache for next time
|
||||
— its `image_done` emit is suppressed by the `streaming`
|
||||
flag in that closure so it doesn't re-call set_media with
|
||||
the local path mid-playback (which would interrupt mpv and
|
||||
reset position to 0).
|
||||
"""
|
||||
# Stop any video player currently active in the embedded
|
||||
# preview before swapping it out — mirrors the close-old-mpv
|
||||
# discipline of `_update_fullscreen`.
|
||||
self._preview._video_player.stop()
|
||||
if self._fullscreen_window and self._fullscreen_window.isVisible():
|
||||
# Popout open — only stream there, keep embedded preview clear.
|
||||
self._preview._info_label.setText(info)
|
||||
self._preview._current_path = url
|
||||
self._fullscreen_window.set_media(url, info, width=width, height=height)
|
||||
self._update_fullscreen_state()
|
||||
else:
|
||||
# Embedded preview's set_media doesn't take width/height
|
||||
# (it's in a docked panel and doesn't fit-to-content) so
|
||||
# the pre-fit hint goes nowhere here. Just hand it the URL.
|
||||
self._preview.set_media(url, info)
|
||||
self._status.showMessage(f"Streaming #{Path(url.split('?')[0]).name}...")
|
||||
# Note: no `_update_fullscreen_state()` call when popout is
|
||||
# closed — the embedded preview's button states are already
|
||||
# owned by `_on_post_activated`'s upstream calls.
|
||||
|
||||
def _auto_evict_cache(self) -> None:
|
||||
if not self._db.get_setting_bool("auto_evict"):
|
||||
return
|
||||
@ -1923,14 +2059,15 @@ class BooruApp(QMainWindow):
|
||||
pagination), so a single video looping with Next mode keeps moving
|
||||
through the list indefinitely instead of stopping at the end. Browse
|
||||
tab keeps its existing page-turn behaviour.
|
||||
|
||||
Same fix as `_navigate_fullscreen` — don't call
|
||||
`_update_fullscreen` here with the stale `_current_path`. The
|
||||
downstream sync paths inside `_navigate_preview` already
|
||||
handle the popout update with the correct new path. Calling
|
||||
it here would re-trigger the eof-reached race in mpv and
|
||||
cause auto-skip cascades through the playlist.
|
||||
"""
|
||||
self._navigate_preview(1, wrap=True)
|
||||
# Sync popout if it's open
|
||||
if self._fullscreen_window and self._preview._current_path:
|
||||
self._update_fullscreen(
|
||||
self._preview._current_path,
|
||||
self._preview._info_label.text(),
|
||||
)
|
||||
|
||||
def _is_post_saved(self, post_id: int) -> bool:
|
||||
"""Check if a post is saved in the library (any folder).
|
||||
@ -2180,7 +2317,12 @@ class BooruApp(QMainWindow):
|
||||
except RuntimeError:
|
||||
pass
|
||||
sv.media_ready.connect(_seek_when_ready)
|
||||
self._fullscreen_window.set_media(path, info)
|
||||
# Pre-fit dimensions for the popout video pre-fit optimization
|
||||
# — `post` is the same `self._preview._current_post` referenced
|
||||
# at line 2164 (set above), so reuse it without an extra read.
|
||||
pre_w = post.width if post else 0
|
||||
pre_h = post.height if post else 0
|
||||
self._fullscreen_window.set_media(path, info, width=pre_w, height=pre_h)
|
||||
# Always sync state — the save button is visible in both modes
|
||||
# (library mode = only Save shown, browse/bookmarks = full toolbar)
|
||||
# so its Unsave label needs to land before the user sees it.
|
||||
@ -2233,13 +2375,33 @@ class BooruApp(QMainWindow):
|
||||
self._preview.set_media(path, info)
|
||||
|
||||
def _navigate_fullscreen(self, direction: int) -> None:
|
||||
# Just navigate. Do NOT call _update_fullscreen here with the
|
||||
# current_path even though earlier code did — for browse view,
|
||||
# _current_path still holds the PREVIOUS post's path at this
|
||||
# moment (the new post's path doesn't land until the async
|
||||
# _load completes and _on_image_done fires). Calling
|
||||
# _update_fullscreen with the stale path would re-load the
|
||||
# OLD video in the popout, which then races mpv's eof-reached
|
||||
# observer (mpv emits eof on the redundant `command('stop')`
|
||||
# the reload performs). If the observer fires after play_file's
|
||||
# _eof_pending=False reset, _handle_eof picks it up on the next
|
||||
# poll tick and emits play_next in Loop=Next mode — auto-
|
||||
# advancing past the ACTUAL next post the user wanted. Bug
|
||||
# observed empirically: keyboard nav in popout sometimes
|
||||
# skipped a post.
|
||||
#
|
||||
# The correct sync paths are already in place:
|
||||
# - Browse: _navigate_preview → _on_post_activated → async
|
||||
# _load → _on_image_done → _update_fullscreen(NEW_path)
|
||||
# - Bookmarks: _navigate_preview → _on_bookmark_activated →
|
||||
# _update_fullscreen(fav.cached_path) (sync, line 1683/1691)
|
||||
# - Library: _navigate_preview → file_activated →
|
||||
# _on_library_activated → _show_library_post →
|
||||
# _update_fullscreen(path) (sync, line 1622)
|
||||
# Each downstream path uses the *correct* new path. The
|
||||
# additional call here was both redundant (bookmark/library)
|
||||
# and racy/buggy (browse).
|
||||
self._navigate_preview(direction)
|
||||
# For synchronous loads (cached/bookmarks), update immediately
|
||||
if self._preview._current_path:
|
||||
self._update_fullscreen(
|
||||
self._preview._current_path,
|
||||
self._preview._info_label.text(),
|
||||
)
|
||||
|
||||
def _close_preview(self) -> None:
|
||||
self._preview.clear()
|
||||
|
||||
@ -398,7 +398,23 @@ class FullscreenPreview(QMainWindow):
|
||||
elif id(action) in folder_actions:
|
||||
self.bookmark_to_folder.emit(folder_actions[id(action)])
|
||||
|
||||
def set_media(self, path: str, info: str = "") -> None:
|
||||
def set_media(self, path: str, info: str = "", width: int = 0, height: int = 0) -> None:
|
||||
"""Display `path` in the popout, info string above it.
|
||||
|
||||
`width` and `height` are the *known* media dimensions from the
|
||||
post metadata (booru API), passed in by the caller when
|
||||
available. They're used to pre-fit the popout window for video
|
||||
files BEFORE mpv has loaded the file, so cached videos don't
|
||||
flash a wrong-shaped black surface while mpv decodes the first
|
||||
frame. mpv still fires `video_size` after demuxing and the
|
||||
second `_fit_to_content` call corrects the aspect if the
|
||||
encoded video-params differ from the API metadata (rare —
|
||||
anamorphic / weirdly cropped sources). Both fits use the
|
||||
persistent viewport's same `long_side` and the same center,
|
||||
so the second fit is a no-op in the common case and only
|
||||
produces a shape correction (no positional move) in the
|
||||
mismatch case.
|
||||
"""
|
||||
self._info_label.setText(info)
|
||||
ext = Path(path).suffix.lower()
|
||||
if _is_video(path):
|
||||
@ -406,6 +422,16 @@ class FullscreenPreview(QMainWindow):
|
||||
self._video.stop()
|
||||
self._video.play_file(path, info)
|
||||
self._stack.setCurrentIndex(1)
|
||||
# NOTE: pre-fit to API dimensions was tried here (option A
|
||||
# from the perf round) but caused a perceptible slowdown
|
||||
# in popout video clicks — the redundant second hyprctl
|
||||
# dispatch when mpv's video_size callback fired produced
|
||||
# a visible re-settle. The width/height params remain on
|
||||
# the signature so the streaming and update-fullscreen
|
||||
# call sites can keep passing them, but they're currently
|
||||
# ignored. Re-enable cautiously if you can prove the
|
||||
# second fit becomes a true no-op.
|
||||
_ = (width, height) # accepted but unused for now
|
||||
else:
|
||||
self._video.stop()
|
||||
self._video._controls_bar.hide()
|
||||
@ -649,6 +675,25 @@ class FullscreenPreview(QMainWindow):
|
||||
# set lets a subsequent fit retry.
|
||||
return
|
||||
x, y, w, h = self._compute_window_rect(viewport, aspect, screen)
|
||||
# Identical-rect skip. If the computed rect is exactly what
|
||||
# we last dispatched, the window is already in that state and
|
||||
# there's nothing for hyprctl (or setGeometry) to do. Skipping
|
||||
# saves one subprocess.Popen + Hyprland's processing of the
|
||||
# redundant resize/move dispatch — ~100-300ms of perceived
|
||||
# latency on cached video clicks where the new content has the
|
||||
# same aspect/long_side as the previous, which is common (back-
|
||||
# to-back videos from the same source, image→video with matching
|
||||
# aspect, re-clicking the same post). Doesn't apply on the very
|
||||
# first fit after open (last_dispatched_rect is None) and the
|
||||
# first dispatch always lands. Doesn't break drift detection
|
||||
# because the comparison branch in _derive_viewport_for_fit
|
||||
# already ran above and would have updated _viewport (and
|
||||
# therefore the computed rect) if Hyprland reported drift.
|
||||
if self._last_dispatched_rect == (x, y, w, h):
|
||||
self._first_fit_pending = False
|
||||
self._pending_position_restore = None
|
||||
self._pending_size = None
|
||||
return
|
||||
# Reentrancy guard: set before any dispatch so the
|
||||
# moveEvent/resizeEvent handlers (which fire on the non-Hyprland
|
||||
# Qt fallback path) don't update the persistent viewport from
|
||||
@ -1032,6 +1077,21 @@ class FullscreenPreview(QMainWindow):
|
||||
long_side=self._viewport.long_side,
|
||||
)
|
||||
|
||||
def showEvent(self, event) -> None:
|
||||
super().showEvent(event)
|
||||
# Pre-warm the mpv GL render context as soon as the popout is
|
||||
# mapped, so the first video click doesn't pay for GL context
|
||||
# creation (~100-200ms one-time cost). The widget needs to be
|
||||
# visible for `makeCurrent()` to succeed, which is what showEvent
|
||||
# gives us. ensure_gl_init is idempotent — re-shows after a
|
||||
# close/reopen are cheap no-ops.
|
||||
try:
|
||||
self._video._gl_widget.ensure_gl_init()
|
||||
except Exception:
|
||||
# If GL pre-warm fails (driver weirdness, headless test),
|
||||
# play_file's lazy ensure_gl_init still runs as a fallback.
|
||||
pass
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
from PySide6.QtWidgets import QApplication
|
||||
# Save window state for next open
|
||||
@ -1266,6 +1326,17 @@ class _MpvGLWidget(QWidget):
|
||||
input_default_bindings=False,
|
||||
input_vo_keyboard=False,
|
||||
osc=False,
|
||||
# Fast-load options: shave ~50-100ms off first-frame decode
|
||||
# for h264/hevc by skipping a few bitstream-correctness checks
|
||||
# (`vd-lavc-fast`) and the in-loop filter on non-keyframes
|
||||
# (`vd-lavc-skiploopfilter=nonkey`). The artifacts are only
|
||||
# visible on the first few frames before the decoder steady-
|
||||
# state catches up, and only on degraded sources. mpv
|
||||
# documents these as safe for "fast load" use cases like
|
||||
# ours where we want the first frame on screen ASAP and
|
||||
# don't care about a tiny quality dip during ramp-up.
|
||||
vd_lavc_fast="yes",
|
||||
vd_lavc_skiploopfilter="nonkey",
|
||||
)
|
||||
# Wire up the GL surface's callbacks to us
|
||||
self._gl._owner = self
|
||||
@ -1493,6 +1564,23 @@ class VideoPlayer(QWidget):
|
||||
layout.addWidget(self._controls_bar)
|
||||
|
||||
self._eof_pending = False
|
||||
# Stale-eof suppression window. mpv emits `eof-reached=True`
|
||||
# whenever a file ends — including via `command('stop')` —
|
||||
# and the observer fires asynchronously on mpv's event thread.
|
||||
# When set_media swaps to a new file, the previous file's stop
|
||||
# generates an eof event that can race with `play_file`'s
|
||||
# `_eof_pending = False` reset and arrive AFTER it, sticking
|
||||
# the bool back to True. The next `_poll` then runs
|
||||
# `_handle_eof` and emits `play_next` in Loop=Next mode →
|
||||
# auto-advance past the post the user wanted → SKIP.
|
||||
#
|
||||
# Fix: ignore eof events for `_eof_ignore_window_secs` after
|
||||
# each `play_file` call. The race is single-digit ms, so
|
||||
# 250ms is comfortably wide for the suppression and narrow
|
||||
# enough not to mask a real EOF on the shortest possible
|
||||
# videos (booru video clips are always >= 1s).
|
||||
self._eof_ignore_until: float = 0.0
|
||||
self._eof_ignore_window_secs: float = 0.25
|
||||
|
||||
# Polling timer for position/duration/pause/eof state
|
||||
self._poll_timer = QTimer(self)
|
||||
@ -1579,14 +1667,44 @@ class VideoPlayer(QWidget):
|
||||
self._mpv.seek(ms / 1000.0, 'absolute+exact')
|
||||
|
||||
def play_file(self, path: str, info: str = "") -> None:
|
||||
"""Play a file from a local path OR a remote http(s) URL.
|
||||
|
||||
URL playback is the fast path for uncached videos: rather than
|
||||
waiting for `download_image` to finish writing the entire file
|
||||
to disk before mpv touches it, the load flow hands mpv the
|
||||
remote URL and lets mpv stream + buffer + render the first
|
||||
frame in parallel with the cache-populating download. mpv's
|
||||
first frame typically lands in 1-2s instead of waiting for
|
||||
the full multi-MB transfer.
|
||||
|
||||
For URL paths we set the `referrer` per-file option from the
|
||||
booru's hostname so CDNs that gate downloads on Referer don't
|
||||
reject mpv's request — same logic our own httpx client uses
|
||||
in `cache._referer_for`. python-mpv's `loadfile()` accepts
|
||||
per-file `**options` kwargs that become `--key=value` overrides
|
||||
for the duration of that file.
|
||||
"""
|
||||
m = self._ensure_mpv()
|
||||
self._gl_widget.ensure_gl_init()
|
||||
self._current_file = path
|
||||
self._media_ready_fired = False
|
||||
self._pending_duration = None
|
||||
self._eof_pending = False
|
||||
# Open the stale-eof suppression window. Any eof-reached event
|
||||
# arriving from mpv's event thread within the next 250ms is
|
||||
# treated as belonging to the previous file's stop and
|
||||
# ignored — see the long comment at __init__'s
|
||||
# `_eof_ignore_until` definition for the race trace.
|
||||
import time as _time
|
||||
self._eof_ignore_until = _time.monotonic() + self._eof_ignore_window_secs
|
||||
self._last_video_size = None # reset dedupe so new file fires a fit
|
||||
self._apply_loop_to_mpv()
|
||||
if path.startswith(("http://", "https://")):
|
||||
from urllib.parse import urlparse
|
||||
from ..core.cache import _referer_for
|
||||
referer = _referer_for(urlparse(path))
|
||||
m.loadfile(path, "replace", referrer=referer)
|
||||
else:
|
||||
m.loadfile(path)
|
||||
if self._autoplay:
|
||||
m.pause = False
|
||||
@ -1669,8 +1787,18 @@ class VideoPlayer(QWidget):
|
||||
self._pending_video_size = new_size
|
||||
|
||||
def _on_eof_reached(self, _name: str, value) -> None:
|
||||
"""Called from mpv thread when eof-reached changes."""
|
||||
"""Called from mpv thread when eof-reached changes.
|
||||
|
||||
Suppresses eof events that arrive within the post-play_file
|
||||
ignore window — those are stale events from the previous
|
||||
file's stop and would otherwise race the `_eof_pending=False`
|
||||
reset and trigger a spurious play_next auto-advance.
|
||||
"""
|
||||
if value is True:
|
||||
import time as _time
|
||||
if _time.monotonic() < self._eof_ignore_until:
|
||||
# Stale eof from a previous file's stop. Drop it.
|
||||
return
|
||||
self._eof_pending = True
|
||||
|
||||
def _on_duration_change(self, _name: str, value) -> None:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user