From 3326caa0702a0544817163fdbdcdd3cd27f98967 Mon Sep 17 00:00:00 2001 From: majestic Date: Sat, 24 Oct 2026 02:28:06 -0600 Subject: [PATCH] Initial commit --- Bolt-Compress/.gitignore | 6 + Bolt-Compress/README.md | 125 ++ Bolt-Compress/app.py | 4 + Bolt-Compress/bolt_compress/__init__.py | 3 + Bolt-Compress/bolt_compress/__main__.py | 4 + Bolt-Compress/bolt_compress/app.py | 47 + Bolt-Compress/bolt_compress/assets/check.svg | 1 + .../bolt_compress/assets/chevron-down.svg | 1 + Bolt-Compress/bolt_compress/main_window.py | 1255 +++++++++++++++++ Bolt-Compress/bolt_compress/media.py | 342 +++++ Bolt-Compress/bolt_compress/models.py | 103 ++ Bolt-Compress/bolt_compress/processing.py | 644 +++++++++ Bolt-Compress/bolt_compress/settings_panel.py | 496 +++++++ Bolt-Compress/bolt_compress/theme.py | 609 ++++++++ Bolt-Compress/bolt_compress/widgets.py | 74 + Bolt-Compress/docs/screenshot.png | Bin 0 -> 57322 bytes Bolt-Compress/requirements.txt | 2 + Bolt-Compress/run.bat | 4 + 18 files changed, 3720 insertions(+) create mode 100644 Bolt-Compress/.gitignore create mode 100644 Bolt-Compress/README.md create mode 100644 Bolt-Compress/app.py create mode 100644 Bolt-Compress/bolt_compress/__init__.py create mode 100644 Bolt-Compress/bolt_compress/__main__.py create mode 100644 Bolt-Compress/bolt_compress/app.py create mode 100644 Bolt-Compress/bolt_compress/assets/check.svg create mode 100644 Bolt-Compress/bolt_compress/assets/chevron-down.svg create mode 100644 Bolt-Compress/bolt_compress/main_window.py create mode 100644 Bolt-Compress/bolt_compress/media.py create mode 100644 Bolt-Compress/bolt_compress/models.py create mode 100644 Bolt-Compress/bolt_compress/processing.py create mode 100644 Bolt-Compress/bolt_compress/settings_panel.py create mode 100644 Bolt-Compress/bolt_compress/theme.py create mode 100644 Bolt-Compress/bolt_compress/widgets.py create mode 100644 Bolt-Compress/docs/screenshot.png create mode 100644 Bolt-Compress/requirements.txt create mode 100755 Bolt-Compress/run.bat 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 0000000000000000000000000000000000000000..39e04a2eb01029dda75d8da7c741fe9ac2a9d843 GIT binary patch literal 57322 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 n7w=SmZ>rlvf6={{nb8Bvcex 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 cw;V#UJK-!yXX3x$kK>Hq)$ literal 0 HcmV?d00001 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