feat: mpv Wayland embedding, timeline redesign, UX polish

mpv embedding:
- Replace wid/QOpenGLWidget with QOffscreenSurface + QOpenGLFramebufferObject
  + QPainter readback — works on Wayland/KDE without sub-surface compositing
- Force desktop OpenGL 3.3 core profile before QApplication (fixes black output on GLES)
- Timer-based render polling (16 ms) replaces signal-flood from mpv C thread;
  fixes playback animation and scrubbing preview
- Fix AB-loop: set ab-loop-a/b to "no" on stop (0 means infinite in mpv)

Timeline:
- Full redesign: time ruler with adaptive major/minor ticks, playhead triangle
  handle, selection region with edge lines, numbered marker badges
- Height 160 px; layout collapsed from 4 rows to 2 below timeline
- Markers appear immediately on export (optimistic update before ffmpeg finishes)
- Right-click marker → context menu to delete from DB

Hotkeys:
- Replace keyPressEvent with QShortcut(ApplicationShortcut) so keys work
  regardless of focused widget; MpvWidget gets NoFocus policy

Export:
- WebP: switch lossless→lossy quality 85, compression_level 1 (~10x faster)
- Add -threads 0 for full CPU utilisation during decode/filter
- Remember last export folder across sessions via QSettings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 22:22:58 +02:00
parent 7931a0e3db
commit 6573fa6e05
5 changed files with 2004 additions and 134 deletions
+46
View File
@@ -0,0 +1,46 @@
# 8-cut Design
## Overview
A Linux desktop tool for dropping a video clip, setting a start point on a timeline, and exporting exactly 8 seconds to a numbered output file.
## Stack
- **Python + PyQt6** — native desktop window, drag & drop, custom timeline widget
- **python-mpv** — embedded video playback with audio, frame-accurate seeking, AB loop for 8s preview
- **ffmpeg** — export via subprocess, always re-encodes to guarantee exactly 8s with no freeze frames
## Layout
Single window, top to bottom:
1. Drop zone / loaded file path display
2. mpv video embed (preview)
3. Custom timeline widget with draggable cursor
4. Playback controls + cursor position display
5. Export controls: base name input, folder picker, next filename preview, Export button
## Behavior
- **Drag & drop** a video file onto the window to load it
- **Timeline**: click or drag to reposition cursor; shows current frame when paused
- **Play 8s**: mpv seeks to cursor position, plays 8 seconds, loops back using mpv AB-loop (`ab-loop-a` = cursor, `ab-loop-b` = cursor + 8s)
- **Pause**: shows current frame at cursor position
- **Export**: runs ffmpeg in a QThread (non-blocking UI)
- Command: `ffmpeg -ss {start} -i {input} -t 8 -c:v libx264 -c:a aac {output}`
- Output: `{folder}/{basename}_{NNN:03d}.mp4`
- Counter auto-increments after each successful export
- Counter resets if base name or folder changes
## Architecture
Single file `main.py` (~300-400 lines):
- `MainWindow(QMainWindow)` — owns all state: file path, cursor (seconds), base name, output folder, export counter
- `TimelineWidget(QWidget)` — custom `paintEvent` draws bar + cursor line; `mousePressEvent`/`mouseMoveEvent` for scrubbing
- `MpvWidget(QWidget)` — embeds mpv using window ID (`wid`), exposes `load(path)`, `seek(t)`, `play_loop(a, b)`, `pause()`
- `ExportWorker(QThread)` — runs ffmpeg subprocess, emits `finished(path)` or `error(msg)` signal
## Export guarantees
Always re-encode (never stream copy) to avoid freeze frames caused by keyframe misalignment at the cut point. Output is always exactly 8.000 seconds.
@@ -0,0 +1,733 @@
# 8-cut Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a Linux desktop tool to drop a video, scrub a timeline, and export exactly 8 seconds to an auto-numbered output file.
**Architecture:** Single `main.py` file with PyQt6 for the window/widgets, python-mpv embedded for playback with AB-loop preview, and ffmpeg subprocess in a QThread for non-blocking export. Pure logic (filename counter, ffmpeg command builder) is tested with pytest; GUI is verified manually.
**Tech Stack:** Python 3.10+, PyQt6, python-mpv, ffmpeg (system), pytest
---
### Task 1: Project setup
**Files:**
- Create: `main.py`
- Create: `requirements.txt`
- Create: `tests/__init__.py`
- Create: `tests/test_utils.py`
**Step 1: Install dependencies**
```bash
pip install PyQt6 python-mpv pytest
```
Verify mpv is on the system:
```bash
mpv --version
ffmpeg -version
```
**Step 2: Create requirements.txt**
```
PyQt6>=6.4
python-mpv>=1.0
pytest>=7.0
```
**Step 3: Create tests/__init__.py**
Empty file.
**Step 4: Create main.py skeleton**
```python
import sys
from PyQt6.QtWidgets import QApplication, QMainWindow
from PyQt6.QtCore import Qt
def main():
app = QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec())
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("8-cut")
self.resize(900, 650)
if __name__ == "__main__":
main()
```
**Step 5: Run to verify window opens**
```bash
python main.py
```
Expected: empty 900×650 window titled "8-cut" appears.
**Step 6: Init git and commit**
```bash
cd /media/p5/8-cut
git init
git add main.py requirements.txt tests/
git commit -m "feat: project skeleton"
```
---
### Task 2: Pure utility functions (TDD)
**Files:**
- Modify: `main.py` — add `build_export_path`, `format_time`
- Modify: `tests/test_utils.py`
**Step 1: Write failing tests**
```python
# tests/test_utils.py
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from main import build_export_path, format_time
def test_build_export_path_first():
assert build_export_path("/out", "clip", 1) == "/out/clip_001.mp4"
def test_build_export_path_counter():
assert build_export_path("/out", "clip", 42) == "/out/clip_042.mp4"
def test_build_export_path_deep_counter():
assert build_export_path("/out", "shot", 999) == "/out/shot_999.mp4"
def test_format_time_seconds():
assert format_time(0.0) == "0:00.0"
def test_format_time_minutes():
assert format_time(75.3) == "1:15.3"
def test_format_time_rounding():
assert format_time(61.05) == "1:01.0"
```
**Step 2: Run tests to verify they fail**
```bash
pytest tests/test_utils.py -v
```
Expected: `ImportError` or `AttributeError` — functions not yet defined.
**Step 3: Add functions to main.py**
Add after the imports:
```python
def build_export_path(folder: str, basename: str, counter: int) -> str:
filename = f"{basename}_{counter:03d}.mp4"
return os.path.join(folder, filename)
def format_time(seconds: float) -> str:
m = int(seconds) // 60
s = seconds - m * 60
return f"{m}:{s:04.1f}"
```
Also add `import os` at the top of main.py.
**Step 4: Run tests to verify they pass**
```bash
pytest tests/test_utils.py -v
```
Expected: all 6 tests PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: add utility functions with tests"
```
---
### Task 3: ExportWorker (QThread)
**Files:**
- Modify: `main.py` — add `ExportWorker` class
- Modify: `tests/test_utils.py` — add ffmpeg command test
**Step 1: Write failing test for command builder**
Add to `tests/test_utils.py`:
```python
from main import build_ffmpeg_command
def test_ffmpeg_command():
cmd = build_ffmpeg_command("/in/video.mp4", 12.5, "/out/clip_001.mp4")
assert cmd[0] == "ffmpeg"
assert "-ss" in cmd
assert str(12.5) in cmd
assert "-t" in cmd
assert "8" in cmd
assert cmd[-1] == "/out/clip_001.mp4"
```
**Step 2: Run to verify it fails**
```bash
pytest tests/test_utils.py::test_ffmpeg_command -v
```
Expected: ImportError.
**Step 3: Add build_ffmpeg_command and ExportWorker to main.py**
Add after imports:
```python
import subprocess
from PyQt6.QtCore import QThread, pyqtSignal
def build_ffmpeg_command(input_path: str, start: float, output_path: str) -> list:
return [
"ffmpeg", "-y",
"-ss", str(start),
"-i", input_path,
"-t", "8",
"-c:v", "libx264",
"-c:a", "aac",
output_path,
]
class ExportWorker(QThread):
finished = pyqtSignal(str) # output path
error = pyqtSignal(str) # error message
def __init__(self, input_path: str, start: float, output_path: str):
super().__init__()
self._input = input_path
self._start = start
self._output = output_path
def run(self):
cmd = build_ffmpeg_command(self._input, self._start, self._output)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0:
self.finished.emit(self._output)
else:
self.error.emit(result.stderr[-500:])
except Exception as e:
self.error.emit(str(e))
```
**Step 4: Run tests**
```bash
pytest tests/test_utils.py -v
```
Expected: all 7 tests PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: ExportWorker with ffmpeg command builder"
```
---
### Task 4: TimelineWidget
**Files:**
- Modify: `main.py` — add `TimelineWidget` class
**Step 1: Add TimelineWidget**
Add before `MainWindow`:
```python
from PyQt6.QtWidgets import QWidget
from PyQt6.QtGui import QPainter, QColor, QPen
from PyQt6.QtCore import pyqtSignal
class TimelineWidget(QWidget):
cursor_changed = pyqtSignal(float) # emits position in seconds
def __init__(self):
super().__init__()
self.setMinimumHeight(40)
self.setMouseTracking(True)
self._duration = 0.0
self._cursor = 0.0
def set_duration(self, duration: float):
self._duration = duration
self._cursor = 0.0
self.update()
def set_cursor(self, seconds: float):
self._cursor = max(0.0, min(seconds, max(0.0, self._duration - 8.0)))
self.update()
def _pos_to_time(self, x: int) -> float:
if self._duration <= 0 or self.width() <= 0:
return 0.0
ratio = max(0.0, min(1.0, x / self.width()))
return ratio * self._duration
def paintEvent(self, event):
p = QPainter(self)
w, h = self.width(), self.height()
# Background
p.fillRect(0, 0, w, h, QColor(30, 30, 30))
if self._duration <= 0:
return
# 8s selection highlight
x_start = int(self._cursor / self._duration * w)
x_end = int(min(self._cursor + 8.0, self._duration) / self._duration * w)
p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120))
# Cursor line
pen = QPen(QColor(255, 200, 0))
pen.setWidth(2)
p.setPen(pen)
p.drawLine(x_start, 0, x_start, h)
def mousePressEvent(self, event):
self._seek(event.position().x())
def mouseMoveEvent(self, event):
if event.buttons():
self._seek(event.position().x())
def _seek(self, x: float):
t = self._pos_to_time(int(x))
self.set_cursor(t)
self.cursor_changed.emit(self._cursor)
```
**Step 2: Verify it renders — quick smoke test**
Temporarily add to `MainWindow.__init__`:
```python
from PyQt6.QtWidgets import QVBoxLayout, QWidget as QW
container = QW()
layout = QVBoxLayout(container)
self._timeline = TimelineWidget()
self._timeline.set_duration(60.0)
layout.addWidget(self._timeline)
self.setCentralWidget(container)
```
Run `python main.py` — you should see a dark bar. Click/drag on it to move the yellow cursor with blue highlight.
**Step 3: Remove the temporary test code from MainWindow**
Revert `MainWindow.__init__` to just `super().__init__()`, `setWindowTitle`, `resize`.
**Step 4: Commit**
```bash
git add main.py
git commit -m "feat: TimelineWidget with cursor and 8s highlight"
```
---
### Task 5: MpvWidget
**Files:**
- Modify: `main.py` — add `MpvWidget` class
**Step 1: Add MpvWidget**
Add before `MainWindow`:
```python
import mpv
from PyQt6.QtWidgets import QFrame
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
class MpvWidget(QFrame):
file_loaded = pyqtSignal() # emitted (on Qt thread) when a file is ready
def __init__(self):
super().__init__()
self.setMinimumSize(640, 360)
self.setStyleSheet("background: black;")
# Required so Qt creates a real native window handle for mpv to embed into.
# Without these, mpv opens a separate window instead of embedding.
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen, True)
self._player = None
def _init_player(self):
if self._player is not None:
return
self._player = mpv.MPV(
wid=str(int(self.winId())),
keep_open=True,
pause=True,
)
# mpv fires events on its own thread; bounce to Qt thread via QTimer.
@self._player.event_callback("file-loaded")
def _on_file_loaded(event):
QTimer.singleShot(0, self.file_loaded.emit)
def load(self, path: str):
self._init_player()
self._player.play(path)
def seek(self, t: float):
if self._player:
self._player.pause = True
self._player.seek(t, "absolute")
def play_loop(self, a: float, b: float):
if self._player:
self._player["ab-loop-a"] = a
self._player["ab-loop-b"] = b
self._player.pause = False
def stop_loop(self):
if self._player:
# ab-loop-a/b are numeric properties — setting to "no" via dict
# accessor throws TypeError. Disable loop via ab_loop_count instead.
self._player.ab_loop_count = 0
self._player.pause = True
def get_duration(self) -> float:
if self._player:
d = self._player.duration
return d if d else 0.0
return 0.0
def closeEvent(self, event):
if self._player:
self._player.terminate()
super().closeEvent(event)
```
**Step 2: Smoke test**
Temporarily in `MainWindow.__init__`:
```python
from PyQt6.QtWidgets import QVBoxLayout, QWidget as QW
container = QW()
layout = QVBoxLayout(container)
self._mpv = MpvWidget()
layout.addWidget(self._mpv)
self.setCentralWidget(container)
# after show(), call: self._mpv.load("/path/to/any/video.mp4")
```
Run, load a real video path. Should display and be paused.
**Step 3: Remove temporary test code**
**Step 4: Commit**
```bash
git add main.py
git commit -m "feat: MpvWidget with seek and AB-loop"
```
---
### Task 6: MainWindow — full UI
**Files:**
- Modify: `main.py` — implement complete `MainWindow`
**Step 1: Add all imports at top of main.py**
```python
import sys
import os
import subprocess
from pathlib import Path
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent
import mpv
```
**Step 2: Replace MainWindow with full implementation**
```python
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("8-cut")
self.resize(900, 680)
self.setAcceptDrops(True)
# State
self._file_path: str = ""
self._cursor: float = 0.0
self._export_counter: int = 1
self._export_worker: ExportWorker | None = None
# Widgets
self._mpv = MpvWidget()
self._mpv.file_loaded.connect(self._after_load)
self._timeline = TimelineWidget()
self._timeline.cursor_changed.connect(self._on_cursor_changed)
self._lbl_file = QLabel("Drop a video file here")
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
self._btn_play = QPushButton("▶ Play 8s")
self._btn_play.setEnabled(False)
self._btn_play.clicked.connect(self._on_play)
self._btn_pause = QPushButton("⏸ Pause")
self._btn_pause.setEnabled(False)
self._btn_pause.clicked.connect(self._on_pause)
self._lbl_cursor = QLabel("cursor: --")
self._lbl_duration = QLabel("dur: --")
self._txt_name = QLineEdit("clip")
self._txt_name.setPlaceholderText("base name")
self._txt_name.setMaximumWidth(150)
self._txt_name.textChanged.connect(self._reset_counter)
self._txt_folder = QLineEdit(str(Path.home()))
self._btn_folder = QPushButton("Browse")
self._btn_folder.clicked.connect(self._pick_folder)
self._lbl_next = QLabel()
self._update_next_label()
self._btn_export = QPushButton("Export")
self._btn_export.setEnabled(False)
self._btn_export.clicked.connect(self._on_export)
# Layout
top_bar = QHBoxLayout()
top_bar.addWidget(self._lbl_file, stretch=1)
controls = QHBoxLayout()
controls.addWidget(self._btn_play)
controls.addWidget(self._btn_pause)
controls.addStretch()
controls.addWidget(self._lbl_cursor)
controls.addWidget(self._lbl_duration)
export_row = QHBoxLayout()
export_row.addWidget(QLabel("Name:"))
export_row.addWidget(self._txt_name)
export_row.addWidget(QLabel("Folder:"))
export_row.addWidget(self._txt_folder, stretch=1)
export_row.addWidget(self._btn_folder)
export_row.addWidget(self._lbl_next)
export_row.addWidget(self._btn_export)
root = QVBoxLayout()
root.addLayout(top_bar)
root.addWidget(self._mpv, stretch=1)
root.addWidget(self._timeline)
root.addLayout(controls)
root.addLayout(export_row)
container = QWidget()
container.setLayout(root)
self.setCentralWidget(container)
self.setStatusBar(QStatusBar())
# --- Drag & Drop ---
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QDropEvent):
urls = event.mimeData().urls()
if urls:
path = urls[0].toLocalFile()
self._load_file(path)
def _load_file(self, path: str):
self._file_path = path
self._lbl_file.setText(os.path.basename(path))
self._mpv.load(path)
# _after_load is triggered by MpvWidget.file_loaded signal (connected in __init__)
def _after_load(self):
dur = self._mpv.get_duration()
self._timeline.set_duration(dur)
self._cursor = 0.0
self._lbl_duration.setText(f"dur: {format_time(dur)}")
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
self._btn_play.setEnabled(True)
self._btn_pause.setEnabled(True)
self._btn_export.setEnabled(True)
# --- Playback ---
def _on_cursor_changed(self, t: float):
self._cursor = t
self._lbl_cursor.setText(f"cursor: {format_time(t)}")
self._mpv.seek(t)
def _on_play(self):
if not self._file_path:
return
self._mpv.play_loop(self._cursor, self._cursor + 8.0)
def _on_pause(self):
self._mpv.stop_loop()
self._mpv.seek(self._cursor)
# --- Export ---
def _pick_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Select output folder")
if folder:
self._txt_folder.setText(folder)
self._reset_counter()
def _reset_counter(self):
self._export_counter = 1
self._update_next_label()
def _update_next_label(self):
path = build_export_path(
self._txt_folder.text(),
self._txt_name.text() or "clip",
self._export_counter,
)
self._lbl_next.setText(f"{os.path.basename(path)}")
def _on_export(self):
if not self._file_path:
return
if self._export_worker and self._export_worker.isRunning():
self.statusBar().showMessage("Export already running…")
return
output = build_export_path(
self._txt_folder.text(),
self._txt_name.text() or "clip",
self._export_counter,
)
self._btn_export.setEnabled(False)
self.statusBar().showMessage(f"Exporting {os.path.basename(output)}")
self._export_worker = ExportWorker(self._file_path, self._cursor, output)
self._export_worker.finished.connect(self._on_export_done)
self._export_worker.error.connect(self._on_export_error)
self._export_worker.start()
def _on_export_done(self, path: str):
self._export_counter += 1
self._update_next_label()
self._btn_export.setEnabled(True)
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
def _on_export_error(self, msg: str):
self._btn_export.setEnabled(True)
self.statusBar().showMessage(f"Export error: {msg}")
```
**Step 3: Run the app**
```bash
python main.py
```
Expected:
- Window opens with dark drop zone
- Drop a video → preview appears, timeline shows duration
- Drag cursor → video seeks to that frame
- Click "▶ Play 8s" → 8-second loop plays with audio
- Click "⏸ Pause" → pauses and seeks back to cursor
- Click "Export" → exports clip_001.mp4 to home folder, counter becomes 2
**Step 4: Run all tests to confirm nothing broken**
```bash
pytest tests/ -v
```
Expected: all tests PASS.
**Step 5: Commit**
```bash
git add main.py
git commit -m "feat: complete MainWindow UI with playback and export"
```
---
### Task 7: Final polish
**Files:**
- Modify: `main.py` — dark theme, minor UX
**Step 1: Add dark stylesheet to main()**
```python
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
app.setStyleSheet("""
QWidget { background: #1e1e1e; color: #ddd; }
QPushButton { background: #333; border: 1px solid #555; padding: 4px 10px; border-radius: 3px; }
QPushButton:hover { background: #444; }
QPushButton:disabled { color: #555; }
QLineEdit { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
QStatusBar { color: #aaa; }
""")
win = MainWindow()
win.show()
sys.exit(app.exec())
```
**Step 2: Run and verify visuals**
```bash
python main.py
```
Drop a video, scrub, export. Everything should look clean and dark.
**Step 3: Final commit**
```bash
git add main.py
git commit -m "feat: dark theme, complete 8-cut tool"
```
@@ -0,0 +1,494 @@
# Playlist & Processed-Files Database Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a file queue (playlist) that auto-advances after each export, and a SQLite database that warns when a newly loaded file is fuzzy-similar to one already processed.
**Architecture:** `ProcessedDB` wraps `sqlite3` with fuzzy matching via `difflib.SequenceMatcher` on normalized filenames (resolution/quality tags stripped). `PlaylistWidget` is a `QListWidget` subclass that owns the queue and emits `file_selected` on advance or click. `MainWindow` is wired to use both.
**Tech Stack:** Python built-ins only — `sqlite3`, `difflib`, `re`, `datetime`. No new dependencies.
---
### Task 1: `_normalize_filename` and `ProcessedDB` (TDD)
**Files:**
- Modify: `main.py` — add `_normalize_filename`, `ProcessedDB`
- Modify: `tests/test_utils.py` — add DB and normalization tests
**Step 1: Add imports at top of main.py**
Add to the existing imports:
```python
import re
import sqlite3
from datetime import datetime
from difflib import SequenceMatcher
```
**Step 2: Write failing tests**
Add to `tests/test_utils.py`:
```python
import tempfile, os
from main import _normalize_filename, ProcessedDB
# --- _normalize_filename ---
def test_normalize_strips_extension():
assert _normalize_filename("clip.mp4") == "clip"
def test_normalize_strips_resolution():
assert _normalize_filename("clip_2160p.mp4") == "clip"
def test_normalize_strips_1080p():
assert _normalize_filename("clip_1080p.mkv") == "clip"
def test_normalize_strips_multiple_tags():
assert _normalize_filename("show_1080p_HDR.mkv") == "show"
def test_normalize_lowercases():
assert _normalize_filename("MyVideo_4K.mp4") == "myvideo"
def test_normalize_collapses_separators():
assert _normalize_filename("my__video--2160p.mp4") == "my_video"
# --- ProcessedDB ---
def test_db_add_and_find_exact():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4")
assert db.find_similar("video.mp4") == "video.mp4"
finally:
os.unlink(path)
def test_db_find_similar_resolution_variant():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("episode_s01e01_2160p.mkv")
assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv"
finally:
os.unlink(path)
def test_db_find_similar_no_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("alpha.mp4")
assert db.find_similar("completely_different_zzzz.mp4") is None
finally:
os.unlink(path)
def test_db_disabled_survives_bad_path():
db = ProcessedDB("/no/such/directory/8cut.db")
db.add("x.mp4") # must not raise
assert db.find_similar("x.mp4") is None # gracefully returns None
```
**Step 3: Run tests to verify they fail**
```bash
pytest tests/test_utils.py -k "normalize or db" -v
```
Expected: ImportError — functions not defined yet.
**Step 4: Add `_normalize_filename` to main.py**
Add after the existing imports, before `build_export_path`:
```python
def _normalize_filename(filename: str) -> str:
"""Strip extension and common resolution/quality tags for fuzzy comparison."""
name = os.path.splitext(filename)[0].lower()
name = re.sub(
r'\b(2160p?|4k|8k|1080p?|720p?|480p?|360p?|240p?'
r'|hdr|sdr|x264|x265|h264|h265|hevc|avc'
r'|blu[-_.]?ray|webrip|web[-_.]dl|dvdrip|hdtv)\b',
'', name, flags=re.IGNORECASE,
)
name = re.sub(r'[\s_\-\.]+', '_', name).strip('_')
return name
```
**Step 5: Add `ProcessedDB` to main.py**
Add after `_normalize_filename`:
```python
class ProcessedDB:
def __init__(self, db_path: str | None = None):
if db_path is None:
db_path = str(Path.home() / ".8cut.db")
try:
self._con = sqlite3.connect(db_path)
self._con.execute(
"CREATE TABLE IF NOT EXISTS processed "
"(filename TEXT NOT NULL, processed_at TEXT NOT NULL)"
)
self._con.commit()
self._enabled = True
except Exception as e:
print(f"8-cut: DB unavailable: {e}", file=sys.stderr)
self._con = None
self._enabled = False
def add(self, filename: str) -> None:
if not self._enabled:
return
self._con.execute(
"INSERT INTO processed (filename, processed_at) VALUES (?, ?)",
(filename, datetime.utcnow().isoformat()),
)
self._con.commit()
def find_similar(self, filename: str) -> str | None:
if not self._enabled:
return None
rows = self._con.execute(
"SELECT DISTINCT filename FROM processed"
).fetchall()
norm_new = _normalize_filename(filename)
best_ratio, best_match = 0.0, None
for (stored,) in rows:
ratio = SequenceMatcher(
None, norm_new, _normalize_filename(stored)
).ratio()
if ratio >= 0.75 and ratio > best_ratio:
best_ratio, best_match = ratio, stored
return best_match
```
**Step 6: Run tests to verify they pass**
```bash
pytest tests/test_utils.py -k "normalize or db" -v
```
Expected: all 10 new tests PASS (plus existing 8 = 18 total).
**Step 7: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: ProcessedDB and _normalize_filename with tests"
```
---
### Task 2: `PlaylistWidget`
**Files:**
- Modify: `main.py` — add `PlaylistWidget` class
**Step 1: Add PlaylistWidget before MainWindow**
Add after `MpvWidget`, before `main()`:
```python
class PlaylistWidget(QListWidget):
file_selected = pyqtSignal(str) # emits full path of selected file
def __init__(self):
super().__init__()
self.setAcceptDrops(True)
self.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly)
self.setFixedWidth(200)
self.setWordWrap(True)
self._paths: list[str] = []
self.itemClicked.connect(self._on_item_clicked)
def add_files(self, paths: list[str]) -> None:
"""Append paths not already in queue; auto-select first if queue was empty."""
was_empty = len(self._paths) == 0
for path in paths:
if path not in self._paths and os.path.isfile(path):
self._paths.append(path)
self.addItem(os.path.basename(path))
if was_empty and self._paths:
self._select(0)
def advance(self) -> None:
"""Move to next item in queue. Does nothing if at end."""
row = self.currentRow()
if row < self.count() - 1:
self._select(row + 1)
def current_path(self) -> str | None:
row = self.currentRow()
return self._paths[row] if 0 <= row < len(self._paths) else None
def _select(self, row: int) -> None:
self.setCurrentRow(row)
self._refresh_labels()
self.file_selected.emit(self._paths[row])
def _refresh_labels(self) -> None:
current = self.currentRow()
for i in range(self.count()):
name = os.path.basename(self._paths[i])
self.item(i).setText(f"{name}" if i == current else name)
def _on_item_clicked(self, item: QListWidgetItem) -> None:
self._select(self.row(item))
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QDropEvent) -> None:
paths = [
u.toLocalFile() for u in event.mimeData().urls()
if os.path.isfile(u.toLocalFile())
]
if paths:
self.add_files(paths)
```
**Step 2: Add missing imports**
`QListWidget`, `QListWidgetItem`, and `QAbstractItemView` need to be in the QtWidgets import line. Check which are missing and add them. The updated import line should be:
```python
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter,
)
```
(`QSplitter` is added now for use in Task 3.)
**Step 3: Verify headless import**
```bash
python -c "from main import PlaylistWidget"
```
Expected: no output.
**Step 4: Run all tests**
```bash
pytest tests/ -v
```
Expected: all 18 tests pass.
**Step 5: Commit**
```bash
git add main.py
git commit -m "feat: PlaylistWidget with drop support and auto-advance"
```
---
### Task 3: Wire MainWindow
**Files:**
- Modify: `main.py` — update `MainWindow.__init__`, `_load_file`, `_after_load`, `_on_export_done`, layout
**Step 1: Update MainWindow.__init__**
Replace the `MainWindow.__init__` method entirely with the version below. Key changes:
- Add `self._db = ProcessedDB()` and `self._playlist = PlaylistWidget()`
- Connect `_playlist.file_selected` to `_load_file`
- Change the root layout to a horizontal split: playlist on left, existing content on right
- Remove `self.setAcceptDrops(True)` from MainWindow (playlist handles drops now)
```python
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("8-cut")
self.resize(1100, 680)
# Services
self._db = ProcessedDB()
# State
self._file_path: str = ""
self._cursor: float = 0.0
self._export_counter: int = 1
self._export_worker: ExportWorker | None = None
# Widgets
self._playlist = PlaylistWidget()
self._playlist.file_selected.connect(self._load_file)
self._mpv = MpvWidget()
self._mpv.file_loaded.connect(self._after_load)
self._timeline = TimelineWidget()
self._timeline.cursor_changed.connect(self._on_cursor_changed)
self._lbl_file = QLabel("Drop files onto the queue →")
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
self._btn_play = QPushButton("▶ Play 8s")
self._btn_play.setEnabled(False)
self._btn_play.clicked.connect(self._on_play)
self._btn_pause = QPushButton("⏸ Pause")
self._btn_pause.setEnabled(False)
self._btn_pause.clicked.connect(self._on_pause)
self._lbl_cursor = QLabel("cursor: --")
self._lbl_duration = QLabel("dur: --")
self._txt_name = QLineEdit("clip")
self._txt_name.setPlaceholderText("base name")
self._txt_name.setMaximumWidth(150)
self._txt_name.textChanged.connect(self._reset_counter)
self._txt_folder = QLineEdit(str(Path.home()))
self._txt_folder.textChanged.connect(self._reset_counter)
self._btn_folder = QPushButton("Browse")
self._btn_folder.clicked.connect(self._pick_folder)
self._lbl_next = QLabel()
self._update_next_label()
self._btn_export = QPushButton("Export")
self._btn_export.setEnabled(False)
self._btn_export.clicked.connect(self._on_export)
# Right-side layout (video + controls)
top_bar = QHBoxLayout()
top_bar.addWidget(self._lbl_file, stretch=1)
controls = QHBoxLayout()
controls.addWidget(self._btn_play)
controls.addWidget(self._btn_pause)
controls.addStretch()
controls.addWidget(self._lbl_cursor)
controls.addWidget(self._lbl_duration)
export_row = QHBoxLayout()
export_row.addWidget(QLabel("Name:"))
export_row.addWidget(self._txt_name)
export_row.addWidget(QLabel("Folder:"))
export_row.addWidget(self._txt_folder, stretch=1)
export_row.addWidget(self._btn_folder)
export_row.addWidget(self._lbl_next)
export_row.addWidget(self._btn_export)
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.addLayout(top_bar)
right_layout.addWidget(self._mpv, stretch=1)
right_layout.addWidget(self._timeline)
right_layout.addLayout(controls)
right_layout.addLayout(export_row)
# Left: playlist label + list
queue_label = QLabel("Queue")
queue_label.setStyleSheet("color: #aaa; padding: 4px;")
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(4, 4, 4, 4)
left_layout.addWidget(queue_label)
left_layout.addWidget(self._playlist)
# Root: horizontal split
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(left)
splitter.addWidget(right)
splitter.setSizes([200, 900])
splitter.setCollapsible(0, False)
splitter.setCollapsible(1, False)
self.setCentralWidget(splitter)
self.setStatusBar(QStatusBar())
```
**Step 2: Update `_load_file` — remove isfile guard (PlaylistWidget already filters)**
```python
def _load_file(self, path: str):
self._file_path = path
self._lbl_file.setText(os.path.basename(path))
self._mpv.load(path)
# _after_load triggered by MpvWidget.file_loaded signal
```
**Step 3: Update `_after_load` — add DB similarity check**
```python
def _after_load(self):
dur = self._mpv.get_duration()
self._timeline.set_duration(dur)
self._cursor = 0.0
self._lbl_duration.setText(f"dur: {format_time(dur)}")
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
self._btn_play.setEnabled(True)
self._btn_pause.setEnabled(True)
self._btn_export.setEnabled(True)
match = self._db.find_similar(os.path.basename(self._file_path))
if match:
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
```
**Step 4: Update `_on_export_done` — record to DB and advance queue**
```python
def _on_export_done(self, path: str):
self._db.add(os.path.basename(self._file_path))
self._export_counter += 1
self._update_next_label()
self._btn_export.setEnabled(True)
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
self._playlist.advance()
```
**Step 5: Remove `dragEnterEvent` and `dropEvent` from MainWindow**
These methods are no longer needed on `MainWindow` — drops go directly to `PlaylistWidget`. Delete both methods.
**Step 6: Verify headless import**
```bash
python -c "from main import MainWindow"
```
Expected: no output.
**Step 7: Run all tests**
```bash
pytest tests/ -v
```
Expected: all 18 tests pass.
**Step 8: Manual smoke test**
```bash
python main.py
```
- Drop one or more video files onto the queue panel → they appear in the list
- First file loads automatically into the player
- Scrub, play, pause — all work as before
- Export → file saved, counter increments, next file in queue loads automatically
- Drop the same file again → `⚠ Similar to already processed:` appears in status bar
**Step 9: Commit**
```bash
git add main.py
git commit -m "feat: wire playlist and DB into MainWindow"
```
@@ -0,0 +1,430 @@
# Timeline Markers Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Show numbered markers on the timeline at positions where clips were previously extracted from the current source file, with a hover tooltip showing the output path.
**Architecture:** DB schema is migrated to store `start_time` and `output_path` per export row (dropping the old UNIQUE constraint). `TimelineWidget` gains `set_markers()`, draws red numbered lines in `paintEvent`, and shows `QToolTip` on hover. `MainWindow` feeds markers to the timeline on load and after each export.
**Tech Stack:** Python built-ins (`sqlite3`, `re`, `difflib`), PyQt6 (`QToolTip`, `QCursor`, `QFont`). No new dependencies.
---
### Task 1: DB schema migration and new methods (TDD)
**Files:**
- Modify: `main.py` — update `ProcessedDB.__init__`, `add`, add `get_markers`
- Modify: `tests/test_utils.py` — update existing DB tests, add marker tests
**Step 1: Write failing tests**
Replace the four existing `test_db_*` tests and add new ones in `tests/test_utils.py`:
```python
# --- ProcessedDB (updated) ---
def test_db_add_and_find_exact():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4", 12.5, "/out/clip_001.mp4")
assert db.find_similar("video.mp4") == "video.mp4"
finally:
os.unlink(path)
def test_db_find_similar_resolution_variant():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("episode_s01e01_2160p.mkv", 0.0, "/out/ep_001.mp4")
assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv"
finally:
os.unlink(path)
def test_db_find_similar_no_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("alpha.mp4", 0.0, "/out/alpha_001.mp4")
assert db.find_similar("completely_different_zzzz.mp4") is None
finally:
os.unlink(path)
def test_db_disabled_survives_bad_path():
db = ProcessedDB("/no/such/directory/8cut.db")
db.add("x.mp4", 0.0, "/out/x_001.mp4") # must not raise
assert db.find_similar("x.mp4") is None
def test_db_get_markers_returns_sorted():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4", 30.0, "/out/clip_002.mp4")
db.add("video.mp4", 10.0, "/out/clip_001.mp4")
db.add("video.mp4", 50.0, "/out/clip_003.mp4")
markers = db.get_markers("video.mp4")
assert len(markers) == 3
assert markers[0] == (10.0, 1, "/out/clip_001.mp4")
assert markers[1] == (30.0, 2, "/out/clip_002.mp4")
assert markers[2] == (50.0, 3, "/out/clip_003.mp4")
finally:
os.unlink(path)
def test_db_get_markers_fuzzy_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("show_2160p.mkv", 5.0, "/out/s_001.mp4")
markers = db.get_markers("show_1080p.mkv")
assert len(markers) == 1
assert markers[0][0] == 5.0
assert markers[0][2] == "/out/s_001.mp4"
finally:
os.unlink(path)
def test_db_get_markers_no_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
markers = db.get_markers("nothing.mp4")
assert markers == []
finally:
os.unlink(path)
def test_db_get_markers_disabled():
db = ProcessedDB("/no/such/directory/8cut.db")
assert db.get_markers("x.mp4") == []
```
**Step 2: Run to verify they fail**
```bash
pytest tests/test_utils.py -k "db" -v
```
Expected: failures — `add` has wrong signature, `get_markers` doesn't exist.
**Step 3: Update `ProcessedDB` in main.py**
Replace the entire `ProcessedDB` class:
```python
class ProcessedDB:
_SCHEMA_VERSION = 2 # bump when schema changes
def __init__(self, db_path: str | None = None):
if db_path is None:
db_path = str(Path.home() / ".8cut.db")
try:
self._con = sqlite3.connect(db_path)
self._migrate()
self._enabled = True
except Exception as e:
print(f"8-cut: DB unavailable: {e}", file=sys.stderr)
self._con = None
self._enabled = False
def _migrate(self) -> None:
"""Create or recreate table if schema is outdated."""
cols = {
row[1]
for row in self._con.execute("PRAGMA table_info(processed)").fetchall()
}
needs_recreate = "start_time" not in cols or "output_path" not in cols
if needs_recreate:
self._con.execute("DROP TABLE IF EXISTS processed")
self._con.execute(
"CREATE TABLE IF NOT EXISTS processed ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" filename TEXT NOT NULL,"
" start_time REAL NOT NULL,"
" output_path TEXT NOT NULL,"
" processed_at TEXT NOT NULL"
")"
)
self._con.execute(
"CREATE INDEX IF NOT EXISTS idx_filename ON processed(filename)"
)
self._con.commit()
def add(self, filename: str, start_time: float, output_path: str) -> None:
if not self._enabled:
return
self._con.execute(
"INSERT INTO processed (filename, start_time, output_path, processed_at)"
" VALUES (?, ?, ?, ?)",
(filename, start_time, output_path, datetime.now(timezone.utc).isoformat()),
)
self._con.commit()
def find_similar(self, filename: str) -> str | None:
if not self._enabled:
return None
rows = self._con.execute(
"SELECT DISTINCT filename FROM processed"
).fetchall()
norm_new = _normalize_filename(filename)
best_ratio, best_match = 0.0, None
for (stored,) in rows:
ratio = SequenceMatcher(
None, norm_new, _normalize_filename(stored)
).ratio()
if ratio >= 0.75 and ratio > best_ratio:
best_ratio, best_match = ratio, stored
return best_match
def get_markers(self, filename: str) -> list[tuple[float, int, str]]:
"""Return [(start_time, marker_number, output_path), ...] for the best
fuzzy match of filename, sorted by start_time. Empty list if no match."""
if not self._enabled:
return []
match = self.find_similar(filename)
if match is None:
return []
rows = self._con.execute(
"SELECT start_time, output_path FROM processed"
" WHERE filename = ? ORDER BY start_time",
(match,),
).fetchall()
return [(t, i + 1, p) for i, (t, p) in enumerate(rows)]
```
**Step 4: Run DB tests**
```bash
pytest tests/test_utils.py -k "db" -v
```
Expected: all 8 DB tests PASS.
**Step 5: Run full suite**
```bash
pytest tests/ -v
```
Expected: all tests pass (normalize tests + db tests = 14 DB/normalize tests + 8 original = 22 total — count may vary).
**Step 6: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: DB schema v2 — store start_time and output_path, add get_markers"
```
---
### Task 2: TimelineWidget markers (paint + hover)
**Files:**
- Modify: `main.py` — update `TimelineWidget`
**Step 1: Add missing imports to main.py**
`QToolTip` and `QCursor` are needed. Add them to the existing imports:
```python
from PyQt6.QtWidgets import (
...existing..., QToolTip,
)
from PyQt6.QtGui import (
...existing..., QCursor, QFont,
)
```
**Step 2: Add `set_markers` and update `paintEvent` and `mouseMoveEvent`**
In `TimelineWidget`, add the `_markers` attribute in `__init__`:
```python
self._markers: list[tuple[float, int, str]] = []
```
Add the `set_markers` method:
```python
def set_markers(self, markers: list[tuple[float, int, str]]) -> None:
"""markers: list of (start_time, number, output_path)"""
self._markers = markers
self.update()
```
Replace `paintEvent` with:
```python
def paintEvent(self, event):
p = QPainter(self)
try:
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, QColor(30, 30, 30))
if self._duration <= 0:
return
# 8s selection highlight
x_start = int(self._cursor / self._duration * w)
x_end = int(min(self._cursor + 8.0, self._duration) / self._duration * w)
p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120))
# Cursor line
pen = QPen(QColor(255, 200, 0))
pen.setWidth(2)
p.setPen(pen)
p.drawLine(x_start, 0, x_start, h)
# Markers
font = QFont()
font.setPixelSize(9)
p.setFont(font)
marker_pen = QPen(QColor(220, 60, 60))
marker_pen.setWidth(2)
for (t, num, _path) in self._markers:
if self._duration <= 0:
break
mx = int(t / self._duration * w)
p.setPen(marker_pen)
p.drawLine(mx, 0, mx, h)
p.setPen(QColor(255, 255, 255))
p.drawText(mx + 2, 10, str(num))
finally:
p.end()
```
Replace `mouseMoveEvent` with:
```python
def mouseMoveEvent(self, event):
x = event.position().x()
# Check marker hover (±4px)
if self._duration > 0 and self._markers:
w = self.width()
for (t, _num, output_path) in self._markers:
mx = t / self._duration * w
if abs(x - mx) <= 4:
QToolTip.showText(QCursor.pos(), output_path, self)
if event.buttons():
self._seek(x)
return
QToolTip.hideText()
if event.buttons():
self._seek(x)
```
**Step 3: Verify headless import**
```bash
python -c "from main import TimelineWidget"
```
Expected: no output.
**Step 4: Run all tests**
```bash
pytest tests/ -v
```
Expected: all tests pass.
**Step 5: Commit**
```bash
git add main.py
git commit -m "feat: timeline markers with hover tooltip"
```
---
### Task 3: Wire MainWindow
**Files:**
- Modify: `main.py` — update `_after_load`, `_on_export_done`, add `_refresh_markers`
**Step 1: Add `_refresh_markers` helper to MainWindow**
Add this method after `_after_load`:
```python
def _refresh_markers(self) -> None:
markers = self._db.get_markers(os.path.basename(self._file_path))
self._timeline.set_markers(markers)
```
**Step 2: Update `_after_load`**
Add `self._refresh_markers()` at the end of `_after_load`:
```python
def _after_load(self):
dur = self._mpv.get_duration()
self._timeline.set_duration(dur)
self._cursor = 0.0
self._lbl_duration.setText(f"dur: {format_time(dur)}")
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
self._btn_play.setEnabled(True)
self._btn_pause.setEnabled(True)
self._btn_export.setEnabled(True)
match = self._db.find_similar(os.path.basename(self._file_path))
if match:
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
else:
self.statusBar().clearMessage()
self._refresh_markers()
```
**Step 3: Update `_on_export_done`**
Pass `start_time` and `output_path` to `db.add`, then refresh markers:
```python
def _on_export_done(self, path: str):
self._db.add(os.path.basename(self._file_path), self._cursor, path)
self._export_counter += 1
self._update_next_label()
self._btn_export.setEnabled(True)
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
self._refresh_markers()
self._playlist.advance()
```
**Step 4: Verify headless import**
```bash
python -c "from main import MainWindow"
```
Expected: no output.
**Step 5: Run all tests**
```bash
pytest tests/ -v
```
Expected: all tests pass.
**Step 6: Manual smoke test**
```bash
python main.py
```
- Drop a video, set cursor, export → a red numbered marker `1` appears on the timeline at that position
- Export again at a different position → marker `2` appears
- Hover over a marker → tooltip shows the output file path
- Drop a resolution variant of the same video → markers from the original appear immediately
**Step 7: Commit**
```bash
git add main.py
git commit -m "feat: wire timeline markers into MainWindow"
```
+301 -134
View File
@@ -17,9 +17,8 @@ from PyQt6.QtWidgets import (
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip, QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
QComboBox, QDialog, QPlainTextEdit, QCheckBox, QComboBox, QDialog, QPlainTextEdit, QCheckBox,
) )
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QSettings 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 import mpv
@@ -51,6 +50,7 @@ def build_ffmpeg_command(
# (libx264/aac), so there is no keyframe-alignment issue from pre-input seek. # (libx264/aac), so there is no keyframe-alignment issue from pre-input seek.
cmd = [ cmd = [
"ffmpeg", "-y", "ffmpeg", "-y",
"-threads", "0",
"-ss", str(start), "-ss", str(start),
"-i", input_path, "-i", input_path,
"-t", "8", "-t", "8",
@@ -73,8 +73,8 @@ def build_ffmpeg_command(
cmd += [ cmd += [
"-an", "-an",
"-c:v", "libwebp", "-c:v", "libwebp",
"-lossless", "1", "-quality", "85",
"-compression_level", "4", "-compression_level", "1",
os.path.join(output_path, "frame_%04d.webp"), os.path.join(output_path, "frame_%04d.webp"),
] ]
else: else:
@@ -220,6 +220,12 @@ class ProcessedDB:
) )
self._con.commit() 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: def find_similar(self, filename: str) -> str | None:
if not self._enabled: if not self._enabled:
return None return None
@@ -321,11 +327,15 @@ class ExportWorker(QThread):
class TimelineWidget(QWidget): class TimelineWidget(QWidget):
cursor_changed = pyqtSignal(float) # emits position in seconds 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): def __init__(self):
super().__init__() super().__init__()
self.setMinimumHeight(40) self.setMinimumHeight(80)
self.setMouseTracking(True) self.setMouseTracking(True)
self._duration = 0.0 self._duration = 0.0
self._cursor = 0.0 self._cursor = 0.0
@@ -333,12 +343,16 @@ class TimelineWidget(QWidget):
self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path) self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path)
# Cached paint resources — created once, reused every frame # 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._cursor_pen.setWidth(2)
self._marker_pen = QPen(QColor(220, 60, 60)) self._marker_pen = QPen(QColor(220, 60, 60))
self._marker_pen.setWidth(2) 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 = QFont()
self._marker_font.setPixelSize(9) self._marker_font.setPixelSize(9)
self._ruler_font = QFont()
self._ruler_font.setPixelSize(9)
# Debounce timer: update visual cursor immediately but only emit # Debounce timer: update visual cursor immediately but only emit
# cursor_changed (which triggers mpv.seek) at most once per interval. # cursor_changed (which triggers mpv.seek) at most once per interval.
@@ -383,33 +397,102 @@ class TimelineWidget(QWidget):
return ratio * self._duration return ratio * self._duration
def paintEvent(self, event): def paintEvent(self, event):
from PyQt6.QtGui import QPolygon
from PyQt6.QtCore import QPoint
p = QPainter(self) p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing, False)
try: try:
w, h = self.width(), self.height() 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: if self._duration <= 0:
p.setPen(QColor(80, 80, 80))
p.drawText(0, 0, w, h, Qt.AlignmentFlag.AlignCenter, "No file loaded")
return 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_start = int(self._cursor / self._duration * w)
x_end = int(min(self._cursor + 8.0, self._duration) / 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 # ── export markers ────────────────────────────────────────────
p.setPen(self._cursor_pen)
p.drawLine(x_start, 0, x_start, h)
# Markers
p.setFont(self._marker_font) p.setFont(self._marker_font)
for (t, num, _path) in self._markers: for (t, num, _path) in self._markers:
if self._duration <= 0:
break
mx = int(t / self._duration * w) mx = int(t / self._duration * w)
p.setPen(self._marker_pen) 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.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: finally:
p.end() p.end()
@@ -436,6 +519,25 @@ class TimelineWidget(QWidget):
self._seek_timer.stop() self._seek_timer.stop()
self.cursor_changed.emit(self._cursor) 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): def _seek(self, x: float):
t = self._pos_to_time(int(x)) t = self._pos_to_time(int(x))
self.set_cursor(t) # update visuals immediately self.set_cursor(t) # update visuals immediately
@@ -445,79 +547,119 @@ class TimelineWidget(QWidget):
import ctypes import ctypes
class MpvWidget(QOpenGLWidget): class MpvWidget(QWidget):
file_loaded = pyqtSignal() # emitted (on Qt thread) when a file is ready """Embeds mpv using an off-screen OpenGL FBO with QPainter readback.
crop_clicked = pyqtSignal(float) # x fraction 01 when user clicks video
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): def __init__(self):
super().__init__() super().__init__()
self.setMinimumSize(640, 360) self.setMinimumSize(640, 360)
_log_file = open("/tmp/8cut-mpv.log", "w", buffering=1) self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
self._log_file = _log_file self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self._frame: "QImage | None" = None
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._render_ctx = 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") from PyQt6.QtGui import QOffscreenSurface, QOpenGLContext, QSurfaceFormat
def _on_file_loaded(event): from PyQt6.QtOpenGL import QOpenGLFramebufferObject
QTimer.singleShot(0, self.file_loaded.emit)
def _8cut_log(self, msg): fmt = QSurfaceFormat.defaultFormat()
self._log_file.write(f"[8-cut] {msg}\n") self._gl_surface = QOffscreenSurface()
self._gl_surface.setFormat(fmt)
self._gl_surface.create()
def initializeGL(self): self._gl_ctx = QOpenGLContext()
from PyQt6.QtGui import QOpenGLContext self._gl_ctx.setFormat(fmt)
self._8cut_log(f"initializeGL called, platform={QApplication.platformName()}") 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 = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p)
@_PROC_ADDR_T @_PROC_ADDR_T
def _get_proc_addr(_, name): def _get_proc_addr(_, name):
ctx = QOpenGLContext.currentContext() addr = self._gl_ctx.getProcAddress(name)
if ctx is None:
self._8cut_log(f"get_proc_addr: no current context for {name}")
return 0
addr = ctx.getProcAddress(name)
return int(addr) if addr else 0 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: try:
self._render_ctx = mpv.MpvRenderContext( self._render_ctx = mpv.MpvRenderContext(
self._player, "opengl", self._player, "opengl",
opengl_init_params={"get_proc_address": self._get_proc_addr_fn}, opengl_init_params={"get_proc_address": self._get_proc_addr_fn},
) )
self._8cut_log("MpvRenderContext created OK") self._render_ctx.update_cb = self._on_mpv_update
except Exception as e: except Exception as e:
self._8cut_log(f"MpvRenderContext FAILED: {e}") print(f"[8-cut] MpvRenderContext failed: {e}", file=sys.stderr)
return
self._render_ctx.update_cb = self._on_mpv_update 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): def _on_mpv_update(self):
# Called from mpv thread; schedule a repaint on the Qt thread. # Called from mpv's C thread — only set a flag, no Qt calls here.
self.update() self._needs_render = True
def paintGL(self): def _poll_render(self):
if self._render_ctx: if self._needs_render and self._render_ctx and self._render_ctx.update():
fbo = self.defaultFramebufferObject() self._needs_render = False
r = self.devicePixelRatio() 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( self._render_ctx.render(
flip_y=True, 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()},
) )
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 resizeGL(self, w, h): def paintEvent(self, event):
if self._render_ctx: p = QPainter(self)
self.update() 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 load(self, path: str): def mousePressEvent(self, event):
self._player.play(path) 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): def seek(self, t: float):
self._player.pause = True self._player.pause = True
@@ -525,14 +667,12 @@ class MpvWidget(QOpenGLWidget):
def play_loop(self, a: float, b: float): def play_loop(self, a: float, b: float):
self._player["ab-loop-a"] = a 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["ab-loop-b"] = min(b, self._player.duration or b)
self._player.pause = False self._player.pause = False
def stop_loop(self): def stop_loop(self):
# ab-loop-a/b are numeric properties — setting to "no" via dict self._player["ab-loop-a"] = "no"
# accessor throws TypeError. Disable loop via ab_loop_count instead. self._player["ab-loop-b"] = "no"
self._player.ab_loop_count = 0
self._player.pause = True self._player.pause = True
def get_duration(self) -> float: def get_duration(self) -> float:
@@ -548,15 +688,13 @@ class MpvWidget(QOpenGLWidget):
def is_playing(self) -> bool: def is_playing(self) -> bool:
return not self._player.pause 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): def closeEvent(self, event):
self._render_timer.stop()
if self._render_ctx: if self._render_ctx:
self._render_ctx.free() self._render_ctx.free()
self._render_ctx = None
self._player.terminate() self._player.terminate()
self._fbo = None
super().closeEvent(event) super().closeEvent(event)
@@ -834,6 +972,15 @@ class SettingsDialog(QDialog):
def main(): 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) app = QApplication(sys.argv)
locale.setlocale(locale.LC_NUMERIC, "C") # QApplication resets locale; re-apply for libmpv locale.setlocale(locale.LC_NUMERIC, "C") # QApplication resets locale; re-apply for libmpv
app.setStyle("Fusion") app.setStyle("Fusion")
@@ -880,7 +1027,9 @@ class MainWindow(QMainWindow):
self._mpv = MpvWidget() self._mpv = MpvWidget()
self._mpv.file_loaded.connect(self._after_load) self._mpv.file_loaded.connect(self._after_load)
self._timeline = TimelineWidget() self._timeline = TimelineWidget()
self._timeline.setFixedHeight(160)
self._timeline.cursor_changed.connect(self._on_cursor_changed) 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 = QLabel("Drop files onto the queue →")
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter) self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -897,17 +1046,20 @@ class MainWindow(QMainWindow):
self._lbl_cursor = QLabel("cursor: --") self._lbl_cursor = QLabel("cursor: --")
self._lbl_duration = QLabel("dur: --") self._lbl_duration = QLabel("dur: --")
self._settings = QSettings("8cut", "8cut")
self._txt_name = QLineEdit("clip") self._txt_name = QLineEdit("clip")
self._txt_name.setPlaceholderText("base name") self._txt_name.setPlaceholderText("base name")
self._txt_name.setMaximumWidth(150) self._txt_name.setMaximumWidth(150)
self._txt_name.textChanged.connect(self._reset_counter) 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(self._reset_counter)
self._txt_folder.textChanged.connect(
lambda v: self._settings.setValue("export_folder", v)
)
self._btn_folder = QPushButton("Browse") self._btn_folder = QPushButton("Browse")
self._btn_folder.clicked.connect(self._pick_folder) self._btn_folder.clicked.connect(self._pick_folder)
self._settings = QSettings("8cut", "8cut")
self._txt_resize = QLineEdit() self._txt_resize = QLineEdit()
self._txt_resize.setPlaceholderText("px (opt.)") self._txt_resize.setPlaceholderText("px (opt.)")
self._txt_resize.setMaximumWidth(70) self._txt_resize.setMaximumWidth(70)
@@ -990,35 +1142,44 @@ class MainWindow(QMainWindow):
top_bar.addWidget(self._lbl_file, stretch=1) top_bar.addWidget(self._lbl_file, stretch=1)
top_bar.addWidget(self._btn_settings) top_bar.addWidget(self._btn_settings)
controls = QHBoxLayout() # Row 1 — transport + annotation + export trigger
controls.addWidget(self._btn_play) transport_row = QHBoxLayout()
controls.addWidget(self._btn_pause) transport_row.addWidget(self._btn_play)
controls.addStretch() transport_row.addWidget(self._btn_pause)
controls.addWidget(self._lbl_cursor) transport_row.addWidget(self._lbl_cursor)
controls.addWidget(self._lbl_duration) 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() # Row 2 — output path + encoding settings (bottom)
export_row.addWidget(QLabel("Name:")) settings_row = QHBoxLayout()
export_row.addWidget(self._txt_name) settings_row.addWidget(QLabel("Name:"))
export_row.addWidget(QLabel("Folder:")) settings_row.addWidget(self._txt_name)
export_row.addWidget(self._txt_folder, stretch=1) settings_row.addWidget(QLabel("Folder:"))
export_row.addWidget(self._btn_folder) settings_row.addWidget(self._txt_folder, stretch=1)
export_row.addWidget(QLabel("Short side:")) settings_row.addWidget(self._btn_folder)
export_row.addWidget(self._txt_resize) settings_row.addWidget(QLabel("Short side:"))
export_row.addWidget(QLabel("Portrait:")) settings_row.addWidget(self._txt_resize)
export_row.addWidget(self._cmb_portrait) settings_row.addWidget(QLabel("Portrait:"))
export_row.addWidget(QLabel("Format:")) settings_row.addWidget(self._cmb_portrait)
export_row.addWidget(self._cmb_format) settings_row.addWidget(QLabel("Format:"))
export_row.addWidget(self._lbl_next) settings_row.addWidget(self._cmb_format)
export_row.addWidget(self._btn_export)
right = QWidget() right = QWidget()
right_layout = QVBoxLayout(right) right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(4)
right_layout.addLayout(top_bar) right_layout.addLayout(top_bar)
right_layout.addWidget(self._mpv, stretch=1) right_layout.addWidget(self._mpv, stretch=1)
right_layout.addWidget(self._timeline) right_layout.addWidget(self._timeline)
right_layout.addWidget(self._crop_bar) right_layout.addWidget(self._crop_bar)
right_layout.addLayout(transport_row)
right_layout.addLayout(settings_row)
self._mask_row_widget = QWidget() self._mask_row_widget = QWidget()
mask_row = QHBoxLayout(self._mask_row_widget) mask_row = QHBoxLayout(self._mask_row_widget)
@@ -1030,16 +1191,6 @@ class MainWindow(QMainWindow):
show_masks = self._settings.value("show_masks_row", "true") == "true" show_masks = self._settings.value("show_masks_row", "true") == "true"
self._mask_row_widget.setVisible(show_masks) 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) right_layout.addWidget(self._mask_row_widget)
# Left: queue label + playlist # Left: queue label + playlist
@@ -1063,6 +1214,32 @@ class MainWindow(QMainWindow):
self.setStatusBar(QStatusBar()) self.setStatusBar(QStatusBar())
self._crop_bar.setVisible(saved_ratio != "Off") 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): def _load_file(self, path: str):
self._file_path = path self._file_path = path
self._lbl_file.setText(os.path.basename(path)) self._lbl_file.setText(os.path.basename(path))
@@ -1110,6 +1287,13 @@ class MainWindow(QMainWindow):
markers = [] markers = []
self._timeline.set_markers(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: def _on_portrait_ratio_changed(self, text: str) -> None:
ratio = None if text == "Off" else text ratio = None if text == "Off" else text
self._crop_bar.set_portrait_ratio(ratio) self._crop_bar.set_portrait_ratio(ratio)
@@ -1131,6 +1315,14 @@ class MainWindow(QMainWindow):
self._lbl_cursor.setText(f"cursor: {format_time(t)}") self._lbl_cursor.setText(f"cursor: {format_time(t)}")
self._mpv.seek(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): def _on_play(self):
if not self._file_path: if not self._file_path:
return return
@@ -1162,35 +1354,6 @@ class MainWindow(QMainWindow):
return return
self._step_cursor(markers[0][0] - self._cursor) # wrap to first 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 --- # --- Export ---
def _pick_folder(self): def _pick_folder(self):
@@ -1240,6 +1403,10 @@ class MainWindow(QMainWindow):
self._btn_export.setEnabled(False) self._btn_export.setEnabled(False)
self.statusBar().showMessage(f"Exporting {os.path.basename(output)}") 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() ratio_text = self._cmb_portrait.currentText()
portrait_ratio = None if ratio_text == "Off" else ratio_text portrait_ratio = None if ratio_text == "Off" else ratio_text