Flatten Bolt-Compress to repo root

This commit is contained in:
majestic
2026-10-24 02:29:12 -06:00
parent 3326caa070
commit 93af8f8415
18 changed files with 0 additions and 6 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Bolt Compress — a local video and image compression desktop app."""
__version__ = "1.0.0"
+4
View File
@@ -0,0 +1,4 @@
from .app import main
if __name__ == "__main__":
raise SystemExit(main())
+47
View File
@@ -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())
+1
View File
@@ -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

+1
View File
@@ -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
+342
View File
@@ -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
+103
View File
@@ -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()
+644
View File
@@ -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,
}
)
+496
View File
@@ -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)
+609
View File
@@ -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)
+74
View File
@@ -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)