Skip to content

v2.0: working engine, rebuilt UI - #1

Open
DhakadG wants to merge 3 commits into
mainfrom
v2
Open

v2.0: working engine, rebuilt UI#1
DhakadG wants to merge 3 commits into
mainfrom
v2

Conversation

@DhakadG

@DhakadG DhakadG commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Rewrite of MacAlpha v0.1. Same idea — drop images, get WebP — but the features the old README advertised now actually run.

Bugs fixed (all were live in v0.1)

Issue Where it was
Drag & drop never existed. tkinterdnd2 was in requirements.txt but never imported; the drop zone was a clickable rectangle. gui/screens/dropzone.py:144
"Preserve EXIF & color profiles" was a dead switch. piexif was imported and unused; metadata was dropped from every output. core/converter.py:254
Single-threaded. run_batch_conversion() (thread pool) existed but was never called — the progress screen used a serial for loop, so MAX_THREADS: 8 did nothing. gui/screens/progress.py:246
ETA never computed. Permanently "Calculating…"; start_time assigned and never read. Results always showed Time: --. gui/screens/progress.py:244
Cancel reported success. Broke the loop, then displayed "Conversion Complete!" gui/screens/progress.py:247
Errors recorded, never shown. gui/screens/results.py
One widget per file. 5,000 images meant 5,000 live Labels and a frozen window. gui/screens/progress.py:173
py2app build always failediconfile: 'assets/icon.icns' was never committed. setup.py:19
CI had never produced a bundle — the workflow cd'd into ConvertImagesToWebP-MacAlpha/, a folder not in this repo. .github/workflows/build_mac_app.yml:24

Two more found while testing this branch, which also affected the new code until fixed:

  • Return started a conversion while you were typing in a settings field. focus_get() returns the inner tkinter.Entry, not CTkEntry, so the guard never matched.
  • The log font was Arial. Tk defines no "monospace" family; it silently fell back, so columns never aligned.

New

  • Formats — WebP, AVIF, JPEG, PNG, filtered at runtime to what the installed Pillow can actually encode
  • Presets — Web · Balanced · Archive · Smallest
  • Resize by longest edge, width, height or megapixels. Never upscales.
  • Destination control — subfolder / chosen folder / in place — and an existing-file policy (skip / overwrite / rename)
  • Preflight — "482 images · 3.1 GB → Pictures/Converted" before you commit
  • Live ETA and images/sec, cancel that reports what actually finished
  • Optional GPS-only stripping (keep the rest of the EXIF)
  • System / Light / Dark, remembered between launches
  • Keyboard shortcuts, savable error log

Structure

core/ has no UI imports, so the engine is scriptable and testable without a display. gui/theme.py holds design tokens as (light, dark) pairs instead of greys hardcoded across fourteen call sites.

Behaviour worth knowing:

  • The output folder is excluded from scans — converting a folder twice no longer re-converts its own results.
  • A failed write is deleted rather than left truncated.
  • With metadata off, pixels are converted to sRGB so untagged output doesn't shift color.

Tests

  • tests/test_engine.py — 12 checks, no display needed
  • tests/test_gui_boot.py — builds the real window and drives a real conversion through the Tk event loop

CI runs the engine on Linux, the GUI on macOS + Windows, and builds Intel and Apple Silicon bundles separately. py2app bundles the interpreter it runs with, so a single runner produces a single-arch app that won't launch on the other kind of Mac — the old workflow would have shipped that silently.

Verification status

Windows 11 · Python 3.12 · Tk 8.6 · CustomTkinter 6.0 both suites pass, app driven end to end
Engine logic 12 checks, any OS
macOS not verified — no Mac was available. This PR's CI is the first real check.

Merging this runs the macOS jobs for the first time. Note the built .app is unsigned, so Gatekeeper will say "damaged and can't be opened" until xattr -dr com.apple.quarantine is run — documented in the README.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Introduced WebP Studio 2.0 as a cross-platform batch image converter.
    • Added support for WebP, JPEG, PNG, and AVIF output, resizing, square canvases, metadata controls, presets, and destination options.
    • Added drag-and-drop, recent folders, progress tracking, cancellation, dark/light themes, and conversion logs.
    • Added macOS application packaging for Intel and Apple Silicon.
  • Bug Fixes

    • Improved handling of existing files, failed conversions, corrupted images, animated images, color profiles, and optional dependencies.
  • Documentation

    • Replaced the legacy README with comprehensive setup, usage, packaging, and verification guidance.
  • Tests

    • Added engine, GUI startup, conversion, cancellation, and rerun coverage.

Rewrite of MacAlpha v0.1. Same idea, but the features it advertised now
actually run.

Fixed (all were live in v0.1):
- Drag & drop was never wired up; tkinterdnd2 was in requirements but never
  imported. The drop zone was a clickable rectangle.
- "Preserve EXIF & color profiles" was ignored. piexif was imported and
  unused; metadata was dropped from every output file.
- MAX_THREADS: 8 did nothing. run_batch_conversion() existed but was never
  called; the progress screen converted serially in a for loop.
- Time remaining was permanently "Calculating..."; start_time was assigned
  and never read. Results always showed "Time: --".
- Cancel broke the loop and then reported "Conversion Complete!".
- Errors were recorded and never displayed anywhere.
- One Label per file meant a frozen window on large batches.
- setup.py hardcoded assets/icon.icns, which was never committed, so every
  py2app build failed.
- The CI workflow cd'd into ConvertImagesToWebP-MacAlpha/, a folder not in
  this repo, so it had never once produced a bundle.
- Return started a conversion while you were typing in a settings field
  (focus_get() returns the inner tkinter.Entry, not CTkEntry).
- The log used font family "monospace", which Tk does not define; it
  silently resolved to Arial, so columns never aligned.

New:
- Output formats WebP / AVIF / JPEG / PNG, filtered to what the installed
  Pillow can actually encode.
- Presets: Web, Balanced, Archive, Smallest.
- Resize by longest edge, width, height or megapixels. Never upscales.
- Destination control (subfolder / chosen folder / in place) and an
  existing-file policy (skip / overwrite / rename).
- Preflight count and total size before the run starts.
- Live ETA and images/sec, working cancel that reports partial results.
- Optional GPS-only metadata stripping.
- System / Light / Dark, remembered between launches.
- Keyboard shortcuts.

Structure: core/ has no UI imports, so the engine is scriptable and testable
without a display. gui/theme.py holds design tokens as (light, dark) pairs
instead of greys hardcoded across fourteen call sites.

Behaviour worth knowing:
- The output folder is excluded from scans, so converting a folder twice no
  longer re-converts its own results.
- A failed write is deleted rather than left truncated.
- With metadata off, pixels are converted to sRGB so untagged output does not
  shift color.

Tests: tests/test_engine.py (12 checks, no display) and tests/test_gui_boot.py
(builds the real window, runs a real conversion through the Tk event loop).
CI runs the engine on Linux, the GUI on macOS and Windows, and builds Intel
and Apple Silicon bundles separately -- py2app bundles the interpreter it runs
with, so a single runner produces an app that will not launch on the other
architecture.

Verified on Windows 11 / Python 3.12 / Tk 8.6 / CustomTkinter 6.0.
Not yet verified on macOS; no Mac was available. CI covers the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@DhakadG, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b9c77d9a-fa0d-4c8c-b898-7b7f3cedd50f

📥 Commits

Reviewing files that changed from the base of the PR and between bad2760 and f11add6.

📒 Files selected for processing (13)
  • .github/workflows/build.yml
  • README.md
  • core/config.py
  • core/imaging.py
  • core/runner.py
  • gui/app.py
  • gui/panel.py
  • gui/screens/home.py
  • gui/screens/progress.py
  • gui/screens/results.py
  • requirements.txt
  • tests/test_engine.py
  • tests/test_gui_boot.py
📝 Walkthrough

Walkthrough

WebP Studio 2.0 replaces the legacy macOS converter with a cross-platform batch conversion engine, configurable settings, a new GUI workflow, concurrent processing, startup diagnostics, automated tests, and architecture-specific macOS packaging.

Changes

WebP Studio 2.0

Layer / File(s) Summary
Conversion settings and imaging pipeline
core/config.py, core/imaging.py, tests/test_engine.py, requirements.txt
Replaced AppConfig with Settings. Added presets, validation, persistence, resizing, metadata handling, square processing, format encoding, and single-image conversion.
Batch scanning and execution
core/runner.py, core/converter.py
Added Runner with scanning, destination selection, concurrent conversion, cancellation, collision policies, progress reporting, and failed-output cleanup. Removed the legacy converter module.
GUI controls and home workflow
gui/theme.py, gui/widgets.py, gui/panel.py, gui/screens/home.py, gui/screens/dropzone.py, gui/screens/settings.py
Added shared themed widgets, live settings controls, drag-and-drop and file selection, recent folders, asynchronous preflight scanning, and destination summaries. Removed the former drop-zone and settings screens.
Application conversion and results flow
gui/app.py, gui/screens/progress.py, gui/screens/results.py
Added the App shell with theme handling, shortcuts, cancellation, queue-based progress updates, status-aware results, output-folder opening, and conversion-log saving.
Startup, packaging, and validation
main.py, setup.py, .github/workflows/build.yml, README.md, tests/test_gui_boot.py, requirements.txt, .gitignore
Added dependency and Tk diagnostics, updated py2app metadata, CI engine and GUI checks, macOS packaging jobs, cross-platform documentation, and compact ignore rules. Deleted the former standalone macOS app workflow.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant HomeScreen
  participant Runner
  participant ProgressScreen
  participant ResultsScreen

  User->>HomeScreen: Select or drop image sources
  HomeScreen->>Runner: Scan sources
  Runner-->>HomeScreen: Return Scan
  User->>HomeScreen: Start conversion
  HomeScreen->>ProgressScreen: Start Scan
  ProgressScreen->>Runner: Run conversion in background
  Runner-->>ProgressScreen: Send progress and file results
  Runner-->>ProgressScreen: Send completion results
  ProgressScreen->>ResultsScreen: Display conversion results
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.20% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: a version 2.0 conversion engine and rebuilt user interface.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch v2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (11)
core/runner.py (2)

175-179: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Take a snapshot of settings instead of mutating and reading the shared instance.

run() calls self.settings.clamp(), which mutates the same Settings object the GUI owns. gui/panel.py edits that instance in response to user input, and gui/panel.py:68 also calls clamp() on it.

Worker threads read self.settings throughout the batch: _claim_destination reads output_suffix() and on_existing, and convert_file reads the format, quality, and geometry fields. If any edit reaches the object while workers run, files in one batch encode with different settings. Nothing in the engine enforces the "progress screen is showing, so no edits happen" assumption.

Copy the settings in __init__ so the batch is self-consistent by construction.

♻️ Proposed change
+import copy
         self.scan = scan
-        self.settings = settings
+        # Snapshot: the GUI keeps editing its own Settings instance, and a
+        # batch must encode every file with one consistent configuration.
+        self.settings = copy.deepcopy(settings)
+        self.settings.clamp()
     def run(self) -> list[FileResult]:
         """Blocking. Call from a worker thread if you have a UI."""
-        self.settings.clamp()
         self._started = time.monotonic()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/runner.py` around lines 175 - 179, Copy the incoming Settings object in
the runner’s __init__ instead of retaining the GUI-owned instance, then use that
snapshot through run(), _claim_destination, and convert_file. Preserve the
existing clamp behavior on the runner-owned copy so worker reads remain
consistent throughout the batch.

244-249: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

The claim lock is held across filesystem syscalls.

self._lock wraps the whole loop, and candidate.exists() runs inside it. _record needs the same lock. With worker_count() up to 16 threads on a network share or a cold spinning disk, every claim serializes behind one stat round trip, and progress reporting waits behind it too.

Consider a two-phase claim: reserve the name string under the lock, and perform the exists() probe outside it, retrying if the reservation loses a race.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/runner.py` around lines 244 - 249, Refactor the claim loop around the
lock in the runner method so the lock only protects reserving and recording the
candidate name, not candidate.exists() filesystem probes. Reserve the lowercased
name under self._lock, check existence outside the lock, and retry when the
reservation loses a race; preserve _record’s locking and existing
collision/index behavior.
tests/test_engine.py (1)

92-109: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add coverage for strip_gps.

test_metadata covers keep_metadata in both states. It does not cover strip_gps. That path is the privacy-relevant one: core/imaging.py:70-84 must remove the GPS IFD and keep the remaining EXIF, and it must drop all EXIF when piexif is absent.

A silent regression there ships coordinates the user asked to remove, and no current test detects it.

💚 Suggested additional test
def test_strip_gps(tmp: Path):
    """GPS must go; the rest of the EXIF must stay."""
    from core.imaging import PIEXIF_OK
    if not PIEXIF_OK:
        print("  strip gps skipped (piexif absent)")
        return

    import piexif
    src = tmp / "in" / "gps.jpg"
    src.parent.mkdir(parents=True, exist_ok=True)
    Image.new("RGB", (60, 60), "blue").save(src)
    piexif.insert(piexif.dump({
        "0th": {piexif.ImageIFD.Make: b"SelfCheckCamera"},
        "GPS": {piexif.GPSIFD.GPSLatitudeRef: b"N"},
    }), str(src))

    out = tmp / "out" / "nogps.webp"
    convert_file(src, out, Settings(keep_metadata=True, strip_gps=True))
    with Image.open(out) as img:
        raw = img.info.get("exif")
    assert raw, "all EXIF was dropped instead of only GPS"
    data = piexif.load(raw)
    assert not data["GPS"], data["GPS"]
    assert data["0th"][piexif.ImageIFD.Make] == b"SelfCheckCamera"
    print("  strip gps ok")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_engine.py` around lines 92 - 109, Add a test_strip_gps test
alongside test_metadata that skips when PIEXIF_OK is false, creates an image
containing both GPS and non-GPS EXIF, and converts it with
Settings(keep_metadata=True, strip_gps=True). Verify the output retains EXIF and
the Make field while its GPS IFD is empty, covering the piexif-backed privacy
path.
core/config.py (2)

138-149: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Handle write failures in save(), and narrow the except in load().

save() has no error handling. gui/app.py:99-104 calls self.settings.save() inside a Tk callback. If the disk is full or the config directory is read-only, the OSError escapes into the Tk event loop and surfaces as an unhandled-exception traceback during a theme switch.

load() catches bare Exception (Ruff BLE001). The intent is corrupt-config tolerance, but the current form also hides a TypeError caused by a future field rename, which would silently reset every user setting.

♻️ Proposed change
     def save(self) -> None:
         # Write-then-rename: a crash mid-write can't leave a truncated config.
-        tmp = CONFIG_FILE.with_suffix(".json.tmp")
-        tmp.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
-        tmp.replace(CONFIG_FILE)
+        tmp = CONFIG_FILE.with_suffix(".json.tmp")
+        try:
+            tmp.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
+            tmp.replace(CONFIG_FILE)
+        except OSError:
+            # Settings are a convenience, not the product. A run must not die
+            # because the config volume is full or read-only.
+            tmp.unlink(missing_ok=True)
 
     `@classmethod`
     def load(cls) -> "Settings":
         try:
             return cls.from_dict(json.loads(CONFIG_FILE.read_text(encoding="utf-8")))
-        except Exception:
+        except (OSError, ValueError, TypeError):
             return cls()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/config.py` around lines 138 - 149, Update Settings.save() to catch
filesystem OSError failures from writing or replacing the temporary config file,
preserving the callback without an unhandled exception. Narrow Settings.load()
to catch only the JSON parsing and file-read errors needed for corrupt or
unavailable config tolerance, allowing TypeError from cls.from_dict() to
propagate instead of silently resetting settings.

Source: Linters/SAST tools


34-53: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Avoid directory creation and path freezing at import time.

CONFIG_FILE is evaluated at import, so config_dir() creates a directory as a side effect of import core.config. Two consequences follow:

  • If mkdir fails (read-only volume, sandboxed container, restricted CI runner), the import raises OSError. The engine and the GUI then fail to start, even for runs that never touch settings.
  • WEBP_STUDIO_CONFIG_DIR is read once. A test that sets the variable after the first import still writes to the real user config path.

Resolve the path lazily instead.

♻️ Lazy path resolution
-CONFIG_FILE = config_dir() / "settings.json"
+def config_file() -> Path:
+    return config_dir() / "settings.json"

Then use config_file() inside save() and load():

    def save(self) -> None:
        target = config_file()
        tmp = target.with_suffix(".json.tmp")
        tmp.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
        tmp.replace(target)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/config.py` around lines 34 - 53, Replace the import-time CONFIG_FILE
initialization with a config_file() function that resolves the current
config_dir() lazily and creates directories only when settings are accessed.
Update the settings save() and load() methods to call config_file() for their
target path, preserving existing read/write behavior and allowing
WEBP_STUDIO_CONFIG_DIR changes after import.
core/imaging.py (1)

17-19: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Prefer a high ceiling over disabling the decompression-bomb guard.

Image.MAX_IMAGE_PIXELS = None removes the limit for the whole process. The stated use case is 100 MP scans, which a raised ceiling also covers. With no limit, one crafted or corrupt file that declares a multi-gigapixel canvas makes Pillow attempt the full allocation. The process is then killed by the OS, and the entire batch is lost rather than one file.

Drag and drop accepts files the user did not create, so "own disk" does not guarantee trusted input.

🛡️ Proposed change
-# We routinely handle 100 MP camera scans; the decompression-bomb guard is for
-# untrusted input, and these files come from the user's own disk.
-Image.MAX_IMAGE_PIXELS = None
+# We routinely handle 100 MP camera scans, so Pillow's default ~89 MP guard is
+# too low. Raise it rather than remove it: an unbounded limit turns one crafted
+# file into an OOM kill that loses the whole batch.
+Image.MAX_IMAGE_PIXELS = 1_000_000_000
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/imaging.py` around lines 17 - 19, Replace the global
Image.MAX_IMAGE_PIXELS = None assignment with a finite high ceiling that
accommodates the supported 100 MP scans while retaining Pillow’s
decompression-bomb protection. Keep the change scoped to the imaging
initialization near Image.MAX_IMAGE_PIXELS.
gui/widgets.py (1)

143-146: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant int call.

round(raw) with a single argument already returns an int. Ruff flags the wrapping int() as unnecessary (RUF046).

♻️ Proposed simplification
     def _changed(self, raw: float) -> None:
-        value = int(round(raw))
+        value = round(raw)
         self.value_label.configure(text=f"{value}{self._suffix}")
         self._on_change(value)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gui/widgets.py` around lines 143 - 146, Remove the redundant int() wrapper in
_changed and assign the rounded raw value directly, preserving the existing
label update and _on_change behavior.

Source: Linters/SAST tools

gui/screens/results.py (1)

159-169: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Use os.startfile on Windows; the injection findings are false positives.

The static analysis rules S603, S607, and subprocess-from-request report command injection here. That is not accurate: the call uses the argument-list form with shell=False, so no shell parses folder. The remaining concerns are real but smaller.

  • On Windows, explorer mishandles some paths passed as a bare argument and returns exit code 1 on success. os.startfile is the supported API.
  • The executables resolve through PATH.
  • If output_folder is None, the button does nothing and gives no feedback.
♻️ Proposed change
     def open_output(self) -> None:
         folder = self.output_folder
         if not folder or not folder.exists():
             return
         system = platform.system()
         if system == "Darwin":
             subprocess.run(["open", str(folder)], check=False)
         elif system == "Windows":
-            subprocess.run(["explorer", str(folder)], check=False)
+            os.startfile(str(folder))  # noqa: S606 - documented Windows shell API
         else:
             subprocess.run(["xdg-open", str(folder)], check=False)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gui/screens/results.py` around lines 159 - 169, Update open_output to use
os.startfile(str(folder)) on Windows instead of invoking explorer via
subprocess. Preserve the existing early return and platform-specific behavior
for macOS and other systems, and keep subprocess executable resolution unchanged
outside Windows.

Source: Linters/SAST tools

gui/screens/home.py (1)

265-269: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Expose a public reload method on SettingsPanel.

on_show calls self.panel._reload_controls(). A screen reaches into a private method of another module. Rename the panel method to reload_controls and keep a thin private alias if internal callers need it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gui/screens/home.py` around lines 265 - 269, Rename SettingsPanel’s
_reload_controls method to the public reload_controls method and update on_show
to call reload_controls instead of the private member. Preserve a thin
_reload_controls alias only where existing internal callers still require it.
gui/screens/progress.py (1)

120-144: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Unbounded LogView.append loops in gui/screens/progress.py and gui/screens/results.py. Both sites append one line per result to a LogView that keeps only MAX_LINES (400). Every extra append performs an insert, a delete, a see("end"), and two configure calls on the Tk thread, and the result is discarded. A large batch therefore blocks the UI to render text that no user ever sees. A batched append API on LogView, or a cap at each call site, fixes both.

  • gui/screens/progress.py#L120-L144: collect the drained file events into a list and render only the last LogView.MAX_LINES entries after the drain loop.
  • gui/screens/results.py#L137-L156: build the failed, skipped, and note lines into one list, truncate it to LogView.MAX_LINES, and append the truncated list.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gui/screens/progress.py` around lines 120 - 144, The unbounded LogView.append
loops should be capped before rendering. In gui/screens/progress.py#L120-L144,
collect drained file-event lines, then append only the last LogView.MAX_LINES
entries after the drain loop; in gui/screens/results.py#L137-L156, combine
failed, skipped, and note lines into one list, truncate it to LogView.MAX_LINES,
and append the truncated list.
gui/panel.py (1)

218-225: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Reload the entry before notifying, or keep the two calls consistent.

_set_resize_mode calls _changed() first and _reload_resize_entry() second. _changed() already calls _sync_conditional_rows(), which calls _reload_resize_entry() when the row is visible. The extra call is redundant and the ordering differs from _commit_resize_value only by chance. Also, switching from megapixels to a pixel mode keeps a small value such as 100, which clamp() accepts as a 100-pixel limit.

♻️ Proposed simplification
     def _set_resize_mode(self, label: str) -> None:
         self.settings.resize_mode = RESIZE_BY_LABEL[label]
         if self.settings.resize_mode == "megapixels" and self.settings.resize_value > 500:
             self.settings.resize_value = 12.0
-        elif self.settings.resize_mode != "megapixels" and self.settings.resize_value < 16:
+        elif self.settings.resize_mode != "megapixels" and self.settings.resize_value < 512:
             self.settings.resize_value = 2048.0
         self._changed()
-        self._reload_resize_entry()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gui/panel.py` around lines 218 - 225, Update _set_resize_mode to normalize
resize_value when switching modes, including replacing small values such as 100
with the valid pixel-mode default rather than allowing them through clamp().
Make _reload_resize_entry and _changed consistent with _commit_resize_value,
removing the redundant reload while preserving the visible-row synchronization.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/build.yml:
- Around line 50-53: Replace the unsupported macos-13 runner with macos-15-intel
in .github/workflows/build.yml lines 50-53. Update the Intel runner label in
README.md lines 82-86, and correct the CI status statement in README.md lines
152-159 to reflect the updated workflow.
- Around line 1-13: Set top-level workflow permissions to contents: read in the
Build workflow, and update all three actions/checkout@v4 steps to use
persist-credentials: false. Preserve the existing checkout behavior and job
configuration.

In `@core/config.py`:
- Around line 170-197: Make preset application capability-aware: update the
"Smallest" preset or apply_preset to avoid selecting AVIF when avif is absent
from available_output_formats(), falling back to webp while preserving the other
preset settings. In core/config.py lines 170-197, implement the preset gate or
fallback; in core/imaging.py lines 39-50, verify the Pillow 11.3
features.check("avif") probe name and make the webp and avif probes consistent
so available_output_formats() is authoritative.
- Line 110: Update the quality fallback in the configuration normalization logic
to use the documented Balanced preset default of 85 instead of 82. Preserve the
existing _clamp_int bounds and valid-value behavior.

In `@core/imaging.py`:
- Around line 229-238: Add a pre-encode dimension check in the image-processing
flow before img.save, using the selected output format’s maximum dimensions
(including WebP’s 16383 px per side and AVIF’s limits). Either downscale
oversized images and record the adjustment through Encoded.note, or raise an
actionable format-specific error instead of allowing the encoder to fail with
its raw message.
- Around line 182-207: Clarify the AVIF lossless behavior in the user-facing UI
documentation or tooltip associated with the AVIF quality/lossless settings,
stating that Pillow implements it as quality=100 rather than a dedicated
lossless mode. Alternatively, if the product promises true lossless AVIF, update
the AVIF handling in _save_kwargs to use an explicit lossless encoder path; do
not change the existing effort-to-speed mapping.

In `@core/runner.py`:
- Around line 251-261: Update the overwrite path in convert_file and its
_process error handling to encode into a temporary .part file in the same
folder, then atomically replace the existing destination only after encoding
succeeds. Preserve direct safe output handling for new files, remove the
temporary file on failure, and ensure overwrite failures never unlink the
previously valid destination.
- Around line 182-191: Ensure run() always invokes on_finish, including when
pool.map or _process raises, by moving completion handling into a finally path
while preserving elapsed-time calculation and exception propagation. Update the
ThreadPoolExecutor.map comment to accurately describe eager task submission and
cancellation via _process checking self._cancel. Harden _process cleanup around
destination.unlink so cleanup failures cannot mask the original processing
error.

In `@gui/app.py`:
- Around line 175-182: Update ProgressScreen.start to retain the thread returned
by Runner.start_background(), then update on_close to cancel the active runner
and briefly join its stored worker thread with a short timeout before calling
self.destroy(). Preserve the existing settings save flow and avoid blocking
indefinitely when the worker does not stop promptly.

In `@gui/screens/home.py`:
- Around line 156-165: Update set_sources so that when filtering leaves no
existing paths, it updates the summary label with a user-facing message before
returning; preserve the current source assignment and rendering flow when at
least one valid path remains.
- Around line 232-235: Update _settings_changed so encoding-only changes such as
Quality and Encoder-effort do not trigger _rescan, while destination- and
exclusion-affecting changes still do. Debounce eligible rescans using the UI
scheduler’s after_cancel/after pattern, retaining and cancelling the pending
callback before scheduling a new one to prevent repeated filesystem walks during
slider dragging.

In `@gui/screens/results.py`:
- Around line 171-188: Update save_log to catch OSError around
Path(target).write_text, and report the failure by configuring save_log_button
with an appropriate error message instead of allowing the exception to escape.
Keep the existing “Saved ✓” text and delayed reset only for successful writes.

In `@README.md`:
- Line 156: Update the README.md environment table entry to document
CustomTkinter as >=5.2.2, matching the customtkinter requirement in
requirements.txt; do not leave the documented 6.0 version inconsistent with the
installed dependency.

In `@requirements.txt`:
- Line 5: Update the Pillow dependency constraint in requirements.txt to require
the first release that patches all reported advisories, rather than allowing
11.3.0; preserve the existing native AVIF support requirement and use the
verified firstPatchedVersion for the minimum.

---

Nitpick comments:
In `@core/config.py`:
- Around line 138-149: Update Settings.save() to catch filesystem OSError
failures from writing or replacing the temporary config file, preserving the
callback without an unhandled exception. Narrow Settings.load() to catch only
the JSON parsing and file-read errors needed for corrupt or unavailable config
tolerance, allowing TypeError from cls.from_dict() to propagate instead of
silently resetting settings.
- Around line 34-53: Replace the import-time CONFIG_FILE initialization with a
config_file() function that resolves the current config_dir() lazily and creates
directories only when settings are accessed. Update the settings save() and
load() methods to call config_file() for their target path, preserving existing
read/write behavior and allowing WEBP_STUDIO_CONFIG_DIR changes after import.

In `@core/imaging.py`:
- Around line 17-19: Replace the global Image.MAX_IMAGE_PIXELS = None assignment
with a finite high ceiling that accommodates the supported 100 MP scans while
retaining Pillow’s decompression-bomb protection. Keep the change scoped to the
imaging initialization near Image.MAX_IMAGE_PIXELS.

In `@core/runner.py`:
- Around line 175-179: Copy the incoming Settings object in the runner’s
__init__ instead of retaining the GUI-owned instance, then use that snapshot
through run(), _claim_destination, and convert_file. Preserve the existing clamp
behavior on the runner-owned copy so worker reads remain consistent throughout
the batch.
- Around line 244-249: Refactor the claim loop around the lock in the runner
method so the lock only protects reserving and recording the candidate name, not
candidate.exists() filesystem probes. Reserve the lowercased name under
self._lock, check existence outside the lock, and retry when the reservation
loses a race; preserve _record’s locking and existing collision/index behavior.

In `@gui/panel.py`:
- Around line 218-225: Update _set_resize_mode to normalize resize_value when
switching modes, including replacing small values such as 100 with the valid
pixel-mode default rather than allowing them through clamp(). Make
_reload_resize_entry and _changed consistent with _commit_resize_value, removing
the redundant reload while preserving the visible-row synchronization.

In `@gui/screens/home.py`:
- Around line 265-269: Rename SettingsPanel’s _reload_controls method to the
public reload_controls method and update on_show to call reload_controls instead
of the private member. Preserve a thin _reload_controls alias only where
existing internal callers still require it.

In `@gui/screens/progress.py`:
- Around line 120-144: The unbounded LogView.append loops should be capped
before rendering. In gui/screens/progress.py#L120-L144, collect drained
file-event lines, then append only the last LogView.MAX_LINES entries after the
drain loop; in gui/screens/results.py#L137-L156, combine failed, skipped, and
note lines into one list, truncate it to LogView.MAX_LINES, and append the
truncated list.

In `@gui/screens/results.py`:
- Around line 159-169: Update open_output to use os.startfile(str(folder)) on
Windows instead of invoking explorer via subprocess. Preserve the existing early
return and platform-specific behavior for macOS and other systems, and keep
subprocess executable resolution unchanged outside Windows.

In `@gui/widgets.py`:
- Around line 143-146: Remove the redundant int() wrapper in _changed and assign
the rounded raw value directly, preserving the existing label update and
_on_change behavior.

In `@tests/test_engine.py`:
- Around line 92-109: Add a test_strip_gps test alongside test_metadata that
skips when PIEXIF_OK is false, creates an image containing both GPS and non-GPS
EXIF, and converts it with Settings(keep_metadata=True, strip_gps=True). Verify
the output retains EXIF and the Make field while its GPS IFD is empty, covering
the piexif-backed privacy path.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 69dd3d0c-628d-49e1-af91-387bbcf094bd

📥 Commits

Reviewing files that changed from the base of the PR and between bb6e16d and bad2760.

📒 Files selected for processing (27)
  • .github/workflows/build.yml
  • .github/workflows/build_mac_app.yml
  • .gitignore
  • README.md
  • core/__init__.py
  • core/config.py
  • core/converter.py
  • core/imaging.py
  • core/runner.py
  • gui/__init__.py
  • gui/app.py
  • gui/components/__init__.py
  • gui/panel.py
  • gui/screens/__init__.py
  • gui/screens/dropzone.py
  • gui/screens/home.py
  • gui/screens/progress.py
  • gui/screens/results.py
  • gui/screens/settings.py
  • gui/theme.py
  • gui/widgets.py
  • main.py
  • requirements.txt
  • setup.py
  • tests/__init__.py
  • tests/test_engine.py
  • tests/test_gui_boot.py
💤 Files with no reviewable changes (5)
  • gui/components/init.py
  • .github/workflows/build_mac_app.yml
  • gui/screens/dropzone.py
  • gui/screens/settings.py
  • core/converter.py

Comment thread .github/workflows/build.yml
Comment thread .github/workflows/build.yml Outdated
Comment thread core/config.py Outdated
Comment thread core/config.py
Comment thread core/imaging.py
Comment thread gui/screens/home.py
Comment thread gui/screens/home.py Outdated
Comment thread gui/screens/results.py Outdated
Comment thread README.md Outdated
Comment thread requirements.txt Outdated
tkinterdnd2 grafts drop_target_register/dnd_bind onto tkinter.BaseWidget,
but tkinter.Tk is not a BaseWidget subclass — so the root window never got
those methods and every registration raised. The bare `except` around the
setup swallowed it and showed "drag & drop unavailable on this build",
which reads exactly like the package simply not being installed. It was
installed. Mixing TkinterDnD.DnDWrapper into the window class fixes it,
and the handler now prints the reason to stderr instead of hiding it.

Caught by installing the optional dependency and checking app.dnd_enabled
rather than trusting the tagline.

test_gui_boot now simulates a real drop, using the brace-quoted string
tkdnd actually delivers so a path containing spaces is covered, and asserts
that a drop arriving mid-run is ignored rather than silently discarded.

Also: the Intel bundle job targeted macos-13, which GitHub has retired.
An unknown runner label queues indefinitely instead of failing, so that
job hung while the other four passed. Switched to macos-15-intel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

Fourteen findings, all valid; each verified against the code before changing
anything. Two were already fixed in 9b122d2.

Data integrity
- The overwrite policy destroyed a good previous output when the new encode
  failed: the failure handler unlinked the destination unconditionally.
  Encoding now goes to a .part sidecar and is renamed over the target only on
  success, so a failed run leaves the previous output untouched. Also makes
  every write atomic — a half-written file never appears at the destination.

Stability
- An exception escaping _process aborted the entire batch and skipped
  on_finish, stranding the user on the progress screen with no completion and
  no results. The likely trigger was the unlink above raising PermissionError
  on Windows when an antivirus or indexer held the handle. _process can no
  longer raise, staging cleanup tolerates OSError, and run() fires on_finish
  from a finally block.
- Closing the window during a run cancelled the runner and immediately
  destroyed the interpreter underneath a worker mid-write. Now joins the
  batch thread with a 5s timeout first.
- save_log let an OSError escape into a console the user cannot see. Reports
  the failure in the details pane instead.

Correctness
- Presets could select an output format the installed Pillow cannot encode,
  the exact failure the capability probe exists to prevent. clamp() now
  reconciles output_format against writable_formats() (lazy-imported and
  cached; core.imaging imports core.config).
- WebP's 16383px ceiling was hit at encode time with a message from inside the
  encoder. Checked up front with one that names the limit and the way out.
- The lossless toggle was offered for AVIF, where quality=100 is
  near-lossless, not lossless. It is now WebP-only: JPEG has no lossless mode
  and PNG is always lossless, so the switch was noise in both.
- The quality clamp fell back to 82 while the field default was 85.

Performance
- Every settings change triggered a full background rescan, so dragging the
  quality slider spawned one directory walk per pixel of travel — punishing on
  a large or network folder. Only the destination settings change which files
  are found, so only those trigger a rescan. Measured: 36 slider ticks now
  cause 0 scans, changing the destination causes exactly 1.

Interface
- A drop where every path was missing (ejected volume, dead network share) was
  silently ignored and looked like a broken app. It now says so.

Security
- Pillow floor raised to 12.3.0. Verified against the GitHub advisory database
  rather than taken on trust: everything below 12.3.0 carries HIGH advisories
  including a heap out-of-bounds write in ImageCmsTransform.apply
  (GHSA-9hw9-ch79-4vh6) and another in crop/paste (GHSA-6r8x-57c9-28j4). This
  app calls ImageCms on profiled images and crop/paste in the square modes.
- Workflow declares permissions: contents: read, and the three checkout steps
  set persist-credentials: false. No job needs write access.

Docs
- README: macos-13 -> macos-15-intel, CustomTkinter version reconciled with
  requirements.txt, and the verification table now reflects that CI has run
  macOS successfully rather than claiming macOS is untested.

Tests: three new engine checks cover the overwrite-preserves-output guarantee,
the WebP dimension limit, and that every preset selects a writable format.
15 engine checks and the GUI boot suite pass on Windows with Pillow 12.3.0.

Two findings needed no change: the macos-13 runner label and the inaccurate
pool.map comment were both fixed in 9b122d2, before this review was posted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@DhakadG

DhakadG commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

All 14 findings addressed in f11add6. Each was checked against the code before changing anything; all were valid, and two had already been fixed in 9b122d2 before the review posted.

Data integrity

overwrite destroyed a good previous output when the encode failed — the failure handler unlinked the destination unconditionally. Encoding now goes to a .part sidecar and is renamed over the target only on success. Every write is atomic as a side effect, so a half-written file never appears at the destination.

Stability

An exception escaping _process aborted the batch and skipped on_finish, stranding the user on the progress screen forever. The likely trigger was exactly the unlink above raising PermissionError on Windows. _process can no longer raise, staging cleanup tolerates OSError, and run() fires on_finish from a finally.

Closing the window mid-run cancelled the runner and destroyed the interpreter underneath a worker mid-write — now joins the batch thread with a 5s timeout.

save_log reports write failures in the details pane instead of raising into a console the user can't see.

Correctness

  • Presets could select a format the installed Pillow can't encode — the exact failure the probe exists to prevent. clamp() now reconciles output_format against writable_formats() (lazy-imported and cached, since core.imaging imports core.config).
  • WebP's 16383px ceiling is checked up front with a message naming the limit and the way out, instead of surfacing an error from inside the encoder.
  • The lossless toggle is now WebP-only. AVIF quality=100 is near-lossless, JPEG has no lossless mode, and PNG is always lossless — offering it in those three promised something the encoder doesn't deliver.
  • quality clamp fallback was 82 while the field default was 85.

Performance

Every settings change triggered a full background rescan, so dragging the quality slider spawned one directory walk per pixel of travel. Only the destination settings change which files are found, so only those trigger a rescan. Measured after the fix: 36 slider ticks → 0 scans; changing the destination → exactly 1.

Security

  • Pillow floor raised to 12.3.0. Verified against the GitHub advisory database rather than taken on trust — everything below 12.3.0 carries HIGH advisories including a heap OOB write in ImageCmsTransform.apply() (GHSA-9hw9-ch79-4vh6) and another in crop/paste (GHSA-6r8x-57c9-28j4). This app calls ImageCms on profiled images and crop/paste in the square modes, so both are on the hot path.
  • Workflow declares permissions: contents: read; all three checkout steps set persist-credentials: false.

Already fixed before the review

  • macos-13macos-15-intel (9b122d2). CI has since built the Intel bundle successfully on that label.
  • The inaccurate pool.map streaming comment.

Tests

Three new engine checks: overwrite preserves a good output when the encode fails, the WebP dimension limit, and every preset selecting a writable format. 15 engine checks plus the GUI boot suite pass on Windows with Pillow 12.3.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant