497 lines
20 KiB
Python
497 lines
20 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Optional, Set
|
||
|
||
from PyQt6.QtCore import Qt, pyqtSignal
|
||
from PyQt6.QtWidgets import (
|
||
QAbstractSpinBox,
|
||
QCheckBox,
|
||
QComboBox,
|
||
QFrame,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QPushButton,
|
||
QScrollArea,
|
||
QSizePolicy,
|
||
QSlider,
|
||
QSpinBox,
|
||
QTabWidget,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from .models import ImageOptions, VideoOptions, default_image_options, default_video_options
|
||
|
||
|
||
_CODEC_INFO = {
|
||
"h264": (
|
||
"H.264 · MP4",
|
||
"Plays nearly everywhere and encodes quickly. A safe default.",
|
||
),
|
||
"h265": (
|
||
"H.265 · MP4",
|
||
"Often 30–50% smaller than H.264, but slower and less widely supported.",
|
||
),
|
||
"vp9": (
|
||
"VP9 · WebM",
|
||
"Efficient open codec. A solid choice for web video.",
|
||
),
|
||
"av1": (
|
||
"AV1 · WebM",
|
||
"Excellent compression, but encoding can be very slow.",
|
||
),
|
||
}
|
||
|
||
_IMAGE_FORMAT_HINTS = {
|
||
"original": "Each image keeps its format. The quality setting applies to JPEG and WebP.",
|
||
"jpeg": "Universally compatible. Transparency is flattened onto white.",
|
||
"webp": "Excellent size-to-quality ratio. Supports transparency.",
|
||
"png": "Lossless and great for graphics. PNG files are often larger.",
|
||
}
|
||
|
||
|
||
def _hint_label(text: str = "") -> QLabel:
|
||
label = QLabel(text)
|
||
label.setObjectName("hintLabel")
|
||
label.setWordWrap(True)
|
||
return label
|
||
|
||
|
||
def _field_label(text: str) -> QLabel:
|
||
label = QLabel(text)
|
||
label.setObjectName("fieldLabel")
|
||
return label
|
||
|
||
|
||
def _divider() -> QFrame:
|
||
line = QFrame()
|
||
line.setObjectName("divider")
|
||
line.setFrameShape(QFrame.Shape.HLine)
|
||
return line
|
||
|
||
|
||
def _make_spin(minimum: int, maximum: int, value: int, suffix: str, step: int = 1) -> QSpinBox:
|
||
spin = QSpinBox()
|
||
spin.setRange(minimum, maximum)
|
||
spin.setValue(value)
|
||
spin.setSuffix(suffix)
|
||
spin.setSingleStep(step)
|
||
spin.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons)
|
||
spin.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
return spin
|
||
|
||
|
||
def _quality_word(value: int) -> str:
|
||
if value < 55:
|
||
return "Compact"
|
||
if value < 72:
|
||
return "Balanced"
|
||
if value < 88:
|
||
return "High"
|
||
return "Maximum"
|
||
|
||
|
||
class SettingsPanel(QFrame):
|
||
settingsChanged = pyqtSignal()
|
||
|
||
def __init__(self, video_codecs: Optional[Set[str]] = None, parent=None):
|
||
super().__init__(parent)
|
||
self.setObjectName("settingsCard")
|
||
self._loading = False
|
||
self._available_codecs = (
|
||
set(video_codecs) if video_codecs is not None else {"h264"}
|
||
)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(18, 18, 18, 16)
|
||
layout.setSpacing(10)
|
||
|
||
header = QHBoxLayout()
|
||
header.setSpacing(8)
|
||
title = QLabel("Settings")
|
||
title.setObjectName("cardTitle")
|
||
header.addWidget(title)
|
||
header.addStretch(1)
|
||
self.reset_button = QPushButton("Reset")
|
||
self.reset_button.setObjectName("ghostButton")
|
||
self.reset_button.setToolTip("Restore the default compression options")
|
||
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.reset_button.clicked.connect(self.reset_all)
|
||
header.addWidget(self.reset_button)
|
||
layout.addLayout(header)
|
||
|
||
subtitle = _hint_label("Applied to each media type independently.")
|
||
layout.addWidget(subtitle)
|
||
|
||
self.tabs = QTabWidget()
|
||
self.tabs.setObjectName("settingsTabs")
|
||
self.tabs.setDocumentMode(True)
|
||
self.tabs.addTab(self._build_image_tab(), "Images")
|
||
self.tabs.addTab(self._build_video_tab(), "Videos")
|
||
self.tabs.tabBar().setDrawBase(False)
|
||
layout.addWidget(self.tabs, 1)
|
||
|
||
self.tabs.currentChanged.connect(self._notify)
|
||
self.max_width_spin.valueChanged.connect(self._notify)
|
||
self.max_height_spin.valueChanged.connect(self._notify)
|
||
self.scale_percent_spin.valueChanged.connect(self._notify)
|
||
self.image_metadata_check.stateChanged.connect(self._notify)
|
||
self.resolution_combo.currentIndexChanged.connect(self._notify)
|
||
self.audio_combo.currentIndexChanged.connect(self._notify)
|
||
self.video_metadata_check.stateChanged.connect(self._notify)
|
||
self._update_image_format_ui()
|
||
self._update_video_codec_ui()
|
||
self._update_resize_ui()
|
||
self._update_speed_ui()
|
||
self._update_quality_labels()
|
||
|
||
# ------------------------------------------------------------------ UI
|
||
|
||
def _scroll_page(self) -> tuple[QScrollArea, QVBoxLayout]:
|
||
scroll = QScrollArea()
|
||
scroll.setObjectName("tabScroll")
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
content = QWidget()
|
||
content.setObjectName("tabContent")
|
||
content_layout = QVBoxLayout(content)
|
||
content_layout.setContentsMargins(0, 12, 4, 4)
|
||
content_layout.setSpacing(11)
|
||
scroll.setWidget(content)
|
||
return scroll, content_layout
|
||
|
||
def _build_image_tab(self) -> QWidget:
|
||
page, layout = self._scroll_page()
|
||
|
||
layout.addWidget(_field_label("Format"))
|
||
self.image_format_combo = QComboBox()
|
||
self.image_format_combo.addItem("Keep original", "original")
|
||
self.image_format_combo.addItem("JPEG · .jpg", "jpeg")
|
||
self.image_format_combo.addItem("WebP · .webp", "webp")
|
||
self.image_format_combo.addItem("PNG · .png", "png")
|
||
self.image_format_combo.currentIndexChanged.connect(self._on_image_format_changed)
|
||
layout.addWidget(self.image_format_combo)
|
||
self.image_format_hint = _hint_label()
|
||
layout.addWidget(self.image_format_hint)
|
||
|
||
layout.addSpacing(3)
|
||
quality_header = QHBoxLayout()
|
||
quality_header.setContentsMargins(0, 0, 0, 0)
|
||
quality_header.addWidget(_field_label("Quality"))
|
||
quality_header.addStretch(1)
|
||
self.image_quality_value = QLabel()
|
||
self.image_quality_value.setObjectName("qualityValue")
|
||
quality_header.addWidget(self.image_quality_value)
|
||
layout.addLayout(quality_header)
|
||
|
||
self.image_quality_slider = QSlider(Qt.Orientation.Horizontal)
|
||
self.image_quality_slider.setRange(30, 100)
|
||
self.image_quality_slider.setValue(82)
|
||
self.image_quality_slider.setPageStep(5)
|
||
self.image_quality_slider.valueChanged.connect(self._on_quality_changed)
|
||
layout.addWidget(self.image_quality_slider)
|
||
self.image_quality_hint = _hint_label()
|
||
layout.addWidget(self.image_quality_hint)
|
||
|
||
layout.addWidget(_divider())
|
||
layout.addWidget(_field_label("Resize"))
|
||
self.resize_combo = QComboBox()
|
||
self.resize_combo.addItem("Original size", "original")
|
||
self.resize_combo.addItem("Fit within a box", "fit")
|
||
self.resize_combo.addItem("Scale by percentage", "percent")
|
||
self.resize_combo.currentIndexChanged.connect(self._update_resize_ui)
|
||
layout.addWidget(self.resize_combo)
|
||
|
||
self.fit_controls = QWidget()
|
||
fit_layout = QHBoxLayout(self.fit_controls)
|
||
fit_layout.setContentsMargins(0, 2, 0, 0)
|
||
fit_layout.setSpacing(9)
|
||
width_col = QVBoxLayout()
|
||
width_col.setSpacing(5)
|
||
width_col.addWidget(_hint_label("Max width"))
|
||
self.max_width_spin = _make_spin(16, 20000, 1920, " px", 16)
|
||
width_col.addWidget(self.max_width_spin)
|
||
fit_layout.addLayout(width_col, 1)
|
||
height_col = QVBoxLayout()
|
||
height_col.setSpacing(5)
|
||
height_col.addWidget(_hint_label("Max height"))
|
||
self.max_height_spin = _make_spin(16, 20000, 1080, " px", 16)
|
||
height_col.addWidget(self.max_height_spin)
|
||
fit_layout.addLayout(height_col, 1)
|
||
layout.addWidget(self.fit_controls)
|
||
|
||
self.percent_controls = QWidget()
|
||
percent_layout = QHBoxLayout(self.percent_controls)
|
||
percent_layout.setContentsMargins(0, 2, 0, 0)
|
||
percent_layout.setSpacing(9)
|
||
self.scale_percent_spin = _make_spin(5, 400, 80, " %", 5)
|
||
percent_layout.addWidget(self.scale_percent_spin)
|
||
percent_layout.addWidget(_hint_label("of the original size"))
|
||
percent_layout.addStretch(1)
|
||
layout.addWidget(self.percent_controls)
|
||
|
||
self.image_resize_hint = _hint_label()
|
||
layout.addWidget(self.image_resize_hint)
|
||
|
||
layout.addSpacing(4)
|
||
self.image_metadata_check = QCheckBox("Keep metadata")
|
||
self.image_metadata_check.setToolTip("Preserve EXIF and color profile information.")
|
||
layout.addWidget(self.image_metadata_check)
|
||
|
||
layout.addStretch(1)
|
||
return page
|
||
|
||
def _build_video_tab(self) -> QWidget:
|
||
page, layout = self._scroll_page()
|
||
|
||
layout.addWidget(_field_label("Codec"))
|
||
self.codec_combo = QComboBox()
|
||
for codec in ("h264", "h265", "vp9", "av1"):
|
||
if codec not in self._available_codecs:
|
||
continue
|
||
label, hint = _CODEC_INFO[codec]
|
||
self.codec_combo.addItem(label, codec)
|
||
self.codec_combo.setItemData(self.codec_combo.count() - 1, hint, Qt.ItemDataRole.ToolTipRole)
|
||
if self.codec_combo.count() == 0:
|
||
self.codec_combo.addItem("No supported encoder found", None)
|
||
self.codec_combo.setEnabled(False)
|
||
self.codec_combo.currentIndexChanged.connect(self._on_codec_changed)
|
||
layout.addWidget(self.codec_combo)
|
||
self.codec_hint = _hint_label()
|
||
layout.addWidget(self.codec_hint)
|
||
|
||
layout.addSpacing(3)
|
||
quality_header = QHBoxLayout()
|
||
quality_header.setContentsMargins(0, 0, 0, 0)
|
||
quality_header.addWidget(_field_label("Quality"))
|
||
quality_header.addStretch(1)
|
||
self.video_quality_value = QLabel()
|
||
self.video_quality_value.setObjectName("qualityValue")
|
||
quality_header.addWidget(self.video_quality_value)
|
||
layout.addLayout(quality_header)
|
||
|
||
self.video_quality_slider = QSlider(Qt.Orientation.Horizontal)
|
||
self.video_quality_slider.setRange(20, 95)
|
||
self.video_quality_slider.setValue(68)
|
||
self.video_quality_slider.setPageStep(5)
|
||
self.video_quality_slider.valueChanged.connect(self._on_quality_changed)
|
||
layout.addWidget(self.video_quality_slider)
|
||
layout.addWidget(
|
||
_hint_label("Higher quality keeps more detail; lower saves space.")
|
||
)
|
||
|
||
layout.addWidget(_divider())
|
||
layout.addWidget(_field_label("Encoding speed"))
|
||
self.speed_combo = QComboBox()
|
||
self.speed_combo.addItem("Fast", "fast")
|
||
self.speed_combo.addItem("Balanced", "balanced")
|
||
self.speed_combo.addItem("Best compression", "best")
|
||
self.speed_combo.setCurrentIndex(1)
|
||
self.speed_combo.currentIndexChanged.connect(self._update_speed_ui)
|
||
layout.addWidget(self.speed_combo)
|
||
self.speed_hint = _hint_label()
|
||
layout.addWidget(self.speed_hint)
|
||
|
||
layout.addWidget(_field_label("Resolution"))
|
||
self.resolution_combo = QComboBox()
|
||
self.resolution_combo.addItem("Original resolution", 0)
|
||
self.resolution_combo.addItem("2160p · 4K", 2160)
|
||
self.resolution_combo.addItem("1440p · QHD", 1440)
|
||
self.resolution_combo.addItem("1080p · Full HD", 1080)
|
||
self.resolution_combo.addItem("720p · HD", 720)
|
||
self.resolution_combo.addItem("480p · SD", 480)
|
||
self.resolution_combo.addItem("360p", 360)
|
||
layout.addWidget(self.resolution_combo)
|
||
layout.addWidget(
|
||
_hint_label("Caps the shorter edge. Videos are never upscaled.")
|
||
)
|
||
|
||
layout.addWidget(_field_label("Audio"))
|
||
self.audio_combo = QComboBox()
|
||
self.audio_combo.addItem("Balanced · 128 kbps", 128)
|
||
self.audio_combo.addItem("High · 192 kbps", 192)
|
||
self.audio_combo.addItem("Data saver · 96 kbps", 96)
|
||
self.audio_combo.addItem("Remove audio", 0)
|
||
layout.addWidget(self.audio_combo)
|
||
layout.addWidget(_hint_label("Audio is re-encoded to match the output container."))
|
||
|
||
layout.addSpacing(4)
|
||
self.video_metadata_check = QCheckBox("Keep metadata")
|
||
self.video_metadata_check.setToolTip("Preserve global metadata and chapters.")
|
||
layout.addWidget(self.video_metadata_check)
|
||
|
||
layout.addStretch(1)
|
||
return page
|
||
|
||
# ------------------------------------------------------------- behavior
|
||
|
||
def _notify(self, *_args) -> None:
|
||
if self._loading:
|
||
return
|
||
self.settingsChanged.emit()
|
||
|
||
def _on_quality_changed(self, _value: int) -> None:
|
||
self._update_quality_labels()
|
||
self._notify()
|
||
|
||
def _on_image_format_changed(self, _index: int) -> None:
|
||
self._update_image_format_ui()
|
||
self._notify()
|
||
|
||
def _on_codec_changed(self, _index: int) -> None:
|
||
self._update_video_codec_ui()
|
||
self._notify()
|
||
|
||
def _update_quality_labels(self) -> None:
|
||
image_quality = self.image_quality_slider.value()
|
||
video_quality = self.video_quality_slider.value()
|
||
self.image_quality_value.setText(f"{image_quality} · {_quality_word(image_quality)}")
|
||
self.video_quality_value.setText(f"{video_quality} · {_quality_word(video_quality)}")
|
||
|
||
def _update_image_format_ui(self) -> None:
|
||
format_choice = self.image_format_combo.currentData() or "original"
|
||
self.image_format_hint.setText(_IMAGE_FORMAT_HINTS.get(format_choice, ""))
|
||
quality_applies = format_choice != "png"
|
||
self.image_quality_slider.setEnabled(quality_applies)
|
||
if not quality_applies:
|
||
self.image_quality_hint.setText("PNG is lossless, so the quality slider does not apply.")
|
||
elif format_choice == "original":
|
||
self.image_quality_hint.setText("Used for JPEG and WebP images; ignored for PNG.")
|
||
else:
|
||
self.image_quality_hint.setText("Higher values preserve more detail and create larger files.")
|
||
|
||
def _update_resize_ui(self, *_args) -> None:
|
||
mode = self.resize_combo.currentData() or "original"
|
||
self.fit_controls.setVisible(mode == "fit")
|
||
self.percent_controls.setVisible(mode == "percent")
|
||
if mode == "fit":
|
||
self.image_resize_hint.setText("Images are scaled down to fit inside the box, keeping their aspect ratio.")
|
||
elif mode == "percent":
|
||
self.image_resize_hint.setText("Scales width and height by the chosen percentage.")
|
||
else:
|
||
self.image_resize_hint.setText("No resizing. Only the format and quality are changed.")
|
||
self._notify()
|
||
|
||
def _update_video_codec_ui(self) -> None:
|
||
codec = self.codec_combo.currentData()
|
||
if codec in _CODEC_INFO:
|
||
self.codec_hint.setText(_CODEC_INFO[codec][1])
|
||
else:
|
||
self.codec_hint.setText("No video encoder is available in this FFmpeg installation.")
|
||
|
||
def _update_speed_ui(self, *_args) -> None:
|
||
speed = self.speed_combo.currentData() or "balanced"
|
||
descriptions = {
|
||
"fast": "Quickest encode. Output is slightly larger.",
|
||
"balanced": "A good balance of encoding time and file size.",
|
||
"best": "Slower, but seeks out extra savings.",
|
||
}
|
||
self.speed_hint.setText(descriptions.get(speed, ""))
|
||
self._notify()
|
||
|
||
# --------------------------------------------------------------- values
|
||
|
||
def image_options(self) -> ImageOptions:
|
||
return ImageOptions(
|
||
output_format=self.image_format_combo.currentData() or "original",
|
||
quality=self.image_quality_slider.value(),
|
||
resize_mode=self.resize_combo.currentData() or "original",
|
||
max_width=self.max_width_spin.value(),
|
||
max_height=self.max_height_spin.value(),
|
||
scale_percent=self.scale_percent_spin.value(),
|
||
keep_metadata=self.image_metadata_check.isChecked(),
|
||
)
|
||
|
||
def video_options(self) -> VideoOptions:
|
||
return VideoOptions(
|
||
codec=self.codec_combo.currentData() or "h264",
|
||
quality=self.video_quality_slider.value(),
|
||
speed=self.speed_combo.currentData() or "balanced",
|
||
resolution=int(self.resolution_combo.currentData() or 0),
|
||
audio_bitrate=int(self.audio_combo.currentData() or 0),
|
||
keep_metadata=self.video_metadata_check.isChecked(),
|
||
)
|
||
|
||
def set_image_options(self, options: ImageOptions) -> None:
|
||
self._loading = True
|
||
try:
|
||
self._set_combo_data(self.image_format_combo, options.output_format)
|
||
self.image_quality_slider.setValue(self._clamp(options.quality, 30, 100))
|
||
self._set_combo_data(self.resize_combo, options.resize_mode)
|
||
self.max_width_spin.setValue(self._clamp(options.max_width, 16, 20000))
|
||
self.max_height_spin.setValue(self._clamp(options.max_height, 16, 20000))
|
||
self.scale_percent_spin.setValue(self._clamp(options.scale_percent, 5, 400))
|
||
self.image_metadata_check.setChecked(bool(options.keep_metadata))
|
||
self._update_image_format_ui()
|
||
self._update_resize_ui()
|
||
self._update_quality_labels()
|
||
finally:
|
||
self._loading = False
|
||
|
||
def set_video_options(self, options: VideoOptions) -> None:
|
||
self._loading = True
|
||
try:
|
||
self._set_combo_data(self.codec_combo, options.codec)
|
||
self.video_quality_slider.setValue(self._clamp(options.quality, 20, 95))
|
||
self._set_combo_data(self.speed_combo, options.speed)
|
||
self._set_combo_data(self.resolution_combo, options.resolution)
|
||
self._set_combo_data(self.audio_combo, options.audio_bitrate)
|
||
self.video_metadata_check.setChecked(bool(options.keep_metadata))
|
||
self._update_video_codec_ui()
|
||
self._update_speed_ui()
|
||
self._update_quality_labels()
|
||
finally:
|
||
self._loading = False
|
||
|
||
def reset_all(self) -> None:
|
||
self._loading = True
|
||
try:
|
||
image_defaults = default_image_options()
|
||
video_defaults = default_video_options()
|
||
self._set_combo_data(self.image_format_combo, image_defaults.output_format)
|
||
self.image_quality_slider.setValue(image_defaults.quality)
|
||
self._set_combo_data(self.resize_combo, image_defaults.resize_mode)
|
||
self.max_width_spin.setValue(image_defaults.max_width)
|
||
self.max_height_spin.setValue(image_defaults.max_height)
|
||
self.scale_percent_spin.setValue(image_defaults.scale_percent)
|
||
self.image_metadata_check.setChecked(image_defaults.keep_metadata)
|
||
|
||
self._set_combo_data(self.codec_combo, video_defaults.codec)
|
||
self.video_quality_slider.setValue(video_defaults.quality)
|
||
self._set_combo_data(self.speed_combo, video_defaults.speed)
|
||
self._set_combo_data(self.resolution_combo, video_defaults.resolution)
|
||
self._set_combo_data(self.audio_combo, video_defaults.audio_bitrate)
|
||
self.video_metadata_check.setChecked(video_defaults.keep_metadata)
|
||
self._update_image_format_ui()
|
||
self._update_resize_ui()
|
||
self._update_video_codec_ui()
|
||
self._update_speed_ui()
|
||
self._update_quality_labels()
|
||
finally:
|
||
self._loading = False
|
||
self.settingsChanged.emit()
|
||
|
||
def current_tab_index(self) -> int:
|
||
return self.tabs.currentIndex()
|
||
|
||
def set_current_tab_index(self, index: int) -> None:
|
||
self._loading = True
|
||
try:
|
||
self.tabs.setCurrentIndex(0 if index not in (0, 1) else index)
|
||
finally:
|
||
self._loading = False
|
||
|
||
@staticmethod
|
||
def _clamp(value: int, minimum: int, maximum: int) -> int:
|
||
try:
|
||
return max(minimum, min(maximum, int(value)))
|
||
except (TypeError, ValueError):
|
||
return minimum
|
||
|
||
@staticmethod
|
||
def _set_combo_data(combo: QComboBox, value) -> None:
|
||
index = combo.findData(value)
|
||
if index >= 0:
|
||
combo.setCurrentIndex(index)
|