diff --git a/README.md b/README.md index 7592e73..e5a0c30 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets - **Playlist** — drag-and-drop or use the Open Files button; right-click to remove items - **Playback loop** — plays the exact selection region on loop so you can preview what will be exported - **Group operations** — delete or overwrite acts on all sub-clips in a batch, not just one +- **Profiles** — switch between independent marker sets (e.g. "landscape" vs "portrait") for the same video ## Keyboard shortcuts @@ -128,7 +129,7 @@ Export history is stored in `~/.8cut.db` (SQLite). The database records filename pytest tests/ -v ``` -49 unit tests covering path builders, ffmpeg command generation, time formatting, database operations, group queries, and annotation handling. +54 unit tests covering path builders, ffmpeg command generation, time formatting, database operations, group queries, profile isolation, and annotation handling. ## License diff --git a/main.py b/main.py index c3ac14e..d0a5f99 100755 --- a/main.py +++ b/main.py @@ -234,6 +234,7 @@ class ProcessedDB: " format TEXT NOT NULL DEFAULT 'MP4'," " clip_count INTEGER NOT NULL DEFAULT 3," " spread REAL NOT NULL DEFAULT 3.0," + " profile TEXT NOT NULL DEFAULT 'default'," " processed_at TEXT NOT NULL" ")" ) @@ -248,6 +249,7 @@ class ProcessedDB: "format": "TEXT NOT NULL DEFAULT 'MP4'", "clip_count": "INTEGER NOT NULL DEFAULT 3", "spread": "REAL NOT NULL DEFAULT 3.0", + "profile": "TEXT NOT NULL DEFAULT 'default'", } for col, typedef in new_cols.items(): if col not in cols: @@ -263,18 +265,19 @@ class ProcessedDB: label: str = "", category: str = "", short_side: int | None = None, portrait_ratio: str = "", crop_center: float = 0.5, fmt: str = "MP4", - clip_count: int = 3, spread: float = 3.0) -> None: + clip_count: int = 3, spread: float = 3.0, + profile: str = "default") -> None: if not self._enabled: return self._con.execute( "INSERT INTO processed" " (filename, start_time, output_path, label, category," " short_side, portrait_ratio, crop_center, format," - " clip_count, spread, processed_at)" - " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + " clip_count, spread, profile, processed_at)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (filename, start_time, output_path, label, category, short_side, portrait_ratio, crop_center, fmt, - clip_count, spread, + clip_count, spread, profile, datetime.now(timezone.utc).isoformat()), ) self._con.commit() @@ -357,11 +360,12 @@ class ProcessedDB: self._con.commit() return paths - def find_similar(self, filename: str) -> str | None: + 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" + "SELECT DISTINCT filename FROM processed WHERE profile = ?", + (profile,), ).fetchall() norm_new = _normalize_filename(filename) best_ratio, best_match = 0.0, None @@ -373,11 +377,11 @@ class ProcessedDB: best_ratio, best_match = ratio, stored return best_match - def _get_markers_for(self, match: str) -> list[tuple[float, int, str]]: + 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" - " WHERE filename = ? ORDER BY start_time", - (match,), + " WHERE filename = ? AND profile = ? ORDER BY start_time", + (match, profile), ).fetchall() # Deduplicate by start_time — batch exports share the same cursor. seen_times: dict[float, tuple[float, int, str]] = {} @@ -388,30 +392,40 @@ class ProcessedDB: seen_times[t] = (t, n, p) return list(seen_times.values()) - def get_markers(self, filename: str) -> list[tuple[float, int, str]]: + 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.""" if not self._enabled: return [] - match = self.find_similar(filename) + match = self.find_similar(filename, profile) if match is None: return [] - return self._get_markers_for(match) + return self._get_markers_for(match, profile) + + def get_profiles(self) -> list[str]: + """Return distinct profile names, ordered alphabetically.""" + if not self._enabled: + return [] + rows = self._con.execute( + "SELECT DISTINCT profile FROM processed ORDER BY profile" + ).fetchall() + return [r[0] for r in rows] class _DBWorker(QThread): """Runs ProcessedDB fuzzy-match lookup off the main thread.""" result = pyqtSignal(str, object, list) # (queried_filename, match|None, markers) - def __init__(self, db: "ProcessedDB", filename: str): + def __init__(self, db: "ProcessedDB", filename: str, profile: str = "default"): super().__init__() self._db = db self._filename = filename + self._profile = profile def run(self): try: - match = self._db.find_similar(self._filename) - markers = self._db._get_markers_for(match) if match else [] + match = self._db.find_similar(self._filename, self._profile) + markers = self._db._get_markers_for(match, self._profile) if match else [] except Exception: match, markers = None, [] self.result.emit(self._filename, match, markers) @@ -1391,9 +1405,24 @@ class MainWindow(QMainWindow): self._btn_delete.setToolTip("Delete last export or selected marker from disk and DB") self._btn_delete.clicked.connect(self._on_delete_export) + self._cmb_profile = QComboBox() + self._cmb_profile.setEditable(True) + self._cmb_profile.setToolTip("Export profile — each profile has its own set of markers") + self._cmb_profile.setMinimumWidth(100) + existing = self._db.get_profiles() + if existing: + self._cmb_profile.addItems(existing) + else: + self._cmb_profile.addItem("default") + saved_profile = self._settings.value("profile", "default") + self._cmb_profile.setCurrentText(saved_profile) + self._cmb_profile.currentTextChanged.connect(self._on_profile_changed) + # Right-side layout (video + controls) top_bar = QHBoxLayout() top_bar.addWidget(self._lbl_file, stretch=1) + top_bar.addWidget(QLabel("Profile:")) + top_bar.addWidget(self._cmb_profile) # Row 1 — transport + export actions transport_row = QHBoxLayout() @@ -1502,6 +1531,16 @@ class MainWindow(QMainWindow): QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker) QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance) + @property + def _profile(self) -> str: + return self._cmb_profile.currentText().strip() or "default" + + def _on_profile_changed(self, text: str) -> None: + self._settings.setValue("profile", text) + if self._file_path: + self._refresh_markers() + self.statusBar().showMessage(f"Profile: {text}", 3000) + def _on_open_files(self) -> None: paths, _ = QFileDialog.getOpenFileNames( self, "Open video files", "", @@ -1510,7 +1549,7 @@ class MainWindow(QMainWindow): if paths: self._playlist.add_files(paths) for p in paths: - if self._db.get_markers(os.path.basename(p)): + if self._db.get_markers(os.path.basename(p), self._profile): self._playlist.mark_done(p) def _load_file(self, path: str): @@ -1546,7 +1585,7 @@ class MainWindow(QMainWindow): # 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 = _DBWorker(self._db, filename, self._profile) self._db_worker.result.connect(self._on_db_result) self._db_worker.start() @@ -1562,13 +1601,14 @@ 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) + 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) + markers = self._db.get_markers(filename, profile) else: markers = [] self._timeline.set_markers(markers) @@ -1942,6 +1982,7 @@ class MainWindow(QMainWindow): fmt=self._export_format, clip_count=self._export_clip_count, spread=self._export_spread, + profile=self._profile, ) folder = self._txt_folder.text() upsert_clip_annotation(folder, path, label) @@ -1966,6 +2007,13 @@ class MainWindow(QMainWindow): self._txt_label.addItems(self._db.get_labels()) self._txt_label.setCurrentText(current) self._txt_label.blockSignals(False) + # Refresh profile list so newly typed profiles appear in the dropdown. + cur_profile = self._cmb_profile.currentText() + self._cmb_profile.blockSignals(True) + self._cmb_profile.clear() + self._cmb_profile.addItems(self._db.get_profiles() or ["default"]) + self._cmb_profile.setCurrentText(cur_profile) + self._cmb_profile.blockSignals(False) def _on_export_error(self, msg: str): self._btn_export.setEnabled(True) @@ -1993,7 +2041,7 @@ class MainWindow(QMainWindow): if paths: self._playlist.add_files(paths) for p in paths: - if self._db.get_markers(os.path.basename(p)): + if self._db.get_markers(os.path.basename(p), self._profile): self._playlist.mark_done(p) if __name__ == "__main__": diff --git a/tests/test_utils.py b/tests/test_utils.py index bbe9184..b34f63b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -344,3 +344,71 @@ def test_db_get_group_disabled(): def test_db_delete_group_disabled(): db = ProcessedDB("/no/such/directory/8cut.db") assert db.delete_group("/out/clip_001.mp4") == [] + + +# --- Profiles --- + +def test_db_markers_isolated_by_profile(): + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + try: + db = ProcessedDB(path) + db.add("video.mp4", 10.0, "/out/a_001.mp4", profile="landscape") + db.add("video.mp4", 20.0, "/out/b_001.mp4", profile="portrait") + land = db.get_markers("video.mp4", profile="landscape") + port = db.get_markers("video.mp4", profile="portrait") + assert len(land) == 1 + assert land[0][0] == 10.0 + assert len(port) == 1 + assert port[0][0] == 20.0 + finally: + 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 + try: + db = ProcessedDB(path) + assert db.get_profiles() == [] + db.add("a.mp4", 0.0, "/out/a.mp4", profile="beta") + db.add("b.mp4", 0.0, "/out/b.mp4", profile="alpha") + db.add("c.mp4", 0.0, "/out/c.mp4", profile="beta") + profiles = db.get_profiles() + assert profiles == ["alpha", "beta"] + finally: + os.unlink(path) + + +def test_db_get_profiles_disabled(): + db = ProcessedDB("/no/such/directory/8cut.db") + assert db.get_profiles() == [] + + +def test_db_default_profile_backward_compat(): + """Existing tests pass without explicit profile — defaults to 'default'.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + try: + db = ProcessedDB(path) + db.add("video.mp4", 5.0, "/out/clip.mp4") + markers = db.get_markers("video.mp4") # no profile arg + assert len(markers) == 1 + assert markers[0][0] == 5.0 + assert db.get_profiles() == ["default"] + finally: + os.unlink(path)