feat: export 3 overlapping 8s clips per press with configurable spread

Each export generates clip_NNN_0, clip_NNN_1, clip_NNN_2 offset by the
spread value (2–8s, default 3s). Preview plays the full span covered by
all three clips. Marker click still overwrites a single clip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 23:22:41 +02:00
parent 47ea6199fa
commit 93cee40b06
2 changed files with 117 additions and 60 deletions
+109 -60
View File
@@ -17,20 +17,25 @@ from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar, QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip, QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
QComboBox, QDialog, QPlainTextEdit, QCheckBox, QComboBox, QDialog, QPlainTextEdit, QCheckBox, QDoubleSpinBox,
) )
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, QKeySequence, QShortcut from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut
import mpv import mpv
def build_export_path(folder: str, basename: str, counter: int) -> str: def build_export_path(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
filename = f"{basename}_{counter:03d}.mp4" name = f"{basename}_{counter:03d}"
return os.path.join(folder, filename) if sub is not None:
name += f"_{sub}"
return os.path.join(folder, name + ".mp4")
def build_sequence_dir(folder: str, basename: str, counter: int) -> str: def build_sequence_dir(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
return os.path.join(folder, f"{basename}_{counter:03d}") name = f"{basename}_{counter:03d}"
if sub is not None:
name += f"_{sub}"
return os.path.join(folder, name)
def format_time(seconds: float) -> str: def format_time(seconds: float) -> str:
@@ -335,50 +340,54 @@ class _DBWorker(QThread):
class ExportWorker(QThread): class ExportWorker(QThread):
finished = pyqtSignal(str) # output path finished = pyqtSignal(str) # emitted per completed clip
error = pyqtSignal(str) # error message error = pyqtSignal(str) # error message
all_done = pyqtSignal() # emitted after all jobs complete
def __init__(self, input_path: str, start: float, output_path: str, def __init__(self, input_path: str,
jobs: list[tuple[float, str]],
short_side: int | None = None, short_side: int | None = None,
portrait_ratio: str | None = None, portrait_ratio: str | None = None,
crop_center: float = 0.5, crop_center: float = 0.5,
image_sequence: bool = False): image_sequence: bool = False):
super().__init__() super().__init__()
self._input = input_path self._input = input_path
self._start = start self._jobs = jobs # [(start_time, output_path), ...]
self._output = output_path
self._short_side = short_side self._short_side = short_side
self._portrait_ratio = portrait_ratio self._portrait_ratio = portrait_ratio
self._crop_center = crop_center self._crop_center = crop_center
self._image_sequence = image_sequence self._image_sequence = image_sequence
def run(self): def run(self):
try: for start, output in self._jobs:
if self._image_sequence: try:
os.makedirs(self._output, exist_ok=True)
cmd = build_ffmpeg_command(
self._input, self._start, self._output,
short_side=self._short_side,
portrait_ratio=self._portrait_ratio,
crop_center=self._crop_center,
image_sequence=self._image_sequence,
)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0:
if self._image_sequence: if self._image_sequence:
audio_cmd = build_audio_extract_command( os.makedirs(output, exist_ok=True)
self._input, self._start, self._output cmd = build_ffmpeg_command(
) self._input, start, output,
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60) short_side=self._short_side,
# Audio extraction failure (e.g. no audio stream) is ignored — portrait_ratio=self._portrait_ratio,
# the frame sequence is the primary output. crop_center=self._crop_center,
self.finished.emit(self._output) image_sequence=self._image_sequence,
else: )
self.error.emit(result.stderr[-500:]) 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:
except Exception as e: audio_cmd = build_audio_extract_command(
self.error.emit(str(e)) self._input, start, output
)
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
self.finished.emit(output)
else:
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()
class TimelineWidget(QWidget): class TimelineWidget(QWidget):
@@ -395,6 +404,7 @@ class TimelineWidget(QWidget):
self.setMouseTracking(True) self.setMouseTracking(True)
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._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)
@@ -423,8 +433,12 @@ class TimelineWidget(QWidget):
self._rebuild_hover_cache() self._rebuild_hover_cache()
self.update() self.update()
def set_clip_span(self, span: float):
self._clip_span = span
self.update()
def set_cursor(self, seconds: float): def set_cursor(self, seconds: float):
clamped = max(0.0, min(seconds, max(0.0, self._duration - 8.0))) clamped = max(0.0, min(seconds, max(0.0, self._duration - self._clip_span)))
if clamped == self._cursor: if clamped == self._cursor:
return return
self._cursor = clamped self._cursor = clamped
@@ -513,9 +527,9 @@ class TimelineWidget(QWidget):
p.setPen(QPen(QColor(55, 55, 55))) p.setPen(QPen(QColor(55, 55, 55)))
p.drawLine(0, rh, w, rh) p.drawLine(0, rh, w, rh)
# ── 8-second selection region ───────────────────────────────── # ── selection region (full clip span) ─────────────────────────
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 + 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))
# left/right edges of selection # left/right edges of selection
@@ -1164,6 +1178,7 @@ class MainWindow(QMainWindow):
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.setFixedHeight(160)
self._timeline.set_clip_span(8.0 + 2 * saved_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._timeline.marker_clicked.connect(self._on_marker_clicked) self._timeline.marker_clicked.connect(self._on_marker_clicked)
@@ -1172,7 +1187,7 @@ class MainWindow(QMainWindow):
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter) self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;") self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
self._btn_play = QPushButton("▶ Play 8s") self._btn_play = QPushButton("▶ Play")
self._btn_play.setEnabled(False) self._btn_play.setEnabled(False)
self._btn_play.clicked.connect(self._on_play) self._btn_play.clicked.connect(self._on_play)
@@ -1226,6 +1241,20 @@ class MainWindow(QMainWindow):
) )
self._cmb_format.currentTextChanged.connect(self._update_next_label) self._cmb_format.currentTextChanged.connect(self._update_next_label)
self._spn_spread = QDoubleSpinBox()
self._spn_spread.setRange(2.0, 8.0)
self._spn_spread.setSingleStep(0.5)
self._spn_spread.setSuffix("s")
self._spn_spread.setToolTip("Offset between the 3 overlapping 8s clips")
saved_spread = float(self._settings.value("spread", "3.0"))
self._spn_spread.setValue(saved_spread)
self._spn_spread.valueChanged.connect(
lambda v: self._settings.setValue("spread", str(v))
)
self._spn_spread.valueChanged.connect(
lambda: self._timeline.set_clip_span(self._clip_span)
)
self._txt_label = QComboBox() self._txt_label = QComboBox()
self._txt_label.setEditable(True) self._txt_label.setEditable(True)
self._txt_label.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self._txt_label.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
@@ -1315,6 +1344,8 @@ class MainWindow(QMainWindow):
settings_row.addWidget(self._cmb_portrait) settings_row.addWidget(self._cmb_portrait)
settings_row.addWidget(QLabel("Format:")) settings_row.addWidget(QLabel("Format:"))
settings_row.addWidget(self._cmb_format) settings_row.addWidget(self._cmb_format)
settings_row.addWidget(QLabel("Spread:"))
settings_row.addWidget(self._spn_spread)
right = QWidget() right = QWidget()
right_layout = QVBoxLayout(right) right_layout = QVBoxLayout(right)
@@ -1513,7 +1544,7 @@ class MainWindow(QMainWindow):
self._btn_delete.setEnabled(False) self._btn_delete.setEnabled(False)
self._btn_delete.setText("Delete") self._btn_delete.setText("Delete")
if self._mpv.is_playing(): if self._mpv.is_playing():
self._mpv.play_loop(t, t + 8.0) self._mpv.play_loop(t, t + self._clip_span)
else: else:
self._mpv.seek(t) self._mpv.seek(t)
@@ -1525,10 +1556,15 @@ class MainWindow(QMainWindow):
else: else:
self._on_play() self._on_play()
@property
def _clip_span(self) -> float:
"""Total time covered by the 3 overlapping clips."""
return 8.0 + 2 * self._spn_spread.value()
def _on_play(self): def _on_play(self):
if not self._file_path: if not self._file_path:
return return
self._mpv.play_loop(self._cursor, self._cursor + 8.0) self._mpv.play_loop(self._cursor, self._cursor + self._clip_span)
def _on_pause(self): def _on_pause(self):
self._mpv.stop_loop() self._mpv.stop_loop()
@@ -1538,7 +1574,7 @@ class MainWindow(QMainWindow):
if not self._file_path: if not self._file_path:
return return
dur = self._mpv.get_duration() dur = self._mpv.get_duration()
new_t = max(0.0, min(self._cursor + delta, max(0.0, dur - 8.0))) new_t = max(0.0, min(self._cursor + delta, max(0.0, dur - self._clip_span)))
# Update label and internal state immediately; route the seek through # Update label and internal state immediately; route the seek through
# the timeline's debounce timer so rapid key repeats don't hammer mpv. # the timeline's debounce timer so rapid key repeats don't hammer mpv.
self._cursor = new_t self._cursor = new_t
@@ -1573,16 +1609,17 @@ class MainWindow(QMainWindow):
folder = self._txt_folder.text() folder = self._txt_folder.text()
name = self._txt_name.text() or "clip" name = self._txt_name.text() or "clip"
is_seq = self._cmb_format.currentText() == "WebP sequence" is_seq = self._cmb_format.currentText() == "WebP sequence"
# Advance past any files/dirs that already exist on disk. # Advance past any counter whose sub-clip _0 already exists on disk.
while True: while True:
if is_seq: if is_seq:
path = build_sequence_dir(folder, name, self._export_counter) path = build_sequence_dir(folder, name, self._export_counter, sub=0)
else: else:
path = build_export_path(folder, name, self._export_counter) path = build_export_path(folder, name, self._export_counter, sub=0)
if not os.path.exists(path): if not os.path.exists(path):
break break
self._export_counter += 1 self._export_counter += 1
self._lbl_next.setText(f"{os.path.basename(path)}") base = f"{name}_{self._export_counter:03d}"
self._lbl_next.setText(f"{base}_0/1/2")
def _on_export(self): def _on_export(self):
if not self._file_path: if not self._file_path:
@@ -1595,15 +1632,22 @@ class MainWindow(QMainWindow):
image_sequence = fmt == "WebP sequence" image_sequence = fmt == "WebP sequence"
folder = self._txt_folder.text() folder = self._txt_folder.text()
os.makedirs(folder, exist_ok=True) os.makedirs(folder, exist_ok=True)
spread = self._spn_spread.value()
if self._overwrite_path: if self._overwrite_path:
output = self._overwrite_path # Single-clip overwrite mode
jobs = [(self._cursor, self._overwrite_path)]
self._overwrite_path = "" self._overwrite_path = ""
else: else:
name = self._txt_name.text() or "clip" name = self._txt_name.text() or "clip"
if image_sequence: jobs = []
output = build_sequence_dir(folder, name, self._export_counter) for sub in range(3):
else: start = self._cursor + sub * spread
output = build_export_path(folder, name, self._export_counter) if image_sequence:
out = build_sequence_dir(folder, name, self._export_counter, sub=sub)
else:
out = build_export_path(folder, name, self._export_counter, sub=sub)
jobs.append((start, out))
raw = self._txt_resize.text().strip() raw = self._txt_resize.text().strip()
try: try:
@@ -1614,27 +1658,31 @@ class MainWindow(QMainWindow):
short_side = None short_side = None
self._btn_export.setEnabled(False) self._btn_export.setEnabled(False)
self.statusBar().showMessage(f"Exporting {os.path.basename(output)}") self.statusBar().showMessage(f"Exporting {len(jobs)} clip(s)")
# Show marker immediately — don't wait for ffmpeg to finish. # Show pending markers immediately.
pending = self._timeline._markers + [(self._cursor, self._export_counter, output)] pending = list(self._timeline._markers)
for start, out in jobs:
pending.append((start, self._export_counter, out))
self._timeline.set_markers(pending) 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
self._export_worker = ExportWorker( self._export_worker = ExportWorker(
self._file_path, self._cursor, output, self._file_path, jobs,
short_side=short_side, short_side=short_side,
portrait_ratio=portrait_ratio, portrait_ratio=portrait_ratio,
crop_center=self._crop_center, crop_center=self._crop_center,
image_sequence=image_sequence, image_sequence=image_sequence,
) )
self._export_worker.finished.connect(self._on_export_done) self._export_worker.finished.connect(self._on_clip_done)
self._export_worker.all_done.connect(self._on_batch_done)
self._export_worker.error.connect(self._on_export_error) self._export_worker.error.connect(self._on_export_error)
self._export_worker.start() self._export_worker.start()
def _on_export_done(self, path: str): def _on_clip_done(self, path: str):
"""Called per clip as each finishes."""
label = self._txt_label.currentText().strip() label = self._txt_label.currentText().strip()
category = self._cmb_category.currentText() category = self._cmb_category.currentText()
self._db.add( self._db.add(
@@ -1646,15 +1694,16 @@ class MainWindow(QMainWindow):
) )
folder = self._txt_folder.text() folder = self._txt_folder.text()
upsert_clip_annotation(folder, path, label) upsert_clip_annotation(folder, path, label)
# For MP4 exports path is a file; for WebP sequence it is a directory.
# build_mask_output_dir handles both correctly via Path.stem.
self._last_export_path = path self._last_export_path = path
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
def _on_batch_done(self):
"""Called once after all clips in the batch are done."""
self._export_counter += 1 self._export_counter += 1
self._update_next_label() self._update_next_label()
self._btn_export.setEnabled(True) self._btn_export.setEnabled(True)
self._btn_delete.setEnabled(True) self._btn_delete.setEnabled(True)
self._btn_delete.setText("Delete") self._btn_delete.setText("Delete")
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
self._refresh_markers() self._refresh_markers()
self._playlist.mark_done(self._file_path) self._playlist.mark_done(self._file_path)
# Refresh label history so the new label is immediately selectable. # Refresh label history so the new label is immediately selectable.
+8
View File
@@ -12,6 +12,14 @@ def test_build_export_path_counter():
def test_build_export_path_deep_counter(): def test_build_export_path_deep_counter():
assert build_export_path("/out", "shot", 999) == "/out/shot_999.mp4" assert build_export_path("/out", "shot", 999) == "/out/shot_999.mp4"
def test_build_export_path_sub():
assert build_export_path("/out", "clip", 1, sub=0) == "/out/clip_001_0.mp4"
assert build_export_path("/out", "clip", 1, sub=2) == "/out/clip_001_2.mp4"
def test_build_sequence_dir_sub():
assert build_sequence_dir("/out", "clip", 1, sub=0) == "/out/clip_001_0"
assert build_sequence_dir("/out", "clip", 1, sub=1) == "/out/clip_001_1"
def test_format_time_seconds(): def test_format_time_seconds():
assert format_time(0.0) == "0:00.0" assert format_time(0.0) == "0:00.0"