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>
431 lines
13 KiB
Markdown
431 lines
13 KiB
Markdown
# Timeline Markers Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Show numbered markers on the timeline at positions where clips were previously extracted from the current source file, with a hover tooltip showing the output path.
|
|
|
|
**Architecture:** DB schema is migrated to store `start_time` and `output_path` per export row (dropping the old UNIQUE constraint). `TimelineWidget` gains `set_markers()`, draws red numbered lines in `paintEvent`, and shows `QToolTip` on hover. `MainWindow` feeds markers to the timeline on load and after each export.
|
|
|
|
**Tech Stack:** Python built-ins (`sqlite3`, `re`, `difflib`), PyQt6 (`QToolTip`, `QCursor`, `QFont`). No new dependencies.
|
|
|
|
---
|
|
|
|
### Task 1: DB schema migration and new methods (TDD)
|
|
|
|
**Files:**
|
|
- Modify: `main.py` — update `ProcessedDB.__init__`, `add`, add `get_markers`
|
|
- Modify: `tests/test_utils.py` — update existing DB tests, add marker tests
|
|
|
|
**Step 1: Write failing tests**
|
|
|
|
Replace the four existing `test_db_*` tests and add new ones in `tests/test_utils.py`:
|
|
|
|
```python
|
|
# --- ProcessedDB (updated) ---
|
|
|
|
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", 12.5, "/out/clip_001.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", 0.0, "/out/ep_001.mp4")
|
|
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", 0.0, "/out/alpha_001.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", 0.0, "/out/x_001.mp4") # must not raise
|
|
assert db.find_similar("x.mp4") is None
|
|
|
|
def test_db_get_markers_returns_sorted():
|
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
path = f.name
|
|
try:
|
|
db = ProcessedDB(path)
|
|
db.add("video.mp4", 30.0, "/out/clip_002.mp4")
|
|
db.add("video.mp4", 10.0, "/out/clip_001.mp4")
|
|
db.add("video.mp4", 50.0, "/out/clip_003.mp4")
|
|
markers = db.get_markers("video.mp4")
|
|
assert len(markers) == 3
|
|
assert markers[0] == (10.0, 1, "/out/clip_001.mp4")
|
|
assert markers[1] == (30.0, 2, "/out/clip_002.mp4")
|
|
assert markers[2] == (50.0, 3, "/out/clip_003.mp4")
|
|
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
|
|
try:
|
|
db = ProcessedDB(path)
|
|
markers = db.get_markers("nothing.mp4")
|
|
assert markers == []
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
def test_db_get_markers_disabled():
|
|
db = ProcessedDB("/no/such/directory/8cut.db")
|
|
assert db.get_markers("x.mp4") == []
|
|
```
|
|
|
|
**Step 2: Run to verify they fail**
|
|
|
|
```bash
|
|
pytest tests/test_utils.py -k "db" -v
|
|
```
|
|
|
|
Expected: failures — `add` has wrong signature, `get_markers` doesn't exist.
|
|
|
|
**Step 3: Update `ProcessedDB` in main.py**
|
|
|
|
Replace the entire `ProcessedDB` class:
|
|
|
|
```python
|
|
class ProcessedDB:
|
|
_SCHEMA_VERSION = 2 # bump when schema changes
|
|
|
|
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._migrate()
|
|
self._enabled = True
|
|
except Exception as e:
|
|
print(f"8-cut: DB unavailable: {e}", file=sys.stderr)
|
|
self._con = None
|
|
self._enabled = False
|
|
|
|
def _migrate(self) -> None:
|
|
"""Create or recreate table if schema is outdated."""
|
|
cols = {
|
|
row[1]
|
|
for row in self._con.execute("PRAGMA table_info(processed)").fetchall()
|
|
}
|
|
needs_recreate = "start_time" not in cols or "output_path" not in cols
|
|
if needs_recreate:
|
|
self._con.execute("DROP TABLE IF EXISTS processed")
|
|
self._con.execute(
|
|
"CREATE TABLE IF NOT EXISTS processed ("
|
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
" filename TEXT NOT NULL,"
|
|
" start_time REAL NOT NULL,"
|
|
" output_path TEXT NOT NULL,"
|
|
" processed_at TEXT NOT NULL"
|
|
")"
|
|
)
|
|
self._con.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_filename ON processed(filename)"
|
|
)
|
|
self._con.commit()
|
|
|
|
def add(self, filename: str, start_time: float, output_path: str) -> None:
|
|
if not self._enabled:
|
|
return
|
|
self._con.execute(
|
|
"INSERT INTO processed (filename, start_time, output_path, processed_at)"
|
|
" VALUES (?, ?, ?, ?)",
|
|
(filename, start_time, output_path, datetime.now(timezone.utc).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
|
|
|
|
def get_markers(self, filename: str) -> 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)
|
|
if match is None:
|
|
return []
|
|
rows = self._con.execute(
|
|
"SELECT start_time, output_path FROM processed"
|
|
" WHERE filename = ? ORDER BY start_time",
|
|
(match,),
|
|
).fetchall()
|
|
return [(t, i + 1, p) for i, (t, p) in enumerate(rows)]
|
|
```
|
|
|
|
**Step 4: Run DB tests**
|
|
|
|
```bash
|
|
pytest tests/test_utils.py -k "db" -v
|
|
```
|
|
|
|
Expected: all 8 DB tests PASS.
|
|
|
|
**Step 5: Run full suite**
|
|
|
|
```bash
|
|
pytest tests/ -v
|
|
```
|
|
|
|
Expected: all tests pass (normalize tests + db tests = 14 DB/normalize tests + 8 original = 22 total — count may vary).
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add main.py tests/test_utils.py
|
|
git commit -m "feat: DB schema v2 — store start_time and output_path, add get_markers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: TimelineWidget markers (paint + hover)
|
|
|
|
**Files:**
|
|
- Modify: `main.py` — update `TimelineWidget`
|
|
|
|
**Step 1: Add missing imports to main.py**
|
|
|
|
`QToolTip` and `QCursor` are needed. Add them to the existing imports:
|
|
|
|
```python
|
|
from PyQt6.QtWidgets import (
|
|
...existing..., QToolTip,
|
|
)
|
|
from PyQt6.QtGui import (
|
|
...existing..., QCursor, QFont,
|
|
)
|
|
```
|
|
|
|
**Step 2: Add `set_markers` and update `paintEvent` and `mouseMoveEvent`**
|
|
|
|
In `TimelineWidget`, add the `_markers` attribute in `__init__`:
|
|
|
|
```python
|
|
self._markers: list[tuple[float, int, str]] = []
|
|
```
|
|
|
|
Add the `set_markers` method:
|
|
|
|
```python
|
|
def set_markers(self, markers: list[tuple[float, int, str]]) -> None:
|
|
"""markers: list of (start_time, number, output_path)"""
|
|
self._markers = markers
|
|
self.update()
|
|
```
|
|
|
|
Replace `paintEvent` with:
|
|
|
|
```python
|
|
def paintEvent(self, event):
|
|
p = QPainter(self)
|
|
try:
|
|
w, h = self.width(), self.height()
|
|
p.fillRect(0, 0, w, h, QColor(30, 30, 30))
|
|
|
|
if self._duration <= 0:
|
|
return
|
|
|
|
# 8s selection highlight
|
|
x_start = int(self._cursor / self._duration * w)
|
|
x_end = int(min(self._cursor + 8.0, self._duration) / self._duration * w)
|
|
p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120))
|
|
|
|
# Cursor line
|
|
pen = QPen(QColor(255, 200, 0))
|
|
pen.setWidth(2)
|
|
p.setPen(pen)
|
|
p.drawLine(x_start, 0, x_start, h)
|
|
|
|
# Markers
|
|
font = QFont()
|
|
font.setPixelSize(9)
|
|
p.setFont(font)
|
|
marker_pen = QPen(QColor(220, 60, 60))
|
|
marker_pen.setWidth(2)
|
|
for (t, num, _path) in self._markers:
|
|
if self._duration <= 0:
|
|
break
|
|
mx = int(t / self._duration * w)
|
|
p.setPen(marker_pen)
|
|
p.drawLine(mx, 0, mx, h)
|
|
p.setPen(QColor(255, 255, 255))
|
|
p.drawText(mx + 2, 10, str(num))
|
|
finally:
|
|
p.end()
|
|
```
|
|
|
|
Replace `mouseMoveEvent` with:
|
|
|
|
```python
|
|
def mouseMoveEvent(self, event):
|
|
x = event.position().x()
|
|
# Check marker hover (±4px)
|
|
if self._duration > 0 and self._markers:
|
|
w = self.width()
|
|
for (t, _num, output_path) in self._markers:
|
|
mx = t / self._duration * w
|
|
if abs(x - mx) <= 4:
|
|
QToolTip.showText(QCursor.pos(), output_path, self)
|
|
if event.buttons():
|
|
self._seek(x)
|
|
return
|
|
QToolTip.hideText()
|
|
if event.buttons():
|
|
self._seek(x)
|
|
```
|
|
|
|
**Step 3: Verify headless import**
|
|
|
|
```bash
|
|
python -c "from main import TimelineWidget"
|
|
```
|
|
|
|
Expected: no output.
|
|
|
|
**Step 4: Run all tests**
|
|
|
|
```bash
|
|
pytest tests/ -v
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add main.py
|
|
git commit -m "feat: timeline markers with hover tooltip"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Wire MainWindow
|
|
|
|
**Files:**
|
|
- Modify: `main.py` — update `_after_load`, `_on_export_done`, add `_refresh_markers`
|
|
|
|
**Step 1: Add `_refresh_markers` helper to MainWindow**
|
|
|
|
Add this method after `_after_load`:
|
|
|
|
```python
|
|
def _refresh_markers(self) -> None:
|
|
markers = self._db.get_markers(os.path.basename(self._file_path))
|
|
self._timeline.set_markers(markers)
|
|
```
|
|
|
|
**Step 2: Update `_after_load`**
|
|
|
|
Add `self._refresh_markers()` at the end of `_after_load`:
|
|
|
|
```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}")
|
|
else:
|
|
self.statusBar().clearMessage()
|
|
|
|
self._refresh_markers()
|
|
```
|
|
|
|
**Step 3: Update `_on_export_done`**
|
|
|
|
Pass `start_time` and `output_path` to `db.add`, then refresh markers:
|
|
|
|
```python
|
|
def _on_export_done(self, path: str):
|
|
self._db.add(os.path.basename(self._file_path), self._cursor, path)
|
|
self._export_counter += 1
|
|
self._update_next_label()
|
|
self._btn_export.setEnabled(True)
|
|
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
|
|
self._refresh_markers()
|
|
self._playlist.advance()
|
|
```
|
|
|
|
**Step 4: Verify headless import**
|
|
|
|
```bash
|
|
python -c "from main import MainWindow"
|
|
```
|
|
|
|
Expected: no output.
|
|
|
|
**Step 5: Run all tests**
|
|
|
|
```bash
|
|
pytest tests/ -v
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
**Step 6: Manual smoke test**
|
|
|
|
```bash
|
|
python main.py
|
|
```
|
|
|
|
- Drop a video, set cursor, export → a red numbered marker `1` appears on the timeline at that position
|
|
- Export again at a different position → marker `2` appears
|
|
- Hover over a marker → tooltip shows the output file path
|
|
- Drop a resolution variant of the same video → markers from the original appear immediately
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add main.py
|
|
git commit -m "feat: wire timeline markers into MainWindow"
|
|
```
|