Files

497 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)