diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 11d91ec..3c6b4fc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,92 +1,162 @@ -name: Build - -on: - push: - branches: ["main"] - pull_request: - workflow_dispatch: - -# No job here creates releases, publishes packages, or writes via the API. -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.12" - cache: pip - # The engine has no UI imports, so it tests without a display. - - run: pip install Pillow piexif - - run: python tests/test_engine.py - - smoke: - needs: test - strategy: - fail-fast: false - matrix: - os: [macos-14, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.12" - cache: pip - - run: pip install -r requirements.txt - # Proves the GUI actually constructs on this OS — imports, fonts, theme - # tokens, widget options — rather than only that the engine passes. - - run: python main.py --check - - run: python tests/test_gui_boot.py - - macos-app: - needs: smoke - strategy: - fail-fast: false - matrix: - include: - # py2app bundles the running interpreter, so a single runner produces - # a single-arch app that will not launch on the other kind of Mac. - # macos-15-intel is the current Intel label; macos-13 was retired and - # a job targeting it queues forever instead of failing. - - runner: macos-15-intel - arch: intel - - runner: macos-14 - arch: apple-silicon - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.12" - cache: pip - - run: | - pip install -r requirements.txt - pip install py2app - # v1's workflow cd'd into a folder that was never in the repo, so this - # job had never once produced a bundle. - - run: python setup.py py2app - - name: Verify the bundle launches - run: | - APP="dist/WebP Studio.app" - test -d "$APP" || { echo "no bundle produced"; exit 1; } - file "$APP/Contents/MacOS/WebP Studio" - # Headless runners have no window server, so a full launch can't be - # tested here — confirm the embedded interpreter starts and imports. - "$APP/Contents/MacOS/WebP Studio" --check - - run: ditto -c -k --keepParent "dist/WebP Studio.app" "WebP-Studio-macOS-${{ matrix.arch }}.zip" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: WebP-Studio-macOS-${{ matrix.arch }} - path: WebP-Studio-macOS-${{ matrix.arch }}.zip - retention-days: 14 - +name: Build + +on: + push: + branches: ["main"] + pull_request: + workflow_dispatch: + +# No job here creates releases, publishes packages, or writes via the API. +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + cache: pip + # The engine has no UI imports, so it tests without a display. + - run: pip install Pillow piexif + - run: python tests/test_engine.py + + smoke: + needs: test + strategy: + fail-fast: false + matrix: + os: [macos-14, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt + # Proves the GUI actually constructs on this OS — imports, fonts, theme + # tokens, widget options — rather than only that the engine passes. + - run: python main.py --check + - run: python tests/test_gui_boot.py + + macos-app: + needs: smoke + strategy: + fail-fast: false + matrix: + include: + # py2app bundles the running interpreter, so a single runner produces + # a single-arch app that will not launch on the other kind of Mac. + # macos-15-intel is the current Intel label; macos-13 was retired and + # a job targeting it queues forever instead of failing. + - runner: macos-15-intel + arch: intel + expect_arch: x86_64 + - runner: macos-14 + arch: apple-silicon + expect_arch: arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + cache: pip + - run: | + pip install -r requirements.txt + pip install py2app + # v1's workflow cd'd into a folder that was never in the repo, so this + # job had never once produced a bundle. + - run: python setup.py py2app + - name: Inspect the bundle + run: | + set -euo pipefail + APP="dist/WebP Studio.app" + BIN="$APP/Contents/MacOS/WebP Studio" + test -d "$APP" || { echo "::error::py2app produced no bundle"; exit 1; } + + # Checking the launcher stub proves nothing: setup-python ships a + # universal2 CPython, so the stub, the framework and _tkinter.so all + # report "x86_64 arm64" on every runner. The binaries that decide + # portability are the pip-installed wheels, which are single-arch. + PILC=$(find "$APP/Contents/Resources" -name '_imaging.cpython-*-darwin.so' -print -quit) + test -n "$PILC" || { echo "::error::Pillow's _imaging module is not in the bundle"; exit 1; } + archs=$(lipo -archs "$PILC") + echo "Pillow _imaging architectures: $archs" + case "$archs" in + *${{ matrix.expect_arch }}*) ;; + *) echo "::error::expected ${{ matrix.expect_arch }}, got $archs"; exit 1 ;; + esac + + # tkinterdnd2 ships one tkdnd build per platform. py2app copying only + # the .tcl files and leaving the macOS .dylib behind would disable + # drag & drop in the bundle — and the app catches that failure and + # carries on, so it would never surface as an error. + if [ -z "$(find "$APP/Contents/Resources" -path '*tkdnd/osx-*' -name '*.dylib' -print -quit)" ]; then + echo "::error::macOS tkdnd library missing — drag & drop would be dead in the bundle" + exit 1 + fi + + # CustomTkinter reads its theme JSON from inside its own package the + # first time a widget is built. If py2app leaves those out, the app + # imports cleanly and then dies the instant a window opens — which is + # exactly the failure `--check` alone cannot see. + if [ -z "$(find "$APP/Contents/Resources" -path '*customtkinter*' -name '*.json' -print -quit)" ]; then + echo "::error::CustomTkinter theme assets missing from the bundle" + exit 1 + fi + + "$BIN" --check + + - name: Launch it and prove a window actually opens + run: | + APP="dist/WebP Studio.app" + BIN="$APP/Contents/MacOS/WebP Studio" + + # `--check` only proves the interpreter imports. This is the step that + # would have caught a bundle that builds green and dies on double-click. + "$BIN" > launch.log 2>&1 & + PID=$! + sleep 25 + + if ! kill -0 "$PID" 2>/dev/null; then + echo "::error::the app exited on its own — it crashed during startup" + echo "--- output ---"; cat launch.log + exit 1 + fi + + echo "still running after 25s" + screencapture -x window.png 2>/dev/null || echo "(no screencapture on this runner)" + kill "$PID" 2>/dev/null || true + wait "$PID" 2>/dev/null || true + echo "--- output ---"; cat launch.log + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: launch-proof-${{ matrix.arch }} + path: | + window.png + launch.log + retention-days: 14 + if-no-files-found: ignore + + - run: ditto -c -k --keepParent "dist/WebP Studio.app" "WebP-Studio-macOS-${{ matrix.arch }}.zip" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: WebP-Studio-macOS-${{ matrix.arch }} + path: WebP-Studio-macOS-${{ matrix.arch }}.zip + # 14 days meant every build quietly disappeared and the download + # links in the README led to nothing. Tagged releases are the + # permanent copy; this is the ceiling GitHub allows for artifacts. + retention-days: 90 + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 545b0f5..b598765 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,73 +1,115 @@ -name: Release - -on: - push: - tags: ["v*.*.*"] - -# Only the publish job needs to write, to create the release and attach assets. -permissions: - contents: read - -jobs: - macos-app: - strategy: - fail-fast: false - matrix: - include: - # py2app bundles the running interpreter, so a single runner produces - # a single-arch app that will not launch on the other kind of Mac. - - runner: macos-15-intel - arch: intel - - runner: macos-14 - arch: apple-silicon - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.12" - cache: pip - - run: | - pip install -r requirements.txt - pip install py2app - - run: python setup.py py2app - - name: Verify the bundle launches - run: | - APP="dist/WebP Studio.app" - test -d "$APP" || { echo "no bundle produced"; exit 1; } - "$APP/Contents/MacOS/WebP Studio" --check - - run: ditto -c -k --keepParent "dist/WebP Studio.app" "WebP-Studio-macOS-${{ matrix.arch }}.zip" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: WebP-Studio-macOS-${{ matrix.arch }} - path: WebP-Studio-macOS-${{ matrix.arch }}.zip - retention-days: 5 - - publish: - needs: macos-app - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - persist-credentials: false - - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 - with: - pattern: WebP-Studio-macOS-* - path: dist - merge-multiple: true - - name: Create GitHub Release - env: - GH_TOKEN: ${{ github.token }} - # Templated into the run script as literal text, so a tag crafted - # with shell metacharacters would be interpreted by bash. Passing - # it through env and referencing $TAG_NAME instead means the shell - # only ever sees it as a variable's value, never as script text. - TAG_NAME: ${{ github.ref_name }} - run: | - gh release create "$TAG_NAME" dist/*.zip \ - --title "$TAG_NAME" \ - --generate-notes +name: Release + +on: + push: + tags: ["v*.*.*"] + +# Only the publish job needs to write, to create the release and attach assets. +permissions: + contents: read + +jobs: + macos-app: + strategy: + fail-fast: false + matrix: + include: + # py2app bundles the running interpreter, so a single runner produces + # a single-arch app that will not launch on the other kind of Mac. + - runner: macos-15-intel + arch: intel + expect_arch: x86_64 + - runner: macos-14 + arch: apple-silicon + expect_arch: arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + cache: pip + - run: | + pip install -r requirements.txt + pip install py2app + - run: python setup.py py2app + - name: Verify the bundle opens before publishing it + run: | + set -euo pipefail + APP="dist/WebP Studio.app" + BIN="$APP/Contents/MacOS/WebP Studio" + test -d "$APP" || { echo "::error::py2app produced no bundle"; exit 1; } + + PILC=$(find "$APP/Contents/Resources" -name '_imaging.cpython-*-darwin.so' -print -quit) + test -n "$PILC" || { echo "::error::Pillow's _imaging module is not in the bundle"; exit 1; } + archs=$(lipo -archs "$PILC") + echo "Pillow _imaging architectures: $archs" + case "$archs" in + *${{ matrix.expect_arch }}*) ;; + *) echo "::error::expected ${{ matrix.expect_arch }}, got $archs"; exit 1 ;; + esac + + # tkinterdnd2 ships one tkdnd build per platform. py2app copying only + # the .tcl files and leaving the macOS .dylib behind would disable + # drag & drop in the bundle — and the app catches that failure and + # carries on, so it would never surface as an error. + if [ -z "$(find "$APP/Contents/Resources" -path '*tkdnd/osx-*' -name '*.dylib' -print -quit)" ]; then + echo "::error::macOS tkdnd library missing — drag & drop would be dead in the bundle" + exit 1 + fi + + if [ -z "$(find "$APP/Contents/Resources" -path '*customtkinter*' -name '*.json' -print -quit)" ]; then + echo "::error::CustomTkinter theme assets missing from the bundle" + exit 1 + fi + + "$BIN" --check + + # Publishing a build nobody has opened is how a broken app reaches + # people with a version number on it. + "$BIN" > launch.log 2>&1 & + PID=$! + sleep 25 + if ! kill -0 "$PID" 2>/dev/null; then + echo "::error::the app crashed at startup — refusing to publish" + cat launch.log + exit 1 + fi + kill "$PID" 2>/dev/null || true + echo "launched and stayed up" + cat launch.log + - run: ditto -c -k --keepParent "dist/WebP Studio.app" "WebP-Studio-macOS-${{ matrix.arch }}.zip" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: WebP-Studio-macOS-${{ matrix.arch }} + path: WebP-Studio-macOS-${{ matrix.arch }}.zip + retention-days: 5 + + publish: + needs: macos-app + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + pattern: WebP-Studio-macOS-* + path: dist + merge-multiple: true + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + # Templated into the run script as literal text, so a tag crafted + # with shell metacharacters would be interpreted by bash. Passing + # it through env and referencing $TAG_NAME instead means the shell + # only ever sees it as a variable's value, never as script text. + TAG_NAME: ${{ github.ref_name }} + run: | + gh release create "$TAG_NAME" dist/*.zip \ + --title "$TAG_NAME" \ + --generate-notes diff --git a/README.md b/README.md index 855d8d8..3917f6d 100644 --- a/README.md +++ b/README.md @@ -1,200 +1,326 @@ -# WebP Studio 2.0 - -[![Build](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/actions/workflows/build.yml/badge.svg)](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/actions/workflows/build.yml) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](requirements.txt) - -Batch image converter for macOS, Windows and Linux. Drop a folder, pick a -preset, get smaller images. - -A rewrite of ConvertImagesToWebP-MacAlpha v0.1 — same idea, working engine. - ---- - -## What's new in 2.0 - -**Things v0.1 advertised but didn't do** - -| | v0.1 | 2.0 | -|---|---|---| -| Drag & drop | `tkinterdnd2` in requirements, never imported | works; the app tells you if the package is missing | -| "Preserve EXIF & color profiles" toggle | ignored — metadata dropped from every file | actually written, plus optional GPS-only removal | -| `MAX_THREADS: 8` | unused; conversion ran one file at a time | real thread pool, auto-sized to your CPU | -| Time remaining | permanently "Calculating…" | live ETA and images/sec | -| Cancel | stopped, then reported "Conversion Complete!" | reports what finished and what never started | -| Errors | recorded, never displayed | listed on screen and savable to a log | -| 5,000-image batches | one widget per file, frozen window | single capped log view | -| `python setup.py py2app` | failed — missing `assets/icon.icns` | builds without an icon | -| GitHub Actions build | `cd` into a folder that isn't in the repo | fixed | - -**New** - -- **Output formats** — WebP, AVIF, JPEG, PNG (only the ones your Pillow build can write are offered) -- **Presets** — Web · Balanced · Archive · Smallest -- **Resize by** longest edge, width, height, or megapixels. Never upscales. -- **Destination control** — subfolder, a folder you choose, or next to each original -- **If a file already exists** — skip, overwrite, or rename -- **Preflight** — "482 images · 3.1 GB → Pictures/Converted" before you commit -- **Lossless mode**, encoder-effort control, worker count -- **System / Light / Dark**, remembered between launches -- **Keyboard** — `Ctrl/⌘O` folder · `Ctrl/⌘⇧O` files · `Return` convert · `Esc` stop or clear - ---- - -## Install - -```bash -pip install -r requirements.txt -python main.py -``` - -Only `customtkinter` and `Pillow` are required. The rest are optional and the -app degrades cleanly without them — check what you have: - -```bash -python main.py --check -``` - -| Optional | Enables | -|---|---| -| `tkinterdnd2` | drag & drop onto the window | -| `piexif` | removing GPS tags while keeping the rest of the EXIF | -| `pillow-heif` | reading iPhone `.heic` / `.heif` | - -### Prebuilt macOS app - -Grab the latest build from [**Releases**](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/releases) — -pick `WebP-Studio-macOS-apple-silicon.zip` or `WebP-Studio-macOS-intel.zip`. -Unzip, then see [Gatekeeper](#the-app-is-damaged-and-cant-be-opened) below -before opening it. These are unsigned dev builds cut from tags, not -App Store releases. - -No tagged release yet, or want the very latest commit instead? Grab a build -straight from CI: [**Actions → Build → latest run**](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/actions/workflows/build.yml) -→ Artifacts. Those expire after 14 days; run `workflow_dispatch` for a fresh one. - -## macOS - -**Use Homebrew's Python, not Apple's.** macOS ships Tk 8.5.9; CustomTkinter -needs 8.6+ and renders as black rectangles below that. `python main.py --check` -prints your Tk version and says so if it's too old. - -```bash -brew install python python-tk -/opt/homebrew/bin/python3 -m venv venv && source venv/bin/activate -pip install -r requirements.txt -python main.py -``` - -### Build a .app - -```bash -python setup.py py2app # -> dist/WebP Studio.app -``` - -Drop an `assets/icon.icns` in first if you want a custom icon — unlike v1, the -build no longer fails without one. - -**py2app bundles the interpreter it is run with, so the result is single-arch.** -An app built on an M-series Mac will not launch on an Intel Mac and vice versa. -CI therefore builds both (`macos-15-intel`, `macos-14` Apple Silicon) and -uploads them as separate artifacts. To produce one universal binary instead, -build with a universal2 python.org interpreter rather than a Homebrew one. - -`LSMinimumSystemVersion` is set to 10.13, but the real floor is whatever the -building Python supports. - -### "The app is damaged and can't be opened" - -That is Gatekeeper, not a broken build — the bundle is unsigned and -un-notarized, and anything downloaded from a browser or CI artifact gets -quarantined. Either right-click → Open the first time, or: - -```bash -xattr -dr com.apple.quarantine "/Applications/WebP Studio.app" -``` - -Signing and notarizing requires a paid Apple Developer account; that is the -only real fix for distributing to other people. - -## Tests - -```bash -python tests/test_engine.py # no display needed -python tests/test_gui_boot.py # needs a display -``` - -No framework. `test_engine` covers sizing math, alpha flattening, metadata -keep/strip, output-collision handling, the skip/overwrite/rename policies, -error isolation, cancel, and savings accounting. `test_gui_boot` builds the -real window and drives a real conversion through the Tk event loop — it exists -to catch what only breaks on a specific OS (fonts that don't resolve, widget -options a platform's Tk rejects) and runs in CI on macOS and Windows. - -Both write settings to a temp directory via `WEBP_STUDIO_CONFIG_DIR`, so they -never touch your real config. - ---- - -## How it works - -``` -main.py entry point + dependency check -core/ - config.py Settings dataclass, presets, JSON persistence - imaging.py one image: open → orient → resize → square → encode - runner.py scan, plan destinations, thread pool, cancel, progress -gui/ - theme.py design tokens — every color is a (light, dark) pair - widgets.py Card, StatTile, ProgressRing, SliderRow, LogView - panel.py the settings panel - screens/ home · progress · results -``` - -`core/` has no UI imports, so the engine is usable from a script and testable -without a display. - -### Notes on behaviour - -- **Resizing never upscales.** A limit larger than the source is a no-op. -- **Metadata off converts to sRGB.** An untagged file is read as sRGB by every - viewer, so baking the profile in keeps colors from shifting. -- **The output folder is excluded from scans.** Converting the same folder - twice will not re-convert its own results. -- **Failed files leave nothing behind.** A partial write is deleted, because a - truncated image looks fine in a file manager and fails later. -- **Animated sources take frame one**, and say so in the log. - -## Verified on - -| | Status | -|---|---| -| Windows 11 · Python 3.12 · Tk 8.6 · CustomTkinter 6.0 (`>=5.2.2` required) | both suites pass; app driven end to end | -| Engine logic (any OS) | 15 checks, no display required | -| macOS 14 (Apple Silicon), CI | GUI boot + real conversion pass; `.app` builds and its interpreter starts | -| macOS (Intel), CI | `.app` builds on `macos-15-intel` | -| Linux | should work; `test_gui_boot` needs `xvfb` in CI | - -Not covered anywhere: a human double-clicking the built `.app`. CI runners have -no window server, so that last step is yours. - -## Contributing - -Issues and PRs welcome. `python tests/test_engine.py` should stay green with -no display, and CI runs it plus the GUI boot suite on every PR. - -### Cutting a release - -Push a tag matching `v*.*.*`: - -```bash -git tag v2.0.0 -git push origin v2.0.0 -``` - -`.github/workflows/release.yml` builds both macOS bundles and publishes them -to a new GitHub Release, titled after the tag with auto-generated notes. - -## License - -[MIT](LICENSE). +# WebP Studio 2.0 + +[![Build](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/actions/workflows/build.yml/badge.svg)](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/actions/workflows/build.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](requirements.txt) + +Batch image converter for macOS, Windows and Linux. Drop a folder, pick a +preset, get smaller images. + +A rewrite of ConvertImagesToWebP-MacAlpha v0.1 — same idea, working engine. + +--- + +## What's new in 2.0 + +**Things v0.1 advertised but didn't do** + +| | v0.1 | 2.0 | +|---|---|---| +| Drag & drop | `tkinterdnd2` in requirements, never imported | works; the app tells you if the package is missing | +| "Preserve EXIF & color profiles" toggle | ignored — metadata dropped from every file | actually written, plus optional GPS-only removal | +| `MAX_THREADS: 8` | unused; conversion ran one file at a time | real thread pool, auto-sized to your CPU | +| Time remaining | permanently "Calculating…" | live ETA and images/sec | +| Cancel | stopped, then reported "Conversion Complete!" | reports what finished and what never started | +| Errors | recorded, never displayed | listed on screen and savable to a log | +| 5,000-image batches | one widget per file, frozen window | single capped log view | +| `python setup.py py2app` | failed — missing `assets/icon.icns` | builds without an icon | +| GitHub Actions build | `cd` into a folder that isn't in the repo | fixed | + +**New** + +- **Output formats** — WebP, AVIF, JPEG, PNG (only the ones your Pillow build can write are offered) +- **Presets** — Web · Balanced · Archive · Smallest +- **Resize by** longest edge, width, height, or megapixels. Never upscales. +- **Destination control** — subfolder, a folder you choose, or next to each original +- **If a file already exists** — skip, overwrite, or rename +- **Preflight** — "482 images · 3.1 GB → Pictures/Converted" before you commit +- **Lossless mode**, encoder-effort control, worker count +- **System / Light / Dark**, remembered between launches +- **Keyboard** — `Ctrl/⌘O` folder · `Ctrl/⌘⇧O` files · `Return` convert · `Esc` stop or clear + +--- + +## Install + +Pick the row that matches you. + +| You want to | Go to | +|---|---| +| Run the Mac app | [Install on macOS](#install-on-macos) | +| Run it on Windows for testing | [Run on Windows](#run-on-windows) | +| Run it on Linux | [Run on Linux](#run-on-linux) | +| Build the `.app` yourself | [Build a .app](#build-a-app) | + +--- + +## Install on macOS + +### 1. Work out which build you need + + → **About This Mac**: + +| It says | Download | +|---|---| +| Chip: Apple M1 / M2 / M3 / M4 … | `WebP-Studio-macOS-apple-silicon.zip` | +| Processor: Intel … | `WebP-Studio-macOS-intel.zip` | + +They are not interchangeable. py2app bundles the interpreter it built with, so +each zip runs on one kind of Mac only. The wrong one will refuse to open. + +### 2. Download it + +[**Releases**](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/releases/latest) +— every tagged version, kept permanently. + +If there is no release yet, or you want the newest commit instead, take a build +from CI: [**Actions → Build**](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/actions/workflows/build.yml) +→ open the most recent green run → **Artifacts**. CI builds are kept 90 days. +You must be signed in to GitHub to download artifacts. + +### 3. Unzip and install + +Double-click the zip, then drag **WebP Studio** into your Applications folder. + +### 4. Get past Gatekeeper + +**Expect this to fail the first time.** macOS will say the app *"is damaged and +can't be opened"* or *"cannot be opened because Apple cannot check it for +malicious software."* + +Nothing is damaged. These builds are unsigned and un-notarized — signing +requires a paid Apple Developer account — and macOS quarantines anything +unsigned that arrives via a browser. You have to clear that flag yourself. + +**The one-liner that always works:** + +```bash +xattr -dr com.apple.quarantine "/Applications/WebP Studio.app" +``` + +Then open the app normally. That is the whole fix. + +
+Prefer to do it without the Terminal? + +**macOS 15 (Sequoia) and later** + +1. Double-click the app. Let it get blocked. Dismiss the dialog. +2. **System Settings → Privacy & Security**. +3. Scroll to the Security section. There is a line saying *"WebP Studio was + blocked to protect your Mac."* +4. Click **Open Anyway**, then authenticate. +5. Double-click the app again and click **Open**. + +Step 1 matters — the button in step 3 does not appear until macOS has blocked +the app at least once. + +**macOS 14 (Sonoma) and earlier** + +Right-click (or Control-click) the app → **Open** → **Open** in the dialog. +The right-click route is what makes the "open anyway" button appear; plain +double-clicking never offers it. + +
+ +> Only do this for builds you produced or trust. Clearing the quarantine flag +> is exactly what you would do for genuine malware too — the check exists for a +> reason, and you are choosing to skip it here because you know where this +> binary came from. + +### 5. If it bounces in the Dock and quits + +That is not Gatekeeper, that is a crash. Get the real error: + +```bash +"/Applications/WebP Studio.app/Contents/MacOS/WebP Studio" --check +``` + +That prints your Python, your Tk version, and which optional features are +available, instead of dying silently. Include its output if you file an issue. + +--- + +## Run on Windows + +There is no packaged `.exe` — Windows runs from source. Takes about a minute. + +**1. Install Python 3.10 or newer** from [python.org](https://www.python.org/downloads/windows/). +Tick **"Add python.exe to PATH"** in the installer. + +> Avoid the Microsoft Store build of Python — it ships without a usable +> `tkinter`, and this is a Tk app. `python main.py --check` will tell you if +> yours is the broken kind. + +**2. Set it up** (PowerShell, from the repo folder): + +```powershell +py -3 -m venv .venv +.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python main.py --check +``` + +If `Activate.ps1` is blocked by execution policy, either use +`.venv\Scripts\activate.bat` or run +`Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass` first. + +**3. Run it:** + +```powershell +python main.py +``` + +Every launch after this only needs the activate line and `python main.py`. + +--- + +## Run on Linux + +```bash +sudo apt install python3-tk # or your distro's Tk package +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +--- + +## Dependencies + +Only `customtkinter` and `Pillow` are required. Everything else is optional and +the app degrades cleanly without it: + +| Optional | Enables | +|---|---| +| `tkinterdnd2` | drag & drop onto the window | +| `piexif` | removing GPS tags while keeping the rest of the EXIF | +| `pillow-heif` | reading iPhone `.heic` / `.heif` | + +`python main.py --check` prints exactly what you have and what each missing +package costs you. + +## Running from source on macOS + +**Use Homebrew's Python, not Apple's.** macOS ships Tk 8.5.9; CustomTkinter +needs 8.6+ and renders as black rectangles below that. `--check` says so if +your Tk is too old. + +```bash +brew install python python-tk +/opt/homebrew/bin/python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +### Build a .app + +```bash +pip install py2app +python setup.py py2app # -> dist/WebP Studio.app +``` + +Drop an `assets/icon.icns` in first if you want a custom icon — unlike v1, the +build no longer fails without one. + +**The result is single-arch, and not for the reason you would guess.** The +launcher stub, the embedded `Python.framework` and `_tkinter.so` are all +universal2 — `lipo -archs` on them reports `x86_64 arm64` on every runner. But +pip installs *platform-specific wheels*, so Pillow's `_imaging`, `_webp`, +`_avif` and `_imagingcms` are single-arch, and they are what the app imports on +every single image. An Apple Silicon build therefore dies on an Intel Mac the +moment it touches a photo. +CI therefore builds both (`macos-15-intel`, `macos-14` Apple Silicon) and +publishes them as separate downloads. For one universal binary instead, build +with a universal2 python.org interpreter rather than a Homebrew one. + +`LSMinimumSystemVersion` is set to 10.13, but the real floor is whatever the +building Python supports. + +## Tests + +```bash +python tests/test_engine.py # no display needed +python tests/test_gui_boot.py # needs a display +``` + +No framework. `test_engine` covers sizing math, alpha flattening, metadata +keep/strip, output-collision handling, the skip/overwrite/rename policies, +error isolation, cancel, and savings accounting. `test_gui_boot` builds the +real window and drives a real conversion through the Tk event loop — it exists +to catch what only breaks on a specific OS (fonts that don't resolve, widget +options a platform's Tk rejects) and runs in CI on macOS and Windows. + +Both write settings to a temp directory via `WEBP_STUDIO_CONFIG_DIR`, so they +never touch your real config. + +--- + +## How it works + +``` +main.py entry point + dependency check +core/ + config.py Settings dataclass, presets, JSON persistence + imaging.py one image: open → orient → resize → square → encode + runner.py scan, plan destinations, thread pool, cancel, progress +gui/ + theme.py design tokens — every color is a (light, dark) pair + widgets.py Card, StatTile, ProgressRing, SliderRow, LogView + panel.py the settings panel + screens/ home · progress · results +``` + +`core/` has no UI imports, so the engine is usable from a script and testable +without a display. + +### Notes on behaviour + +- **Resizing never upscales.** A limit larger than the source is a no-op. +- **Metadata off converts to sRGB.** An untagged file is read as sRGB by every + viewer, so baking the profile in keeps colors from shifting. +- **The output folder is excluded from scans.** Converting the same folder + twice will not re-convert its own results. +- **Failed files leave nothing behind.** A partial write is deleted, because a + truncated image looks fine in a file manager and fails later. +- **Animated sources take frame one**, and say so in the log. + +## Verified on + +| | Status | +|---|---| +| Windows 11 · Python 3.12 · Tk 8.6 · CustomTkinter 6.0 (`>=5.2.2` required) | both suites pass; app driven end to end | +| Engine logic (any OS) | 15 checks, no display required | +| macOS 14 (Apple Silicon), CI | GUI boot + real conversion pass; `.app` builds, launches and stays up | +| macOS (Intel), CI | `.app` builds on `macos-15-intel`, launches and stays up | +| Linux | should work; `test_gui_boot` needs `xvfb` in CI | + +CI now launches the built bundle and fails if it exits on its own, so a build +that dies at startup cannot ship. It also checks the three things that break +bundles silently: Pillow's architecture, CustomTkinter's theme JSON, and the +macOS `tkdnd` dylib that drag & drop needs. + +Still not covered: a human double-clicking it and using it. `screencapture` +returns nothing on the runners, so there is no screenshot to prove a window +was drawn — only that the process was alive 25 seconds in. + +## Contributing + +Issues and PRs welcome. `python tests/test_engine.py` should stay green with +no display, and CI runs it plus the GUI boot suite on every PR. + +### Cutting a release + +Push a tag matching `v*.*.*`: + +```bash +git tag v2.0.0 +git push origin v2.0.0 +``` + +`.github/workflows/release.yml` builds both macOS bundles and publishes them +to a new GitHub Release, titled after the tag with auto-generated notes. + +## License + +[MIT](LICENSE).