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)