diff --git a/main.py b/main.py index 68f3c65..8f70f42 100755 --- a/main.py +++ b/main.py @@ -13,7 +13,6 @@ import subprocess import tempfile from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone -from difflib import SequenceMatcher from pathlib import Path from PyQt6.QtWidgets import ( @@ -230,26 +229,9 @@ def _portrait_crop_filter(ratio: str, crop_center: float) -> str: return f"crop={cw}:ih:{x}:0" -_QUALITY_RE = re.compile( - r'(? str: - """Strip extension and common resolution/quality tags for fuzzy comparison.""" - # Use lookaround assertions instead of \b: \b treats '_' as a word char, - # so 'clip_2160p' would not form a word boundary before '2160p'. - name = os.path.splitext(filename)[0].lower() - name = _QUALITY_RE.sub('', name) - name = _SEP_RE.sub('_', name).strip('_') - return name - - # --------------------------------------------------------------------------- # Subject tracking (YOLO-based, optional) # --------------------------------------------------------------------------- @@ -519,23 +501,6 @@ class ProcessedDB: self._con.commit() return paths - def find_similar(self, filename: str, profile: str = "default") -> str | None: - if not self._enabled: - return None - rows = self._con.execute( - "SELECT DISTINCT filename FROM processed WHERE profile = ?", - (profile,), - ).fetchall() - norm_new = _normalize_filename(filename) - best_ratio, best_match = 0.0, None - for (stored,) in rows: - ratio = SequenceMatcher( - None, norm_new, _normalize_filename(stored) - ).ratio() - if ratio >= 0.75 and ratio > best_ratio: - best_ratio, best_match = ratio, stored - return best_match - def _get_markers_for(self, match: str, profile: str = "default") -> list[tuple[float, int, str]]: rows = self._con.execute( "SELECT start_time, output_path FROM processed" @@ -552,14 +517,11 @@ class ProcessedDB: return list(seen_times.values()) def get_markers(self, filename: str, profile: str = "default") -> list[tuple[float, int, str]]: - """Return [(start_time, marker_number, output_path), ...] for the best - fuzzy match of filename, sorted by start_time. Empty list if no match.""" + """Return [(start_time, marker_number, output_path), ...] for exact + filename match, sorted by start_time. Empty list if no match.""" if not self._enabled: return [] - match = self.find_similar(filename, profile) - if match is None: - return [] - return self._get_markers_for(match, profile) + return self._get_markers_for(filename, profile) def get_profiles(self) -> list[str]: """Return distinct profile names, ordered alphabetically.""" @@ -583,11 +545,10 @@ class _DBWorker(QThread): def run(self): try: - match = self._db.find_similar(self._filename, self._profile) - markers = self._db._get_markers_for(match, self._profile) if match else [] + markers = self._db._get_markers_for(self._filename, self._profile) except Exception: - match, markers = None, [] - self.result.emit(self._filename, match, markers) + markers = [] + self.result.emit(self._filename, self._filename if markers else None, markers) class ExportWorker(QThread): @@ -682,6 +643,7 @@ class FrameGrabber(QThread): class TimelineWidget(QWidget): cursor_changed = pyqtSignal(float) # emits position in seconds + seek_changed = pyqtSignal(float) # emits seek position (lock mode) marker_delete_requested = pyqtSignal(str) # emits output_path marker_clicked = pyqtSignal(float, str) # emits (start_time, output_path) marker_deselected = pyqtSignal() # double-click on empty space @@ -697,6 +659,8 @@ class TimelineWidget(QWidget): self._cursor = 0.0 self._clip_span = 14.0 # 8 + 2*spread, updated from MainWindow self._play_pos: float | None = None # current playback position (seconds) + self._locked = False # when True, clicks scrub playback, not cursor + self._crop_keyframes: list[tuple[float, float]] = [] # [(time, center)] self._markers: list[tuple[float, int, str]] = [] self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path) @@ -717,7 +681,7 @@ class TimelineWidget(QWidget): 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)) + self._seek_timer.timeout.connect(self._emit_seek) def set_duration(self, duration: float): self._duration = duration @@ -747,6 +711,10 @@ class TimelineWidget(QWidget): self._play_pos = t self.update() + def set_crop_keyframes(self, kfs: list[tuple[float, float]]) -> None: + self._crop_keyframes = kfs + self.update() + def _rebuild_hover_cache(self) -> None: """Pre-compute (pixel_x_fraction, output_path) for hover detection.""" if self._duration > 0: @@ -855,6 +823,20 @@ class TimelineWidget(QWidget): p.drawText(mx + 1, rh + 2, 13, 12, Qt.AlignmentFlag.AlignCenter, str(num)) + # ── crop keyframe diamonds ──────────────────────────────────── + if self._crop_keyframes and self._duration > 0: + for (kt, _kc) in self._crop_keyframes: + kx = int(kt / self._duration * w) + d = 4 # half-size of diamond + ky = h - d - 2 # near bottom of track + diamond = QPolygon([ + QPoint(kx, ky - d), QPoint(kx + d, ky), + QPoint(kx, ky + d), QPoint(kx - d, ky), + ]) + p.setBrush(QColor(255, 180, 0)) + p.setPen(Qt.PenStyle.NoPen) + p.drawPolygon(diamond) + # ── playhead ────────────────────────────────────────────────── p.setPen(self._cursor_pen) p.drawLine(x_start, rh, x_start, h) @@ -905,10 +887,16 @@ class TimelineWidget(QWidget): if event.buttons(): self._seek(x) + def _emit_seek(self): + if self._locked: + self.seek_changed.emit(self._play_pos or 0.0) + else: + self.cursor_changed.emit(self._cursor) + def mouseReleaseEvent(self, event): # On release, flush any pending debounced seek immediately. self._seek_timer.stop() - self.cursor_changed.emit(self._cursor) + self._emit_seek() def contextMenuEvent(self, event): if not self._hover_cache or self._duration <= 0: @@ -931,8 +919,13 @@ class TimelineWidget(QWidget): def _seek(self, x: float): t = self._pos_to_time(int(x)) - self.set_cursor(t) # update visuals immediately - self._seek_timer.start() # debounce the mpv seek + if self._locked: + self._play_pos = t + self.update() + self._seek_timer.start() + else: + self.set_cursor(t) # update visuals immediately + self._seek_timer.start() # debounce the mpv seek import ctypes @@ -1529,6 +1522,7 @@ class MainWindow(QMainWindow): 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._crop_keyframes: list[tuple[float, float]] = [] # [(time, center), ...] sorted # Widgets self._playlist = PlaylistWidget() @@ -1560,6 +1554,7 @@ class MainWindow(QMainWindow): _init_spread = float(self._settings.value("spread", "3.0")) self._timeline.set_clip_span(8.0 + (_init_clips - 1) * _init_spread) self._timeline.cursor_changed.connect(self._on_cursor_changed) + self._timeline.seek_changed.connect(self._on_seek_changed) self._timeline.marker_delete_requested.connect(self._on_delete_marker) self._mpv.time_pos_changed.connect(self._timeline.set_play_position) self._timeline.marker_clicked.connect(self._on_marker_clicked) @@ -1582,6 +1577,11 @@ class MainWindow(QMainWindow): self._btn_pause.setToolTip("Pause playback (Space / K)") self._btn_pause.clicked.connect(self._on_pause) + self._btn_lock = QPushButton("🔒 Lock") + self._btn_lock.setCheckable(True) + self._btn_lock.setToolTip("Lock cursor — click/drag scrubs playback without moving the export point") + self._btn_lock.toggled.connect(self._on_lock_toggled) + self._lbl_time = QLabel("-- / --") self._txt_name = QLineEdit("clip") @@ -1793,6 +1793,7 @@ class MainWindow(QMainWindow): transport_row = QHBoxLayout() transport_row.addWidget(self._btn_play) transport_row.addWidget(self._btn_pause) + transport_row.addWidget(self._btn_lock) transport_row.addWidget(self._lbl_time) transport_row.addStretch() transport_row.addWidget(self._lbl_next) @@ -1894,6 +1895,7 @@ class MainWindow(QMainWindow): QShortcut(QKeySequence("E"), self, context=ctx).activated.connect(self._on_export) QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker) QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance) + QShortcut(QKeySequence("G"), self, context=ctx).activated.connect(self._btn_lock.toggle) for key in ("?", "F1"): QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts) @@ -1909,6 +1911,7 @@ class MainWindow(QMainWindow): "