feat: parallel export, playback position, double-click markers, reset clips on video switch

- Parallelize batch ffmpeg exports with ThreadPoolExecutor
- Show playback progress as color fill in timeline selection region
- Single click moves playhead, double-click selects/deselects markers
- Reset clip count and spread to defaults when switching videos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 11:42:40 +02:00
parent c287788d9e
commit 89e0478777
+91 -49
View File
@@ -10,6 +10,7 @@ import random
import shutil import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone from datetime import datetime, timezone
from difflib import SequenceMatcher from difflib import SequenceMatcher
from pathlib import Path from pathlib import Path
@@ -404,35 +405,47 @@ class ExportWorker(QThread):
self._short_side = short_side self._short_side = short_side
self._image_sequence = image_sequence self._image_sequence = image_sequence
def _run_one(self, start: float, output: str,
portrait_ratio: str | None, crop_center: float) -> str:
"""Encode a single clip. Returns output path on success, raises on error."""
if self._image_sequence:
os.makedirs(output, exist_ok=True)
cmd = build_ffmpeg_command(
self._input, start, output,
short_side=self._short_side,
portrait_ratio=portrait_ratio,
crop_center=crop_center,
image_sequence=self._image_sequence,
)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise RuntimeError(result.stderr[-500:] if result.stderr else "ffmpeg failed")
if self._image_sequence:
audio_cmd = build_audio_extract_command(self._input, start, output)
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
return output
def run(self): def run(self):
for start, output, portrait_ratio, crop_center in self._jobs: workers = min(len(self._jobs), os.cpu_count() or 2)
try: try:
if self._image_sequence: with ThreadPoolExecutor(max_workers=workers) as pool:
os.makedirs(output, exist_ok=True) futures = {
cmd = build_ffmpeg_command( pool.submit(self._run_one, s, o, pr, cc): o
self._input, start, output, for s, o, pr, cc in self._jobs
short_side=self._short_side, }
portrait_ratio=portrait_ratio, for fut in as_completed(futures):
crop_center=crop_center, try:
image_sequence=self._image_sequence, path = fut.result()
) self.finished.emit(path)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) except FileNotFoundError:
if result.returncode == 0: self.error.emit("ffmpeg not found — is it installed and on PATH?")
if self._image_sequence: return
audio_cmd = build_audio_extract_command( except Exception as e:
self._input, start, output self.error.emit(str(e))
) return
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60) except Exception as e:
self.finished.emit(output) self.error.emit(str(e))
else: return
self.error.emit(result.stderr[-500:])
return
except FileNotFoundError:
self.error.emit("ffmpeg not found — is it installed and on PATH?")
return
except Exception as e:
self.error.emit(str(e))
return
self.all_done.emit() self.all_done.emit()
@@ -465,6 +478,7 @@ 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 marker_delete_requested = pyqtSignal(str) # emits output_path
marker_clicked = pyqtSignal(float, str) # emits (start_time, output_path) marker_clicked = pyqtSignal(float, str) # emits (start_time, output_path)
marker_deselected = pyqtSignal() # double-click on empty space
_RULER_H = 22 # pixels reserved for the time ruler _RULER_H = 22 # pixels reserved for the time ruler
_HANDLE_H = 8 # height of the playhead triangle _HANDLE_H = 8 # height of the playhead triangle
@@ -476,6 +490,7 @@ class TimelineWidget(QWidget):
self._duration = 0.0 self._duration = 0.0
self._cursor = 0.0 self._cursor = 0.0
self._clip_span = 14.0 # 8 + 2*spread, updated from MainWindow self._clip_span = 14.0 # 8 + 2*spread, updated from MainWindow
self._play_pos: float | None = None # current playback position (seconds)
self._markers: list[tuple[float, int, str]] = [] self._markers: list[tuple[float, int, str]] = []
self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path) self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path)
@@ -501,6 +516,7 @@ class TimelineWidget(QWidget):
def set_duration(self, duration: float): def set_duration(self, duration: float):
self._duration = duration self._duration = duration
self._cursor = 0.0 self._cursor = 0.0
self._play_pos = None
self._rebuild_hover_cache() self._rebuild_hover_cache()
self.update() self.update()
@@ -521,6 +537,10 @@ class TimelineWidget(QWidget):
self._rebuild_hover_cache() self._rebuild_hover_cache()
self.update() self.update()
def set_play_position(self, t: float | None) -> None:
self._play_pos = t
self.update()
def _rebuild_hover_cache(self) -> None: def _rebuild_hover_cache(self) -> None:
"""Pre-compute (pixel_x_fraction, output_path) for hover detection.""" """Pre-compute (pixel_x_fraction, output_path) for hover detection."""
if self._duration > 0: if self._duration > 0:
@@ -603,6 +623,15 @@ class TimelineWidget(QWidget):
x_end = int(min(self._cursor + self._clip_span, self._duration) / self._duration * w) x_end = int(min(self._cursor + self._clip_span, self._duration) / self._duration * w)
sel_w = max(x_end - x_start, 1) sel_w = max(x_end - x_start, 1)
p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90)) p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90))
# ── playback progress fill ────────────────────────────────────
if self._play_pos is not None and self._play_pos > self._cursor:
prog_end = min(self._play_pos, self._cursor + self._clip_span, self._duration)
x_prog = int(prog_end / self._duration * w)
prog_w = max(x_prog - x_start, 0)
if prog_w > 0:
p.fillRect(x_start, rh, prog_w, th, QColor(100, 200, 255, 60))
# left/right edges of selection # left/right edges of selection
p.setPen(QPen(QColor(60, 130, 220, 180), 1)) p.setPen(QPen(QColor(60, 130, 220, 180), 1))
p.drawLine(x_start, rh, x_start, h) p.drawLine(x_start, rh, x_start, h)
@@ -638,20 +667,23 @@ class TimelineWidget(QWidget):
p.end() p.end()
def mousePressEvent(self, event): def mousePressEvent(self, event):
from PyQt6.QtCore import Qt as _Qt
if event.button() == _Qt.MouseButton.LeftButton and self._hover_cache:
x = event.position().x()
w = self.width()
for (frac, output_path) in self._hover_cache:
if abs(x - frac * w) <= 6:
t = frac * self._duration
# Emit marker_clicked BEFORE seek so the handler can set
# _overwrite_path before _on_cursor_changed clears it.
self.marker_clicked.emit(t, output_path)
self._seek(x)
return
self._seek(event.position().x()) self._seek(event.position().x())
def mouseDoubleClickEvent(self, event):
from PyQt6.QtCore import Qt as _Qt
if event.button() == _Qt.MouseButton.LeftButton:
x = event.position().x()
if self._hover_cache:
w = self.width()
for (frac, output_path) in self._hover_cache:
if abs(x - frac * w) <= 6:
t = frac * self._duration
self.marker_clicked.emit(t, output_path)
self._seek(x)
return
self.marker_deselected.emit()
self._seek(x)
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
x = event.position().x() x = event.position().x()
# Check marker hover (±4px) using pre-computed fractions. # Check marker hover (±4px) using pre-computed fractions.
@@ -710,6 +742,7 @@ class MpvWidget(QWidget):
""" """
file_loaded = pyqtSignal() file_loaded = pyqtSignal()
crop_clicked = pyqtSignal(float) crop_clicked = pyqtSignal(float)
time_pos_changed = pyqtSignal(float) # emits current playback position in seconds
_do_file_loaded = pyqtSignal() # mpv thread → Qt main thread for file-loaded event _do_file_loaded = pyqtSignal() # mpv thread → Qt main thread for file-loaded event
def __init__(self): def __init__(self):
@@ -797,6 +830,10 @@ class MpvWidget(QWidget):
if self._needs_render and self._render_ctx and self._render_ctx.update(): if self._needs_render and self._render_ctx and self._render_ctx.update():
self._needs_render = False self._needs_render = False
self._render_frame() self._render_frame()
if not self._player.pause:
tp = self._player.time_pos
if tp is not None:
self.time_pos_changed.emit(tp)
def _render_frame(self): def _render_frame(self):
from PyQt6.QtOpenGL import QOpenGLFramebufferObject from PyQt6.QtOpenGL import QOpenGLFramebufferObject
@@ -1250,7 +1287,6 @@ class MainWindow(QMainWindow):
self._export_worker: ExportWorker | None = None self._export_worker: ExportWorker | None = None
self._last_export_path: str = "" self._last_export_path: str = ""
self._overwrite_path: str = "" # set when a marker is selected for re-export self._overwrite_path: str = "" # set when a marker is selected for re-export
self._marker_just_clicked: bool = False
self._mask_worker: MaskWorker | None = None self._mask_worker: MaskWorker | None = None
self._db_worker: _DBWorker | None = None self._db_worker: _DBWorker | None = None
self._frame_grabber: FrameGrabber | None = None self._frame_grabber: FrameGrabber | None = None
@@ -1287,7 +1323,9 @@ class MainWindow(QMainWindow):
self._timeline.set_clip_span(8.0 + (_init_clips - 1) * _init_spread) self._timeline.set_clip_span(8.0 + (_init_clips - 1) * _init_spread)
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._timeline.marker_delete_requested.connect(self._on_delete_marker)
self._mpv.time_pos_changed.connect(self._timeline.set_play_position)
self._timeline.marker_clicked.connect(self._on_marker_clicked) self._timeline.marker_clicked.connect(self._on_marker_clicked)
self._timeline.marker_deselected.connect(self._on_marker_deselected)
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)
@@ -1580,6 +1618,9 @@ class MainWindow(QMainWindow):
self._btn_export.setEnabled(True) self._btn_export.setEnabled(True)
self._fps = self._mpv.get_fps() self._fps = self._mpv.get_fps()
self._crop_bar.set_source_ratio(*self._mpv.get_video_size()) self._crop_bar.set_source_ratio(*self._mpv.get_video_size())
# Reset export settings to defaults for the new video
self._spn_clips.setValue(int(self._settings.value("clip_count", "3")))
self._spn_spread.setValue(float(self._settings.value("spread", "3.0")))
self._preview_win.show() self._preview_win.show()
self._preview_timer.start() self._preview_timer.start()
@@ -1620,7 +1661,6 @@ class MainWindow(QMainWindow):
) )
def _on_marker_clicked(self, start_time: float, output_path: str) -> None: def _on_marker_clicked(self, start_time: float, output_path: str) -> None:
self._marker_just_clicked = True
self._overwrite_path = output_path self._overwrite_path = output_path
self._lbl_next.setText(f"{os.path.basename(output_path)}") self._lbl_next.setText(f"{os.path.basename(output_path)}")
self._btn_delete.setEnabled(True) self._btn_delete.setEnabled(True)
@@ -1652,6 +1692,14 @@ class MainWindow(QMainWindow):
f"Overwrite mode: {os.path.basename(output_path)} — export to replace", 5000 f"Overwrite mode: {os.path.basename(output_path)} — export to replace", 5000
) )
def _on_marker_deselected(self) -> None:
if self._overwrite_path:
self._overwrite_path = ""
self._update_next_label()
if not self._last_export_path:
self._btn_delete.setEnabled(False)
self._btn_delete.setText("Delete")
def _on_delete_export(self) -> None: def _on_delete_export(self) -> None:
target = self._overwrite_path or self._last_export_path target = self._overwrite_path or self._last_export_path
if not target: if not target:
@@ -1756,13 +1804,6 @@ class MainWindow(QMainWindow):
self._cursor = t self._cursor = t
self._lbl_cursor.setText(f"cursor: {format_time(t)}") self._lbl_cursor.setText(f"cursor: {format_time(t)}")
self._preview_timer.start() self._preview_timer.start()
if self._overwrite_path and not self._marker_just_clicked:
self._overwrite_path = ""
self._update_next_label()
if not self._last_export_path:
self._btn_delete.setEnabled(False)
self._btn_delete.setText("Delete")
self._marker_just_clicked = False
if self._mpv.is_playing(): if self._mpv.is_playing():
self._mpv.play_loop(t, t + self._clip_span) self._mpv.play_loop(t, t + self._clip_span)
else: else:
@@ -1789,6 +1830,7 @@ class MainWindow(QMainWindow):
def _on_pause(self): def _on_pause(self):
self._mpv.stop_loop() self._mpv.stop_loop()
self._mpv.seek(self._cursor) self._mpv.seek(self._cursor)
self._timeline.set_play_position(None)
def _step_cursor(self, delta: float) -> None: def _step_cursor(self, delta: float) -> None:
if not self._file_path: if not self._file_path: