feat: end-frame preview panel showing last frame of clip spread
Small panel to the right of the video displays the frame at cursor + clip_span. Updated with 300ms debounce on cursor move, spread/clip count change, and file load. Uses ffmpeg to grab a single PNG frame off the main thread. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QComboBox, QDialog, QPlainTextEdit, QCheckBox, QSpinBox, QDoubleSpinBox,
|
QComboBox, QDialog, QPlainTextEdit, QCheckBox, QSpinBox, QDoubleSpinBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QSettings
|
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QSettings
|
||||||
from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut
|
from PyQt6.QtGui import QPainter, QColor, QPen, QPixmap, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut
|
||||||
import mpv
|
import mpv
|
||||||
|
|
||||||
|
|
||||||
@@ -387,6 +387,31 @@ class ExportWorker(QThread):
|
|||||||
self.all_done.emit()
|
self.all_done.emit()
|
||||||
|
|
||||||
|
|
||||||
|
class FrameGrabber(QThread):
|
||||||
|
"""Grab a single frame via ffmpeg and emit it as raw PNG bytes."""
|
||||||
|
frame_ready = pyqtSignal(bytes)
|
||||||
|
|
||||||
|
def __init__(self, input_path: str, time: float):
|
||||||
|
super().__init__()
|
||||||
|
self._input = input_path
|
||||||
|
self._time = time
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-ss", str(self._time),
|
||||||
|
"-i", self._input,
|
||||||
|
"-frames:v", "1",
|
||||||
|
"-f", "image2pipe", "-vcodec", "png",
|
||||||
|
"pipe:1",
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
||||||
|
if result.returncode == 0 and result.stdout:
|
||||||
|
self.frame_ready.emit(result.stdout)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TimelineWidget(QWidget):
|
class TimelineWidget(QWidget):
|
||||||
cursor_changed = pyqtSignal(float) # emits position in seconds
|
cursor_changed = pyqtSignal(float) # emits position in seconds
|
||||||
marker_delete_requested = pyqtSignal(str) # emits output_path
|
marker_delete_requested = pyqtSignal(str) # emits output_path
|
||||||
@@ -1166,6 +1191,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._overwrite_path: str = "" # set when a marker is selected for re-export
|
self._overwrite_path: str = "" # set when a marker is selected for re-export
|
||||||
self._mask_worker: MaskWorker | None = None
|
self._mask_worker: MaskWorker | None = None
|
||||||
self._db_worker: _DBWorker | None = None
|
self._db_worker: _DBWorker | None = None
|
||||||
|
self._frame_grabber: FrameGrabber | None = None
|
||||||
self._fps: float = 25.0 # cached on file load via get_fps()
|
self._fps: float = 25.0 # cached on file load via get_fps()
|
||||||
|
|
||||||
# Widgets
|
# Widgets
|
||||||
@@ -1174,6 +1200,19 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self._mpv = MpvWidget()
|
self._mpv = MpvWidget()
|
||||||
self._mpv.file_loaded.connect(self._after_load)
|
self._mpv.file_loaded.connect(self._after_load)
|
||||||
|
|
||||||
|
self._end_preview = QLabel()
|
||||||
|
self._end_preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self._end_preview.setMinimumWidth(160)
|
||||||
|
self._end_preview.setMaximumWidth(320)
|
||||||
|
self._end_preview.setStyleSheet("background: #1a1a1a; border: 1px solid #333;")
|
||||||
|
self._end_preview.setScaledContents(True)
|
||||||
|
|
||||||
|
self._preview_timer = QTimer()
|
||||||
|
self._preview_timer.setSingleShot(True)
|
||||||
|
self._preview_timer.setInterval(300)
|
||||||
|
self._preview_timer.timeout.connect(self._grab_end_frame)
|
||||||
|
|
||||||
self._timeline = TimelineWidget()
|
self._timeline = TimelineWidget()
|
||||||
self._timeline.setFixedHeight(160)
|
self._timeline.setFixedHeight(160)
|
||||||
_init_clips = int(self._settings.value("clip_count", "3"))
|
_init_clips = int(self._settings.value("clip_count", "3"))
|
||||||
@@ -1251,6 +1290,7 @@ class MainWindow(QMainWindow):
|
|||||||
lambda: self._timeline.set_clip_span(self._clip_span)
|
lambda: self._timeline.set_clip_span(self._clip_span)
|
||||||
)
|
)
|
||||||
self._spn_clips.valueChanged.connect(lambda: self._update_next_label())
|
self._spn_clips.valueChanged.connect(lambda: self._update_next_label())
|
||||||
|
self._spn_clips.valueChanged.connect(lambda: self._preview_timer.start())
|
||||||
|
|
||||||
self._spn_spread = QDoubleSpinBox()
|
self._spn_spread = QDoubleSpinBox()
|
||||||
self._spn_spread.setRange(2.0, 8.0)
|
self._spn_spread.setRange(2.0, 8.0)
|
||||||
@@ -1265,6 +1305,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._spn_spread.valueChanged.connect(
|
self._spn_spread.valueChanged.connect(
|
||||||
lambda: self._timeline.set_clip_span(self._clip_span)
|
lambda: self._timeline.set_clip_span(self._clip_span)
|
||||||
)
|
)
|
||||||
|
self._spn_spread.valueChanged.connect(lambda: self._preview_timer.start())
|
||||||
|
|
||||||
self._chk_rand_portrait = QCheckBox("1 random portrait")
|
self._chk_rand_portrait = QCheckBox("1 random portrait")
|
||||||
self._chk_rand_portrait.setToolTip(
|
self._chk_rand_portrait.setToolTip(
|
||||||
@@ -1377,7 +1418,10 @@ class MainWindow(QMainWindow):
|
|||||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
right_layout.setSpacing(4)
|
right_layout.setSpacing(4)
|
||||||
right_layout.addLayout(top_bar)
|
right_layout.addLayout(top_bar)
|
||||||
right_layout.addWidget(self._mpv, stretch=1)
|
video_row = QHBoxLayout()
|
||||||
|
video_row.addWidget(self._mpv, stretch=1)
|
||||||
|
video_row.addWidget(self._end_preview)
|
||||||
|
right_layout.addLayout(video_row, stretch=1)
|
||||||
right_layout.addWidget(self._timeline)
|
right_layout.addWidget(self._timeline)
|
||||||
right_layout.addWidget(self._crop_bar)
|
right_layout.addWidget(self._crop_bar)
|
||||||
right_layout.addLayout(transport_row)
|
right_layout.addLayout(transport_row)
|
||||||
@@ -1464,6 +1508,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
self._fps = self._mpv.get_fps()
|
self._fps = self._mpv.get_fps()
|
||||||
self._crop_bar.set_source_ratio(*self._mpv.get_video_size())
|
self._crop_bar.set_source_ratio(*self._mpv.get_video_size())
|
||||||
|
self._preview_timer.start()
|
||||||
|
|
||||||
# Run DB fuzzy match off the main thread — can be slow on large databases.
|
# Run DB fuzzy match off the main thread — can be slow on large databases.
|
||||||
filename = os.path.basename(self._file_path)
|
filename = os.path.basename(self._file_path)
|
||||||
@@ -1557,11 +1602,33 @@ class MainWindow(QMainWindow):
|
|||||||
self._crop_bar.set_crop_center(self._crop_center)
|
self._crop_bar.set_crop_center(self._crop_center)
|
||||||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||||||
|
|
||||||
|
# --- End-frame preview ---
|
||||||
|
|
||||||
|
def _grab_end_frame(self):
|
||||||
|
if not self._file_path:
|
||||||
|
return
|
||||||
|
if self._frame_grabber and self._frame_grabber.isRunning():
|
||||||
|
return
|
||||||
|
end_t = self._cursor + self._clip_span
|
||||||
|
dur = self._mpv.get_duration()
|
||||||
|
if dur:
|
||||||
|
end_t = min(end_t, dur)
|
||||||
|
self._frame_grabber = FrameGrabber(self._file_path, end_t)
|
||||||
|
self._frame_grabber.frame_ready.connect(self._show_end_frame)
|
||||||
|
self._frame_grabber.start()
|
||||||
|
|
||||||
|
def _show_end_frame(self, png_data: bytes):
|
||||||
|
px = QPixmap()
|
||||||
|
px.loadFromData(png_data)
|
||||||
|
if not px.isNull():
|
||||||
|
self._end_preview.setPixmap(px)
|
||||||
|
|
||||||
# --- Playback ---
|
# --- Playback ---
|
||||||
|
|
||||||
def _on_cursor_changed(self, t: float):
|
def _on_cursor_changed(self, t: float):
|
||||||
self._cursor = t
|
self._cursor = t
|
||||||
self._lbl_cursor.setText(f"cursor: {format_time(t)}")
|
self._lbl_cursor.setText(f"cursor: {format_time(t)}")
|
||||||
|
self._preview_timer.start()
|
||||||
if self._overwrite_path:
|
if self._overwrite_path:
|
||||||
self._overwrite_path = ""
|
self._overwrite_path = ""
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
|
|||||||
Reference in New Issue
Block a user