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:
@@ -11,7 +11,7 @@ from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
|
||||
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
|
||||
QComboBox, QDialog, QPlainTextEdit,
|
||||
QComboBox, QDialog, QPlainTextEdit, QCheckBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QSettings
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
m = int(seconds // 60)
|
||||
# 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,
|
||||
portrait_ratio: str | None = None,
|
||||
crop_center: float = 0.5,
|
||||
image_sequence: bool = False,
|
||||
) -> list[str]:
|
||||
# -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.
|
||||
@@ -59,6 +64,14 @@ def build_ffmpeg_command(
|
||||
if 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]
|
||||
return cmd
|
||||
|
||||
@@ -605,6 +618,7 @@ class SettingsDialog(QDialog):
|
||||
"""Settings dialog: shows ML venv status and Install/Reinstall button."""
|
||||
|
||||
venv_installed = pyqtSignal() # emitted when install completes successfully
|
||||
masks_visibility_changed = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -613,6 +627,7 @@ class SettingsDialog(QDialog):
|
||||
self.setMinimumHeight(300)
|
||||
|
||||
self._worker: SetupWorker | None = None
|
||||
self._qsettings = QSettings("8cut", "8cut")
|
||||
|
||||
status_text = "Installed" if Path(_VENV_PYTHON).exists() else "Not installed"
|
||||
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.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.setReadOnly(True)
|
||||
self._log.setPlaceholderText("Install output will appear here…")
|
||||
@@ -632,8 +652,13 @@ class SettingsDialog(QDialog):
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addLayout(top)
|
||||
layout.addWidget(self._chk_masks)
|
||||
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):
|
||||
self._btn_install.setEnabled(False)
|
||||
self._log.clear()
|
||||
@@ -766,6 +791,7 @@ class MainWindow(QMainWindow):
|
||||
# Settings dialog
|
||||
self._settings_dialog = SettingsDialog(self)
|
||||
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.clicked.connect(self._settings_dialog.show)
|
||||
@@ -809,15 +835,20 @@ class MainWindow(QMainWindow):
|
||||
right_layout.addWidget(self._mpv, stretch=1)
|
||||
right_layout.addWidget(self._timeline)
|
||||
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(self._cmb_mask)
|
||||
mask_row.addWidget(self._btn_masks)
|
||||
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(export_row)
|
||||
right_layout.addLayout(mask_row)
|
||||
right_layout.addWidget(self._mask_row_widget)
|
||||
|
||||
# Left: queue label + playlist
|
||||
queue_label = QLabel("Queue")
|
||||
@@ -976,6 +1007,9 @@ class MainWindow(QMainWindow):
|
||||
def _on_venv_installed(self) -> None:
|
||||
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:
|
||||
if not self._last_export_path:
|
||||
self.statusBar().showMessage("No clip exported yet — export first.")
|
||||
|
||||
+30
-1
@@ -1,5 +1,5 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -182,3 +182,32 @@ def test_mask_output_dir_mkv():
|
||||
|
||||
def test_mask_output_dir_nested():
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user