6573fa6e05
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>
495 lines
15 KiB
Markdown
495 lines
15 KiB
Markdown
# 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"
|
|
```
|