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:
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user