feat: mpv Wayland embedding, timeline redesign, UX polish
mpv embedding: - Replace wid/QOpenGLWidget with QOffscreenSurface + QOpenGLFramebufferObject + QPainter readback — works on Wayland/KDE without sub-surface compositing - Force desktop OpenGL 3.3 core profile before QApplication (fixes black output on GLES) - Timer-based render polling (16 ms) replaces signal-flood from mpv C thread; fixes playback animation and scrubbing preview - Fix AB-loop: set ab-loop-a/b to "no" on stop (0 means infinite in mpv) Timeline: - Full redesign: time ruler with adaptive major/minor ticks, playhead triangle handle, selection region with edge lines, numbered marker badges - Height 160 px; layout collapsed from 4 rows to 2 below timeline - Markers appear immediately on export (optimistic update before ffmpeg finishes) - Right-click marker → context menu to delete from DB Hotkeys: - Replace keyPressEvent with QShortcut(ApplicationShortcut) so keys work regardless of focused widget; MpvWidget gets NoFocus policy Export: - WebP: switch lossless→lossy quality 85, compression_level 1 (~10x faster) - Add -threads 0 for full CPU utilisation during decode/filter - Remember last export folder across sessions via QSettings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
# Playlist & Processed-Files Database Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a file queue (playlist) that auto-advances after each export, and a SQLite database that warns when a newly loaded file is fuzzy-similar to one already processed.
|
||||
|
||||
**Architecture:** `ProcessedDB` wraps `sqlite3` with fuzzy matching via `difflib.SequenceMatcher` on normalized filenames (resolution/quality tags stripped). `PlaylistWidget` is a `QListWidget` subclass that owns the queue and emits `file_selected` on advance or click. `MainWindow` is wired to use both.
|
||||
|
||||
**Tech Stack:** Python built-ins only — `sqlite3`, `difflib`, `re`, `datetime`. No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `_normalize_filename` and `ProcessedDB` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — add `_normalize_filename`, `ProcessedDB`
|
||||
- Modify: `tests/test_utils.py` — add DB and normalization tests
|
||||
|
||||
**Step 1: Add imports at top of main.py**
|
||||
|
||||
Add to the existing imports:
|
||||
|
||||
```python
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from difflib import SequenceMatcher
|
||||
```
|
||||
|
||||
**Step 2: Write failing tests**
|
||||
|
||||
Add to `tests/test_utils.py`:
|
||||
|
||||
```python
|
||||
import tempfile, os
|
||||
from main import _normalize_filename, ProcessedDB
|
||||
|
||||
|
||||
# --- _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():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("video.mp4")
|
||||
assert db.find_similar("video.mp4") == "video.mp4"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_find_similar_resolution_variant():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("episode_s01e01_2160p.mkv")
|
||||
assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_find_similar_no_match():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
db = ProcessedDB(path)
|
||||
db.add("alpha.mp4")
|
||||
assert db.find_similar("completely_different_zzzz.mp4") is None
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_db_disabled_survives_bad_path():
|
||||
db = ProcessedDB("/no/such/directory/8cut.db")
|
||||
db.add("x.mp4") # must not raise
|
||||
assert db.find_similar("x.mp4") is None # gracefully returns None
|
||||
```
|
||||
|
||||
**Step 3: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
pytest tests/test_utils.py -k "normalize or db" -v
|
||||
```
|
||||
|
||||
Expected: ImportError — functions not defined yet.
|
||||
|
||||
**Step 4: Add `_normalize_filename` to main.py**
|
||||
|
||||
Add after the existing imports, before `build_export_path`:
|
||||
|
||||
```python
|
||||
def _normalize_filename(filename: str) -> str:
|
||||
"""Strip extension and common resolution/quality tags for fuzzy comparison."""
|
||||
name = os.path.splitext(filename)[0].lower()
|
||||
name = re.sub(
|
||||
r'\b(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)\b',
|
||||
'', name, flags=re.IGNORECASE,
|
||||
)
|
||||
name = re.sub(r'[\s_\-\.]+', '_', name).strip('_')
|
||||
return name
|
||||
```
|
||||
|
||||
**Step 5: Add `ProcessedDB` to main.py**
|
||||
|
||||
Add after `_normalize_filename`:
|
||||
|
||||
```python
|
||||
class ProcessedDB:
|
||||
def __init__(self, db_path: str | None = None):
|
||||
if db_path is None:
|
||||
db_path = str(Path.home() / ".8cut.db")
|
||||
try:
|
||||
self._con = sqlite3.connect(db_path)
|
||||
self._con.execute(
|
||||
"CREATE TABLE IF NOT EXISTS processed "
|
||||
"(filename TEXT NOT NULL, processed_at TEXT NOT NULL)"
|
||||
)
|
||||
self._con.commit()
|
||||
self._enabled = True
|
||||
except Exception as e:
|
||||
print(f"8-cut: DB unavailable: {e}", file=sys.stderr)
|
||||
self._con = None
|
||||
self._enabled = False
|
||||
|
||||
def add(self, filename: str) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
self._con.execute(
|
||||
"INSERT INTO processed (filename, processed_at) VALUES (?, ?)",
|
||||
(filename, datetime.utcnow().isoformat()),
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def find_similar(self, filename: str) -> str | None:
|
||||
if not self._enabled:
|
||||
return None
|
||||
rows = self._con.execute(
|
||||
"SELECT DISTINCT filename FROM processed"
|
||||
).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
|
||||
```
|
||||
|
||||
**Step 6: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
pytest tests/test_utils.py -k "normalize or db" -v
|
||||
```
|
||||
|
||||
Expected: all 10 new tests PASS (plus existing 8 = 18 total).
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py tests/test_utils.py
|
||||
git commit -m "feat: ProcessedDB and _normalize_filename with tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `PlaylistWidget`
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — add `PlaylistWidget` class
|
||||
|
||||
**Step 1: Add PlaylistWidget before MainWindow**
|
||||
|
||||
Add after `MpvWidget`, before `main()`:
|
||||
|
||||
```python
|
||||
class PlaylistWidget(QListWidget):
|
||||
file_selected = pyqtSignal(str) # emits full path of selected file
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setAcceptDrops(True)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly)
|
||||
self.setFixedWidth(200)
|
||||
self.setWordWrap(True)
|
||||
self._paths: list[str] = []
|
||||
self.itemClicked.connect(self._on_item_clicked)
|
||||
|
||||
def add_files(self, paths: list[str]) -> None:
|
||||
"""Append paths not already in queue; auto-select first if queue was empty."""
|
||||
was_empty = len(self._paths) == 0
|
||||
for path in paths:
|
||||
if path not in self._paths and os.path.isfile(path):
|
||||
self._paths.append(path)
|
||||
self.addItem(os.path.basename(path))
|
||||
if was_empty and self._paths:
|
||||
self._select(0)
|
||||
|
||||
def advance(self) -> None:
|
||||
"""Move to next item in queue. Does nothing if at end."""
|
||||
row = self.currentRow()
|
||||
if row < self.count() - 1:
|
||||
self._select(row + 1)
|
||||
|
||||
def current_path(self) -> str | None:
|
||||
row = self.currentRow()
|
||||
return self._paths[row] if 0 <= row < len(self._paths) else None
|
||||
|
||||
def _select(self, row: int) -> None:
|
||||
self.setCurrentRow(row)
|
||||
self._refresh_labels()
|
||||
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:
|
||||
self._select(self.row(item))
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event: QDropEvent) -> None:
|
||||
paths = [
|
||||
u.toLocalFile() for u in event.mimeData().urls()
|
||||
if os.path.isfile(u.toLocalFile())
|
||||
]
|
||||
if paths:
|
||||
self.add_files(paths)
|
||||
```
|
||||
|
||||
**Step 2: Add missing imports**
|
||||
|
||||
`QListWidget`, `QListWidgetItem`, and `QAbstractItemView` need to be in the QtWidgets import line. Check which are missing and add them. The updated import line should be:
|
||||
|
||||
```python
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
|
||||
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter,
|
||||
)
|
||||
```
|
||||
|
||||
(`QSplitter` is added now for use in Task 3.)
|
||||
|
||||
**Step 3: Verify headless import**
|
||||
|
||||
```bash
|
||||
python -c "from main import PlaylistWidget"
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
**Step 4: Run all tests**
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: all 18 tests pass.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: PlaylistWidget with drop support and auto-advance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire MainWindow
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` — update `MainWindow.__init__`, `_load_file`, `_after_load`, `_on_export_done`, layout
|
||||
|
||||
**Step 1: Update MainWindow.__init__**
|
||||
|
||||
Replace the `MainWindow.__init__` method entirely with the version below. Key changes:
|
||||
- Add `self._db = ProcessedDB()` and `self._playlist = PlaylistWidget()`
|
||||
- Connect `_playlist.file_selected` to `_load_file`
|
||||
- Change the root layout to a horizontal split: playlist on left, existing content on right
|
||||
- Remove `self.setAcceptDrops(True)` from MainWindow (playlist handles drops now)
|
||||
|
||||
```python
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("8-cut")
|
||||
self.resize(1100, 680)
|
||||
|
||||
# Services
|
||||
self._db = ProcessedDB()
|
||||
|
||||
# State
|
||||
self._file_path: str = ""
|
||||
self._cursor: float = 0.0
|
||||
self._export_counter: int = 1
|
||||
self._export_worker: ExportWorker | None = None
|
||||
|
||||
# Widgets
|
||||
self._playlist = PlaylistWidget()
|
||||
self._playlist.file_selected.connect(self._load_file)
|
||||
|
||||
self._mpv = MpvWidget()
|
||||
self._mpv.file_loaded.connect(self._after_load)
|
||||
self._timeline = TimelineWidget()
|
||||
self._timeline.cursor_changed.connect(self._on_cursor_changed)
|
||||
|
||||
self._lbl_file = QLabel("Drop files onto the queue →")
|
||||
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
|
||||
|
||||
self._btn_play = QPushButton("▶ Play 8s")
|
||||
self._btn_play.setEnabled(False)
|
||||
self._btn_play.clicked.connect(self._on_play)
|
||||
|
||||
self._btn_pause = QPushButton("⏸ Pause")
|
||||
self._btn_pause.setEnabled(False)
|
||||
self._btn_pause.clicked.connect(self._on_pause)
|
||||
|
||||
self._lbl_cursor = QLabel("cursor: --")
|
||||
self._lbl_duration = QLabel("dur: --")
|
||||
|
||||
self._txt_name = QLineEdit("clip")
|
||||
self._txt_name.setPlaceholderText("base name")
|
||||
self._txt_name.setMaximumWidth(150)
|
||||
self._txt_name.textChanged.connect(self._reset_counter)
|
||||
|
||||
self._txt_folder = QLineEdit(str(Path.home()))
|
||||
self._txt_folder.textChanged.connect(self._reset_counter)
|
||||
self._btn_folder = QPushButton("Browse")
|
||||
self._btn_folder.clicked.connect(self._pick_folder)
|
||||
|
||||
self._lbl_next = QLabel()
|
||||
self._update_next_label()
|
||||
|
||||
self._btn_export = QPushButton("Export")
|
||||
self._btn_export.setEnabled(False)
|
||||
self._btn_export.clicked.connect(self._on_export)
|
||||
|
||||
# Right-side layout (video + controls)
|
||||
top_bar = QHBoxLayout()
|
||||
top_bar.addWidget(self._lbl_file, stretch=1)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.addWidget(self._btn_play)
|
||||
controls.addWidget(self._btn_pause)
|
||||
controls.addStretch()
|
||||
controls.addWidget(self._lbl_cursor)
|
||||
controls.addWidget(self._lbl_duration)
|
||||
|
||||
export_row = QHBoxLayout()
|
||||
export_row.addWidget(QLabel("Name:"))
|
||||
export_row.addWidget(self._txt_name)
|
||||
export_row.addWidget(QLabel("Folder:"))
|
||||
export_row.addWidget(self._txt_folder, stretch=1)
|
||||
export_row.addWidget(self._btn_folder)
|
||||
export_row.addWidget(self._lbl_next)
|
||||
export_row.addWidget(self._btn_export)
|
||||
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout(right)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.addLayout(top_bar)
|
||||
right_layout.addWidget(self._mpv, stretch=1)
|
||||
right_layout.addWidget(self._timeline)
|
||||
right_layout.addLayout(controls)
|
||||
right_layout.addLayout(export_row)
|
||||
|
||||
# Left: playlist label + list
|
||||
queue_label = QLabel("Queue")
|
||||
queue_label.setStyleSheet("color: #aaa; padding: 4px;")
|
||||
left = QWidget()
|
||||
left_layout = QVBoxLayout(left)
|
||||
left_layout.setContentsMargins(4, 4, 4, 4)
|
||||
left_layout.addWidget(queue_label)
|
||||
left_layout.addWidget(self._playlist)
|
||||
|
||||
# Root: horizontal split
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.addWidget(left)
|
||||
splitter.addWidget(right)
|
||||
splitter.setSizes([200, 900])
|
||||
splitter.setCollapsible(0, False)
|
||||
splitter.setCollapsible(1, False)
|
||||
|
||||
self.setCentralWidget(splitter)
|
||||
self.setStatusBar(QStatusBar())
|
||||
```
|
||||
|
||||
**Step 2: Update `_load_file` — remove isfile guard (PlaylistWidget already filters)**
|
||||
|
||||
```python
|
||||
def _load_file(self, path: str):
|
||||
self._file_path = path
|
||||
self._lbl_file.setText(os.path.basename(path))
|
||||
self._mpv.load(path)
|
||||
# _after_load triggered by MpvWidget.file_loaded signal
|
||||
```
|
||||
|
||||
**Step 3: Update `_after_load` — add DB similarity check**
|
||||
|
||||
```python
|
||||
def _after_load(self):
|
||||
dur = self._mpv.get_duration()
|
||||
self._timeline.set_duration(dur)
|
||||
self._cursor = 0.0
|
||||
self._lbl_duration.setText(f"dur: {format_time(dur)}")
|
||||
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
|
||||
self._btn_play.setEnabled(True)
|
||||
self._btn_pause.setEnabled(True)
|
||||
self._btn_export.setEnabled(True)
|
||||
|
||||
match = self._db.find_similar(os.path.basename(self._file_path))
|
||||
if match:
|
||||
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
|
||||
```
|
||||
|
||||
**Step 4: Update `_on_export_done` — record to DB and advance queue**
|
||||
|
||||
```python
|
||||
def _on_export_done(self, path: str):
|
||||
self._db.add(os.path.basename(self._file_path))
|
||||
self._export_counter += 1
|
||||
self._update_next_label()
|
||||
self._btn_export.setEnabled(True)
|
||||
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
|
||||
self._playlist.advance()
|
||||
```
|
||||
|
||||
**Step 5: Remove `dragEnterEvent` and `dropEvent` from MainWindow**
|
||||
|
||||
These methods are no longer needed on `MainWindow` — drops go directly to `PlaylistWidget`. Delete both methods.
|
||||
|
||||
**Step 6: Verify headless import**
|
||||
|
||||
```bash
|
||||
python -c "from main import MainWindow"
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
**Step 7: Run all tests**
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: all 18 tests pass.
|
||||
|
||||
**Step 8: Manual smoke test**
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
- Drop one or more video files onto the queue panel → they appear in the list
|
||||
- First file loads automatically into the player
|
||||
- Scrub, play, pause — all work as before
|
||||
- Export → file saved, counter increments, next file in queue loads automatically
|
||||
- Drop the same file again → `⚠ Similar to already processed:` appears in status bar
|
||||
|
||||
**Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: wire playlist and DB into MainWindow"
|
||||
```
|
||||
Reference in New Issue
Block a user