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,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"
|
||||
```
|
||||
@@ -17,9 +17,8 @@ from PyQt6.QtWidgets import (
|
||||
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
|
||||
QComboBox, QDialog, QPlainTextEdit, QCheckBox,
|
||||
)
|
||||
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QSettings
|
||||
from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeyEvent
|
||||
from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut
|
||||
import mpv
|
||||
|
||||
|
||||
@@ -51,6 +50,7 @@ def build_ffmpeg_command(
|
||||
# (libx264/aac), so there is no keyframe-alignment issue from pre-input seek.
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-threads", "0",
|
||||
"-ss", str(start),
|
||||
"-i", input_path,
|
||||
"-t", "8",
|
||||
@@ -73,8 +73,8 @@ def build_ffmpeg_command(
|
||||
cmd += [
|
||||
"-an",
|
||||
"-c:v", "libwebp",
|
||||
"-lossless", "1",
|
||||
"-compression_level", "4",
|
||||
"-quality", "85",
|
||||
"-compression_level", "1",
|
||||
os.path.join(output_path, "frame_%04d.webp"),
|
||||
]
|
||||
else:
|
||||
@@ -220,6 +220,12 @@ class ProcessedDB:
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def delete_by_output_path(self, output_path: str) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
self._con.execute("DELETE FROM processed WHERE output_path = ?", (output_path,))
|
||||
self._con.commit()
|
||||
|
||||
def find_similar(self, filename: str) -> str | None:
|
||||
if not self._enabled:
|
||||
return None
|
||||
@@ -322,10 +328,14 @@ class ExportWorker(QThread):
|
||||
|
||||
class TimelineWidget(QWidget):
|
||||
cursor_changed = pyqtSignal(float) # emits position in seconds
|
||||
marker_delete_requested = pyqtSignal(str) # emits output_path
|
||||
|
||||
_RULER_H = 22 # pixels reserved for the time ruler
|
||||
_HANDLE_H = 8 # height of the playhead triangle
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setMinimumHeight(40)
|
||||
self.setMinimumHeight(80)
|
||||
self.setMouseTracking(True)
|
||||
self._duration = 0.0
|
||||
self._cursor = 0.0
|
||||
@@ -333,12 +343,16 @@ class TimelineWidget(QWidget):
|
||||
self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path)
|
||||
|
||||
# Cached paint resources — created once, reused every frame
|
||||
self._cursor_pen = QPen(QColor(255, 200, 0))
|
||||
self._cursor_pen = QPen(QColor(255, 210, 0))
|
||||
self._cursor_pen.setWidth(2)
|
||||
self._marker_pen = QPen(QColor(220, 60, 60))
|
||||
self._marker_pen.setWidth(2)
|
||||
self._ruler_pen = QPen(QColor(120, 120, 120))
|
||||
self._ruler_pen.setWidth(1)
|
||||
self._marker_font = QFont()
|
||||
self._marker_font.setPixelSize(9)
|
||||
self._ruler_font = QFont()
|
||||
self._ruler_font.setPixelSize(9)
|
||||
|
||||
# Debounce timer: update visual cursor immediately but only emit
|
||||
# cursor_changed (which triggers mpv.seek) at most once per interval.
|
||||
@@ -383,33 +397,102 @@ class TimelineWidget(QWidget):
|
||||
return ratio * self._duration
|
||||
|
||||
def paintEvent(self, event):
|
||||
from PyQt6.QtGui import QPolygon
|
||||
from PyQt6.QtCore import QPoint
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing, False)
|
||||
try:
|
||||
w, h = self.width(), self.height()
|
||||
p.fillRect(0, 0, w, h, QColor(30, 30, 30))
|
||||
rh = self._RULER_H
|
||||
th = h - rh # track height
|
||||
|
||||
# ── backgrounds ──────────────────────────────────────────────
|
||||
p.fillRect(0, 0, w, rh, QColor(22, 22, 22)) # ruler bg
|
||||
p.fillRect(0, rh, w, th, QColor(32, 32, 32)) # track bg
|
||||
|
||||
# subtle track lane (slightly raised strip in the middle)
|
||||
lane_y = rh + th // 4
|
||||
lane_h = th // 2
|
||||
p.fillRect(0, lane_y, w, lane_h, QColor(42, 42, 42))
|
||||
|
||||
if self._duration <= 0:
|
||||
p.setPen(QColor(80, 80, 80))
|
||||
p.drawText(0, 0, w, h, Qt.AlignmentFlag.AlignCenter, "No file loaded")
|
||||
return
|
||||
|
||||
# 8s selection highlight
|
||||
# ── time ruler ticks & labels ─────────────────────────────────
|
||||
# Pick a tick interval so we get ~8-12 major ticks across the width
|
||||
raw_step = self._duration / 10.0
|
||||
for candidate in (0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300):
|
||||
if candidate >= raw_step:
|
||||
major_step = candidate
|
||||
break
|
||||
else:
|
||||
major_step = int(raw_step / 60 + 1) * 60
|
||||
|
||||
minor_step = major_step / 5.0
|
||||
p.setFont(self._ruler_font)
|
||||
|
||||
t = 0.0
|
||||
while t <= self._duration + minor_step * 0.1:
|
||||
rx = int(t / self._duration * w)
|
||||
is_major = (round(t / major_step) * major_step - t) < minor_step * 0.1
|
||||
if is_major:
|
||||
p.setPen(self._ruler_pen)
|
||||
p.drawLine(rx, rh - 10, rx, rh)
|
||||
# label
|
||||
mins = int(t) // 60
|
||||
secs = int(t) % 60
|
||||
label = f"{mins}:{secs:02d}" if mins else f"{secs}s"
|
||||
p.setPen(QColor(160, 160, 160))
|
||||
p.drawText(rx + 3, 0, 60, rh - 2,
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom,
|
||||
label)
|
||||
else:
|
||||
p.setPen(QPen(QColor(70, 70, 70)))
|
||||
p.drawLine(rx, rh - 5, rx, rh)
|
||||
t += minor_step
|
||||
|
||||
# ruler bottom border
|
||||
p.setPen(QPen(QColor(55, 55, 55)))
|
||||
p.drawLine(0, rh, w, rh)
|
||||
|
||||
# ── 8-second selection region ─────────────────────────────────
|
||||
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))
|
||||
sel_w = max(x_end - x_start, 1)
|
||||
p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90))
|
||||
# left/right edges of selection
|
||||
p.setPen(QPen(QColor(60, 130, 220, 180), 1))
|
||||
p.drawLine(x_start, rh, x_start, h)
|
||||
p.drawLine(x_end, rh, x_end, h)
|
||||
|
||||
# Cursor line
|
||||
p.setPen(self._cursor_pen)
|
||||
p.drawLine(x_start, 0, x_start, h)
|
||||
|
||||
# Markers
|
||||
# ── export markers ────────────────────────────────────────────
|
||||
p.setFont(self._marker_font)
|
||||
for (t, num, _path) in self._markers:
|
||||
if self._duration <= 0:
|
||||
break
|
||||
mx = int(t / self._duration * w)
|
||||
p.setPen(self._marker_pen)
|
||||
p.drawLine(mx, 0, mx, h)
|
||||
p.drawLine(mx, rh, mx, h)
|
||||
# small filled rectangle label
|
||||
p.fillRect(mx, rh + 2, 14, 12, QColor(200, 50, 50))
|
||||
p.setPen(QColor(255, 255, 255))
|
||||
p.drawText(mx + 2, 10, str(num))
|
||||
p.drawText(mx + 1, rh + 2, 13, 12,
|
||||
Qt.AlignmentFlag.AlignCenter, str(num))
|
||||
|
||||
# ── playhead ──────────────────────────────────────────────────
|
||||
p.setPen(self._cursor_pen)
|
||||
p.drawLine(x_start, rh, x_start, h)
|
||||
# downward-pointing triangle handle in the ruler
|
||||
hh = self._HANDLE_H
|
||||
tri = QPolygon([
|
||||
QPoint(x_start - hh // 2, rh - hh),
|
||||
QPoint(x_start + hh // 2, rh - hh),
|
||||
QPoint(x_start, rh),
|
||||
])
|
||||
p.setBrush(QColor(255, 210, 0))
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.drawPolygon(tri)
|
||||
|
||||
finally:
|
||||
p.end()
|
||||
|
||||
@@ -436,6 +519,25 @@ class TimelineWidget(QWidget):
|
||||
self._seek_timer.stop()
|
||||
self.cursor_changed.emit(self._cursor)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
if not self._hover_cache or self._duration <= 0:
|
||||
return
|
||||
x = event.pos().x()
|
||||
w = self.width()
|
||||
hit_path = None
|
||||
for (frac, output_path) in self._hover_cache:
|
||||
if abs(x - frac * w) <= 6:
|
||||
hit_path = output_path
|
||||
break
|
||||
if hit_path is None:
|
||||
return
|
||||
from PyQt6.QtWidgets import QMenu
|
||||
menu = QMenu(self)
|
||||
name = os.path.basename(hit_path)
|
||||
action = menu.addAction(f"Delete marker: {name}")
|
||||
if menu.exec(event.globalPos()) == action:
|
||||
self.marker_delete_requested.emit(hit_path)
|
||||
|
||||
def _seek(self, x: float):
|
||||
t = self._pos_to_time(int(x))
|
||||
self.set_cursor(t) # update visuals immediately
|
||||
@@ -445,79 +547,119 @@ class TimelineWidget(QWidget):
|
||||
import ctypes
|
||||
|
||||
|
||||
class MpvWidget(QOpenGLWidget):
|
||||
file_loaded = pyqtSignal() # emitted (on Qt thread) when a file is ready
|
||||
crop_clicked = pyqtSignal(float) # x fraction 0–1 when user clicks video
|
||||
class MpvWidget(QWidget):
|
||||
"""Embeds mpv using an off-screen OpenGL FBO with QPainter readback.
|
||||
|
||||
mpv renders each frame into a QOpenGLFramebufferObject on an off-screen
|
||||
surface. The FBO is read back to a QImage and displayed via QPainter,
|
||||
bypassing Wayland sub-surface compositing issues that affect both
|
||||
QOpenGLWidget and QOpenGLWindow+createWindowContainer.
|
||||
"""
|
||||
file_loaded = pyqtSignal()
|
||||
crop_clicked = pyqtSignal(float)
|
||||
_do_file_loaded = pyqtSignal() # mpv thread → Qt main thread for file-loaded event
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setMinimumSize(640, 360)
|
||||
_log_file = open("/tmp/8cut-mpv.log", "w", buffering=1)
|
||||
self._log_file = _log_file
|
||||
|
||||
def _log_handler(level, component, message):
|
||||
_log_file.write(f"[mpv/{component}] {level}: {message}\n")
|
||||
|
||||
self._player = mpv.MPV(keep_open=True, pause=True, log_handler=_log_handler, loglevel="info")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self._frame: "QImage | None" = None
|
||||
self._render_ctx = None
|
||||
self._fbo = None
|
||||
self._needs_render = False # set True by mpv update_cb (any thread)
|
||||
|
||||
@self._player.event_callback("file-loaded")
|
||||
def _on_file_loaded(event):
|
||||
QTimer.singleShot(0, self.file_loaded.emit)
|
||||
from PyQt6.QtGui import QOffscreenSurface, QOpenGLContext, QSurfaceFormat
|
||||
from PyQt6.QtOpenGL import QOpenGLFramebufferObject
|
||||
|
||||
def _8cut_log(self, msg):
|
||||
self._log_file.write(f"[8-cut] {msg}\n")
|
||||
fmt = QSurfaceFormat.defaultFormat()
|
||||
self._gl_surface = QOffscreenSurface()
|
||||
self._gl_surface.setFormat(fmt)
|
||||
self._gl_surface.create()
|
||||
|
||||
def initializeGL(self):
|
||||
from PyQt6.QtGui import QOpenGLContext
|
||||
self._8cut_log(f"initializeGL called, platform={QApplication.platformName()}")
|
||||
self._gl_ctx = QOpenGLContext()
|
||||
self._gl_ctx.setFormat(fmt)
|
||||
self._gl_ctx.create()
|
||||
self._gl_ctx.makeCurrent(self._gl_surface)
|
||||
|
||||
# Build the get_proc_address C callback using the live Qt OpenGL context.
|
||||
# Must be created here (inside initializeGL) so QOpenGLContext.currentContext()
|
||||
# is valid, and stored on self to prevent garbage collection.
|
||||
_PROC_ADDR_T = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p)
|
||||
|
||||
@_PROC_ADDR_T
|
||||
def _get_proc_addr(_, name):
|
||||
ctx = QOpenGLContext.currentContext()
|
||||
if ctx is None:
|
||||
self._8cut_log(f"get_proc_addr: no current context for {name}")
|
||||
return 0
|
||||
addr = ctx.getProcAddress(name)
|
||||
addr = self._gl_ctx.getProcAddress(name)
|
||||
return int(addr) if addr else 0
|
||||
|
||||
self._get_proc_addr_fn = _get_proc_addr # keep alive
|
||||
self._get_proc_addr_fn = _get_proc_addr
|
||||
|
||||
self._player = mpv.MPV(keep_open=True, pause=True, vo="libmpv")
|
||||
try:
|
||||
self._render_ctx = mpv.MpvRenderContext(
|
||||
self._player, "opengl",
|
||||
opengl_init_params={"get_proc_address": self._get_proc_addr_fn},
|
||||
)
|
||||
self._8cut_log("MpvRenderContext created OK")
|
||||
except Exception as e:
|
||||
self._8cut_log(f"MpvRenderContext FAILED: {e}")
|
||||
return
|
||||
self._render_ctx.update_cb = self._on_mpv_update
|
||||
except Exception as e:
|
||||
print(f"[8-cut] MpvRenderContext failed: {e}", file=sys.stderr)
|
||||
|
||||
self._gl_ctx.doneCurrent()
|
||||
|
||||
# Timer polls for new frames at ~60 fps; avoids flooding the event loop
|
||||
# from mpv's C thread which calls update_cb at playback rate.
|
||||
self._render_timer = QTimer(self)
|
||||
self._render_timer.setInterval(16)
|
||||
self._render_timer.timeout.connect(self._poll_render)
|
||||
self._render_timer.start()
|
||||
|
||||
self._do_file_loaded.connect(self.file_loaded)
|
||||
|
||||
@self._player.event_callback("file-loaded")
|
||||
def _on_file_loaded(event):
|
||||
self._do_file_loaded.emit()
|
||||
|
||||
def _on_mpv_update(self):
|
||||
# Called from mpv thread; schedule a repaint on the Qt thread.
|
||||
self.update()
|
||||
# Called from mpv's C thread — only set a flag, no Qt calls here.
|
||||
self._needs_render = True
|
||||
|
||||
def paintGL(self):
|
||||
if self._render_ctx:
|
||||
fbo = self.defaultFramebufferObject()
|
||||
r = self.devicePixelRatio()
|
||||
def _poll_render(self):
|
||||
if self._needs_render and self._render_ctx and self._render_ctx.update():
|
||||
self._needs_render = False
|
||||
self._render_frame()
|
||||
|
||||
def _render_frame(self):
|
||||
from PyQt6.QtOpenGL import QOpenGLFramebufferObject
|
||||
if not self._render_ctx:
|
||||
return
|
||||
w, h = max(self.width(), 1), max(self.height(), 1)
|
||||
self._gl_ctx.makeCurrent(self._gl_surface)
|
||||
try:
|
||||
if self._fbo is None or self._fbo.width() != w or self._fbo.height() != h:
|
||||
self._fbo = QOpenGLFramebufferObject(w, h)
|
||||
self._render_ctx.render(
|
||||
flip_y=True,
|
||||
opengl_fbo={"w": int(self.width() * r), "h": int(self.height() * r), "fbo": fbo},
|
||||
opengl_fbo={"w": w, "h": h, "fbo": self._fbo.handle()},
|
||||
)
|
||||
|
||||
def resizeGL(self, w, h):
|
||||
if self._render_ctx:
|
||||
self._render_ctx.report_swap()
|
||||
self._frame = self._fbo.toImage()
|
||||
except Exception as e:
|
||||
print(f"[8-cut] render error: {e}", file=sys.stderr)
|
||||
finally:
|
||||
self._gl_ctx.doneCurrent()
|
||||
self.update()
|
||||
|
||||
def load(self, path: str):
|
||||
self._player.play(path)
|
||||
def paintEvent(self, event):
|
||||
p = QPainter(self)
|
||||
if self._frame and not self._frame.isNull():
|
||||
p.drawImage(self.rect(), self._frame)
|
||||
else:
|
||||
p.fillRect(self.rect(), QColor(0, 0, 0))
|
||||
p.end()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
w = self.width()
|
||||
if w > 0:
|
||||
self.crop_clicked.emit(event.position().x() / w)
|
||||
|
||||
def load(self, path: str): self._player.play(path)
|
||||
|
||||
def seek(self, t: float):
|
||||
self._player.pause = True
|
||||
@@ -525,14 +667,12 @@ class MpvWidget(QOpenGLWidget):
|
||||
|
||||
def play_loop(self, a: float, b: float):
|
||||
self._player["ab-loop-a"] = a
|
||||
# Clamp b to duration so AB loop fires even on clips shorter than 8s.
|
||||
self._player["ab-loop-b"] = min(b, self._player.duration or b)
|
||||
self._player.pause = False
|
||||
|
||||
def stop_loop(self):
|
||||
# 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["ab-loop-a"] = "no"
|
||||
self._player["ab-loop-b"] = "no"
|
||||
self._player.pause = True
|
||||
|
||||
def get_duration(self) -> float:
|
||||
@@ -548,15 +688,13 @@ class MpvWidget(QOpenGLWidget):
|
||||
def is_playing(self) -> bool:
|
||||
return not self._player.pause
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
w = self.width()
|
||||
if w > 0:
|
||||
self.crop_clicked.emit(event.position().x() / w)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._render_timer.stop()
|
||||
if self._render_ctx:
|
||||
self._render_ctx.free()
|
||||
self._render_ctx = None
|
||||
self._player.terminate()
|
||||
self._fbo = None
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
@@ -834,6 +972,15 @@ class SettingsDialog(QDialog):
|
||||
|
||||
|
||||
def main():
|
||||
# Force desktop OpenGL (not GLES) so mpv's render context produces non-black output.
|
||||
# Must be set before QApplication.
|
||||
from PyQt6.QtGui import QSurfaceFormat
|
||||
_fmt = QSurfaceFormat()
|
||||
_fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL)
|
||||
_fmt.setVersion(3, 3)
|
||||
_fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
|
||||
QSurfaceFormat.setDefaultFormat(_fmt)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
locale.setlocale(locale.LC_NUMERIC, "C") # QApplication resets locale; re-apply for libmpv
|
||||
app.setStyle("Fusion")
|
||||
@@ -880,7 +1027,9 @@ class MainWindow(QMainWindow):
|
||||
self._mpv = MpvWidget()
|
||||
self._mpv.file_loaded.connect(self._after_load)
|
||||
self._timeline = TimelineWidget()
|
||||
self._timeline.setFixedHeight(160)
|
||||
self._timeline.cursor_changed.connect(self._on_cursor_changed)
|
||||
self._timeline.marker_delete_requested.connect(self._on_delete_marker)
|
||||
|
||||
self._lbl_file = QLabel("Drop files onto the queue →")
|
||||
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -897,17 +1046,20 @@ class MainWindow(QMainWindow):
|
||||
self._lbl_cursor = QLabel("cursor: --")
|
||||
self._lbl_duration = QLabel("dur: --")
|
||||
|
||||
self._settings = QSettings("8cut", "8cut")
|
||||
|
||||
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 = QLineEdit(self._settings.value("export_folder", str(Path.home())))
|
||||
self._txt_folder.textChanged.connect(self._reset_counter)
|
||||
self._txt_folder.textChanged.connect(
|
||||
lambda v: self._settings.setValue("export_folder", v)
|
||||
)
|
||||
self._btn_folder = QPushButton("Browse")
|
||||
self._btn_folder.clicked.connect(self._pick_folder)
|
||||
|
||||
self._settings = QSettings("8cut", "8cut")
|
||||
self._txt_resize = QLineEdit()
|
||||
self._txt_resize.setPlaceholderText("px (opt.)")
|
||||
self._txt_resize.setMaximumWidth(70)
|
||||
@@ -990,35 +1142,44 @@ class MainWindow(QMainWindow):
|
||||
top_bar.addWidget(self._lbl_file, stretch=1)
|
||||
top_bar.addWidget(self._btn_settings)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.addWidget(self._btn_play)
|
||||
controls.addWidget(self._btn_pause)
|
||||
controls.addStretch()
|
||||
controls.addWidget(self._lbl_cursor)
|
||||
controls.addWidget(self._lbl_duration)
|
||||
# Row 1 — transport + annotation + export trigger
|
||||
transport_row = QHBoxLayout()
|
||||
transport_row.addWidget(self._btn_play)
|
||||
transport_row.addWidget(self._btn_pause)
|
||||
transport_row.addWidget(self._lbl_cursor)
|
||||
transport_row.addWidget(self._lbl_duration)
|
||||
transport_row.addStretch()
|
||||
transport_row.addWidget(QLabel("Label:"))
|
||||
transport_row.addWidget(self._txt_label)
|
||||
transport_row.addWidget(QLabel("Cat:"))
|
||||
transport_row.addWidget(self._cmb_category)
|
||||
transport_row.addWidget(self._lbl_next)
|
||||
transport_row.addWidget(self._btn_export)
|
||||
|
||||
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(QLabel("Short side:"))
|
||||
export_row.addWidget(self._txt_resize)
|
||||
export_row.addWidget(QLabel("Portrait:"))
|
||||
export_row.addWidget(self._cmb_portrait)
|
||||
export_row.addWidget(QLabel("Format:"))
|
||||
export_row.addWidget(self._cmb_format)
|
||||
export_row.addWidget(self._lbl_next)
|
||||
export_row.addWidget(self._btn_export)
|
||||
# Row 2 — output path + encoding settings (bottom)
|
||||
settings_row = QHBoxLayout()
|
||||
settings_row.addWidget(QLabel("Name:"))
|
||||
settings_row.addWidget(self._txt_name)
|
||||
settings_row.addWidget(QLabel("Folder:"))
|
||||
settings_row.addWidget(self._txt_folder, stretch=1)
|
||||
settings_row.addWidget(self._btn_folder)
|
||||
settings_row.addWidget(QLabel("Short side:"))
|
||||
settings_row.addWidget(self._txt_resize)
|
||||
settings_row.addWidget(QLabel("Portrait:"))
|
||||
settings_row.addWidget(self._cmb_portrait)
|
||||
settings_row.addWidget(QLabel("Format:"))
|
||||
settings_row.addWidget(self._cmb_format)
|
||||
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout(right)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.setSpacing(4)
|
||||
right_layout.addLayout(top_bar)
|
||||
right_layout.addWidget(self._mpv, stretch=1)
|
||||
right_layout.addWidget(self._timeline)
|
||||
right_layout.addWidget(self._crop_bar)
|
||||
right_layout.addLayout(transport_row)
|
||||
right_layout.addLayout(settings_row)
|
||||
|
||||
self._mask_row_widget = QWidget()
|
||||
mask_row = QHBoxLayout(self._mask_row_widget)
|
||||
@@ -1030,16 +1191,6 @@ class MainWindow(QMainWindow):
|
||||
show_masks = self._settings.value("show_masks_row", "true") == "true"
|
||||
self._mask_row_widget.setVisible(show_masks)
|
||||
|
||||
annotation_row = QHBoxLayout()
|
||||
annotation_row.addWidget(QLabel("Label:"))
|
||||
annotation_row.addWidget(self._txt_label)
|
||||
annotation_row.addWidget(QLabel("Category:"))
|
||||
annotation_row.addWidget(self._cmb_category)
|
||||
annotation_row.addStretch()
|
||||
|
||||
right_layout.addLayout(controls)
|
||||
right_layout.addLayout(export_row)
|
||||
right_layout.addLayout(annotation_row)
|
||||
right_layout.addWidget(self._mask_row_widget)
|
||||
|
||||
# Left: queue label + playlist
|
||||
@@ -1063,6 +1214,32 @@ class MainWindow(QMainWindow):
|
||||
self.setStatusBar(QStatusBar())
|
||||
self._crop_bar.setVisible(saved_ratio != "Off")
|
||||
|
||||
# Application-wide shortcuts — fire regardless of which widget has focus.
|
||||
ctx = Qt.ShortcutContext.ApplicationShortcut
|
||||
for key in ("Left", "J"):
|
||||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||||
lambda: self._step_cursor(-1.0 / self._fps)
|
||||
)
|
||||
for key in ("Right", "L"):
|
||||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||||
lambda: self._step_cursor(1.0 / self._fps)
|
||||
)
|
||||
for key in ("Shift+Left", "Shift+J"):
|
||||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||||
lambda: self._step_cursor(-1.0)
|
||||
)
|
||||
for key in ("Shift+Right", "Shift+L"):
|
||||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||||
lambda: self._step_cursor(1.0)
|
||||
)
|
||||
for key in ("Space", "P"):
|
||||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||||
self._toggle_play
|
||||
)
|
||||
QShortcut(QKeySequence("K"), self, context=ctx).activated.connect(self._on_pause)
|
||||
QShortcut(QKeySequence("E"), self, context=ctx).activated.connect(self._on_export)
|
||||
QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker)
|
||||
|
||||
def _load_file(self, path: str):
|
||||
self._file_path = path
|
||||
self._lbl_file.setText(os.path.basename(path))
|
||||
@@ -1110,6 +1287,13 @@ class MainWindow(QMainWindow):
|
||||
markers = []
|
||||
self._timeline.set_markers(markers)
|
||||
|
||||
def _on_delete_marker(self, output_path: str) -> None:
|
||||
self._db.delete_by_output_path(output_path)
|
||||
self._refresh_markers()
|
||||
self.statusBar().showMessage(
|
||||
f"Deleted marker: {os.path.basename(output_path)}", 4000
|
||||
)
|
||||
|
||||
def _on_portrait_ratio_changed(self, text: str) -> None:
|
||||
ratio = None if text == "Off" else text
|
||||
self._crop_bar.set_portrait_ratio(ratio)
|
||||
@@ -1131,6 +1315,14 @@ class MainWindow(QMainWindow):
|
||||
self._lbl_cursor.setText(f"cursor: {format_time(t)}")
|
||||
self._mpv.seek(t)
|
||||
|
||||
def _toggle_play(self):
|
||||
if not self._file_path:
|
||||
return
|
||||
if self._mpv.is_playing():
|
||||
self._on_pause()
|
||||
else:
|
||||
self._on_play()
|
||||
|
||||
def _on_play(self):
|
||||
if not self._file_path:
|
||||
return
|
||||
@@ -1162,35 +1354,6 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
self._step_cursor(markers[0][0] - self._cursor) # wrap to first
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
focused = QApplication.focusWidget()
|
||||
if isinstance(focused, (QLineEdit, QPlainTextEdit)):
|
||||
super().keyPressEvent(event)
|
||||
return
|
||||
|
||||
key = event.key()
|
||||
shift = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
|
||||
frame = 1.0 / self._fps
|
||||
step = 1.0 if shift else frame
|
||||
|
||||
if key in (Qt.Key.Key_Left, Qt.Key.Key_J):
|
||||
self._step_cursor(-step)
|
||||
elif key in (Qt.Key.Key_Right, Qt.Key.Key_L):
|
||||
self._step_cursor(step)
|
||||
elif key in (Qt.Key.Key_Space, Qt.Key.Key_P):
|
||||
if self._mpv.is_playing():
|
||||
self._on_pause()
|
||||
else:
|
||||
self._on_play()
|
||||
elif key == Qt.Key.Key_K:
|
||||
self._on_pause()
|
||||
elif key == Qt.Key.Key_E:
|
||||
self._on_export()
|
||||
elif key == Qt.Key.Key_M:
|
||||
self._jump_to_next_marker()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
# --- Export ---
|
||||
|
||||
def _pick_folder(self):
|
||||
@@ -1240,6 +1403,10 @@ class MainWindow(QMainWindow):
|
||||
self._btn_export.setEnabled(False)
|
||||
self.statusBar().showMessage(f"Exporting {os.path.basename(output)}…")
|
||||
|
||||
# Show marker immediately — don't wait for ffmpeg to finish.
|
||||
pending = self._timeline._markers + [(self._cursor, self._export_counter, output)]
|
||||
self._timeline.set_markers(pending)
|
||||
|
||||
ratio_text = self._cmb_portrait.currentText()
|
||||
portrait_ratio = None if ratio_text == "Off" else ratio_text
|
||||
|
||||
|
||||
Reference in New Issue
Block a user