feat: build_sequence_dir and image_sequence flag for build_ffmpeg_command

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 15:56:16 +02:00
parent c57ab4df77
commit 93028d9ac7
2 changed files with 68 additions and 5 deletions
+37 -3
View File
@@ -11,7 +11,7 @@ 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, QComboBox, QDialog, QPlainTextEdit, QCheckBox,
) )
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 from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent, QCursor, QFont
@@ -23,6 +23,10 @@ def build_export_path(folder: str, basename: str, counter: int) -> str:
return os.path.join(folder, filename) return os.path.join(folder, filename)
def build_sequence_dir(folder: str, basename: str, counter: int) -> str:
return os.path.join(folder, f"{basename}_{counter:03d}")
def format_time(seconds: float) -> str: def format_time(seconds: float) -> str:
m = int(seconds // 60) m = int(seconds // 60)
# Floor-truncate to 1 dp (not round) — prevents "X:60.0" rollover when # Floor-truncate to 1 dp (not round) — prevents "X:60.0" rollover when
@@ -36,6 +40,7 @@ def build_ffmpeg_command(
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,
) -> list[str]: ) -> list[str]:
# -ss before -i: fast input-seeking. Safe here because we always re-encode # -ss before -i: fast input-seeking. Safe here because we always re-encode
# (libx264/aac), so there is no keyframe-alignment issue from pre-input seek. # (libx264/aac), so there is no keyframe-alignment issue from pre-input seek.
@@ -59,6 +64,14 @@ def build_ffmpeg_command(
if filters: if filters:
cmd += ["-vf", ",".join(filters)] cmd += ["-vf", ",".join(filters)]
if image_sequence:
cmd += [
"-vcodec", "libwebp",
"-lossless", "1",
"-compression_level", "4",
os.path.join(output_path, "frame_%04d.webp"),
]
else:
cmd += ["-c:v", "libx264", "-c:a", "aac", output_path] cmd += ["-c:v", "libx264", "-c:a", "aac", output_path]
return cmd return cmd
@@ -605,6 +618,7 @@ class SettingsDialog(QDialog):
"""Settings dialog: shows ML venv status and Install/Reinstall button.""" """Settings dialog: shows ML venv status and Install/Reinstall button."""
venv_installed = pyqtSignal() # emitted when install completes successfully venv_installed = pyqtSignal() # emitted when install completes successfully
masks_visibility_changed = pyqtSignal(bool)
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -613,6 +627,7 @@ class SettingsDialog(QDialog):
self.setMinimumHeight(300) self.setMinimumHeight(300)
self._worker: SetupWorker | None = None self._worker: SetupWorker | None = None
self._qsettings = QSettings("8cut", "8cut")
status_text = "Installed" if Path(_VENV_PYTHON).exists() else "Not installed" status_text = "Installed" if Path(_VENV_PYTHON).exists() else "Not installed"
self._lbl_status = QLabel(f"ML Tools: {status_text}") self._lbl_status = QLabel(f"ML Tools: {status_text}")
@@ -621,6 +636,11 @@ class SettingsDialog(QDialog):
self._btn_install = QPushButton(btn_label) self._btn_install = QPushButton(btn_label)
self._btn_install.clicked.connect(self._on_install) self._btn_install.clicked.connect(self._on_install)
self._chk_masks = QCheckBox("Show mask generation row")
show_masks = self._qsettings.value("show_masks_row", "true") == "true"
self._chk_masks.setChecked(show_masks)
self._chk_masks.toggled.connect(self._on_masks_toggled)
self._log = QPlainTextEdit() self._log = QPlainTextEdit()
self._log.setReadOnly(True) self._log.setReadOnly(True)
self._log.setPlaceholderText("Install output will appear here…") self._log.setPlaceholderText("Install output will appear here…")
@@ -632,8 +652,13 @@ class SettingsDialog(QDialog):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.addLayout(top) layout.addLayout(top)
layout.addWidget(self._chk_masks)
layout.addWidget(self._log) layout.addWidget(self._log)
def _on_masks_toggled(self, checked: bool) -> None:
self._qsettings.setValue("show_masks_row", "true" if checked else "false")
self.masks_visibility_changed.emit(checked)
def _on_install(self): def _on_install(self):
self._btn_install.setEnabled(False) self._btn_install.setEnabled(False)
self._log.clear() self._log.clear()
@@ -766,6 +791,7 @@ class MainWindow(QMainWindow):
# Settings dialog # Settings dialog
self._settings_dialog = SettingsDialog(self) self._settings_dialog = SettingsDialog(self)
self._settings_dialog.venv_installed.connect(self._on_venv_installed) self._settings_dialog.venv_installed.connect(self._on_venv_installed)
self._settings_dialog.masks_visibility_changed.connect(self._on_masks_visibility_changed)
self._btn_settings = QPushButton("Settings…") self._btn_settings = QPushButton("Settings…")
self._btn_settings.clicked.connect(self._settings_dialog.show) self._btn_settings.clicked.connect(self._settings_dialog.show)
@@ -809,15 +835,20 @@ class MainWindow(QMainWindow):
right_layout.addWidget(self._mpv, stretch=1) right_layout.addWidget(self._mpv, stretch=1)
right_layout.addWidget(self._timeline) right_layout.addWidget(self._timeline)
right_layout.addWidget(self._crop_bar) right_layout.addWidget(self._crop_bar)
mask_row = QHBoxLayout()
self._mask_row_widget = QWidget()
mask_row = QHBoxLayout(self._mask_row_widget)
mask_row.setContentsMargins(0, 0, 0, 0)
mask_row.addWidget(QLabel("Masks:")) mask_row.addWidget(QLabel("Masks:"))
mask_row.addWidget(self._cmb_mask) mask_row.addWidget(self._cmb_mask)
mask_row.addWidget(self._btn_masks) mask_row.addWidget(self._btn_masks)
mask_row.addStretch() mask_row.addStretch()
show_masks = QSettings("8cut", "8cut").value("show_masks_row", "true") == "true"
self._mask_row_widget.setVisible(show_masks)
right_layout.addLayout(controls) right_layout.addLayout(controls)
right_layout.addLayout(export_row) right_layout.addLayout(export_row)
right_layout.addLayout(mask_row) right_layout.addWidget(self._mask_row_widget)
# Left: queue label + playlist # Left: queue label + playlist
queue_label = QLabel("Queue") queue_label = QLabel("Queue")
@@ -976,6 +1007,9 @@ class MainWindow(QMainWindow):
def _on_venv_installed(self) -> None: def _on_venv_installed(self) -> None:
self._btn_masks.setEnabled(True) self._btn_masks.setEnabled(True)
def _on_masks_visibility_changed(self, visible: bool) -> None:
self._mask_row_widget.setVisible(visible)
def _on_generate_masks(self) -> None: def _on_generate_masks(self) -> None:
if not self._last_export_path: if not self._last_export_path:
self.statusBar().showMessage("No clip exported yet — export first.") self.statusBar().showMessage("No clip exported yet — export first.")
+30 -1
View File
@@ -1,5 +1,5 @@
import tempfile, os import tempfile, os
from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir, build_sequence_dir
from main import _normalize_filename, ProcessedDB from main import _normalize_filename, ProcessedDB
@@ -182,3 +182,32 @@ def test_mask_output_dir_mkv():
def test_mask_output_dir_nested(): def test_mask_output_dir_nested():
assert build_mask_output_dir("/a/b/c/shot_042.mp4") == "/a/b/c/shot_042_masks" assert build_mask_output_dir("/a/b/c/shot_042.mp4") == "/a/b/c/shot_042_masks"
def test_build_sequence_dir_basic():
assert build_sequence_dir("/out", "clip", 1) == "/out/clip_001"
def test_build_sequence_dir_counter():
assert build_sequence_dir("/out", "clip", 42) == "/out/clip_042"
def test_ffmpeg_command_image_sequence():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/seq_001", image_sequence=True)
assert "-vcodec" in cmd
assert cmd[cmd.index("-vcodec") + 1] == "libwebp"
assert "-lossless" in cmd
assert cmd[cmd.index("-lossless") + 1] == "1"
assert "-compression_level" in cmd
assert cmd[cmd.index("-compression_level") + 1] == "4"
assert cmd[-1] == "/out/seq_001/frame_%04d.webp"
def test_ffmpeg_command_image_sequence_with_resize():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/seq_001", image_sequence=True, short_side=256)
assert "-vf" in cmd
vf = cmd[cmd.index("-vf") + 1]
assert "scale" in vf
assert cmd[-1] == "/out/seq_001/frame_%04d.webp"
def test_ffmpeg_command_image_sequence_no_audio():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/seq_001", image_sequence=True)
assert "-c:a" not in cmd
assert "aac" not in cmd