feat: profile support for independent marker sets

Each profile has its own set of timeline markers, so the same video
can be cut with different settings (e.g. landscape vs portrait) without
markers interfering. Profile selector in the top bar, persisted in
QSettings, stored per-row in the DB.

- Add `profile` column to ProcessedDB schema (migrates existing rows
  to 'default')
- Scope find_similar, get_markers, _get_markers_for by profile
- Add get_profiles() for populating the combo dropdown
- Thread profile through _DBWorker, _after_load, _refresh_markers,
  _on_clip_done, dropEvent, _on_open_files
- Editable profile QComboBox in top bar, refreshed after each export
- 5 new tests for profile isolation and backward compatibility (54 total)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 11:08:50 +02:00
parent 462af36bce
commit f8b148f77d
3 changed files with 138 additions and 21 deletions
+2 -1
View File
@@ -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
+68 -20
View File
@@ -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__":
+68
View File
@@ -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)