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:
2026-04-06 22:22:58 +02:00
parent 7931a0e3db
commit 6573fa6e05
5 changed files with 2004 additions and 134 deletions
+46
View File
@@ -0,0 +1,46 @@
# 8-cut Design
## Overview
A Linux desktop tool for dropping a video clip, setting a start point on a timeline, and exporting exactly 8 seconds to a numbered output file.
## Stack
- **Python + PyQt6** — native desktop window, drag & drop, custom timeline widget
- **python-mpv** — embedded video playback with audio, frame-accurate seeking, AB loop for 8s preview
- **ffmpeg** — export via subprocess, always re-encodes to guarantee exactly 8s with no freeze frames
## Layout
Single window, top to bottom:
1. Drop zone / loaded file path display
2. mpv video embed (preview)
3. Custom timeline widget with draggable cursor
4. Playback controls + cursor position display
5. Export controls: base name input, folder picker, next filename preview, Export button
## Behavior
- **Drag & drop** a video file onto the window to load it
- **Timeline**: click or drag to reposition cursor; shows current frame when paused
- **Play 8s**: mpv seeks to cursor position, plays 8 seconds, loops back using mpv AB-loop (`ab-loop-a` = cursor, `ab-loop-b` = cursor + 8s)
- **Pause**: shows current frame at cursor position
- **Export**: runs ffmpeg in a QThread (non-blocking UI)
- Command: `ffmpeg -ss {start} -i {input} -t 8 -c:v libx264 -c:a aac {output}`
- Output: `{folder}/{basename}_{NNN:03d}.mp4`
- Counter auto-increments after each successful export
- Counter resets if base name or folder changes
## Architecture
Single file `main.py` (~300-400 lines):
- `MainWindow(QMainWindow)` — owns all state: file path, cursor (seconds), base name, output folder, export counter
- `TimelineWidget(QWidget)` — custom `paintEvent` draws bar + cursor line; `mousePressEvent`/`mouseMoveEvent` for scrubbing
- `MpvWidget(QWidget)` — embeds mpv using window ID (`wid`), exposes `load(path)`, `seek(t)`, `play_loop(a, b)`, `pause()`
- `ExportWorker(QThread)` — runs ffmpeg subprocess, emits `finished(path)` or `error(msg)` signal
## Export guarantees
Always re-encode (never stream copy) to avoid freeze frames caused by keyframe misalignment at the cut point. Output is always exactly 8.000 seconds.
@@ -0,0 +1,733 @@
# 8-cut Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a Linux desktop tool to drop a video, scrub a timeline, and export exactly 8 seconds to an auto-numbered output file.
**Architecture:** Single `main.py` file with PyQt6 for the window/widgets, python-mpv embedded for playback with AB-loop preview, and ffmpeg subprocess in a QThread for non-blocking export. Pure logic (filename counter, ffmpeg command builder) is tested with pytest; GUI is verified manually.
**Tech Stack:** Python 3.10+, PyQt6, python-mpv, ffmpeg (system), pytest
---
### Task 1: Project setup
**Files:**
- Create: `main.py`
- Create: `requirements.txt`
- Create: `tests/__init__.py`
- Create: `tests/test_utils.py`
**Step 1: Install dependencies**
```bash
pip install PyQt6 python-mpv pytest
```
Verify mpv is on the system:
```bash
mpv --version
ffmpeg -version
```
**Step 2: Create requirements.txt**
```
PyQt6>=6.4
python-mpv>=1.0
pytest>=7.0
```
**Step 3: Create tests/__init__.py**
Empty file.
**Step 4: Create main.py skeleton**
```python
import sys
from PyQt6.QtWidgets import QApplication, QMainWindow
from PyQt6.QtCore import Qt
def main():
app = QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec())
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("8-cut")
self.resize(900, 650)
if __name__ == "__main__":
main()
```
**Step 5: Run to verify window opens**
```bash
python main.py
```
Expected: empty 900×650 window titled "8-cut" appears.
**Step 6: Init git and commit**
```bash
cd /media/p5/8-cut
git init
git add main.py requirements.txt tests/
git commit -m "feat: project skeleton"
```
---
### Task 2: Pure utility functions (TDD)
**Files:**
- Modify: `main.py` — add `build_export_path`, `format_time`
- Modify: `tests/test_utils.py`
**Step 1: Write failing tests**
```python
# tests/test_utils.py
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from main import build_export_path, format_time
def test_build_export_path_first():
assert build_export_path("/out", "clip", 1) == "/out/clip_001.mp4"
def test_build_export_path_counter():
assert build_export_path("/out", "clip", 42) == "/out/clip_042.mp4"
def test_build_export_path_deep_counter():
assert build_export_path("/out", "shot", 999) == "/out/shot_999.mp4"
def test_format_time_seconds():
assert format_time(0.0) == "0:00.0"
def test_format_time_minutes():
assert format_time(75.3) == "1:15.3"
def test_format_time_rounding():
assert format_time(61.05) == "1:01.0"
```
**Step 2: Run tests to verify they fail**
```bash
pytest tests/test_utils.py -v
```
Expected: `ImportError` or `AttributeError` — functions not yet defined.
**Step 3: Add functions to main.py**
Add after the imports:
```python
def build_export_path(folder: str, basename: str, counter: int) -> str:
filename = f"{basename}_{counter:03d}.mp4"
return os.path.join(folder, filename)
def format_time(seconds: float) -> str:
m = int(seconds) // 60
s = seconds - m * 60
return f"{m}:{s:04.1f}"
```
Also add `import os` at the top of main.py.
**Step 4: Run tests to verify they pass**
```bash
pytest tests/test_utils.py -v
```
Expected: all 6 tests PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: add utility functions with tests"
```
---
### Task 3: ExportWorker (QThread)
**Files:**
- Modify: `main.py` — add `ExportWorker` class
- Modify: `tests/test_utils.py` — add ffmpeg command test
**Step 1: Write failing test for command builder**
Add to `tests/test_utils.py`:
```python
from main import build_ffmpeg_command
def test_ffmpeg_command():
cmd = build_ffmpeg_command("/in/video.mp4", 12.5, "/out/clip_001.mp4")
assert cmd[0] == "ffmpeg"
assert "-ss" in cmd
assert str(12.5) in cmd
assert "-t" in cmd
assert "8" in cmd
assert cmd[-1] == "/out/clip_001.mp4"
```
**Step 2: Run to verify it fails**
```bash
pytest tests/test_utils.py::test_ffmpeg_command -v
```
Expected: ImportError.
**Step 3: Add build_ffmpeg_command and ExportWorker to main.py**
Add after imports:
```python
import subprocess
from PyQt6.QtCore import QThread, pyqtSignal
def build_ffmpeg_command(input_path: str, start: float, output_path: str) -> list:
return [
"ffmpeg", "-y",
"-ss", str(start),
"-i", input_path,
"-t", "8",
"-c:v", "libx264",
"-c:a", "aac",
output_path,
]
class ExportWorker(QThread):
finished = pyqtSignal(str) # output path
error = pyqtSignal(str) # error message
def __init__(self, input_path: str, start: float, output_path: str):
super().__init__()
self._input = input_path
self._start = start
self._output = output_path
def run(self):
cmd = build_ffmpeg_command(self._input, self._start, self._output)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0:
self.finished.emit(self._output)
else:
self.error.emit(result.stderr[-500:])
except Exception as e:
self.error.emit(str(e))
```
**Step 4: Run tests**
```bash
pytest tests/test_utils.py -v
```
Expected: all 7 tests PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: ExportWorker with ffmpeg command builder"
```
---
### Task 4: TimelineWidget
**Files:**
- Modify: `main.py` — add `TimelineWidget` class
**Step 1: Add TimelineWidget**
Add before `MainWindow`:
```python
from PyQt6.QtWidgets import QWidget
from PyQt6.QtGui import QPainter, QColor, QPen
from PyQt6.QtCore import pyqtSignal
class TimelineWidget(QWidget):
cursor_changed = pyqtSignal(float) # emits position in seconds
def __init__(self):
super().__init__()
self.setMinimumHeight(40)
self.setMouseTracking(True)
self._duration = 0.0
self._cursor = 0.0
def set_duration(self, duration: float):
self._duration = duration
self._cursor = 0.0
self.update()
def set_cursor(self, seconds: float):
self._cursor = max(0.0, min(seconds, max(0.0, self._duration - 8.0)))
self.update()
def _pos_to_time(self, x: int) -> float:
if self._duration <= 0 or self.width() <= 0:
return 0.0
ratio = max(0.0, min(1.0, x / self.width()))
return ratio * self._duration
def paintEvent(self, event):
p = QPainter(self)
w, h = self.width(), self.height()
# Background
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)
def mousePressEvent(self, event):
self._seek(event.position().x())
def mouseMoveEvent(self, event):
if event.buttons():
self._seek(event.position().x())
def _seek(self, x: float):
t = self._pos_to_time(int(x))
self.set_cursor(t)
self.cursor_changed.emit(self._cursor)
```
**Step 2: Verify it renders — quick smoke test**
Temporarily add to `MainWindow.__init__`:
```python
from PyQt6.QtWidgets import QVBoxLayout, QWidget as QW
container = QW()
layout = QVBoxLayout(container)
self._timeline = TimelineWidget()
self._timeline.set_duration(60.0)
layout.addWidget(self._timeline)
self.setCentralWidget(container)
```
Run `python main.py` — you should see a dark bar. Click/drag on it to move the yellow cursor with blue highlight.
**Step 3: Remove the temporary test code from MainWindow**
Revert `MainWindow.__init__` to just `super().__init__()`, `setWindowTitle`, `resize`.
**Step 4: Commit**
```bash
git add main.py
git commit -m "feat: TimelineWidget with cursor and 8s highlight"
```
---
### Task 5: MpvWidget
**Files:**
- Modify: `main.py` — add `MpvWidget` class
**Step 1: Add MpvWidget**
Add before `MainWindow`:
```python
import mpv
from PyQt6.QtWidgets import QFrame
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
class MpvWidget(QFrame):
file_loaded = pyqtSignal() # emitted (on Qt thread) when a file is ready
def __init__(self):
super().__init__()
self.setMinimumSize(640, 360)
self.setStyleSheet("background: black;")
# Required so Qt creates a real native window handle for mpv to embed into.
# Without these, mpv opens a separate window instead of embedding.
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen, True)
self._player = None
def _init_player(self):
if self._player is not None:
return
self._player = mpv.MPV(
wid=str(int(self.winId())),
keep_open=True,
pause=True,
)
# mpv fires events on its own thread; bounce to Qt thread via QTimer.
@self._player.event_callback("file-loaded")
def _on_file_loaded(event):
QTimer.singleShot(0, self.file_loaded.emit)
def load(self, path: str):
self._init_player()
self._player.play(path)
def seek(self, t: float):
if self._player:
self._player.pause = True
self._player.seek(t, "absolute")
def play_loop(self, a: float, b: float):
if self._player:
self._player["ab-loop-a"] = a
self._player["ab-loop-b"] = b
self._player.pause = False
def stop_loop(self):
if self._player:
# ab-loop-a/b are numeric properties — setting to "no" via dict
# accessor throws TypeError. Disable loop via ab_loop_count instead.
self._player.ab_loop_count = 0
self._player.pause = True
def get_duration(self) -> float:
if self._player:
d = self._player.duration
return d if d else 0.0
return 0.0
def closeEvent(self, event):
if self._player:
self._player.terminate()
super().closeEvent(event)
```
**Step 2: Smoke test**
Temporarily in `MainWindow.__init__`:
```python
from PyQt6.QtWidgets import QVBoxLayout, QWidget as QW
container = QW()
layout = QVBoxLayout(container)
self._mpv = MpvWidget()
layout.addWidget(self._mpv)
self.setCentralWidget(container)
# after show(), call: self._mpv.load("/path/to/any/video.mp4")
```
Run, load a real video path. Should display and be paused.
**Step 3: Remove temporary test code**
**Step 4: Commit**
```bash
git add main.py
git commit -m "feat: MpvWidget with seek and AB-loop"
```
---
### Task 6: MainWindow — full UI
**Files:**
- Modify: `main.py` — implement complete `MainWindow`
**Step 1: Add all imports at top of main.py**
```python
import sys
import os
import subprocess
from pathlib import Path
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent
import mpv
```
**Step 2: Replace MainWindow with full implementation**
```python
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("8-cut")
self.resize(900, 680)
self.setAcceptDrops(True)
# State
self._file_path: str = ""
self._cursor: float = 0.0
self._export_counter: int = 1
self._export_worker: ExportWorker | None = None
# Widgets
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 a video file here")
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._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)
# Layout
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)
root = QVBoxLayout()
root.addLayout(top_bar)
root.addWidget(self._mpv, stretch=1)
root.addWidget(self._timeline)
root.addLayout(controls)
root.addLayout(export_row)
container = QWidget()
container.setLayout(root)
self.setCentralWidget(container)
self.setStatusBar(QStatusBar())
# --- Drag & Drop ---
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QDropEvent):
urls = event.mimeData().urls()
if urls:
path = urls[0].toLocalFile()
self._load_file(path)
def _load_file(self, path: str):
self._file_path = path
self._lbl_file.setText(os.path.basename(path))
self._mpv.load(path)
# _after_load is triggered by MpvWidget.file_loaded signal (connected in __init__)
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)
# --- Playback ---
def _on_cursor_changed(self, t: float):
self._cursor = t
self._lbl_cursor.setText(f"cursor: {format_time(t)}")
self._mpv.seek(t)
def _on_play(self):
if not self._file_path:
return
self._mpv.play_loop(self._cursor, self._cursor + 8.0)
def _on_pause(self):
self._mpv.stop_loop()
self._mpv.seek(self._cursor)
# --- Export ---
def _pick_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Select output folder")
if folder:
self._txt_folder.setText(folder)
self._reset_counter()
def _reset_counter(self):
self._export_counter = 1
self._update_next_label()
def _update_next_label(self):
path = build_export_path(
self._txt_folder.text(),
self._txt_name.text() or "clip",
self._export_counter,
)
self._lbl_next.setText(f"{os.path.basename(path)}")
def _on_export(self):
if not self._file_path:
return
if self._export_worker and self._export_worker.isRunning():
self.statusBar().showMessage("Export already running…")
return
output = build_export_path(
self._txt_folder.text(),
self._txt_name.text() or "clip",
self._export_counter,
)
self._btn_export.setEnabled(False)
self.statusBar().showMessage(f"Exporting {os.path.basename(output)}")
self._export_worker = ExportWorker(self._file_path, self._cursor, output)
self._export_worker.finished.connect(self._on_export_done)
self._export_worker.error.connect(self._on_export_error)
self._export_worker.start()
def _on_export_done(self, path: str):
self._export_counter += 1
self._update_next_label()
self._btn_export.setEnabled(True)
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
def _on_export_error(self, msg: str):
self._btn_export.setEnabled(True)
self.statusBar().showMessage(f"Export error: {msg}")
```
**Step 3: Run the app**
```bash
python main.py
```
Expected:
- Window opens with dark drop zone
- Drop a video → preview appears, timeline shows duration
- Drag cursor → video seeks to that frame
- Click "▶ Play 8s" → 8-second loop plays with audio
- Click "⏸ Pause" → pauses and seeks back to cursor
- Click "Export" → exports clip_001.mp4 to home folder, counter becomes 2
**Step 4: Run all tests to confirm nothing broken**
```bash
pytest tests/ -v
```
Expected: all tests PASS.
**Step 5: Commit**
```bash
git add main.py
git commit -m "feat: complete MainWindow UI with playback and export"
```
---
### Task 7: Final polish
**Files:**
- Modify: `main.py` — dark theme, minor UX
**Step 1: Add dark stylesheet to main()**
```python
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
app.setStyleSheet("""
QWidget { background: #1e1e1e; color: #ddd; }
QPushButton { background: #333; border: 1px solid #555; padding: 4px 10px; border-radius: 3px; }
QPushButton:hover { background: #444; }
QPushButton:disabled { color: #555; }
QLineEdit { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
QStatusBar { color: #aaa; }
""")
win = MainWindow()
win.show()
sys.exit(app.exec())
```
**Step 2: Run and verify visuals**
```bash
python main.py
```
Drop a video, scrub, export. Everything should look clean and dark.
**Step 3: Final commit**
```bash
git add main.py
git commit -m "feat: dark theme, complete 8-cut tool"
```
@@ -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"
```
@@ -0,0 +1,430 @@
# 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"
```