From 6573fa6e0588726233bc487cd76d777048da1442 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 6 Apr 2026 22:22:58 +0200 Subject: [PATCH] feat: mpv Wayland embedding, timeline redesign, UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/plans/2026-04-06-8cut-design.md | 46 ++ docs/plans/2026-04-06-8cut-implementation.md | 733 ++++++++++++++++++ .../2026-04-06-playlist-db-implementation.md | 494 ++++++++++++ ...6-04-06-timeline-markers-implementation.md | 430 ++++++++++ main.py | 435 +++++++---- 5 files changed, 2004 insertions(+), 134 deletions(-) create mode 100644 docs/plans/2026-04-06-8cut-design.md create mode 100644 docs/plans/2026-04-06-8cut-implementation.md create mode 100644 docs/plans/2026-04-06-playlist-db-implementation.md create mode 100644 docs/plans/2026-04-06-timeline-markers-implementation.md diff --git a/docs/plans/2026-04-06-8cut-design.md b/docs/plans/2026-04-06-8cut-design.md new file mode 100644 index 0000000..7264f8c --- /dev/null +++ b/docs/plans/2026-04-06-8cut-design.md @@ -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. diff --git a/docs/plans/2026-04-06-8cut-implementation.md b/docs/plans/2026-04-06-8cut-implementation.md new file mode 100644 index 0000000..13cedae --- /dev/null +++ b/docs/plans/2026-04-06-8cut-implementation.md @@ -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" +``` diff --git a/docs/plans/2026-04-06-playlist-db-implementation.md b/docs/plans/2026-04-06-playlist-db-implementation.md new file mode 100644 index 0000000..1b0f651 --- /dev/null +++ b/docs/plans/2026-04-06-playlist-db-implementation.md @@ -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" +``` diff --git a/docs/plans/2026-04-06-timeline-markers-implementation.md b/docs/plans/2026-04-06-timeline-markers-implementation.md new file mode 100644 index 0000000..e96a441 --- /dev/null +++ b/docs/plans/2026-04-06-timeline-markers-implementation.md @@ -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" +``` diff --git a/main.py b/main.py index 0dd56a1..35269e0 100755 --- a/main.py +++ b/main.py @@ -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 @@ -321,11 +327,15 @@ class ExportWorker(QThread): 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): 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)) + x_end = int(min(self._cursor + 8.0, self._duration) / self._duration * w) + 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") + self._render_ctx.update_cb = self._on_mpv_update except Exception as e: - self._8cut_log(f"MpvRenderContext FAILED: {e}") - return - self._render_ctx.update_cb = self._on_mpv_update + 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()}, ) + 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): - if self._render_ctx: - self.update() + 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 load(self, path: str): - self._player.play(path) + 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