Compare commits

10 Commits

Author SHA1 Message Date
Ethanfel e62a712de2 perf: skip redundant repaints, cache fps, O(1) playlist dedup, fix worker leak
- TimelineWidget.set_cursor: skip update() when clamped value is
  unchanged (e.g. clicking at the boundary repeatedly).
- MainWindow: cache container_fps in self._fps on file load; keyPressEvent
  reads the cached value instead of hitting the mpv property on every key.
- PlaylistWidget: add _path_set (set[str]) alongside _paths (list[str])
  so duplicate detection in add_files is O(1) instead of O(N).
- SettingsDialog._on_install: quit+wait any previous SetupWorker before
  starting a new one, preventing leaked threads and duplicate signal
  connections on repeated installs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:18:31 +02:00
Ethanfel 16e0bc231c perf: playlist O(1) updates, marker hover cache, crop pen cache, keyboard debounce, export marker fast-path
- PlaylistWidget._select: only update the two items whose label changes
  (old current → plain name, new current → add ▶) instead of rewriting
  every item in the playlist.
- TimelineWidget: pre-compute (t/duration, path) fractions in
  _hover_cache on set_markers/set_duration so mouseMoveEvent avoids
  dividing by duration on every pixel of mouse movement.
- CropBarWidget: cache QPen in __init__ instead of recreating per frame.
- _step_cursor: update label/state immediately and start the timeline's
  existing _seek_timer instead of calling _on_cursor_changed directly,
  so held arrow keys are debounced the same as mouse drag.
- _refresh_markers: call _get_markers_for(filename) directly after
  export (exact match known) and only fall back to fuzzy find_similar
  when no exact-match rows exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:07:58 +02:00
Ethanfel 4c44d78c37 perf: seek debounce, cached paint resources, async DB lookup, regex pre-compile
- TimelineWidget: debounce cursor_changed signal with 16ms timer so
  mpv.seek is called at most ~60/s during drag; flush on mouseRelease.
  Cache QPen/QFont objects in __init__ instead of recreating per frame.
- _normalize_filename: compile _QUALITY_RE and _SEP_RE once at module
  level instead of on every call.
- ProcessedDB: add check_same_thread=False; add _get_markers_for() to
  avoid a second find_similar pass; store db path.
- _DBWorker(QThread): runs find_similar + _get_markers_for off the main
  thread. _after_load starts the worker instead of blocking; stale
  results discarded if the user loads a different file first.
- MainWindow: reuse self._settings instead of creating a new QSettings
  instance in the mask row setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:04:18 +02:00
Ethanfel b57131a3d9 feat: keyboard navigation for timeline
Arrow keys / J / L: step one frame; Shift = 1 second jump
Space / P: toggle play/pause
K: pause and return to cursor
E: trigger export
M: jump to next export marker (wraps)

Keys are suppressed when a text field has focus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:35:46 +02:00
Ethanfel 9652b249ba feat: extract audio alongside WebP image sequence
Adds build_audio_extract_command and runs it in ExportWorker after the
frame sequence completes. Audio written to <sequence_dir>.wav (lossless
pcm_s16le). Extraction failure (no audio stream) is silently ignored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:20:40 +02:00
Ethanfel fe3e642dfa fix: guard mask generation against WebP sequence export path 2026-04-06 17:19:03 +02:00
Ethanfel 855797a1b3 docs: clarify _last_export_path holds dir path for WebP sequence 2026-04-06 16:07:44 +02:00
Ethanfel f9649f7584 feat: wire WebP sequence format into export flow 2026-04-06 16:05:26 +02:00
Ethanfel 13c8a8aeab style: rename fmt_idx to idx in _cmb_format setup 2026-04-06 16:04:30 +02:00
Ethanfel c163b298c4 feat: add format combo (MP4 / WebP sequence) to export row 2026-04-06 16:02:26 +02:00
2 changed files with 267 additions and 61 deletions
+243 -60
View File
@@ -14,7 +14,7 @@ from PyQt6.QtWidgets import (
QComboBox, QDialog, QPlainTextEdit, QCheckBox, QComboBox, QDialog, QPlainTextEdit, QCheckBox,
) )
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 from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeyEvent
import mpv import mpv
@@ -77,6 +77,20 @@ def build_ffmpeg_command(
return cmd return cmd
def build_audio_extract_command(input_path: str, start: float, sequence_dir: str) -> list[str]:
"""Return an ffmpeg command that extracts audio to <sequence_dir>.wav."""
audio_path = sequence_dir + ".wav"
return [
"ffmpeg", "-y",
"-ss", str(start),
"-i", input_path,
"-t", "8",
"-vn",
"-c:a", "pcm_s16le",
audio_path,
]
def build_mask_output_dir(video_path: str) -> str: def build_mask_output_dir(video_path: str) -> str:
"""Return path of mask output directory: <stem>_masks/ next to the video.""" """Return path of mask output directory: <stem>_masks/ next to the video."""
p = Path(video_path) p = Path(video_path)
@@ -106,18 +120,22 @@ def _portrait_crop_filter(ratio: str, crop_center: float) -> str:
return f"crop={cw}:ih:{x}:0" return f"crop={cw}:ih:{x}:0"
_QUALITY_RE = re.compile(
r'(?<![a-z0-9])(2160p?|4k|8k|1080p?|720p?|480p?|360p?|240p?'
r'|hdr|sdr|x264|x265|h264|h265|hevc|avc'
r'|blu[-_.]?ray|webrip|web[-_.]dl|dvdrip|hdtv)(?![a-z0-9])',
re.IGNORECASE,
)
_SEP_RE = re.compile(r'[\s_\-\.]+')
def _normalize_filename(filename: str) -> str: def _normalize_filename(filename: str) -> str:
"""Strip extension and common resolution/quality tags for fuzzy comparison.""" """Strip extension and common resolution/quality tags for fuzzy comparison."""
name = os.path.splitext(filename)[0].lower()
# Use lookaround assertions instead of \b: \b treats '_' as a word char, # Use lookaround assertions instead of \b: \b treats '_' as a word char,
# so 'clip_2160p' would not form a word boundary before '2160p'. # so 'clip_2160p' would not form a word boundary before '2160p'.
name = re.sub( name = os.path.splitext(filename)[0].lower()
r'(?<![a-z0-9])(2160p?|4k|8k|1080p?|720p?|480p?|360p?|240p?' name = _QUALITY_RE.sub('', name)
r'|hdr|sdr|x264|x265|h264|h265|hevc|avc' name = _SEP_RE.sub('_', name).strip('_')
r'|blu[-_.]?ray|webrip|web[-_.]dl|dvdrip|hdtv)(?![a-z0-9])',
'', name, flags=re.IGNORECASE,
)
name = re.sub(r'[\s_\-\.]+', '_', name).strip('_')
return name return name
@@ -127,8 +145,9 @@ class ProcessedDB:
def __init__(self, db_path: str | None = None): def __init__(self, db_path: str | None = None):
if db_path is None: if db_path is None:
db_path = str(Path.home() / ".8cut.db") db_path = str(Path.home() / ".8cut.db")
self._path = db_path
try: try:
self._con = sqlite3.connect(db_path) self._con = sqlite3.connect(db_path, check_same_thread=False)
self._migrate() self._migrate()
self._enabled = True self._enabled = True
except Exception as e: except Exception as e:
@@ -185,6 +204,14 @@ class ProcessedDB:
best_ratio, best_match = ratio, stored best_ratio, best_match = ratio, stored
return best_match return best_match
def _get_markers_for(self, match: str) -> list[tuple[float, int, str]]:
rows = self._con.execute(
"SELECT start_time, output_path FROM processed"
" WHERE filename = ? ORDER BY start_time",
(match,),
).fetchall()
return [(t, i + 1, p) for i, (t, p) in enumerate(rows)]
def get_markers(self, filename: str) -> list[tuple[float, int, str]]: def get_markers(self, filename: str) -> list[tuple[float, int, str]]:
"""Return [(start_time, marker_number, output_path), ...] for the best """Return [(start_time, marker_number, output_path), ...] for the best
fuzzy match of filename, sorted by start_time. Empty list if no match.""" fuzzy match of filename, sorted by start_time. Empty list if no match."""
@@ -193,12 +220,25 @@ class ProcessedDB:
match = self.find_similar(filename) match = self.find_similar(filename)
if match is None: if match is None:
return [] return []
rows = self._con.execute( return self._get_markers_for(match)
"SELECT start_time, output_path FROM processed"
" WHERE filename = ? ORDER BY start_time",
(match,), class _DBWorker(QThread):
).fetchall() """Runs ProcessedDB fuzzy-match lookup off the main thread."""
return [(t, i + 1, p) for i, (t, p) in enumerate(rows)] result = pyqtSignal(str, object, list) # (queried_filename, match|None, markers)
def __init__(self, db: "ProcessedDB", filename: str):
super().__init__()
self._db = db
self._filename = filename
def run(self):
try:
match = self._db.find_similar(self._filename)
markers = self._db._get_markers_for(match) if match else []
except Exception:
match, markers = None, []
self.result.emit(self._filename, match, markers)
class ExportWorker(QThread): class ExportWorker(QThread):
@@ -232,6 +272,13 @@ class ExportWorker(QThread):
) )
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0: if result.returncode == 0:
if self._image_sequence:
audio_cmd = build_audio_extract_command(
self._input, self._start, self._output
)
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
# Audio extraction failure (e.g. no audio stream) is ignored —
# the frame sequence is the primary output.
self.finished.emit(self._output) self.finished.emit(self._output)
else: else:
self.error.emit(result.stderr[-500:]) self.error.emit(result.stderr[-500:])
@@ -251,21 +298,52 @@ class TimelineWidget(QWidget):
self._duration = 0.0 self._duration = 0.0
self._cursor = 0.0 self._cursor = 0.0
self._markers: list[tuple[float, int, str]] = [] self._markers: list[tuple[float, int, str]] = []
self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path)
# Cached paint resources — created once, reused every frame
self._cursor_pen = QPen(QColor(255, 200, 0))
self._cursor_pen.setWidth(2)
self._marker_pen = QPen(QColor(220, 60, 60))
self._marker_pen.setWidth(2)
self._marker_font = QFont()
self._marker_font.setPixelSize(9)
# Debounce timer: update visual cursor immediately but only emit
# cursor_changed (which triggers mpv.seek) at most once per interval.
self._seek_timer = QTimer()
self._seek_timer.setSingleShot(True)
self._seek_timer.setInterval(16) # ~60 fps
self._seek_timer.timeout.connect(lambda: self.cursor_changed.emit(self._cursor))
def set_duration(self, duration: float): def set_duration(self, duration: float):
self._duration = duration self._duration = duration
self._cursor = 0.0 self._cursor = 0.0
self._rebuild_hover_cache()
self.update() self.update()
def set_cursor(self, seconds: float): def set_cursor(self, seconds: float):
self._cursor = max(0.0, min(seconds, max(0.0, self._duration - 8.0))) clamped = max(0.0, min(seconds, max(0.0, self._duration - 8.0)))
if clamped == self._cursor:
return
self._cursor = clamped
self.update() self.update()
def set_markers(self, markers: list[tuple[float, int, str]]) -> None: def set_markers(self, markers: list[tuple[float, int, str]]) -> None:
"""markers: list of (start_time, number, output_path)""" """markers: list of (start_time, number, output_path)"""
self._markers = markers self._markers = markers
self._rebuild_hover_cache()
self.update() self.update()
def _rebuild_hover_cache(self) -> None:
"""Pre-compute (pixel_x_fraction, output_path) for hover detection."""
if self._duration > 0:
self._hover_cache = [
(t / self._duration, path)
for (t, _num, path) in self._markers
]
else:
self._hover_cache: list[tuple[float, str]] = []
def _pos_to_time(self, x: int) -> float: def _pos_to_time(self, x: int) -> float:
if self._duration <= 0 or self.width() <= 0: if self._duration <= 0 or self.width() <= 0:
return 0.0 return 0.0
@@ -287,22 +365,16 @@ class TimelineWidget(QWidget):
p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120)) p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120))
# Cursor line # Cursor line
pen = QPen(QColor(255, 200, 0)) p.setPen(self._cursor_pen)
pen.setWidth(2)
p.setPen(pen)
p.drawLine(x_start, 0, x_start, h) p.drawLine(x_start, 0, x_start, h)
# Markers # Markers
font = QFont() p.setFont(self._marker_font)
font.setPixelSize(9)
p.setFont(font)
marker_pen = QPen(QColor(220, 60, 60))
marker_pen.setWidth(2)
for (t, num, _path) in self._markers: for (t, num, _path) in self._markers:
if self._duration <= 0: if self._duration <= 0:
break break
mx = int(t / self._duration * w) mx = int(t / self._duration * w)
p.setPen(marker_pen) p.setPen(self._marker_pen)
p.drawLine(mx, 0, mx, h) p.drawLine(mx, 0, mx, h)
p.setPen(QColor(255, 255, 255)) p.setPen(QColor(255, 255, 255))
p.drawText(mx + 2, 10, str(num)) p.drawText(mx + 2, 10, str(num))
@@ -314,12 +386,11 @@ class TimelineWidget(QWidget):
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
x = event.position().x() x = event.position().x()
# Check marker hover (±4px) # Check marker hover (±4px) using pre-computed fractions.
if self._duration > 0 and self._markers: if self._hover_cache:
w = self.width() w = self.width()
for (t, _num, output_path) in self._markers: for (frac, output_path) in self._hover_cache:
mx = t / self._duration * w if abs(x - frac * w) <= 4:
if abs(x - mx) <= 4:
QToolTip.showText(QCursor.pos(), output_path, self) QToolTip.showText(QCursor.pos(), output_path, self)
if event.buttons(): if event.buttons():
self._seek(x) self._seek(x)
@@ -328,10 +399,15 @@ class TimelineWidget(QWidget):
if event.buttons(): if event.buttons():
self._seek(x) self._seek(x)
def mouseReleaseEvent(self, event):
# On release, flush any pending debounced seek immediately.
self._seek_timer.stop()
self.cursor_changed.emit(self._cursor)
def _seek(self, x: float): def _seek(self, x: float):
t = self._pos_to_time(int(x)) t = self._pos_to_time(int(x))
self.set_cursor(t) self.set_cursor(t) # update visuals immediately
self.cursor_changed.emit(self._cursor) self._seek_timer.start() # debounce the mpv seek
class MpvWidget(QFrame): class MpvWidget(QFrame):
@@ -395,6 +471,14 @@ class MpvWidget(QFrame):
return (self._player.width or 0, self._player.height or 0) return (self._player.width or 0, self._player.height or 0)
return (0, 0) return (0, 0)
def get_fps(self) -> float:
if self._player:
return self._player.container_fps or 25.0
return 25.0
def is_playing(self) -> bool:
return bool(self._player and not self._player.pause)
def mousePressEvent(self, event): def mousePressEvent(self, event):
w = self.width() w = self.width()
if w > 0: if w > 0:
@@ -422,6 +506,8 @@ class CropBarWidget(QWidget):
self._source_ratio: float = 16 / 9 # w/h of source video self._source_ratio: float = 16 / 9 # w/h of source video
self._portrait_ratio: tuple[int, int] | None = None # (num, den) self._portrait_ratio: tuple[int, int] | None = None # (num, den)
self._crop_center: float = 0.5 self._crop_center: float = 0.5
self._crop_pen = QPen(QColor(100, 160, 240))
self._crop_pen.setWidth(1)
def set_source_ratio(self, w: int, h: int) -> None: def set_source_ratio(self, w: int, h: int) -> None:
self._source_ratio = w / h if h > 0 else 16 / 9 self._source_ratio = w / h if h > 0 else 16 / 9
@@ -458,9 +544,7 @@ class CropBarWidget(QWidget):
x = int(max_x * self._crop_center) x = int(max_x * self._crop_center)
p.fillRect(x, 1, win_px, h - 2, QColor(80, 140, 220, 160)) p.fillRect(x, 1, win_px, h - 2, QColor(80, 140, 220, 160))
pen = QPen(QColor(100, 160, 240)) p.setPen(self._crop_pen)
pen.setWidth(1)
p.setPen(pen)
p.drawRect(x, 1, win_px - 1, h - 2) p.drawRect(x, 1, win_px - 1, h - 2)
finally: finally:
p.end() p.end()
@@ -498,14 +582,16 @@ class PlaylistWidget(QListWidget):
self.setMinimumWidth(200) self.setMinimumWidth(200)
self.setWordWrap(True) self.setWordWrap(True)
self._paths: list[str] = [] self._paths: list[str] = []
self._path_set: set[str] = set() # O(1) duplicate check
self.itemClicked.connect(self._on_item_clicked) self.itemClicked.connect(self._on_item_clicked)
def add_files(self, paths: list[str]) -> None: def add_files(self, paths: list[str]) -> None:
"""Append paths not already in queue; auto-select first if queue was empty.""" """Append paths not already in queue; auto-select first if queue was empty."""
was_empty = len(self._paths) == 0 was_empty = len(self._paths) == 0
for path in paths: for path in paths:
if path not in self._paths and os.path.isfile(path): if path not in self._path_set and os.path.isfile(path):
self._paths.append(path) self._paths.append(path)
self._path_set.add(path)
self.addItem(os.path.basename(path)) self.addItem(os.path.basename(path))
if was_empty and self._paths: if was_empty and self._paths:
self._select(0) self._select(0)
@@ -521,16 +607,15 @@ class PlaylistWidget(QListWidget):
return self._paths[row] if 0 <= row < len(self._paths) else None return self._paths[row] if 0 <= row < len(self._paths) else None
def _select(self, row: int) -> None: def _select(self, row: int) -> None:
prev = self.currentRow()
self.setCurrentRow(row) self.setCurrentRow(row)
self._refresh_labels() # Only update the two items that actually changed label.
if prev >= 0 and prev != row and self.item(prev):
self.item(prev).setText(os.path.basename(self._paths[prev]))
if self.item(row):
self.item(row).setText(f"{os.path.basename(self._paths[row])}")
self.file_selected.emit(self._paths[row]) self.file_selected.emit(self._paths[row])
def _refresh_labels(self) -> None:
current = self.currentRow()
for i in range(self.count()):
name = os.path.basename(self._paths[i])
self.item(i).setText(f"{name}" if i == current else name)
def _on_item_clicked(self, item: QListWidgetItem) -> None: def _on_item_clicked(self, item: QListWidgetItem) -> None:
self._select(self.row(item)) self._select(self.row(item))
@@ -666,6 +751,11 @@ class SettingsDialog(QDialog):
self.masks_visibility_changed.emit(checked) self.masks_visibility_changed.emit(checked)
def _on_install(self): def _on_install(self):
if self._worker and self._worker.isRunning():
return
if self._worker:
self._worker.quit()
self._worker.wait()
self._btn_install.setEnabled(False) self._btn_install.setEnabled(False)
self._log.clear() self._log.clear()
self._worker = SetupWorker() self._worker = SetupWorker()
@@ -724,6 +814,8 @@ class MainWindow(QMainWindow):
self._export_worker: ExportWorker | None = None self._export_worker: ExportWorker | None = None
self._last_export_path: str = "" self._last_export_path: str = ""
self._mask_worker: MaskWorker | None = None self._mask_worker: MaskWorker | None = None
self._db_worker: _DBWorker | None = None
self._fps: float = 25.0 # cached on file load via get_fps()
# Widgets # Widgets
self._playlist = PlaylistWidget() self._playlist = PlaylistWidget()
@@ -779,6 +871,16 @@ class MainWindow(QMainWindow):
self._cmb_portrait.setCurrentIndex(idx if idx >= 0 else 0) self._cmb_portrait.setCurrentIndex(idx if idx >= 0 else 0)
self._cmb_portrait.currentTextChanged.connect(self._on_portrait_ratio_changed) self._cmb_portrait.currentTextChanged.connect(self._on_portrait_ratio_changed)
self._cmb_format = QComboBox()
self._cmb_format.addItems(["MP4", "WebP sequence"])
saved_fmt = self._settings.value("export_format", "MP4")
idx = self._cmb_format.findText(saved_fmt)
self._cmb_format.setCurrentIndex(idx if idx >= 0 else 0)
self._cmb_format.currentTextChanged.connect(
lambda v: self._settings.setValue("export_format", v)
)
self._cmb_format.currentTextChanged.connect(self._update_next_label)
self._crop_bar = CropBarWidget() self._crop_bar = CropBarWidget()
self._crop_bar.set_crop_center(self._crop_center) self._crop_bar.set_crop_center(self._crop_center)
self._crop_bar.set_portrait_ratio( self._crop_bar.set_portrait_ratio(
@@ -831,6 +933,8 @@ class MainWindow(QMainWindow):
export_row.addWidget(self._txt_resize) export_row.addWidget(self._txt_resize)
export_row.addWidget(QLabel("Portrait:")) export_row.addWidget(QLabel("Portrait:"))
export_row.addWidget(self._cmb_portrait) export_row.addWidget(self._cmb_portrait)
export_row.addWidget(QLabel("Format:"))
export_row.addWidget(self._cmb_format)
export_row.addWidget(self._lbl_next) export_row.addWidget(self._lbl_next)
export_row.addWidget(self._btn_export) export_row.addWidget(self._btn_export)
@@ -849,7 +953,7 @@ class MainWindow(QMainWindow):
mask_row.addWidget(self._cmb_mask) mask_row.addWidget(self._cmb_mask)
mask_row.addWidget(self._btn_masks) mask_row.addWidget(self._btn_masks)
mask_row.addStretch() mask_row.addStretch()
show_masks = QSettings("8cut", "8cut").value("show_masks_row", "true") == "true" show_masks = self._settings.value("show_masks_row", "true") == "true"
self._mask_row_widget.setVisible(show_masks) self._mask_row_widget.setVisible(show_masks)
right_layout.addLayout(controls) right_layout.addLayout(controls)
@@ -892,18 +996,36 @@ class MainWindow(QMainWindow):
self._btn_play.setEnabled(True) self._btn_play.setEnabled(True)
self._btn_pause.setEnabled(True) self._btn_pause.setEnabled(True)
self._btn_export.setEnabled(True) self._btn_export.setEnabled(True)
self._fps = self._mpv.get_fps()
self._crop_bar.set_source_ratio(*self._mpv.get_video_size())
match = self._db.find_similar(os.path.basename(self._file_path)) # Run DB fuzzy match off the main thread — can be slow on large databases.
filename = os.path.basename(self._file_path)
self._db_worker = _DBWorker(self._db, filename)
self._db_worker.result.connect(self._on_db_result)
self._db_worker.start()
def _on_db_result(self, queried: str, match: object, markers: list) -> None:
# Discard stale results if the user loaded a different file already.
if os.path.basename(self._file_path) != queried:
return
if match: if match:
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}") self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
else: else:
self.statusBar().clearMessage() self.statusBar().clearMessage()
self._timeline.set_markers(markers)
self._crop_bar.set_source_ratio(*self._mpv.get_video_size())
self._refresh_markers()
def _refresh_markers(self) -> None: def _refresh_markers(self) -> None:
markers = self._db.get_markers(os.path.basename(self._file_path)) filename = os.path.basename(self._file_path)
# After an export we already know the exact stored filename, so skip
# the expensive fuzzy match and query directly.
if self._db._enabled:
markers = self._db._get_markers_for(filename)
if not markers:
# First export for this file — fall back to fuzzy match once.
markers = self._db.get_markers(filename)
else:
markers = []
self._timeline.set_markers(markers) self._timeline.set_markers(markers)
def _on_portrait_ratio_changed(self, text: str) -> None: def _on_portrait_ratio_changed(self, text: str) -> None:
@@ -936,6 +1058,57 @@ class MainWindow(QMainWindow):
self._mpv.stop_loop() self._mpv.stop_loop()
self._mpv.seek(self._cursor) self._mpv.seek(self._cursor)
def _step_cursor(self, delta: float) -> None:
if not self._file_path:
return
dur = self._mpv.get_duration()
new_t = max(0.0, min(self._cursor + delta, max(0.0, dur - 8.0)))
# Update label and internal state immediately; route the seek through
# the timeline's debounce timer so rapid key repeats don't hammer mpv.
self._cursor = new_t
self._lbl_cursor.setText(f"cursor: {format_time(new_t)}")
self._timeline.set_cursor(new_t)
self._timeline._seek_timer.start()
def _jump_to_next_marker(self) -> None:
markers = sorted(self._timeline._markers, key=lambda m: m[0])
if not markers:
return
for (t, _num, _path) in markers:
if t > self._cursor + 0.1:
self._step_cursor(t - self._cursor)
return
self._step_cursor(markers[0][0] - self._cursor) # wrap to first
def keyPressEvent(self, event: QKeyEvent) -> None:
focused = QApplication.focusWidget()
if isinstance(focused, (QLineEdit, QPlainTextEdit)):
super().keyPressEvent(event)
return
key = event.key()
shift = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
frame = 1.0 / self._fps
step = 1.0 if shift else frame
if key in (Qt.Key.Key_Left, Qt.Key.Key_J):
self._step_cursor(-step)
elif key in (Qt.Key.Key_Right, Qt.Key.Key_L):
self._step_cursor(step)
elif key in (Qt.Key.Key_Space, Qt.Key.Key_P):
if self._mpv.is_playing():
self._on_pause()
else:
self._on_play()
elif key == Qt.Key.Key_K:
self._on_pause()
elif key == Qt.Key.Key_E:
self._on_export()
elif key == Qt.Key.Key_M:
self._jump_to_next_marker()
else:
super().keyPressEvent(event)
# --- Export --- # --- Export ---
def _pick_folder(self): def _pick_folder(self):
@@ -950,11 +1123,12 @@ class MainWindow(QMainWindow):
self._update_next_label() self._update_next_label()
def _update_next_label(self): def _update_next_label(self):
path = build_export_path( folder = self._txt_folder.text()
self._txt_folder.text(), name = self._txt_name.text() or "clip"
self._txt_name.text() or "clip", if self._cmb_format.currentText() == "WebP sequence":
self._export_counter, path = build_sequence_dir(folder, name, self._export_counter)
) else:
path = build_export_path(folder, name, self._export_counter)
self._lbl_next.setText(f"{os.path.basename(path)}") self._lbl_next.setText(f"{os.path.basename(path)}")
def _on_export(self): def _on_export(self):
@@ -964,11 +1138,14 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage("Export already running…") self.statusBar().showMessage("Export already running…")
return return
output = build_export_path( fmt = self._cmb_format.currentText()
self._txt_folder.text(), image_sequence = fmt == "WebP sequence"
self._txt_name.text() or "clip", folder = self._txt_folder.text()
self._export_counter, name = self._txt_name.text() or "clip"
) if image_sequence:
output = build_sequence_dir(folder, name, self._export_counter)
else:
output = build_export_path(folder, name, self._export_counter)
raw = self._txt_resize.text().strip() raw = self._txt_resize.text().strip()
try: try:
@@ -989,6 +1166,7 @@ class MainWindow(QMainWindow):
short_side=short_side, short_side=short_side,
portrait_ratio=portrait_ratio, portrait_ratio=portrait_ratio,
crop_center=self._crop_center, crop_center=self._crop_center,
image_sequence=image_sequence,
) )
self._export_worker.finished.connect(self._on_export_done) self._export_worker.finished.connect(self._on_export_done)
self._export_worker.error.connect(self._on_export_error) self._export_worker.error.connect(self._on_export_error)
@@ -996,6 +1174,8 @@ class MainWindow(QMainWindow):
def _on_export_done(self, path: str): def _on_export_done(self, path: str):
self._db.add(os.path.basename(self._file_path), self._cursor, path) self._db.add(os.path.basename(self._file_path), self._cursor, path)
# For MP4 exports path is a file; for WebP sequence it is a directory.
# build_mask_output_dir handles both correctly via Path.stem.
self._last_export_path = path self._last_export_path = path
self._export_counter += 1 self._export_counter += 1
self._update_next_label() self._update_next_label()
@@ -1020,6 +1200,9 @@ class MainWindow(QMainWindow):
if not self._last_export_path: if not self._last_export_path:
self.statusBar().showMessage("No clip exported yet — export first.") self.statusBar().showMessage("No clip exported yet — export first.")
return return
if os.path.isdir(self._last_export_path):
self.statusBar().showMessage("Mask generation requires an MP4 export — switch format to MP4 and export first.")
return
if self._mask_worker and self._mask_worker.isRunning(): if self._mask_worker and self._mask_worker.isRunning():
self.statusBar().showMessage("Mask generation already running…") self.statusBar().showMessage("Mask generation already running…")
return return
+24 -1
View File
@@ -1,5 +1,5 @@
import tempfile, os import tempfile, os
from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir, build_sequence_dir from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir, build_sequence_dir, build_audio_extract_command
from main import _normalize_filename, ProcessedDB from main import _normalize_filename, ProcessedDB
@@ -184,6 +184,29 @@ def test_mask_output_dir_nested():
assert build_mask_output_dir("/a/b/c/shot_042.mp4") == "/a/b/c/shot_042_masks" assert build_mask_output_dir("/a/b/c/shot_042.mp4") == "/a/b/c/shot_042_masks"
# --- build_audio_extract_command ---
def test_audio_extract_output_path():
cmd = build_audio_extract_command("/in/v.mp4", 0.0, "/out/clip_001")
assert cmd[-1] == "/out/clip_001.wav"
def test_audio_extract_no_video():
cmd = build_audio_extract_command("/in/v.mp4", 0.0, "/out/clip_001")
assert "-vn" in cmd
def test_audio_extract_lossless_codec():
cmd = build_audio_extract_command("/in/v.mp4", 0.0, "/out/clip_001")
assert "-c:a" in cmd
assert cmd[cmd.index("-c:a") + 1] == "pcm_s16le"
def test_audio_extract_timing():
cmd = build_audio_extract_command("/in/v.mp4", 12.5, "/out/clip_001")
assert "-ss" in cmd
assert cmd[cmd.index("-ss") + 1] == "12.5"
assert "-t" in cmd
assert cmd[cmd.index("-t") + 1] == "8"
def test_build_sequence_dir_basic(): def test_build_sequence_dir_basic():
assert build_sequence_dir("/out", "clip", 1) == "/out/clip_001" assert build_sequence_dir("/out", "clip", 1) == "/out/clip_001"