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,
|
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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user