Files
8-cut/docs/plans/2026-04-06-8cut-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

734 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```