commit 3326caa0702a0544817163fdbdcdd3cd27f98967 Author: majestic Date: Sat Oct 24 02:28:06 2026 -0600 Initial commit diff --git a/Bolt-Compress/.gitignore b/Bolt-Compress/.gitignore new file mode 100644 index 0000000..cc09024 --- /dev/null +++ b/Bolt-Compress/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.part +.venv/ +venv/ +.pytest_cache/ diff --git a/Bolt-Compress/README.md b/Bolt-Compress/README.md new file mode 100644 index 0000000..a3d8a73 --- /dev/null +++ b/Bolt-Compress/README.md @@ -0,0 +1,125 @@ +# Bolt Compress + +A local desktop app for batch-compressing and resizing videos and images. Drop in a +handful of files, choose how small you want them, and hit compress. No uploads, no +accounts, no waiting on a website. + +Built with **PyQt6**, **Pillow**, and **FFmpeg**. + +![Bolt Compress screenshot](docs/screenshot.png) + +## Features + +- **Drag and drop batch queue** for images, videos, or a mix of both +- **Image tools** — resize to a bounding box, scale by percentage, or keep dimensions; + save as JPEG, WebP, PNG, or the original format; adjust quality; keep or strip metadata +- **Video tools** — H.264, H.265, VP9, and AV1 encoders; quality (CRF), encoding speed, + resolution, and audio bitrate controls +- **Per-file progress and status** with before/after sizes and percentage saved +- **Cancellable background encoding** so the interface stays responsive +- **Output next to originals or in a folder you choose**; existing files are never overwritten +- **Remembers settings** between launches +- **100% offline** — files are processed entirely on your machine + +## Requirements + +- Python 3.9 or newer +- PyQt6 and Pillow (`requirements.txt`) +- **FFmpeg and ffprobe on `PATH`** for video compression. Images work without FFmpeg. + +On Windows, FFmpeg can be installed with: + +```powershell +winget install Gyan.FFmpeg +``` + +Make sure both `ffmpeg` and `ffprobe` resolve from a terminal after installation: + +```powershell +ffmpeg -version +ffprobe -version +``` + +If you prefer not to modify `PATH`, put `ffmpeg.exe` and `ffprobe.exe` in a `bin` +folder beside `app.py`, or point the `BOLT_FFMPEG_DIR` environment variable at the +folder that contains them. + +## Run it + +```powershell +python -m pip install -r requirements.txt +python app.py +``` + +Or double-click **`run.bat`** on Windows. You can also pass files or folders directly: + +```powershell +python app.py "C:\Videos\holiday.mp4" "C:\Photos" +``` + +Bolt Compress is also runnable as a module: + +```powershell +python -m bolt_compress +``` + +## How to use it + +1. Drag media into the window, or use **Add files** / **Add folder**. +2. Choose **Images** or **Videos** in the settings panel and tune the options. +3. Pick an output destination (or leave it on *Next to originals*). +4. Press **Compress**. + +Output filenames use the suffix `-compressed`. If that file already exists, +`-compressed (2)`, `-compressed (3)`, and so on are used. Originals are never modified. + +Files that already have a result are skipped on the next run, so you can add more media +without reprocessing the whole batch. Once everything in the queue is finished, the +button changes to **Compress again** if you want a fresh set of copies with new settings. + +## Supported formats + +**Images in:** JPG/JPEG, PNG, WebP, BMP, TIFF +**Images out:** original format, JPEG, WebP, or PNG + +**Videos in:** MP4, MOV, MKV, AVI, WebM, M4V, MPEG, WMV, FLV, TS, 3GP, MTS, M2TS +**Videos out:** MP4 (H.264/H.265) or WebM (VP9/AV1) + +Animated images (GIF, APNG, animated WebP) are not supported yet. Multi-page TIFFs are +also skipped to avoid silently dropping frames. + +## Notes on video encoding + +- The quality slider maps to each codec's CRF value: higher means better quality and a + larger file. The default (`68`) is a good balance for H.264. +- **AV1** produces excellent compression but can be extremely slow. The **Fast** speed + setting is recommended for long videos. +- Resizing caps the shorter edge at the selected resolution and never upscales. For + example, 1080p gives a landscape video a height of 1080 and a portrait video a width + of 1080. +- Audio is re-encoded to AAC for MP4 and Opus for WebM, or removed entirely. +- Only the primary video stream and the audio tracks are carried over. Subtitle and data + tracks are omitted. +- Output video is 8-bit 4:2:0 for broad player compatibility. HDR sources are converted, + so their colors may look different. +- Video metadata is stripped by default to maximize savings; enable **Keep metadata** + if you need chapters or global tags. + +## Project layout + +``` +app.py Launcher +bolt_compress/ + app.py Application setup and entry point + main_window.py Main window, queue, drag/drop, destinations + settings_panel.py Image and video settings UI + processing.py Pillow/FFmpeg engines and background worker + media.py Probing, path handling, FFmpeg discovery + models.py Shared data models + theme.py Dark theme, app mark, media icons + widgets.py Empty-state drop target + assets/ Small SVG assets used by the stylesheet +run.bat Windows launcher +requirements.txt +docs/screenshot.png Interface preview +``` diff --git a/Bolt-Compress/app.py b/Bolt-Compress/app.py new file mode 100644 index 0000000..7831936 --- /dev/null +++ b/Bolt-Compress/app.py @@ -0,0 +1,4 @@ +from bolt_compress.app import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Bolt-Compress/bolt_compress/__init__.py b/Bolt-Compress/bolt_compress/__init__.py new file mode 100644 index 0000000..d305b5d --- /dev/null +++ b/Bolt-Compress/bolt_compress/__init__.py @@ -0,0 +1,3 @@ +"""Bolt Compress — a local video and image compression desktop app.""" + +__version__ = "1.0.0" diff --git a/Bolt-Compress/bolt_compress/__main__.py b/Bolt-Compress/bolt_compress/__main__.py new file mode 100644 index 0000000..c58ddac --- /dev/null +++ b/Bolt-Compress/bolt_compress/__main__.py @@ -0,0 +1,4 @@ +from .app import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Bolt-Compress/bolt_compress/app.py b/Bolt-Compress/bolt_compress/app.py new file mode 100644 index 0000000..b6cc017 --- /dev/null +++ b/Bolt-Compress/bolt_compress/app.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import ctypes +import sys + +from PyQt6.QtCore import QTimer +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import QApplication + +from . import __version__ +from .main_window import MainWindow +from .theme import make_app_icon, stylesheet + + +def _set_windows_app_id() -> None: + if sys.platform != "win32": + return + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("Bolt.BoltCompress.1") + except Exception: + pass + + +def main() -> int: + _set_windows_app_id() + app = QApplication(sys.argv) + app.setApplicationName("Bolt Compress") + app.setApplicationDisplayName("Bolt Compress") + app.setOrganizationName("Bolt") + app.setApplicationVersion(__version__) + app.setStyle("Fusion") + app.setWindowIcon(make_app_icon()) + app.setStyleSheet(stylesheet()) + app.setFont(QFont("Segoe UI Variable Text", 10)) + + window = MainWindow() + window.show() + + paths = [argument for argument in sys.argv[1:] if not argument.startswith("-")] + if paths: + QTimer.singleShot(150, lambda: window.add_paths(paths)) + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Bolt-Compress/bolt_compress/assets/check.svg b/Bolt-Compress/bolt_compress/assets/check.svg new file mode 100644 index 0000000..6a9fb35 --- /dev/null +++ b/Bolt-Compress/bolt_compress/assets/check.svg @@ -0,0 +1 @@ + diff --git a/Bolt-Compress/bolt_compress/assets/chevron-down.svg b/Bolt-Compress/bolt_compress/assets/chevron-down.svg new file mode 100644 index 0000000..6ba0b3b --- /dev/null +++ b/Bolt-Compress/bolt_compress/assets/chevron-down.svg @@ -0,0 +1 @@ + diff --git a/Bolt-Compress/bolt_compress/main_window.py b/Bolt-Compress/bolt_compress/main_window.py new file mode 100644 index 0000000..3ea63ae --- /dev/null +++ b/Bolt-Compress/bolt_compress/main_window.py @@ -0,0 +1,1255 @@ +from __future__ import annotations + +import os +import subprocess +import uuid +from pathlib import Path +from typing import Dict, List, Optional, Set + +from PyQt6.QtCore import QEventLoop, QSettings, QSize, Qt, QTimer, QUrl +from PyQt6.QtGui import QAction, QBrush, QColor, QDesktopServices, QKeySequence, QShortcut +from PyQt6.QtWidgets import ( + QApplication, + QAbstractItemView, + QFileDialog, + QFrame, + QHBoxLayout, + QHeaderView, + QLabel, + QMainWindow, + QMenu, + QMessageBox, + QProgressBar, + QPushButton, + QSizePolicy, + QStackedWidget, + QToolButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from .media import ( + available_video_codecs, + classify_path, + find_ffmpeg_tools, + format_duration, + human_size, + probe_media, +) +from .models import ( + IMAGE_EXTENSIONS, + VIDEO_EXTENSIONS, + ImageOptions, + MediaItem, + MediaKind, + VideoOptions, +) +from .processing import CompressionJob, CompressionWorker +from .settings_panel import SettingsPanel +from .theme import ACCENT, AMBER, GREEN, MUTED, RED, app_logo_pixmap, make_app_icon, media_icon +from .widgets import DropZone + +APP_NAME = "Bolt Compress" +OUTPUT_SUFFIX = "-compressed" + +_IMAGE_PATTERN = " ".join(f"*{suffix}" for suffix in sorted(IMAGE_EXTENSIONS)) +_VIDEO_PATTERN = " ".join(f"*{suffix}" for suffix in sorted(VIDEO_EXTENSIONS)) +_MEDIA_FILTER = ( + f"Media files ({_IMAGE_PATTERN} {_VIDEO_PATTERN});;" + f"Images ({_IMAGE_PATTERN});;" + f"Videos ({_VIDEO_PATTERN});;" + "All files (*)" +) + + +def _open_path(path: Path) -> None: + QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) + + +def _reveal_path(path: Path) -> None: + if os.name == "nt": + try: + subprocess.Popen( + ["explorer", f"/select,{os.path.normpath(str(path))}"], + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + return + except OSError: + pass + _open_path(path.parent) + + +def _one_line(text: str, limit: int = 160) -> str: + cleaned = " ".join((text or "").split()) + return cleaned if len(cleaned) <= limit else cleaned[: limit - 1] + "…" + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle(APP_NAME) + self.setWindowIcon(make_app_icon()) + self.setMinimumSize(1040, 680) + self.resize(1200, 800) + self.setAcceptDrops(True) + + self._settings = QSettings("Bolt", "Bolt Compress") + self._tools = find_ffmpeg_tools() + self._available_codecs = available_video_codecs(self._tools) + + self._items: Dict[str, MediaItem] = {} + self._order: List[str] = [] + self._rows: Dict[str, QTreeWidgetItem] = {} + self._seen: Set[str] = set() + self._output_dir: Optional[Path] = None + self._last_dir = str(Path.home()) + self._worker: Optional[CompressionWorker] = None + self._running = False + self._current_id: Optional[str] = None + self._progress_visible = False + self._drag_active = False + self._last_run_summary: Optional[dict] = None + self._scanning = False + + self._save_timer = QTimer(self) + self._save_timer.setSingleShot(True) + self._save_timer.setInterval(450) + self._save_timer.timeout.connect(self._save_preferences) + + self._build_ui() + self._install_shortcuts() + self._load_preferences() + self._update_engine_status() + self._refresh_ui() + + # ------------------------------------------------------------------ UI + + def _build_ui(self) -> None: + root = QWidget() + root.setObjectName("root") + self.setCentralWidget(root) + outer = QVBoxLayout(root) + outer.setContentsMargins(24, 18, 24, 12) + outer.setSpacing(14) + + outer.addLayout(self._build_header()) + + body = QHBoxLayout() + body.setSpacing(16) + self.queue_card = self._build_queue_card() + body.addWidget(self.queue_card, 1) + + self.settings_panel = SettingsPanel(self._available_codecs) + self.settings_panel.setFixedWidth(372) + self.settings_panel.settingsChanged.connect(self._on_settings_changed) + body.addWidget(self.settings_panel) + outer.addLayout(body, 1) + + outer.addLayout(self._build_status_footer()) + + def _build_header(self) -> QHBoxLayout: + layout = QHBoxLayout() + layout.setSpacing(12) + layout.setContentsMargins(2, 0, 2, 0) + + logo = QLabel() + screen = QApplication.primaryScreen() + dpr = screen.devicePixelRatio() if screen else 1.0 + logo_pixmap = app_logo_pixmap(max(46, int(46 * dpr))) + logo_pixmap.setDevicePixelRatio(dpr) + logo.setPixmap(logo_pixmap) + logo.setFixedSize(46, 46) + layout.addWidget(logo) + + titles = QVBoxLayout() + titles.setSpacing(1) + title = QLabel(APP_NAME) + title.setObjectName("appTitle") + titles.addWidget(title) + subtitle = QLabel("Compress and resize video & images, privately on your machine.") + subtitle.setObjectName("appSubtitle") + titles.addWidget(subtitle) + layout.addLayout(titles) + layout.addStretch(1) + + privacy_chip = QLabel("● 100% local") + privacy_chip.setObjectName("privacyChip") + privacy_chip.setToolTip("Files are processed on this computer. Nothing is uploaded.") + layout.addWidget(privacy_chip) + return layout + + def _build_queue_card(self) -> QFrame: + card = QFrame() + card.setObjectName("queueCard") + card.setProperty("dragActive", False) + + layout = QVBoxLayout(card) + layout.setContentsMargins(18, 16, 18, 16) + layout.setSpacing(12) + + header = QHBoxLayout() + header.setSpacing(8) + title = QLabel("Queue") + title.setObjectName("cardTitle") + header.addWidget(title) + self.count_badge = QLabel("0") + self.count_badge.setObjectName("badge") + self.count_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + header.addWidget(self.count_badge) + header.addStretch(1) + + self.add_files_button = QPushButton("Add files") + self.add_files_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_files_button.setToolTip("Add videos and images (Ctrl+O)") + self.add_files_button.clicked.connect(self._choose_files) + header.addWidget(self.add_files_button) + + self.add_folder_button = QPushButton("Add folder") + self.add_folder_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_folder_button.setToolTip("Add supported files from a folder (Ctrl+Shift+O)") + self.add_folder_button.clicked.connect(self._choose_folder) + header.addWidget(self.add_folder_button) + + self.clear_button = QPushButton("Clear") + self.clear_button.setObjectName("ghostButton") + self.clear_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.clear_button.setToolTip("Remove all files from the queue") + self.clear_button.clicked.connect(self._clear_queue) + header.addWidget(self.clear_button) + layout.addLayout(header) + + self.queue_stack = QStackedWidget() + self.queue_stack.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + empty_page = QWidget() + empty_layout = QVBoxLayout(empty_page) + empty_layout.setContentsMargins(0, 0, 0, 0) + self.drop_zone = DropZone() + self.drop_zone.browseRequested.connect(self._choose_files) + empty_layout.addWidget(self.drop_zone) + self.queue_stack.addWidget(empty_page) + + queue_page = QWidget() + queue_layout = QVBoxLayout(queue_page) + queue_layout.setContentsMargins(0, 0, 0, 0) + self.queue_view = QTreeWidget() + self.queue_view.setObjectName("mediaQueue") + self.queue_view.setColumnCount(5) + self.queue_view.setHeaderLabels(["FILE", "DETAILS", "BEFORE", "AFTER", "STATUS"]) + self.queue_view.setRootIsDecorated(False) + self.queue_view.setUniformRowHeights(True) + self.queue_view.setAlternatingRowColors(False) + self.queue_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.queue_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.queue_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.queue_view.setIndentation(0) + self.queue_view.setIconSize(QSize(30, 30)) + self.queue_view.setTextElideMode(Qt.TextElideMode.ElideMiddle) + self.queue_view.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) + self.queue_view.setAcceptDrops(False) + self.queue_view.viewport().setAcceptDrops(False) + self.queue_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.queue_view.customContextMenuRequested.connect(self._show_queue_menu) + self.queue_view.itemDoubleClicked.connect(self._open_queue_item) + header_view = self.queue_view.header() + header_view.setStretchLastSection(False) + header_view.setSectionsClickable(False) + header_view.setHighlightSections(False) + header_view.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + header_view.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + for column, width in ((1, 155), (2, 76), (3, 76), (4, 90)): + header_view.setSectionResizeMode(column, QHeaderView.ResizeMode.Fixed) + self.queue_view.setColumnWidth(column, width) + queue_layout.addWidget(self.queue_view) + self.queue_stack.addWidget(queue_page) + layout.addWidget(self.queue_stack, 1) + + divider = QFrame() + divider.setObjectName("divider") + divider.setFrameShape(QFrame.Shape.HLine) + layout.addWidget(divider) + + self.stats_label = QLabel("No files yet") + self.stats_label.setObjectName("statsLabel") + layout.addWidget(self.stats_label) + + output_row = QHBoxLayout() + output_row.setSpacing(8) + output_label = QLabel("OUTPUT") + output_label.setObjectName("outputLabel") + output_row.addWidget(output_label) + self.dest_button = QToolButton() + self.dest_button.setObjectName("destButton") + self.dest_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly) + self.dest_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.dest_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.dest_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._build_destination_menu() + output_row.addWidget(self.dest_button, 1) + layout.addLayout(output_row) + + self.progress_wrap = QWidget() + progress_layout = QHBoxLayout(self.progress_wrap) + progress_layout.setContentsMargins(0, 4, 0, 0) + progress_layout.setSpacing(10) + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.setTextVisible(False) + progress_layout.addWidget(self.progress_bar, 1) + self.percent_label = QLabel("0%") + self.percent_label.setObjectName("percentLabel") + self.percent_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + progress_layout.addWidget(self.percent_label) + self.progress_wrap.setVisible(False) + layout.addWidget(self.progress_wrap) + + actions = QHBoxLayout() + actions.setSpacing(10) + self.compress_button = QPushButton("Compress") + self.compress_button.setObjectName("primaryAction") + self.compress_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.compress_button.clicked.connect(self._start_compression) + actions.addWidget(self.compress_button, 1) + self.cancel_button = QPushButton("Cancel") + self.cancel_button.setObjectName("cancelButton") + self.cancel_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.cancel_button.clicked.connect(self._cancel_compression) + self.cancel_button.setVisible(False) + actions.addWidget(self.cancel_button) + layout.addLayout(actions) + return card + + def _build_destination_menu(self) -> None: + menu = QMenu(self.dest_button) + self._dest_source_action = QAction("Next to original files", menu) + self._dest_source_action.setCheckable(True) + self._dest_source_action.triggered.connect(lambda: self._set_output_dir(None)) + menu.addAction(self._dest_source_action) + + self._dest_choose_action = QAction("Choose folder…", menu) + self._dest_choose_action.triggered.connect(self._choose_output_dir) + menu.addAction(self._dest_choose_action) + + menu.addSeparator() + self._dest_open_action = QAction("Open destination folder", menu) + self._dest_open_action.triggered.connect(self._open_destination) + menu.addAction(self._dest_open_action) + self.dest_button.setMenu(menu) + + def _build_status_footer(self) -> QHBoxLayout: + layout = QHBoxLayout() + layout.setContentsMargins(4, 0, 4, 0) + layout.setSpacing(12) + self.status_label = QLabel("Ready. Add media to begin.") + self.status_label.setObjectName("footerText") + self.status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + layout.addWidget(self.status_label, 1) + self.engine_label = QLabel() + self.engine_label.setObjectName("engineLabel") + layout.addWidget(self.engine_label) + return layout + + def _install_shortcuts(self) -> None: + self._shortcuts = [] + self._shortcuts.append(QShortcut(QKeySequence("Ctrl+O"), self, activated=self._choose_files)) + self._shortcuts.append( + QShortcut(QKeySequence("Ctrl+Shift+O"), self, activated=self._choose_folder) + ) + delete_shortcut = QShortcut(QKeySequence("Delete"), self.queue_view) + delete_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + delete_shortcut.activated.connect(self._remove_selected) + self._shortcuts.append(delete_shortcut) + + # ---------------------------------------------------------- preferences + + def _load_preferences(self) -> None: + geometry = self._settings.value("window/geometry") + if geometry is not None: + self.restoreGeometry(geometry) + + stored_dir = self._settings.value("paths/last_dir", str(Path.home())) + if stored_dir: + self._last_dir = str(stored_dir) + + output_folder = self._settings.value("output/folder", "") + if output_folder: + self._output_dir = Path(str(output_folder)) + + image_options = ImageOptions( + output_format=str(self._settings.value("image/format", "original")), + quality=self._setting_int("image/quality", 82), + resize_mode=str(self._settings.value("image/resize_mode", "original")), + max_width=self._setting_int("image/max_width", 1920), + max_height=self._setting_int("image/max_height", 1080), + scale_percent=self._setting_int("image/scale_percent", 80), + keep_metadata=self._setting_bool("image/keep_metadata", False), + ) + video_options = VideoOptions( + codec=str(self._settings.value("video/codec", "h264")), + quality=self._setting_int("video/quality", 68), + speed=str(self._settings.value("video/speed", "balanced")), + resolution=self._setting_int("video/resolution", 0), + audio_bitrate=self._setting_int("video/audio_bitrate", 128), + keep_metadata=self._setting_bool("video/keep_metadata", False), + ) + self.settings_panel.set_image_options(image_options) + self.settings_panel.set_video_options(video_options) + self.settings_panel.set_current_tab_index(self._setting_int("ui/tab", 0)) + self._update_destination_button() + + if not self._settings.contains("window/geometry"): + screen = QApplication.primaryScreen() + if screen is not None: + available = screen.availableGeometry() + width = min(1200, max(1040, available.width() - 160)) + height = min(820, max(680, available.height() - 140)) + self.resize(width, height) + frame = self.frameGeometry() + frame.moveCenter(available.center()) + self.move(frame.topLeft()) + + def _setting_int(self, key: str, default: int) -> int: + try: + return int(self._settings.value(key, default)) + except (TypeError, ValueError): + return default + + def _setting_bool(self, key: str, default: bool) -> bool: + value = self._settings.value(key, default) + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + def _on_settings_changed(self) -> None: + self._save_timer.start() + + def _schedule_save(self) -> None: + self._save_timer.start() + + def _save_preferences(self) -> None: + self._settings.setValue("window/geometry", self.saveGeometry()) + self._settings.setValue("paths/last_dir", self._last_dir) + self._settings.setValue("output/folder", str(self._output_dir) if self._output_dir else "") + + image_options = self.settings_panel.image_options() + self._settings.setValue("image/format", image_options.output_format) + self._settings.setValue("image/quality", image_options.quality) + self._settings.setValue("image/resize_mode", image_options.resize_mode) + self._settings.setValue("image/max_width", image_options.max_width) + self._settings.setValue("image/max_height", image_options.max_height) + self._settings.setValue("image/scale_percent", image_options.scale_percent) + self._settings.setValue("image/keep_metadata", image_options.keep_metadata) + + video_options = self.settings_panel.video_options() + self._settings.setValue("video/codec", video_options.codec) + self._settings.setValue("video/quality", video_options.quality) + self._settings.setValue("video/speed", video_options.speed) + self._settings.setValue("video/resolution", video_options.resolution) + self._settings.setValue("video/audio_bitrate", video_options.audio_bitrate) + self._settings.setValue("video/keep_metadata", video_options.keep_metadata) + self._settings.setValue("ui/tab", self.settings_panel.current_tab_index()) + self._settings.sync() + + # ---------------------------------------------------------------- files + + def _choose_files(self) -> None: + if self._running or self._scanning: + return + start_dir = self._last_dir if Path(self._last_dir).exists() else str(Path.home()) + paths, _selected_filter = QFileDialog.getOpenFileNames( + self, + "Add media", + start_dir, + _MEDIA_FILTER, + ) + if paths: + self._last_dir = str(Path(paths[0]).parent) + self.add_paths(paths) + + def _choose_folder(self) -> None: + if self._running or self._scanning: + return + start_dir = self._last_dir if Path(self._last_dir).exists() else str(Path.home()) + folder = QFileDialog.getExistingDirectory(self, "Add a folder", start_dir) + if folder: + self._last_dir = folder + self.add_paths([folder]) + + def add_paths(self, paths) -> None: + if self._running or self._scanning: + return + entries = [Path(path) for path in paths] + if not entries: + return + + self._scanning = True + added_ids: List[str] = [] + ignored = 0 + missing = 0 + duplicates = 0 + failures: List[tuple[str, str]] = [] + already_seen = set(self._seen) + was_empty = not self._order + + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + try: + candidates: List[Path] = [] + for entry in entries: + if entry.is_dir(): + for root, dirs, files in os.walk(entry): + dirs[:] = sorted( + ( + name + for name in dirs + if not name.startswith(".") + and name.lower() not in {"$recycle.bin", "system volume information"} + ), + key=str.casefold, + ) + for filename in sorted(files, key=str.casefold): + candidate = Path(root) / filename + if classify_path(candidate): + candidates.append(candidate) + else: + ignored += 1 + elif entry.is_file(): + if classify_path(entry): + candidates.append(entry) + else: + ignored += 1 + else: + missing += 1 + + candidates.sort(key=lambda p: (str(p.parent).casefold(), p.name.casefold())) + unique_candidates: List[Path] = [] + new_keys: Set[str] = set() + for candidate in candidates: + absolute = Path(os.path.abspath(candidate)) + key = os.path.normcase(str(absolute)) + if key in already_seen or key in new_keys: + duplicates += 1 + continue + new_keys.add(key) + unique_candidates.append(absolute) + + if unique_candidates: + self._set_status(f"Inspecting {len(unique_candidates)} file(s)…", MUTED) + QApplication.processEvents(QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents) + + self.queue_view.setUpdatesEnabled(False) + try: + for path in unique_candidates: + try: + info = probe_media(path, self._tools) + except Exception as exc: + failures.append((path.name, _one_line(str(exc), 240))) + continue + item = MediaItem(item_id=uuid.uuid4().hex, info=info) + self._items[item.item_id] = item + self._order.append(item.item_id) + self._seen.add(os.path.normcase(str(path))) + self._add_queue_row(item) + added_ids.append(item.item_id) + finally: + self.queue_view.setUpdatesEnabled(True) + finally: + QApplication.restoreOverrideCursor() + self._scanning = False + + if added_ids and was_empty: + kinds = {self._items[item_id].kind for item_id in added_ids} + if kinds == {MediaKind.VIDEO}: + self.settings_panel.set_current_tab_index(1) + elif kinds == {MediaKind.IMAGE}: + self.settings_panel.set_current_tab_index(0) + + if added_ids: + self._progress_visible = False + self._set_status( + f"Added {len(added_ids)} file{'s' if len(added_ids) != 1 else ''}. " + "Adjust settings, then compress.", + MUTED, + ) + elif failures: + self._set_status("No files could be added. See the message for details.", RED) + + extras = [] + if duplicates: + extras.append(f"{duplicates} duplicate{'s' if duplicates != 1 else ''} skipped") + if ignored: + extras.append(f"{ignored} unsupported file{'s' if ignored != 1 else ''} ignored") + if missing: + extras.append(f"{missing} path{'s' if missing != 1 else ''} not found") + if extras and added_ids: + self._set_status( + f"Added {len(added_ids)} file{'s' if len(added_ids) != 1 else ''} · " + + " · ".join(extras), + MUTED, + ) + elif not added_ids and not failures and (ignored or missing or duplicates): + detail = " · ".join(extras) if extras else "No supported media was found." + self._set_status(detail, AMBER) + + if added_ids: + self._last_dir = str(unique_candidates[0].parent) + self._schedule_save() + self._refresh_ui() + + if failures: + shown = failures[:6] + message = "\n".join(f"• {name}: {error}" for name, error in shown) + if len(failures) > len(shown): + message += f"\n…and {len(failures) - len(shown)} more." + QMessageBox.warning( + self, + "Some files couldn't be added", + message, + ) + + def _add_queue_row(self, item: MediaItem) -> None: + info = item.info + details = f"{info.width} × {info.height}" + if info.kind == MediaKind.IMAGE: + details += f" · {info.format_name}" + else: + if info.duration: + details += f" · {format_duration(info.duration)}" + + row = QTreeWidgetItem( + [ + item.path.name, + details, + human_size(item.size), + "—", + "Ready", + ] + ) + row.setIcon(0, media_icon(item.kind)) + row.setData(0, Qt.ItemDataRole.UserRole, item.item_id) + row.setSizeHint(0, QSize(0, 50)) + row.setToolTip(0, str(item.path)) + row.setToolTip(1, self._full_details(item)) + row.setToolTip(2, f"Original size: {human_size(item.size)}") + row.setToolTip(3, "Compressed size appears here.") + row.setToolTip(4, "Ready to compress") + muted = QBrush(QColor(MUTED)) + row.setForeground(1, muted) + row.setForeground(2, muted) + row.setForeground(3, muted) + row.setForeground(4, muted) + self.queue_view.addTopLevelItem(row) + self._rows[item.item_id] = row + + @staticmethod + def _full_details(item: MediaItem) -> str: + info = item.info + lines = [ + str(item.path), + f"Dimensions: {info.width} × {info.height}", + f"Size: {human_size(info.size)}", + f"Format: {info.format_name}", + ] + if info.kind == MediaKind.VIDEO: + if info.duration: + lines.append(f"Duration: {format_duration(info.duration)}") + if info.codec: + lines.append(f"Codec: {info.codec}") + return "\n".join(lines) + + # --------------------------------------------------------------- queue + + def _refresh_ui(self) -> None: + self.queue_stack.setCurrentIndex(1 if self._order else 0) + self.count_badge.setText(str(len(self._order))) + self._update_stats() + self._update_destination_button() + self._refresh_controls() + + def _refresh_controls(self) -> None: + running = self._running + has_items = bool(self._order) + self.add_files_button.setEnabled(not running) + self.add_folder_button.setEnabled(not running) + self.clear_button.setEnabled(not running and has_items) + self.dest_button.setEnabled(not running) + self.settings_panel.setEnabled(not running) + self.progress_wrap.setVisible(self._progress_visible) + self.cancel_button.setVisible(running) + self.compress_button.setVisible(True) + if running: + self.compress_button.setEnabled(False) + self.compress_button.setText("Compressing…") + self.compress_button.setToolTip("Compression is in progress.") + self.cancel_button.setEnabled(True) + self.cancel_button.setText("Cancel") + else: + self.compress_button.setEnabled(has_items) + if has_items: + pending = sum( + 1 + for item_id in self._order + if not self._item_has_output(self._items[item_id]) + ) + if pending == 0: + self.compress_button.setText("Compress again") + self.compress_button.setToolTip( + "Re-encode every queued file and create new compressed copies." + ) + else: + self.compress_button.setText( + f"Compress {pending} file{'s' if pending != 1 else ''}" + ) + self.compress_button.setToolTip( + "Only files without a compressed result will be processed." + if pending != len(self._order) + else "Compress the files in the queue." + ) + else: + self.compress_button.setText("Compress") + self.compress_button.setToolTip("Add media to the queue first.") + self._update_destination_menu_state() + + @staticmethod + def _item_has_output(item: MediaItem) -> bool: + return item.output_path is not None and item.output_path.exists() + + def _update_stats(self) -> None: + if not self._order: + self.stats_label.setText("No files yet") + self.stats_label.setToolTip("") + return + items = [self._items[item_id] for item_id in self._order] + total_input = sum(item.size for item in items) + completed = [item for item in items if self._item_has_output(item)] + if completed: + completed_input = sum(item.size for item in completed) + completed_output = sum(item.output_size for item in completed) + prefix = ( + f"{len(completed)} of {len(items)} compressed" + if len(completed) != len(items) + else f"{len(items)} file{'s' if len(items) != 1 else ''}" + ) + savings = 0.0 + if completed_input > 0: + savings = (1 - completed_output / completed_input) * 100 + if savings >= 0: + saving_text = f"{round(savings)}% smaller" + else: + saving_text = f"{round(abs(savings))}% larger" + text = ( + f"{prefix} · {human_size(completed_input)} → {human_size(completed_output)}" + f" · {saving_text}" + ) + full_tooltip = ( + f"Queue: {human_size(total_input)} across {len(items)} file(s)\n" + f"Completed: {human_size(completed_input)} → {human_size(completed_output)}" + ) + self.stats_label.setToolTip(full_tooltip) + else: + text = f"{len(items)} file{'s' if len(items) != 1 else ''} · {human_size(total_input)} total" + self.stats_label.setToolTip(f"Total input size: {human_size(total_input)}") + self.stats_label.setText(text) + + def _set_row_status(self, item_id: str, text: str, color: str = MUTED, tooltip: Optional[str] = None) -> None: + row = self._rows.get(item_id) + if row is None: + return + row.setText(4, text) + row.setForeground(4, QBrush(QColor(color))) + if tooltip is not None: + row.setToolTip(4, tooltip) + + def _set_row_result(self, item_id: str, text: str, color: str = MUTED, tooltip: Optional[str] = None) -> None: + row = self._rows.get(item_id) + if row is None: + return + row.setText(3, text) + row.setForeground(3, QBrush(QColor(color))) + if tooltip is not None: + row.setToolTip(3, tooltip) + + def _show_queue_menu(self, position) -> None: + row = self.queue_view.itemAt(position) + if row is None: + return + if not row.isSelected(): + self.queue_view.clearSelection() + row.setSelected(True) + item_id = row.data(0, Qt.ItemDataRole.UserRole) + item = self._items.get(item_id) + if item is None: + return + + menu = QMenu(self) + open_original = menu.addAction("Open original") + reveal_original = menu.addAction("Show original in folder") + output_action = None + reveal_output_action = None + if item.output_path is not None and item.output_path.exists(): + menu.addSeparator() + output_action = menu.addAction("Open compressed file") + reveal_output_action = menu.addAction("Show compressed file in folder") + error_action = None + if item.message and not self._running: + menu.addSeparator() + error_action = menu.addAction("Show error details…") + menu.addSeparator() + remove_action = menu.addAction("Remove selected from queue") + remove_action.setEnabled(not self._running) + + chosen = menu.exec(self.queue_view.viewport().mapToGlobal(position)) + if chosen is open_original: + _open_path(item.path) + elif chosen is reveal_original: + _reveal_path(item.path) + elif output_action is not None and chosen is output_action: + _open_path(item.output_path) + elif reveal_output_action is not None and chosen is reveal_output_action: + _reveal_path(item.output_path) + elif error_action is not None and chosen is error_action: + QMessageBox.warning( + self, + f"Could not compress {item.path.name}", + item.message or "No further details are available.", + ) + elif chosen is remove_action: + self._remove_selected() + + def _open_queue_item(self, row: QTreeWidgetItem, _column: int) -> None: + item_id = row.data(0, Qt.ItemDataRole.UserRole) + item = self._items.get(item_id) + if item is None: + return + target = item.output_path if item.output_path and item.output_path.exists() else item.path + _open_path(target) + + def _remove_selected(self) -> None: + if self._running: + return + selected_ids = set() + for row in self.queue_view.selectedItems(): + item_id = row.data(0, Qt.ItemDataRole.UserRole) + if item_id: + selected_ids.add(item_id) + if not selected_ids: + return + for index in reversed(range(self.queue_view.topLevelItemCount())): + row = self.queue_view.topLevelItem(index) + if row.data(0, Qt.ItemDataRole.UserRole) in selected_ids: + self.queue_view.takeTopLevelItem(index) + for item_id in selected_ids: + item = self._items.pop(item_id, None) + self._rows.pop(item_id, None) + if item is not None: + self._seen.discard(os.path.normcase(str(item.path))) + self._order = [item_id for item_id in self._order if item_id in self._items] + self._progress_visible = False + self._last_run_summary = None + self._set_status( + f"Removed {len(selected_ids)} file{'s' if len(selected_ids) != 1 else ''} from the queue.", + MUTED, + ) + self._refresh_ui() + + def _clear_queue(self) -> None: + if self._running or not self._order: + return + self.queue_view.clear() + self._items.clear() + self._rows.clear() + self._order.clear() + self._seen.clear() + self._progress_visible = False + self._last_run_summary = None + self.progress_bar.setValue(0) + self.percent_label.setText("0%") + self._set_status("Queue cleared.", MUTED) + self._refresh_ui() + + # ------------------------------------------------------------- outputs + + def _set_output_dir(self, folder: Optional[Path]) -> None: + if self._running: + return + self._output_dir = Path(folder) if folder else None + self._update_destination_button() + self._schedule_save() + if folder: + self._set_status(f"Output folder set to {folder}.", MUTED) + else: + self._set_status("Output will be saved next to each original file.", MUTED) + + def _choose_output_dir(self) -> None: + if self._running: + return + if self._output_dir and self._output_dir.exists(): + start_dir = str(self._output_dir) + elif self._order: + start_dir = str(self._items[self._order[0]].path.parent) + else: + start_dir = self._last_dir if Path(self._last_dir).exists() else str(Path.home()) + folder = QFileDialog.getExistingDirectory(self, "Choose output folder", start_dir) + if folder: + self._set_output_dir(Path(folder)) + + def _update_destination_button(self) -> None: + if self._output_dir: + name = self._output_dir.name or str(self._output_dir) + self.dest_button.setText(f"Save to {name}") + self.dest_button.setToolTip(f"Compressed files will be saved to:\n{self._output_dir}") + else: + self.dest_button.setText("Next to originals") + self.dest_button.setToolTip( + "Compressed files are saved beside their originals with a -compressed suffix." + ) + self._dest_source_action.setChecked(self._output_dir is None) + self._update_destination_menu_state() + + def _update_destination_menu_state(self) -> None: + target = self._destination_folder() + exists = target is not None and target.exists() + if hasattr(self, "_dest_open_action"): + self._dest_open_action.setEnabled(exists) + if hasattr(self, "_dest_choose_action"): + self._dest_choose_action.setEnabled(not self._running) + if hasattr(self, "_dest_source_action"): + self._dest_source_action.setEnabled(not self._running) + + def _destination_folder(self) -> Optional[Path]: + if self._output_dir is not None: + return self._output_dir + if self._order: + return self._items[self._order[0]].path.parent + return None + + def _open_destination(self) -> None: + target = self._destination_folder() + if target is not None and target.exists(): + _open_path(target) + + # ----------------------------------------------------------- processing + + def _start_compression(self) -> None: + if self._running or not self._order: + return + all_items = [self._items[item_id] for item_id in self._order] + pending_items = [ + item for item in all_items if not self._item_has_output(item) + ] + # If everything already has a result, "Compress again" re-encodes the whole queue. + items = pending_items or all_items + video_items = [item for item in items if item.kind == MediaKind.VIDEO] + if video_items and not self._tools.can_encode_video: + QMessageBox.warning( + self, + "FFmpeg is required for video", + "FFmpeg (and ffprobe) could not be found.\n\n" + "Install FFmpeg and make sure both tools are on PATH, then restart Bolt Compress. " + "Image compression will still work without FFmpeg.", + ) + return + + if self._output_dir is not None: + if self._output_dir.exists() and not self._output_dir.is_dir(): + QMessageBox.warning( + self, + "Invalid output folder", + "The chosen output destination is a file, not a folder.", + ) + return + + self._last_run_summary = None + self._progress_visible = True + self.progress_bar.setValue(0) + self.percent_label.setText("0%") + self._set_status("Preparing compression…", MUTED) + for item in items: + item.output_path = None + item.output_size = 0 + item.message = "" + self._set_row_result(item.item_id, "—", MUTED, "Compressed size appears here.") + self._set_row_status(item.item_id, "Queued", MUTED, "Waiting to compress") + self._update_stats() + + self._running = True + self._refresh_controls() + self.queue_view.clearSelection() + + jobs = [ + CompressionJob( + item_id=item.item_id, + source=item.path, + kind=item.kind, + width=item.info.width, + height=item.info.height, + duration=item.info.duration, + ) + for item in items + ] + worker = CompressionWorker( + jobs, + image_options=self.settings_panel.image_options(), + video_options=self.settings_panel.video_options(), + output_dir=self._output_dir, + suffix=OUTPUT_SUFFIX, + parent=self, + ) + worker.itemStarted.connect(self._on_item_started) + worker.itemProgress.connect(self._on_item_progress) + worker.itemFinished.connect(self._on_item_finished) + worker.overallProgress.connect(self._on_overall_progress) + worker.summary.connect(self._on_worker_summary) + worker.finished.connect(self._on_worker_finished) + self._worker = worker + worker.start() + + def _cancel_compression(self) -> None: + if self._worker is None or not self._running: + return + self._worker.cancel() + self.cancel_button.setEnabled(False) + self.cancel_button.setText("Stopping…") + self._set_status("Stopping the current encode…", AMBER) + + def _on_item_started(self, item_id: str) -> None: + item = self._items.get(item_id) + if item is None: + return + self._current_id = item_id + self._set_row_status(item_id, "Working", ACCENT, "Compressing this file") + row = self._rows.get(item_id) + if row is not None: + self.queue_view.scrollToItem(row, QAbstractItemView.ScrollHint.PositionAtCenter) + self._set_status(f"Compressing {item.path.name}…", MUTED) + + def _on_item_progress(self, item_id: str, percent: int) -> None: + self._set_row_status(item_id, f"{percent}%", ACCENT, "Compressing this file") + + def _on_overall_progress(self, percent: int) -> None: + self.progress_bar.setValue(percent) + self.percent_label.setText(f"{percent}%") + + def _on_item_finished( + self, + item_id: str, + status: str, + message: str, + output_path: str, + output_size: int, + ) -> None: + item = self._items.get(item_id) + if item is None: + return + if status == "done": + item.output_path = Path(output_path) if output_path else None + item.output_size = output_size + self._set_row_result( + item_id, + human_size(output_size), + "#D7E0EA", + f"Compressed file: {output_path}", + ) + if item.size > 0 and output_size < item.size: + saved_percent = max(1, round((1 - output_size / item.size) * 100)) + status_text = f"Saved {saved_percent}%" + status_color = GREEN + detail = f"{human_size(item.size - output_size)} saved ({saved_percent}%)" + elif item.size > 0: + growth = round((output_size / item.size - 1) * 100) + status_text = "No gain" if growth < 1 else f"Grew {growth}%" + status_color = AMBER + detail = "no size gain" + else: + status_text = "Done" + status_color = GREEN + detail = "compression finished" + self._set_row_status( + item_id, + status_text, + status_color, + f"{detail}\n{output_path}", + ) + self._set_status( + f"Compressed {item.path.name} · {detail}.", + status_color, + ) + elif status == "cancelled": + item.output_path = None + item.output_size = 0 + self._set_row_result(item_id, "—", MUTED, "No output was created.") + self._set_row_status(item_id, "Cancelled", AMBER, "Compression was cancelled.") + else: + item.output_path = None + item.output_size = 0 + item.message = message + self._set_row_result(item_id, "—", MUTED, "No output was created.") + self._set_row_status(item_id, "Failed", RED, message or "Compression failed.") + self._set_status( + f"Could not compress {item.path.name}: {_one_line(message, 120)}", + RED, + ) + self._update_stats() + + def _on_worker_summary(self, summary: dict) -> None: + self._last_run_summary = summary + + def _on_worker_finished(self) -> None: + worker = self._worker + self._worker = None + if worker is not None: + worker.deleteLater() + self._running = False + self._current_id = None + self._refresh_controls() + self._update_stats() + + summary = self._last_run_summary or { + "total": len(self._order), + "done": 0, + "errors": 0, + "cancelled": 0, + "output_bytes": 0, + "input_bytes": 0, + } + total = summary.get("total", 0) + done = summary.get("done", 0) + errors = summary.get("errors", 0) + cancelled = summary.get("cancelled", 0) + if cancelled: + self._set_status( + f"Cancelled. {done} file{'s' if done != 1 else ''} completed" + + (f", {errors} failed." if errors else "."), + AMBER, + ) + elif errors: + self._set_status( + f"Finished with {errors} failure{'s' if errors != 1 else ''}. " + f"{done} of {total} compressed. Hover a failed row for details.", + RED, + ) + elif done: + input_bytes = summary.get("input_bytes", 0) + output_bytes = summary.get("output_bytes", 0) + if input_bytes > 0: + savings = (1 - output_bytes / input_bytes) * 100 + if savings >= 0: + savings_text = f"{human_size(max(0, input_bytes - output_bytes))} saved ({round(savings)}%)" + else: + savings_text = f"output is {human_size(max(0, output_bytes - input_bytes))} larger" + else: + savings_text = "done" + self._set_status( + f"Done — {done} file{'s' if done != 1 else ''} compressed · {savings_text}.", + GREEN, + ) + else: + self._set_status("Queue is ready.", MUTED) + self.progress_wrap.setVisible(self._progress_visible) + self._refresh_ui() + + # ------------------------------------------------------------- drag/drop + + def dragEnterEvent(self, event) -> None: + if self._running or self._scanning or not event.mimeData().hasUrls(): + event.ignore() + return + if any(url.isLocalFile() for url in event.mimeData().urls()): + self._set_drag_active(True) + event.acceptProposedAction() + else: + event.ignore() + + def dragMoveEvent(self, event) -> None: + if self._running or self._scanning: + event.ignore() + return + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + event.ignore() + + def dragLeaveEvent(self, event) -> None: + self._set_drag_active(False) + super().dragLeaveEvent(event) + + def dropEvent(self, event) -> None: + self._set_drag_active(False) + if self._running or self._scanning: + event.ignore() + return + local_paths = [ + url.toLocalFile() + for url in event.mimeData().urls() + if url.isLocalFile() and url.toLocalFile() + ] + if local_paths: + event.acceptProposedAction() + self.add_paths(local_paths) + else: + event.ignore() + + def _set_drag_active(self, active: bool) -> None: + if self._drag_active == active: + return + self._drag_active = active + self.queue_card.setProperty("dragActive", active) + self.queue_card.style().unpolish(self.queue_card) + self.queue_card.style().polish(self.queue_card) + self.queue_card.update() + self.drop_zone.set_drag_active(active) + + # --------------------------------------------------------------- status + + def _set_status(self, text: str, color: Optional[str] = None) -> None: + self.status_label.setText(text) + if color: + self.status_label.setStyleSheet(f"color: {color};") + else: + self.status_label.setStyleSheet("") + + def _update_engine_status(self) -> None: + tools = self._tools + if tools.can_encode_video: + self.engine_label.setText("● Engines ready · nothing uploaded") + self.engine_label.setStyleSheet(f"color: {GREEN};") + tooltip = f"FFmpeg: {tools.ffmpeg}\nffprobe: {tools.ffprobe}\nImages: Pillow" + elif tools.ffmpeg is not None: + self.engine_label.setText("● ffprobe missing — video unavailable") + self.engine_label.setStyleSheet(f"color: {AMBER};") + tooltip = "ffprobe was not found. Add it to PATH to enable video compression." + else: + self.engine_label.setText("● FFmpeg missing — video unavailable") + self.engine_label.setStyleSheet(f"color: {RED};") + tooltip = ( + "FFmpeg was not found. Install it and add ffmpeg + ffprobe to PATH " + "to enable video compression. Images work without it." + ) + self.engine_label.setToolTip(tooltip) + + # --------------------------------------------------------------- events + + def closeEvent(self, event) -> None: + if self._worker is not None and self._worker.isRunning(): + reply = QMessageBox.question( + self, + "Stop compression and quit?", + "A compression is still running. Stop it and close Bolt Compress?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + event.ignore() + return + self._worker.cancel() + if not self._worker.wait(15000): + QMessageBox.warning( + self, + "Still stopping", + "The current file is still finishing. Please wait a moment and close again.", + ) + event.ignore() + return + self._save_preferences() + super().closeEvent(event) diff --git a/Bolt-Compress/bolt_compress/media.py b/Bolt-Compress/bolt_compress/media.py new file mode 100644 index 0000000..87cc435 --- /dev/null +++ b/Bolt-Compress/bolt_compress/media.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Set + +from PIL import Image + +from .models import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, MediaInfo, MediaKind + +# Keep Pillow from warning on very large, legitimate photos. +Image.MAX_IMAGE_PIXELS = 300_000_000 + + +class MediaError(Exception): + """A media file could not be read or processed.""" + + +@dataclass(frozen=True) +class FFmpegTools: + ffmpeg: Optional[Path] + ffprobe: Optional[Path] + + @property + def can_encode_video(self) -> bool: + return self.ffmpeg is not None and self.ffprobe is not None + + +def _creation_flags() -> int: + if os.name == "nt": + return getattr(subprocess, "CREATE_NO_WINDOW", 0) + return 0 + + +def find_ffmpeg_tools() -> FFmpegTools: + """Locate ffmpeg/ffprobe, preferring a bundled bin folder, then PATH.""" + override = os.environ.get("BOLT_FFMPEG_DIR") + candidates = [] + if override: + candidates.append(Path(override)) + + app_root = Path(__file__).resolve().parent.parent + candidates.extend([app_root / "bin", app_root]) + + exe = ".exe" if os.name == "nt" else "" + + def locate(name: str) -> Optional[Path]: + for directory in candidates: + candidate = directory / f"{name}{exe}" + if candidate.is_file(): + return candidate + resolved = shutil.which(name) + return Path(resolved) if resolved else None + + return FFmpegTools(ffmpeg=locate("ffmpeg"), ffprobe=locate("ffprobe")) + + +def classify_path(path: Path) -> Optional[MediaKind]: + suffix = path.suffix.lower() + if suffix in IMAGE_EXTENSIONS: + return MediaKind.IMAGE + if suffix in VIDEO_EXTENSIONS: + return MediaKind.VIDEO + return None + + +def human_size(num_bytes: int) -> str: + value = float(max(0, num_bytes)) + units = ("B", "KB", "MB", "GB", "TB") + for unit in units: + if value < 1024 or unit == units[-1]: + if unit == "B": + return f"{int(value)} {unit}" + if value >= 100: + return f"{value:.0f} {unit}" + if value >= 10: + return f"{value:.1f} {unit}" + return f"{value:.2f} {unit}" + value /= 1024 + return f"{value:.1f} TB" + + +def format_duration(seconds: float) -> str: + if not seconds or seconds <= 0: + return "—" + total = int(round(seconds)) + hours, rem = divmod(total, 3600) + minutes, secs = divmod(rem, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes}:{secs:02d}" + + +def _display_rotation(stream: dict) -> int: + rotation = 0 + for side_data in stream.get("side_data_list", []) or []: + if "rotation" in side_data: + try: + rotation = int(round(float(side_data["rotation"]))) + except (TypeError, ValueError): + pass + if rotation == 0: + tags = stream.get("tags", {}) or {} + if "rotate" in tags: + try: + rotation = int(round(float(tags["rotate"]))) + except (TypeError, ValueError): + pass + return rotation % 360 + + +def _probe_video(path: Path, size: int, tools: FFmpegTools) -> MediaInfo: + if tools.ffprobe is None: + raise MediaError("ffprobe was not found. Install FFmpeg and make sure it is on PATH.") + + cmd = [ + str(tools.ffprobe), + "-v", + "error", + "-show_streams", + "-show_format", + "-of", + "json", + str(path), + ] + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=30, + creationflags=_creation_flags(), + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise MediaError(f"Could not inspect video: {exc}") from exc + + if result.returncode != 0: + message = (result.stderr or "Unreadable video file").strip().splitlines()[-1] + raise MediaError(message[:300]) + + try: + payload = json.loads(result.stdout or "{}") + except json.JSONDecodeError as exc: + raise MediaError("Could not read video metadata.") from exc + + streams = payload.get("streams", []) or [] + stream = next( + (s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic") != 1), + None, + ) + if stream is None: + stream = next((s for s in streams if s.get("codec_type") == "video"), None) + if stream is None: + raise MediaError("No video stream was found in this file.") + + try: + width = int(stream.get("width") or 0) + height = int(stream.get("height") or 0) + except (TypeError, ValueError): + width = height = 0 + + rotation = _display_rotation(stream) + if rotation in (90, 270): + width, height = height, width + + duration = 0.0 + for candidate in (payload.get("format", {}).get("duration"), stream.get("duration")): + try: + candidate_float = float(candidate) + if candidate_float > 0: + duration = candidate_float + break + except (TypeError, ValueError): + continue + + format_name = str(payload.get("format", {}).get("format_name", "")) + primary_format = format_name.split(",")[0] if format_name else path.suffix.lstrip(".") + primary_format = primary_format.upper() + codec = str(stream.get("codec_name", "")).upper() + + return MediaInfo( + path=path, + kind=MediaKind.VIDEO, + size=size, + width=width, + height=height, + format_name=primary_format, + duration=duration, + codec=codec, + ) + + +def _probe_image(path: Path, size: int) -> MediaInfo: + try: + with Image.open(path) as image: + if getattr(image, "is_animated", False) and getattr(image, "n_frames", 1) > 1: + raise MediaError("Animated images are not supported yet.") + width, height = image.size + try: + orientation = image.getexif().get(0x0112, 1) + except Exception: + orientation = 1 + if orientation in (5, 6, 7, 8): + width, height = height, width + format_name = (image.format or path.suffix.lstrip(".")).upper() + except MediaError: + raise + except Exception as exc: + raise MediaError(f"Could not read image: {exc}") from exc + + if width <= 0 or height <= 0: + raise MediaError("The image has no readable dimensions.") + + return MediaInfo( + path=path, + kind=MediaKind.IMAGE, + size=size, + width=width, + height=height, + format_name=format_name, + ) + + +def probe_media(path: Path, tools: Optional[FFmpegTools] = None) -> MediaInfo: + path = Path(path) + kind = classify_path(path) + if kind is None: + raise MediaError("Unsupported file type.") + try: + size = path.stat().st_size + except OSError as exc: + raise MediaError(f"File is not accessible: {exc}") from exc + + if kind == MediaKind.IMAGE: + return _probe_image(path, size) + return _probe_video(path, size, tools or find_ffmpeg_tools()) + + +def image_output_extension(source: Path, format_choice: str) -> str: + if format_choice == "jpeg": + return ".jpg" + if format_choice == "webp": + return ".webp" + if format_choice == "png": + return ".png" + keep_map = { + ".jpg": ".jpg", + ".jpeg": ".jpg", + ".png": ".png", + ".webp": ".webp", + ".bmp": ".bmp", + ".tif": ".tif", + ".tiff": ".tif", + } + return keep_map.get(source.suffix.lower(), ".jpg") + + +def video_output_extension(codec: str) -> str: + return ".webm" if codec in {"vp9", "av1"} else ".mp4" + + +def unique_output_path(source: Path, folder: Optional[Path], suffix: str, extension: str) -> Path: + destination_dir = Path(folder) if folder else source.parent + base = destination_dir / f"{source.stem}{suffix}{extension}" + candidate = base + counter = 2 + while candidate.exists(): + candidate = destination_dir / f"{source.stem}{suffix} ({counter}){extension}" + counter += 1 + return candidate + + +def _run_encoders(ffmpeg: Path) -> str: + try: + result = subprocess.run( + [str(ffmpeg), "-hide_banner", "-encoders"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=20, + creationflags=_creation_flags(), + ) + except (OSError, subprocess.TimeoutExpired): + return "" + return result.stdout or "" + + +_encoder_cache: dict[str, Set[str]] = {} + + +def available_encoders(tools: Optional[FFmpegTools] = None) -> Set[str]: + tools = tools or find_ffmpeg_tools() + if tools.ffmpeg is None: + return set() + key = str(tools.ffmpeg) + if key not in _encoder_cache: + encoders: Set[str] = set() + for line in _run_encoders(tools.ffmpeg).splitlines(): + match = re.match(r"^\s*V\S*\s+(\S+)", line) + if match: + encoders.add(match.group(1)) + _encoder_cache[key] = encoders + return set(_encoder_cache[key]) + + +def available_video_codecs(tools: Optional[FFmpegTools] = None) -> Set[str]: + encoders = available_encoders(tools) + codecs: Set[str] = set() + if "libx264" in encoders: + codecs.add("h264") + if "libx265" in encoders: + codecs.add("h265") + if "libvpx-vp9" in encoders: + codecs.add("vp9") + if {"libsvtav1", "libaom-av1"} & encoders: + codecs.add("av1") + return codecs + + +def compact_error(text: str, limit: int = 400) -> str: + lines = [] + for raw in (text or "").splitlines(): + line = raw.strip() + if not line: + continue + if re.match( + r"^(frame|fps|stream_\d|bitrate|total_size|out_time|dup_frames|drop_frames|speed|progress)=", + line, + ): + continue + lines.append(line) + message = " ".join(lines) if lines else (text or "Encoding failed.").strip() + message = re.sub(r"\s+", " ", message) + return message[-limit:] if len(message) > limit else message diff --git a/Bolt-Compress/bolt_compress/models.py b/Bolt-Compress/bolt_compress/models.py new file mode 100644 index 0000000..baae151 --- /dev/null +++ b/Bolt-Compress/bolt_compress/models.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Optional + + +class MediaKind(str, Enum): + IMAGE = "image" + VIDEO = "video" + + +IMAGE_EXTENSIONS = { + ".jpg", + ".jpeg", + ".png", + ".webp", + ".bmp", + ".tif", + ".tiff", +} + +VIDEO_EXTENSIONS = { + ".mp4", + ".mov", + ".mkv", + ".avi", + ".webm", + ".m4v", + ".mpg", + ".mpeg", + ".wmv", + ".flv", + ".ts", + ".3gp", + ".mts", + ".m2ts", +} + +SUPPORTED_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS + + +@dataclass(frozen=True) +class MediaInfo: + path: Path + kind: MediaKind + size: int + width: int + height: int + format_name: str + duration: float = 0.0 + codec: str = "" + + +@dataclass +class MediaItem: + item_id: str + info: MediaInfo + output_path: Optional[Path] = None + output_size: int = 0 + message: str = "" + + @property + def path(self) -> Path: + return self.info.path + + @property + def kind(self) -> MediaKind: + return self.info.kind + + @property + def size(self) -> int: + return self.info.size + + +@dataclass +class ImageOptions: + output_format: str = "original" # original | jpeg | webp | png + quality: int = 82 + resize_mode: str = "original" # original | fit | percent + max_width: int = 1920 + max_height: int = 1080 + scale_percent: int = 80 + keep_metadata: bool = False + + +@dataclass +class VideoOptions: + codec: str = "h264" # h264 | h265 | vp9 | av1 + quality: int = 68 + speed: str = "balanced" # fast | balanced | best + resolution: int = 0 # 0 = original, otherwise target short edge + audio_bitrate: int = 128 # 0 = mute + keep_metadata: bool = False + + +def default_image_options() -> ImageOptions: + return ImageOptions() + + +def default_video_options() -> VideoOptions: + return VideoOptions() diff --git a/Bolt-Compress/bolt_compress/processing.py b/Bolt-Compress/bolt_compress/processing.py new file mode 100644 index 0000000..40b10c3 --- /dev/null +++ b/Bolt-Compress/bolt_compress/processing.py @@ -0,0 +1,644 @@ +from __future__ import annotations + +import os +import subprocess +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, List, Optional + +from PIL import Image, ImageOps +from PyQt6.QtCore import QThread, pyqtSignal + +from .media import ( + FFmpegTools, + MediaError, + available_encoders, + compact_error, + find_ffmpeg_tools, + image_output_extension, + unique_output_path, + video_output_extension, +) +from .models import ImageOptions, MediaKind, VideoOptions + +ProgressCallback = Callable[[int], None] +CancelCheck = Callable[[], bool] + + +class CancelledError(Exception): + pass + + +def _check_cancel(cancelled: Optional[CancelCheck]) -> None: + if cancelled and cancelled(): + raise CancelledError() + + +def _safe_remove(path: Path) -> None: + try: + path.unlink() + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Images +# --------------------------------------------------------------------------- + + +def _target_image_size(width: int, height: int, options: ImageOptions): + if options.resize_mode == "fit": + max_width = max(1, options.max_width) + max_height = max(1, options.max_height) + ratio = min(1.0, max_width / width, max_height / height) + target = (max(1, round(width * ratio)), max(1, round(height * ratio))) + elif options.resize_mode == "percent": + ratio = max(1, options.scale_percent) / 100.0 + target = (max(1, round(width * ratio)), max(1, round(height * ratio))) + else: + target = (width, height) + return target + + +def _has_alpha(image: Image.Image) -> bool: + return image.mode in ("RGBA", "LA", "PA") or ( + image.mode == "P" and "transparency" in image.info + ) + + +def _prepare_image_mode(image: Image.Image, target_format: str) -> Image.Image: + if target_format == "JPEG": + if _has_alpha(image): + rgba = image.convert("RGBA") + background = Image.new("RGB", rgba.size, (255, 255, 255)) + background.paste(rgba, mask=rgba.getchannel("A")) + return background + return image.convert("RGB") if image.mode != "RGB" else image + + if target_format == "WEBP": + if _has_alpha(image): + return image.convert("RGBA") + return image.convert("RGB") if image.mode not in ("RGB",) else image + + if target_format == "PNG": + if image.mode not in ("RGB", "RGBA", "L", "LA", "P", "I", "I;16"): + return image.convert("RGBA") + return image + + if target_format == "BMP": + return image.convert("RGB") if image.mode != "RGB" else image + + return image + + +def _resolve_image_format(source_format: str, options: ImageOptions) -> str: + mapping = {"jpeg": "JPEG", "webp": "WEBP", "png": "PNG"} + if options.output_format in mapping: + return mapping[options.output_format] + source_format = (source_format or "").upper() + if source_format in {"JPEG", "PNG", "WEBP", "BMP", "TIFF"}: + return source_format + raise MediaError("This image format cannot be saved in place.") + + +def compress_image( + source: Path, + destination: Path, + options: ImageOptions, + progress: Optional[ProgressCallback] = None, + cancelled: Optional[CancelCheck] = None, +) -> Path: + """Resize and re-encode one image. Saves atomically via a .part file.""" + progress = progress or (lambda _value: None) + _check_cancel(cancelled) + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(destination.name + ".part") + + try: + with Image.open(source) as opened: + if getattr(opened, "is_animated", False) and getattr(opened, "n_frames", 1) > 1: + raise MediaError("Animated images are not supported yet.") + source_format = opened.format or "" + icc_profile = opened.info.get("icc_profile") if options.keep_metadata else None + # Detach from the lazy file handle before leaving the context manager. + image = ImageOps.exif_transpose(opened).copy() + progress(8) + + _check_cancel(cancelled) + target_format = _resolve_image_format(source_format, options) + target_size = _target_image_size(image.width, image.height, options) + if target_size != image.size: + image = image.resize(target_size, Image.Resampling.LANCZOS) + progress(42) + + _check_cancel(cancelled) + image = _prepare_image_mode(image, target_format) + progress(58) + + exif_bytes = None + if options.keep_metadata: + try: + exif = image.getexif() + exif.pop(0x0112, None) # Rotation has already been applied. + if len(exif): + exif_bytes = exif.tobytes() + except Exception: + exif_bytes = None + + save_options = {} + if target_format == "JPEG": + save_options.update( + quality=max(1, min(100, options.quality)), + optimize=True, + progressive=True, + subsampling=0 if options.quality >= 90 else 2, + ) + elif target_format == "WEBP": + save_options.update( + quality=max(1, min(100, options.quality)), + method=6, + ) + elif target_format == "PNG": + save_options.update(optimize=True, compress_level=9) + elif target_format == "TIFF": + save_options.update(compression="tiff_lzw") + + supports_metadata = target_format in {"JPEG", "WEBP", "PNG", "TIFF"} + if supports_metadata and exif_bytes: + save_options["exif"] = exif_bytes + if supports_metadata and icc_profile: + save_options["icc_profile"] = icc_profile + + _check_cancel(cancelled) + progress(72) + _safe_remove(temporary) + image.save(temporary, format=target_format, **save_options) + progress(96) + _check_cancel(cancelled) + os.replace(temporary, destination) + progress(100) + return destination + except BaseException: + _safe_remove(temporary) + raise + + +# --------------------------------------------------------------------------- +# Video +# --------------------------------------------------------------------------- + +_VIDEO_PRESETS = { + "fast": "veryfast", + "balanced": "medium", + "best": "slow", +} + +_VP9_SPEED = {"fast": "5", "balanced": "2", "best": "0"} +_AV1_AOM_SPEED = {"fast": "8", "balanced": "6", "best": "4"} +_AV1_SVT_SPEED = {"fast": "10", "balanced": "8", "best": "6"} + + +def calculate_crf(codec: str, quality: int) -> int: + """Map the simple 20-95 quality scale onto each codec's CRF range.""" + quality = max(0, min(100, quality)) + if codec in {"h264", "h265"}: + return max(16, min(34, round(36 - 0.18 * quality))) + if codec == "vp9": + return max(18, min(46, round(46 - 0.22 * quality))) + return max(18, min(50, round(52 - 0.28 * quality))) + + +def _scale_filter(width: int, height: int, target_short_edge: int) -> Optional[str]: + if not width or not height or not target_short_edge: + return None + if min(width, height) <= target_short_edge: + return None + if width >= height: + return f"scale=-2:{int(target_short_edge)}:flags=lanczos,setsar=1" + return f"scale={int(target_short_edge)}:-2:flags=lanczos,setsar=1" + + +def build_video_command( + source: Path, + destination: Path, + options: VideoOptions, + width: int, + height: int, + tools: Optional[FFmpegTools] = None, +) -> List[str]: + tools = tools or find_ffmpeg_tools() + if tools.ffmpeg is None: + raise MediaError("FFmpeg was not found. Add it to PATH to compress videos.") + + encoders = available_encoders(tools) + codec = options.codec + audio_bitrate = int(options.audio_bitrate) + crf = str(calculate_crf(codec, options.quality)) + speed = options.speed if options.speed in _VIDEO_PRESETS else "balanced" + + cmd = [ + str(tools.ffmpeg), + "-hide_banner", + "-nostdin", + "-loglevel", + "error", + "-y", + "-i", + str(source), + "-map", + "0:v:0", + ] + if audio_bitrate > 0: + cmd += ["-map", "0:a?"] + else: + cmd += ["-an"] + cmd += ["-sn", "-dn"] + if not options.keep_metadata: + cmd += ["-map_metadata", "-1", "-map_chapters", "-1"] + + scale = _scale_filter(width, height, options.resolution) + if scale: + cmd += ["-vf", scale] + elif width and height and (width % 2 or height % 2): + # yuv420p needs even dimensions, and we never want to distort the source. + cmd += ["-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0"] + + if codec == "h264": + if "libx264" not in encoders: + raise MediaError("This FFmpeg build cannot encode H.264.") + cmd += [ + "-c:v", + "libx264", + "-preset", + _VIDEO_PRESETS[speed], + "-crf", + crf, + "-pix_fmt", + "yuv420p", + ] + elif codec == "h265": + if "libx265" not in encoders: + raise MediaError("This FFmpeg build cannot encode H.265.") + cmd += [ + "-c:v", + "libx265", + "-preset", + _VIDEO_PRESETS[speed], + "-crf", + crf, + "-pix_fmt", + "yuv420p", + "-tag:v", + "hvc1", + "-x265-params", + "log-level=error", + ] + elif codec == "vp9": + if "libvpx-vp9" not in encoders: + raise MediaError("This FFmpeg build cannot encode VP9.") + cmd += [ + "-c:v", + "libvpx-vp9", + "-b:v", + "0", + "-crf", + crf, + "-deadline", + "good", + "-cpu-used", + _VP9_SPEED[speed], + "-row-mt", + "1", + "-pix_fmt", + "yuv420p", + ] + elif codec == "av1": + if "libsvtav1" in encoders: + cmd += [ + "-c:v", + "libsvtav1", + "-crf", + crf, + "-preset", + _AV1_SVT_SPEED[speed], + "-pix_fmt", + "yuv420p", + ] + elif "libaom-av1" in encoders: + cmd += [ + "-c:v", + "libaom-av1", + "-b:v", + "0", + "-crf", + crf, + "-cpu-used", + _AV1_AOM_SPEED[speed], + "-row-mt", + "1", + "-pix_fmt", + "yuv420p", + ] + else: + raise MediaError("This FFmpeg build cannot encode AV1.") + else: + raise MediaError("Unknown video codec.") + + if audio_bitrate > 0: + audio_codec = "libopus" if destination.suffix.lower() == ".webm" else "aac" + cmd += ["-c:a", audio_codec, "-b:a", f"{audio_bitrate}k"] + + if destination.suffix.lower() == ".mp4": + cmd += ["-movflags", "+faststart"] + + cmd += ["-progress", "pipe:1", "-nostats", str(destination)] + return cmd + + +def _progress_line_ignored(line: str) -> bool: + prefixes = ( + "frame=", + "fps=", + "stream_", + "bitrate=", + "total_size=", + "out_time_us=", + "out_time_ms=", + "out_time=", + "dup_frames=", + "drop_frames=", + "speed=", + "progress=", + ) + return line.startswith(prefixes) + + +def compress_video( + source: Path, + destination: Path, + options: VideoOptions, + width: int, + height: int, + duration: float, + progress: Optional[ProgressCallback] = None, + cancelled: Optional[CancelCheck] = None, + process_changed: Optional[Callable[[Optional[subprocess.Popen]], None]] = None, + tools: Optional[FFmpegTools] = None, +) -> Path: + progress = progress or (lambda _value: None) + tools = tools or find_ffmpeg_tools() + _check_cancel(cancelled) + destination.parent.mkdir(parents=True, exist_ok=True) + cmd = build_video_command(source, destination, options, width, height, tools) + + failure: Optional[BaseException] = None + last_percent = -1 + error_lines: List[str] = [] + process: Optional[subprocess.Popen] = None + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0, + ) + if process_changed: + process_changed(process) + assert process.stdout is not None + for raw_line in process.stdout: + if cancelled and cancelled(): + try: + process.kill() + except OSError: + pass + line = raw_line.strip() + if not line: + continue + if line.startswith("out_time_us=") or line.startswith("out_time_ms="): + if duration > 0: + try: + microseconds = int(line.split("=", 1)[1]) + percent = int(min(99, max(0, microseconds / 1_000_000.0 / duration * 100))) + if percent > last_percent: + last_percent = percent + progress(percent) + except (TypeError, ValueError): + pass + continue + if line.startswith("progress=end"): + if duration > 0: + progress(100) + continue + if not _progress_line_ignored(line): + error_lines.append(line) + if len(error_lines) > 20: + error_lines = error_lines[-20:] + return_code = process.wait() + if process_changed: + process_changed(None) + process = None + + if cancelled and cancelled(): + raise CancelledError() + if return_code != 0: + message = compact_error("\n".join(error_lines)) or f"FFmpeg stopped with exit code {return_code}." + raise MediaError(message) + if not destination.exists() or destination.stat().st_size == 0: + raise MediaError("FFmpeg did not produce an output file.") + progress(100) + return destination + except BaseException as exc: + failure = exc + raise + finally: + if process is not None: + try: + process.kill() + process.wait(timeout=3) + except Exception: + pass + if process_changed: + process_changed(None) + if failure is not None: + _safe_remove(destination) + + +# --------------------------------------------------------------------------- +# Worker +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class CompressionJob: + item_id: str + source: Path + kind: MediaKind + width: int + height: int + duration: float = 0.0 + + +class CompressionWorker(QThread): + itemStarted = pyqtSignal(str) + itemProgress = pyqtSignal(str, int) + itemFinished = pyqtSignal(str, str, str, str, int) + overallProgress = pyqtSignal(int) + summary = pyqtSignal(object) + + def __init__( + self, + jobs: List[CompressionJob], + image_options: ImageOptions, + video_options: VideoOptions, + output_dir: Optional[Path], + suffix: str = "-compressed", + parent=None, + ): + super().__init__(parent) + self._jobs = list(jobs) + self._image_options = ImageOptions(**vars(image_options)) + self._video_options = VideoOptions(**vars(video_options)) + self._output_dir = Path(output_dir) if output_dir else None + self._suffix = suffix + self._cancelled = threading.Event() + self._process_lock = threading.Lock() + self._process: Optional[subprocess.Popen] = None + self._tools = find_ffmpeg_tools() + + @property + def cancelled(self) -> bool: + return self._cancelled.is_set() + + def cancel(self) -> None: + self._cancelled.set() + with self._process_lock: + process = self._process + if process is not None and process.poll() is None: + try: + process.kill() + except OSError: + pass + + def _set_process(self, process: Optional[subprocess.Popen]) -> None: + with self._process_lock: + self._process = process + + def _is_cancelled(self) -> bool: + return self._cancelled.is_set() + + def run(self) -> None: # noqa: C901 - a small, explicit batch loop is clearest here + total = len(self._jobs) + completed = 0 + errors = 0 + cancelled_count = 0 + output_bytes = 0 + done_input_bytes = 0 + if total == 0: + self.summary.emit( + { + "total": 0, + "done": 0, + "errors": 0, + "cancelled": 0, + "output_bytes": 0, + "input_bytes": 0, + } + ) + return + + for index, job in enumerate(self._jobs): + if self._cancelled.is_set(): + break + + self.itemStarted.emit(job.item_id) + destination: Optional[Path] = None + + def report(value: int, job_id: str = job.item_id, item_index: int = index) -> None: + clamped = max(0, min(100, int(value))) + self.itemProgress.emit(job_id, clamped) + overall = int(((item_index + clamped / 100.0) / total) * 100) + self.overallProgress.emit(min(99, overall)) + + try: + extension = ( + image_output_extension(job.source, self._image_options.output_format) + if job.kind == MediaKind.IMAGE + else video_output_extension(self._video_options.codec) + ) + destination = unique_output_path( + job.source, self._output_dir, self._suffix, extension + ) + if not job.source.exists(): + raise MediaError("The original file is no longer available.") + if job.kind == MediaKind.IMAGE: + compress_image( + job.source, + destination, + self._image_options, + progress=report, + cancelled=self._is_cancelled, + ) + else: + compress_video( + job.source, + destination, + self._video_options, + width=job.width, + height=job.height, + duration=job.duration, + progress=report, + cancelled=self._is_cancelled, + process_changed=self._set_process, + tools=self._tools, + ) + + output_size = destination.stat().st_size + output_bytes += output_size + done_input_bytes += job.source.stat().st_size + completed += 1 + self.itemFinished.emit(job.item_id, "done", "", str(destination), output_size) + except CancelledError: + cancelled_count += 1 + if destination is not None: + _safe_remove(destination) + self.itemFinished.emit(job.item_id, "cancelled", "", "", 0) + except MediaError as exc: + errors += 1 + if destination is not None: + _safe_remove(destination) + self.itemFinished.emit(job.item_id, "error", compact_error(str(exc)), "", 0) + except Exception as exc: + errors += 1 + if destination is not None: + _safe_remove(destination) + self.itemFinished.emit(job.item_id, "error", compact_error(str(exc)), "", 0) + finally: + self._set_process(None) + + self.overallProgress.emit(min(100, int(((index + 1) / total) * 100))) + + if self._cancelled.is_set(): + break + + if self._cancelled.is_set(): + # Anything that never started should not sit at "Working" forever. + completed_or_handled = completed + errors + cancelled_count + for job in self._jobs[completed_or_handled:]: + self.itemFinished.emit(job.item_id, "cancelled", "", "", 0) + cancelled_count += 1 + + self.summary.emit( + { + "total": total, + "done": completed, + "errors": errors, + "cancelled": cancelled_count, + "output_bytes": output_bytes, + "input_bytes": done_input_bytes, + } + ) diff --git a/Bolt-Compress/bolt_compress/settings_panel.py b/Bolt-Compress/bolt_compress/settings_panel.py new file mode 100644 index 0000000..4bed142 --- /dev/null +++ b/Bolt-Compress/bolt_compress/settings_panel.py @@ -0,0 +1,496 @@ +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) diff --git a/Bolt-Compress/bolt_compress/theme.py b/Bolt-Compress/bolt_compress/theme.py new file mode 100644 index 0000000..b2fd3cd --- /dev/null +++ b/Bolt-Compress/bolt_compress/theme.py @@ -0,0 +1,609 @@ +from __future__ import annotations + +from pathlib import Path + +from PyQt6.QtCore import QPointF, QRectF, Qt +from PyQt6.QtGui import ( + QColor, + QIcon, + QLinearGradient, + QPainter, + QPainterPath, + QPen, + QPixmap, + QPolygonF, +) + +from .models import MediaKind + +BG = "#0A0D12" +CARD = "#11161E" +ACCENT = "#D7FF3F" +TEXT = "#E8EDF4" +MUTED = "#8795A7" +GREEN = "#8FE3A6" +RED = "#FF7D7D" +AMBER = "#F6C96B" + +_ASSET_DIR = Path(__file__).resolve().parent / "assets" + + +def _asset_url(name: str) -> str: + return (_ASSET_DIR / name).as_posix() + + +def stylesheet() -> str: + chevron = _asset_url("chevron-down.svg") + check = _asset_url("check.svg") + qss = """ +QWidget { + color: #E8EDF4; + font-family: "Segoe UI Variable Text", "Segoe UI", "Inter", sans-serif; + font-size: 13px; +} +QMainWindow, QWidget#root { + background-color: #0A0D12; +} +QLabel { + background: transparent; +} +QFrame#queueCard, QFrame#settingsCard { + background-color: #11161E; + border: 1px solid #202A35; + border-radius: 16px; +} +QFrame#queueCard[dragActive="true"] { + border: 1px solid #D7FF3F; + background-color: #121A17; +} +QFrame#divider { + background-color: #1E2832; + border: none; + min-height: 1px; + max-height: 1px; +} +QFrame#dropZone { + background-color: #0D1219; + border: 1px dashed #2B3745; + border-radius: 13px; +} +QFrame#dropZone[dragActive="true"] { + background-color: #111B17; + border: 1px dashed #D7FF3F; +} + +QLabel#appTitle { + font-size: 22px; + font-weight: 700; + color: #F5F8FC; +} +QLabel#appSubtitle { + color: #8491A2; + font-size: 12px; +} +QLabel#cardTitle { + font-size: 16px; + font-weight: 700; + color: #F1F5FA; +} +QLabel#sectionHintTitle { + font-size: 15px; + font-weight: 700; + color: #F1F5FA; +} +QLabel#badge { + background-color: #1C2631; + border: 1px solid #293543; + border-radius: 9px; + color: #9EABBA; + font-size: 11px; + font-weight: 700; + padding: 2px 8px; +} +QLabel#privacyChip { + background-color: #142018; + border: 1px solid #27402E; + border-radius: 11px; + color: #91D8A4; + font-size: 11px; + font-weight: 600; + padding: 5px 10px; +} +QLabel#fieldLabel { + color: #A8B4C3; + font-size: 12px; + font-weight: 600; +} +QLabel#hintLabel { + color: #7B8898; + font-size: 11px; +} +QLabel#qualityValue { + color: #D7FF3F; + font-size: 12px; + font-weight: 700; +} +QLabel#emptyTitle { + font-size: 17px; + font-weight: 700; + color: #EDF2F8; +} +QLabel#emptyText { + color: #778595; + font-size: 12px; +} +QLabel#statsLabel { + color: #A9B5C4; + font-size: 12px; +} +QLabel#outputLabel { + color: #6F7D8E; + font-size: 10px; + font-weight: 700; +} +QLabel#percentLabel { + color: #D7FF3F; + font-size: 11px; + font-weight: 700; + min-width: 32px; +} +QLabel#footerText { + color: #758294; + font-size: 11px; +} +QLabel#engineLabel { + color: #768496; + font-size: 11px; +} +QLabel#successText { + color: #8FE3A6; + font-size: 11px; +} + +QPushButton { + background-color: #1A232D; + border: 1px solid #2A3745; + border-radius: 8px; + padding: 7px 12px; + color: #D9E1EA; + font-size: 12px; + font-weight: 600; +} +QPushButton:hover { + background-color: #202B37; + border-color: #3A4A5C; +} +QPushButton:pressed { + background-color: #18212A; +} +QPushButton:disabled { + background-color: #141A21; + border-color: #202831; + color: #586473; +} +QPushButton#ghostButton { + background-color: transparent; + border: 1px solid transparent; + color: #8593A4; + padding: 7px 9px; +} +QPushButton#ghostButton:hover { + background-color: #19222C; + color: #D9E1EA; +} +QPushButton#accentSmall { + background-color: #D7FF3F; + border: 1px solid #D7FF3F; + border-radius: 9px; + color: #101500; + padding: 8px 18px; + font-weight: 700; +} +QPushButton#accentSmall:hover { + background-color: #E4FF70; + border-color: #E4FF70; +} +QPushButton#primaryAction { + background-color: #D7FF3F; + border: 1px solid #D7FF3F; + border-radius: 10px; + color: #0D1203; + font-size: 13px; + font-weight: 700; + padding: 11px 18px; + min-height: 22px; +} +QPushButton#primaryAction:hover { + background-color: #E5FF77; + border-color: #E5FF77; +} +QPushButton#primaryAction:pressed { + background-color: #C9F42F; +} +QPushButton#primaryAction:disabled { + background-color: #222B20; + border-color: #293523; + color: #65705A; +} +QPushButton#cancelButton { + background-color: transparent; + border: 1px solid #4A2A2E; + color: #E79A9A; + border-radius: 10px; + padding: 11px 16px; + font-weight: 600; +} +QPushButton#cancelButton:hover { + background-color: #221719; + border-color: #784047; +} + +QToolButton#destButton { + background-color: #0E141B; + border: 1px solid #25303C; + border-radius: 8px; + padding: 7px 26px 7px 10px; + color: #B7C3D1; + font-size: 12px; + text-align: left; +} +QToolButton#destButton:hover { + border-color: #3A4B5D; + background-color: #121A23; +} +QToolButton#destButton::menu-indicator { + image: url("__CHEVRON__"); + subcontrol-origin: padding; + subcontrol-position: center right; + width: 14px; + height: 14px; + right: 8px; +} + +QTabWidget#settingsTabs::pane { + border: none; + margin-top: 4px; +} +QTabWidget#settingsTabs QTabBar::tab { + background-color: #0E141B; + border: 1px solid #202A35; + border-radius: 8px; + color: #8694A5; + padding: 7px 17px; + margin-right: 5px; + font-size: 12px; + font-weight: 600; + min-width: 62px; +} +QTabWidget#settingsTabs QTabBar::tab:hover { + color: #DCE4ED; + border-color: #334253; +} +QTabWidget#settingsTabs QTabBar::tab:selected { + background-color: #D7FF3F; + border-color: #D7FF3F; + color: #101500; + font-weight: 700; +} +QScrollArea#tabScroll { + border: none; + background-color: transparent; +} +QScrollArea#tabScroll > QWidget > QWidget { + background-color: transparent; +} +QWidget#tabContent { + background-color: transparent; +} + +QComboBox, QSpinBox { + background-color: #0D131A; + border: 1px solid #25303D; + border-radius: 8px; + padding: 7px 10px; + color: #DDE5EE; + min-height: 20px; + selection-background-color: #293644; +} +QComboBox:hover, QSpinBox:hover { + border-color: #38495B; +} +QComboBox:focus, QSpinBox:focus { + border-color: #D7FF3F; +} +QComboBox:disabled, QSpinBox:disabled { + color: #5E6978; + background-color: #10151C; + border-color: #1C242D; +} +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 26px; + border: none; + background: transparent; +} +QComboBox::down-arrow { + image: url("__CHEVRON__"); + width: 14px; + height: 14px; +} +QComboBox QAbstractItemView { + background-color: #141B24; + border: 1px solid #2A3745; + border-radius: 8px; + padding: 4px; + color: #DDE5EE; + selection-background-color: #24313E; + selection-color: #F4F8FC; + outline: 0; +} + +QSlider::groove:horizontal { + height: 4px; + background-color: #26313D; + border-radius: 2px; +} +QSlider::sub-page:horizontal { + background-color: #D7FF3F; + border-radius: 2px; +} +QSlider::handle:horizontal { + background-color: #F7FFE2; + border: 3px solid #D7FF3F; + width: 12px; + height: 12px; + margin: -7px 0; + border-radius: 9px; +} +QSlider::handle:horizontal:hover { + background-color: #FFFFFF; + border-color: #E5FF77; +} +QSlider:disabled::sub-page:horizontal { + background-color: #47533A; +} +QSlider:disabled::handle:horizontal { + background-color: #87937B; + border-color: #47533A; +} + +QCheckBox { + color: #AEBAC8; + font-size: 12px; + spacing: 9px; +} +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 1px solid #354250; + border-radius: 4px; + background-color: #0D131A; +} +QCheckBox::indicator:hover { + border-color: #526679; +} +QCheckBox::indicator:checked { + background-color: #D7FF3F; + border-color: #D7FF3F; + image: url("__CHECK__"); +} + +QTreeWidget { + background-color: transparent; + border: none; + outline: 0; + show-decoration-selected: 1; + alternate-background-color: transparent; + selection-background-color: #19222C; +} +QTreeWidget::item { + border-bottom: 1px solid #1A222B; + padding: 0px; + color: #D8E0E9; +} +QTreeWidget::item:hover { + background-color: #151D26; +} +QTreeWidget::item:selected { + background-color: #1B2530; + color: #F2F6FA; +} +QTreeWidget::branch { + background: transparent; +} +QHeaderView { + background-color: transparent; +} +QHeaderView::section { + background-color: transparent; + border: none; + border-bottom: 1px solid #202A34; + color: #768496; + font-size: 10px; + font-weight: 700; + padding: 0px 2px 9px 2px; +} +QHeaderView::section:first { + padding-left: 8px; +} + +QProgressBar { + background-color: #232C37; + border: none; + border-radius: 3px; + min-height: 6px; + max-height: 6px; + text-align: center; +} +QProgressBar::chunk { + background-color: #D7FF3F; + border-radius: 3px; +} + +QScrollBar:vertical { + background: transparent; + width: 8px; + margin: 2px 0px; +} +QScrollBar::handle:vertical { + background: #2A3542; + min-height: 28px; + border-radius: 4px; +} +QScrollBar::handle:vertical:hover { + background: #3C4A5A; +} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; +} +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + background: transparent; +} +QScrollBar:horizontal { + background: transparent; + height: 8px; +} +QScrollBar::handle:horizontal { + background: #2A3542; + min-width: 28px; + border-radius: 4px; +} + +QMenu { + background-color: #151C25; + border: 1px solid #2A3745; + border-radius: 8px; + padding: 5px; +} +QMenu::item { + background-color: transparent; + color: #DCE4ED; + padding: 7px 24px 7px 12px; + border-radius: 5px; +} +QMenu::item:selected { + background-color: #232F3C; + color: #FFFFFF; +} +QMenu::item:disabled { + color: #687483; +} +QMenu::separator { + height: 1px; + background-color: #27323E; + margin: 5px 8px; +} + +QToolTip { + background-color: #1A222C; + color: #E6ECF3; + border: 1px solid #354250; + padding: 6px 8px; +} +QMessageBox { + background-color: #11161E; +} +QMessageBox QLabel { + color: #E6ECF3; +} +""" + return qss.replace("__CHEVRON__", chevron).replace("__CHECK__", check) + + +def make_app_icon() -> QIcon: + icon = QIcon() + for size in (16, 24, 32, 48, 64, 128, 256, 512): + icon.addPixmap(_render_logo(size)) + return icon + + +def app_logo_pixmap(size: int) -> QPixmap: + return _render_logo(size) + + +def _render_logo(size: int) -> QPixmap: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + margin = size * 0.045 + rect = QRectF(margin, margin, size - margin * 2, size - margin * 2) + path = QPainterPath() + path.addRoundedRect(rect, size * 0.235, size * 0.235) + + gradient = QLinearGradient(rect.topLeft(), rect.bottomRight()) + gradient.setColorAt(0.0, QColor("#E5FF77")) + gradient.setColorAt(0.55, QColor("#D7FF3F")) + gradient.setColorAt(1.0, QColor("#B8EA22")) + painter.fillPath(path, gradient) + + # A soft inner highlight gives the mark a little dimension. + highlight = QPainterPath() + highlight.addRoundedRect(QRectF(margin, margin, size - margin * 2, (size - margin * 2) * 0.48), size * 0.22, size * 0.22) + painter.fillPath(highlight, QColor(255, 255, 255, 18)) + + points = [ + (0.575, 0.135), + (0.245, 0.555), + (0.465, 0.555), + (0.405, 0.875), + (0.765, 0.445), + (0.535, 0.445), + ] + bolt = QPolygonF([QPointF(x * size, y * size) for x, y in points]) + painter.setPen(QPen(QColor("#142000"), max(1.0, size * 0.012), Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)) + painter.setBrush(QColor("#0B1005")) + painter.drawPolygon(bolt) + painter.end() + return pixmap + + +def media_icon(kind: MediaKind, size: int = 64) -> QIcon: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + scale = size / 64.0 + + if kind == MediaKind.VIDEO: + background = QColor("#202319") + border = QColor("#3B4527") + glyph = QColor(ACCENT) + else: + background = QColor("#15231D") + border = QColor("#2A4639") + glyph = QColor("#91E1B5") + + painter.setPen(QPen(border, 2.0 * scale)) + painter.setBrush(background) + painter.drawRoundedRect(QRectF(1.5 * scale, 1.5 * scale, 61 * scale, 61 * scale), 15 * scale, 15 * scale) + + if kind == MediaKind.VIDEO: + triangle = QPolygonF( + [ + QPointF(25 * scale, 19 * scale), + QPointF(48 * scale, 32 * scale), + QPointF(25 * scale, 45 * scale), + ] + ) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(glyph) + painter.drawPolygon(triangle) + else: + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(glyph, 3.2 * scale, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)) + painter.drawEllipse(QRectF(17 * scale, 16 * scale, 9 * scale, 9 * scale)) + mountain = QPainterPath() + mountain.moveTo(13 * scale, 47 * scale) + mountain.lineTo(27 * scale, 31 * scale) + mountain.lineTo(36 * scale, 40 * scale) + mountain.lineTo(43 * scale, 32 * scale) + mountain.lineTo(52 * scale, 47 * scale) + painter.drawPath(mountain) + painter.setPen(QPen(glyph, 2.4 * scale)) + painter.drawRoundedRect(QRectF(11 * scale, 13 * scale, 42 * scale, 38 * scale), 6 * scale, 6 * scale) + + painter.end() + return QIcon(pixmap) diff --git a/Bolt-Compress/bolt_compress/widgets.py b/Bolt-Compress/bolt_compress/widgets.py new file mode 100644 index 0000000..b2f6814 --- /dev/null +++ b/Bolt-Compress/bolt_compress/widgets.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QMouseEvent +from PyQt6.QtWidgets import QApplication, QFrame, QLabel, QPushButton, QVBoxLayout + +from .theme import app_logo_pixmap + + +class DropZone(QFrame): + """The empty-state target for drag and drop.""" + + browseRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("dropZone") + self.setProperty("dragActive", False) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMinimumHeight(250) + + layout = QVBoxLayout(self) + layout.setContentsMargins(28, 26, 28, 26) + layout.setSpacing(12) + layout.addStretch(1) + + icon_label = QLabel() + icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + screen = QApplication.primaryScreen() + dpr = screen.devicePixelRatio() if screen else 1.0 + pixmap = app_logo_pixmap(max(64, int(64 * dpr))) + pixmap.setDevicePixelRatio(dpr) + icon_label.setPixmap(pixmap) + layout.addWidget(icon_label) + + self.title_label = QLabel("Drop media to get started") + self.title_label.setObjectName("emptyTitle") + self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.title_label) + + subtitle = QLabel( + "Videos and images are compressed locally, on your computer.\n" + "MP4 · MOV · MKV · JPG · PNG · WebP" + ) + subtitle.setObjectName("emptyText") + subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) + subtitle.setWordWrap(True) + layout.addWidget(subtitle) + + button = QPushButton("Browse files") + button.setObjectName("accentSmall") + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.clicked.connect(self.browseRequested) + layout.addWidget(button, 0, Qt.AlignmentFlag.AlignHCenter) + + hint = QLabel("or drag and drop anywhere in this window") + hint.setObjectName("hintLabel") + hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(hint) + layout.addStretch(1) + + def set_drag_active(self, active: bool) -> None: + if bool(self.property("dragActive")) == active: + return + self.setProperty("dragActive", active) + self.title_label.setText("Drop to add to the queue" if active else "Drop media to get started") + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.browseRequested.emit() + super().mouseReleaseEvent(event) diff --git a/Bolt-Compress/docs/screenshot.png b/Bolt-Compress/docs/screenshot.png new file mode 100644 index 0000000..39e04a2 Binary files /dev/null and b/Bolt-Compress/docs/screenshot.png differ diff --git a/Bolt-Compress/requirements.txt b/Bolt-Compress/requirements.txt new file mode 100644 index 0000000..173e806 --- /dev/null +++ b/Bolt-Compress/requirements.txt @@ -0,0 +1,2 @@ +PyQt6>=6.6 +Pillow>=10.0 diff --git a/Bolt-Compress/run.bat b/Bolt-Compress/run.bat new file mode 100755 index 0000000..7adcbff --- /dev/null +++ b/Bolt-Compress/run.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +python app.py +if errorlevel 1 pause