Files
8-cut/docs/plans/2026-04-06-timeline-markers-implementation.md
T
Ethanfel 6573fa6e05 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>
2026-04-06 22:22:58 +02:00

13 KiB

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:

# --- 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

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:

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

pytest tests/test_utils.py -k "db" -v

Expected: all 8 DB tests PASS.

Step 5: Run full suite

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

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:

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__:

        self._markers: list[tuple[float, int, str]] = []

Add the set_markers method:

    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:

    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:

    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

python -c "from main import TimelineWidget"

Expected: no output.

Step 4: Run all tests

pytest tests/ -v

Expected: all tests pass.

Step 5: Commit

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:

    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:

    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:

    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

python -c "from main import MainWindow"

Expected: no output.

Step 5: Run all tests

pytest tests/ -v

Expected: all tests pass.

Step 6: Manual smoke test

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

git add main.py
git commit -m "feat: wire timeline markers into MainWindow"