Initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.part
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
@@ -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**.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
from bolt_compress.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Bolt Compress — a local video and image compression desktop app."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0B1005" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 4.2 4.2L19 6.5"/></svg>
|
||||
|
After Width: | Height: | Size: 215 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#8F9CAD" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 209 B |
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,2 @@
|
||||
PyQt6>=6.6
|
||||
Pillow>=10.0
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
python app.py
|
||||
if errorlevel 1 pause
|
||||
Reference in New Issue
Block a user