From d4357f0da4966b5c72ac8464a5c720a5e773fedd Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 13 Apr 2026 16:34:49 +0200 Subject: [PATCH] feat: cursor lock with crop keyframing, remove fuzzy filename matching - Lock button (G key) freezes export cursor, timeline scrubs playback only - In lock mode, clicking crop bar sets a keyframe at current playback time - Orange diamonds on timeline show keyframe positions - Export resolves per-clip crop center from nearest preceding keyframe - Crop bar/overlay updates while scrubbing to preview effective crop - Unlocking clears all keyframes - Replace fuzzy filename matching with exact match to prevent marker bleed Co-Authored-By: Claude Opus 4.6 --- main.py | 179 +++++++++++++++++++++++++++++--------------- tests/test_utils.py | 69 ++++------------- 2 files changed, 132 insertions(+), 116 deletions(-) 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): "EExport" "MJump to next marker" "NNext file in playlist" + "GToggle cursor lock" "? / F1This help" "
" "Double-click markerEnter overwrite mode" @@ -2043,16 +2046,7 @@ class MainWindow(QMainWindow): def _refresh_markers(self) -> None: filename = os.path.basename(self._file_path) - profile = self._profile - # 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, profile) - if not markers: - # First export for this file — fall back to fuzzy match once. - markers = self._db.get_markers(filename, profile) - else: - markers = [] + markers = self._db.get_markers(filename, self._profile) self._timeline.set_markers(markers) def _refresh_playlist_checks(self) -> None: @@ -2230,7 +2224,26 @@ class MainWindow(QMainWindow): any_rand = self._chk_rand_portrait.isChecked() or self._chk_rand_square.isChecked() if ratio == "Off" and not any_rand: return - self._crop_center = max(0.0, min(1.0, frac)) + frac = max(0.0, min(1.0, frac)) + if self._btn_lock.isChecked(): + # Lock mode: set a crop keyframe at the current playback position. + play_t = self._timeline._play_pos + if play_t is None: + play_t = self._cursor + # Replace existing keyframe at same time, or insert sorted. + self._crop_keyframes = [ + (t, c) for t, c in self._crop_keyframes + if abs(t - play_t) > 0.05 + ] + self._crop_keyframes.append((play_t, frac)) + self._crop_keyframes.sort() + self._timeline.set_crop_keyframes(self._crop_keyframes) + _log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ({len(self._crop_keyframes)} total)") + self._crop_bar.set_crop_center(frac) + if ratio != "Off": + self._mpv.set_crop_overlay(_RATIOS[ratio], frac) + return + self._crop_center = frac self._settings.setValue("crop_center", str(self._crop_center)) self._crop_bar.set_crop_center(self._crop_center) if ratio != "Off": @@ -2269,6 +2282,38 @@ class MainWindow(QMainWindow): # --- Playback --- + def _on_lock_toggled(self, locked: bool): + self._timeline._locked = locked + self._btn_lock.setText("🔒 Lock" if locked else "🔓 Lock") + if locked: + self._btn_lock.setStyleSheet("background: #4a3000; border-color: #ffd230;") + else: + self._btn_lock.setStyleSheet("") + # Clear keyframes when unlocking. + if self._crop_keyframes: + n = len(self._crop_keyframes) + self._crop_keyframes.clear() + self._timeline.set_crop_keyframes([]) + _log(f"Cleared {n} crop keyframe(s)") + + def _on_seek_changed(self, t: float): + """Lock mode: scrub playback without moving the export cursor.""" + dur = self._mpv.get_duration() + self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}") + self._mpv.seek(t) + # Update crop bar to show the effective center at this time. + if self._crop_keyframes: + center = self._crop_center + for kt, kc in self._crop_keyframes: + if kt <= t + 0.05: + center = kc + else: + break + self._crop_bar.set_crop_center(center) + ratio = self._cmb_portrait.currentText() + if ratio != "Off": + self._mpv.set_crop_overlay(_RATIOS[ratio], center) + def _on_cursor_changed(self, t: float): self._cursor = t dur = self._mpv.get_duration() @@ -2400,6 +2445,20 @@ class MainWindow(QMainWindow): out = build_export_path(folder, name, self._export_counter, sub=sub) jobs.append((start, out, base_ratio, base_center)) + # Apply crop keyframes: each sub-clip uses the latest keyframe + # at or before its start time (keyframes set in lock mode). + if self._crop_keyframes: + for i, (s, o, r, c) in enumerate(jobs): + if r is None: + continue # no crop → skip + center = base_center + for kt, kc in self._crop_keyframes: + if kt <= s + 0.05: + center = kc + else: + break + jobs[i] = (s, o, r, center) + # Random crop: ~1 per 3 clips gets a random crop + random position. # When both portrait and square are on, they share the quota. rand_portrait = self._chk_rand_portrait.isChecked() diff --git a/tests/test_utils.py b/tests/test_utils.py index b34f63b..9bce74e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ import tempfile, os, json from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, build_annotation_json_path, upsert_clip_annotation -from main import _normalize_filename, ProcessedDB +from main import ProcessedDB def test_build_export_path_first(): @@ -53,63 +53,47 @@ def test_ffmpeg_command_with_resize(): assert cmd[-1] == "/out/clip_001.mp4" -# --- _normalize_filename --- - -def test_normalize_strips_extension(): - assert _normalize_filename("clip.mp4") == "clip" - -def test_normalize_strips_resolution(): - assert _normalize_filename("clip_2160p.mp4") == "clip" - -def test_normalize_strips_1080p(): - assert _normalize_filename("clip_1080p.mkv") == "clip" - -def test_normalize_strips_multiple_tags(): - assert _normalize_filename("show_1080p_HDR.mkv") == "show" - -def test_normalize_lowercases(): - assert _normalize_filename("MyVideo_4K.mp4") == "myvideo" - -def test_normalize_collapses_separators(): - assert _normalize_filename("my__video--2160p.mp4") == "my_video" - - # --- ProcessedDB --- -def test_db_add_and_find_exact(): +def test_db_add_and_get_markers(): with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: path = f.name try: db = ProcessedDB(path) db.add("video.mp4", 12.5, "/out/clip_001.mp4") - assert db.find_similar("video.mp4") == "video.mp4" + markers = db.get_markers("video.mp4") + assert len(markers) == 1 + assert markers[0][0] == 12.5 finally: os.unlink(path) -def test_db_find_similar_resolution_variant(): +def test_db_exact_match_only(): with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: path = f.name try: db = ProcessedDB(path) db.add("episode_s01e01_2160p.mkv", 0.0, "/out/ep_001.mp4") - assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv" + # Different filename — no match even if similar + assert db.get_markers("episode_s01e01_1080p.mkv") == [] + # Exact filename — match + assert len(db.get_markers("episode_s01e01_2160p.mkv")) == 1 finally: os.unlink(path) -def test_db_find_similar_no_match(): +def test_db_no_match(): with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: path = f.name try: db = ProcessedDB(path) db.add("alpha.mp4", 0.0, "/out/alpha_001.mp4") - assert db.find_similar("completely_different_zzzz.mp4") is None + assert db.get_markers("completely_different.mp4") == [] finally: os.unlink(path) def test_db_disabled_survives_bad_path(): db = ProcessedDB("/no/such/directory/8cut.db") db.add("x.mp4", 0.0, "/out/x_001.mp4") # must not raise - assert db.find_similar("x.mp4") is None + assert db.get_markers("x.mp4") == [] def test_db_get_markers_returns_sorted(): with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: @@ -127,19 +111,6 @@ def test_db_get_markers_returns_sorted(): finally: os.unlink(path) -def test_db_get_markers_fuzzy_match(): - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: - path = f.name - try: - db = ProcessedDB(path) - db.add("show_2160p.mkv", 5.0, "/out/s_001.mp4") - markers = db.get_markers("show_1080p.mkv") - assert len(markers) == 1 - assert markers[0][0] == 5.0 - assert markers[0][2] == "/out/s_001.mp4" - finally: - os.unlink(path) - def test_db_get_markers_no_match(): with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: path = f.name @@ -365,20 +336,6 @@ def test_db_markers_isolated_by_profile(): os.unlink(path) -def test_db_find_similar_isolated_by_profile(): - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: - path = f.name - try: - db = ProcessedDB(path) - db.add("episode_2160p.mkv", 0.0, "/out/a.mp4", profile="hires") - # Same normalized name but different profile → no match - assert db.find_similar("episode_1080p.mkv", profile="lores") is None - # Same profile → match - assert db.find_similar("episode_1080p.mkv", profile="hires") == "episode_2160p.mkv" - finally: - os.unlink(path) - - def test_db_get_profiles(): with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: path = f.name