From efc38eb9a65052aa79a4a04376d993d603efc808 Mon Sep 17 00:00:00 2001
From: Awetspoon <122938825+Awetspoon@users.noreply.github.com>
Date: Wed, 15 Jul 2026 19:47:32 +0100
Subject: [PATCH] Checkpoint Sprite Factory 1.2.2 before staged rebuild
---
CHANGELOG.md | 10 +-
README.md | 20 +-
docs/PROJECT_STRUCTURE.md | 5 +-
docs/RELEASE_1.2.2.md | 6 +-
docs/RELEASE_CHECKLIST.md | 2 +-
docs/WEB_SOURCES_README.md | 72 +-
image_engine_app/app/audit.py | 2 +-
image_engine_app/app/logging_config.py | 66 +-
image_engine_app/app/main.py | 42 +-
image_engine_app/app/services/__init__.py | 3 +-
.../app/services/batch_preset_rules.py | 67 +-
.../app/services/export_workflow.py | 3 +
.../app/services/web_sources_service.py | 33 +-
image_engine_app/app/ui_controller.py | 543 +---
image_engine_app/app/web_sources_models.py | 219 +-
image_engine_app/assets/__init__.py | 1 +
image_engine_app/assets/icons/__init__.py | 1 +
.../assets/icons/spritefactory.ico | Bin
.../assets/icons/spritefactory.png | Bin
.../assets/icons/spritefactory_pro.ico | Bin
.../assets/icons/spritefactory_pro.png | Bin
.../WEB_SOURCES_SMART_LAYER_FULL_BREAKDOWN.md | 129 -
image_engine_app/engine/analyze/recommend.py | 17 +-
image_engine_app/engine/batch/batch_runner.py | 112 +-
image_engine_app/engine/export/exporters.py | 109 +-
.../engine/export/size_predictor.py | 14 +-
image_engine_app/engine/models/__init__.py | 3 +-
.../engine/models/asset_record.py | 9 +-
.../engine/models/queue_models.py | 9 +
image_engine_app/engine/presets/__init__.py | 19 +
image_engine_app/engine/presets/builtin.py | 237 ++
image_engine_app/engine/process/bounds.py | 6 +-
.../engine/process/edit_baseline.py | 211 ++
.../engine/process/light_steps.py | 74 +-
.../engine/process/output_size.py | 73 +
.../engine/process/presets_apply.py | 17 +-
.../engine/process/preview_support.py | 46 +-
image_engine_app/tests/test_asset_tabs.py | 4 +-
.../tests/test_batch_coordinator.py | 44 +-
.../tests/test_batch_manager_dialog.py | 24 +-
.../tests/test_batch_preset_rules_service.py | 6 +-
image_engine_app/tests/test_batch_run_prep.py | 6 +-
image_engine_app/tests/test_batch_runner.py | 57 +-
.../tests/test_builtin_presets.py | 39 +
image_engine_app/tests/test_control_strip.py | 36 +-
.../tests/test_control_strip_state.py | 32 +-
image_engine_app/tests/test_export_bar.py | 23 +-
.../tests/test_export_controls.py | 125 +
image_engine_app/tests/test_launcher.py | 8 +
image_engine_app/tests/test_light_steps.py | 30 +-
.../tests/test_local_import_coordinator.py | 48 +-
image_engine_app/tests/test_logging_config.py | 61 +
.../tests/test_main_window_smoke.py | 82 +-
image_engine_app/tests/test_models.py | 12 +-
image_engine_app/tests/test_output_size.py | 54 +
.../tests/test_preset_manager_dialog.py | 81 +-
image_engine_app/tests/test_preset_store.py | 4 +-
image_engine_app/tests/test_presets.py | 4 +-
.../tests/test_session_coordinator.py | 44 +-
image_engine_app/tests/test_settings_panel.py | 108 +-
.../tests/test_settings_panel_state.py | 2 +-
image_engine_app/tests/test_ui_controller.py | 1593 ++++++------
.../tests/test_web_sources_coordinator.py | 963 +++----
.../tests/test_web_sources_panel.py | 1164 +++------
.../tests/test_web_sources_service.py | 58 +-
image_engine_app/ui/common/shell_theme.py | 36 +-
image_engine_app/ui/common/state_bindings.py | 64 +-
image_engine_app/ui/main_window/__init__.py | 5 +-
.../ui/main_window/apply_coordinator.py | 7 +-
.../ui/main_window/batch_coordinator.py | 34 +-
.../ui/main_window/batch_run_prep.py | 32 +-
.../ui/main_window/control_strip.py | 142 +-
.../ui/main_window/control_strip_state.py | 60 +-
.../ui/main_window/encoding_coordinator.py | 67 -
image_engine_app/ui/main_window/export_bar.py | 6 +-
.../main_window/local_import_coordinator.py | 70 +-
.../ui/main_window/main_window.py | 291 +--
.../ui/main_window/preview_panel.py | 16 +-
.../ui/main_window/session_coordinator.py | 111 +-
.../ui/main_window/settings_group_builders.py | 73 +-
.../ui/main_window/settings_panel.py | 215 +-
.../ui/main_window/web_sources_coordinator.py | 707 ++----
.../ui/main_window/web_sources_panel.py | 2215 +++++++----------
image_engine_app/ui/windows/__init__.py | 2 -
image_engine_app/ui/windows/batch_manager.py | 75 +-
.../ui/windows/export_encoding.py | 148 --
image_engine_app/ui/windows/preset_manager.py | 312 ++-
pyproject.toml | 3 +
spritefactory.spec | 11 +-
spritefactory_onefile.spec | 11 +-
90 files changed, 5235 insertions(+), 6370 deletions(-)
create mode 100644 image_engine_app/assets/__init__.py
create mode 100644 image_engine_app/assets/icons/__init__.py
rename spritefactory.ico => image_engine_app/assets/icons/spritefactory.ico (100%)
rename spritefactory.png => image_engine_app/assets/icons/spritefactory.png (100%)
rename spritefactory_pro.ico => image_engine_app/assets/icons/spritefactory_pro.ico (100%)
rename spritefactory_pro.png => image_engine_app/assets/icons/spritefactory_pro.png (100%)
delete mode 100644 image_engine_app/docs/WEB_SOURCES_SMART_LAYER_FULL_BREAKDOWN.md
create mode 100644 image_engine_app/engine/presets/__init__.py
create mode 100644 image_engine_app/engine/presets/builtin.py
create mode 100644 image_engine_app/engine/process/edit_baseline.py
create mode 100644 image_engine_app/engine/process/output_size.py
create mode 100644 image_engine_app/tests/test_builtin_presets.py
create mode 100644 image_engine_app/tests/test_export_controls.py
create mode 100644 image_engine_app/tests/test_logging_config.py
create mode 100644 image_engine_app/tests/test_output_size.py
delete mode 100644 image_engine_app/ui/main_window/encoding_coordinator.py
delete mode 100644 image_engine_app/ui/windows/export_encoding.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac22647..582cbf8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,10 +28,10 @@
- Added clearer Web Sources scan status messages for HTTP 401/403/429 cases.
- Added regression tests for HTTP 403 scan retry + friendly error mapping.
- Rewrote README for clearer product overview, workflow guidance, and release usage.
-## 1.1.1 - 2026-03-12
-- Replaced README/UI screenshot with a true full-size capture of the running **Sprite Factory Pro** app window.
-- Synced repository docs to reflect the current branded UI state.
-
+## 1.1.1 - 2026-03-12
+- Replaced README/UI screenshot with a true full-size capture of the running **Sprite Factory Pro** app window.
+- Synced repository docs to reflect the current branded UI state.
+
## 1.1.0 - 2026-03-12
- Rebranded UI and runtime identity to **Sprite Factory Pro**.
- Moved `Encoding Window` access from top toolbar into `Settings > Export Encoding` and kept full encoding controls.
@@ -63,4 +63,4 @@
- Session/save/export UX fixes.
## 1.0.1 - 2026-03-09
-- Initial SpriteFactory baseline import.
+- Initial SpriteFactory baseline import.
diff --git a/README.md b/README.md
index b1b11cf..20a6371 100644
--- a/README.md
+++ b/README.md
@@ -11,9 +11,10 @@ Sprite Factory Pro is a Windows desktop app for cleaning, enhancing, previewing,
- Import local files, folders, ZIP archives, or images found from web pages.
- Preview `Current` and `Final` output side by side before exporting.
- Apply cleanup, color, detail, transparency, GIF, export, and encoding controls.
+- Choose real output sizes using sprite-safe 2x/3x/4x/8x scaling or standard 240p-2160p heights while preserving aspect ratio.
- Remove white or black backgrounds when you choose to, without forcing it on import.
-- Use presets for faster sprite, photo, GIF, and mixed-format workflows.
-- Batch process large queues with presets, background overrides, file naming, and export rules.
+- Save presets directly from changed controls, then reuse them for sprite, photo, GIF, and mixed-format workflows.
+- Batch process large queues with one clear edit source, background overrides, file naming, and export rules.
- Export to `PNG`, `WEBP`, `JPG`, `GIF`, `ICO`, `TIFF`, and `BMP`.
## Download
@@ -22,16 +23,16 @@ The easiest way to use the app is the latest Windows release:
[Sprite Factory Pro Releases](https://github.com/Awetspoon/SpriteFactory/releases/latest)
-Download the `.exe`, run it, then import files from the top `Import` menu or use the `Web Sources` tab.
+Download the `.exe`, run it, then add files from the top `File` menu or use `Web Sources`.
## Main Workflow
-1. Open a new session or continue working in the current workspace.
+1. Create a workspace or open an existing one from `File`.
2. Import files, folders, ZIPs, or scan web pages for sprite/image links.
3. Select an asset from the workspace.
-4. Choose a preset or adjust edit settings manually.
-5. Use `Preview` to check the result in the final pane.
-6. Use `Apply` when you want to commit the edit to the asset.
+4. Start with the controls detected for that asset, then choose a preset or make small adjustments.
+5. Watch visual changes update the Final pane automatically, or use `Refresh Final` to rebuild it manually.
+6. Use `Run Heavy` only when a selected preset or control requires heavier processing.
7. Export one file, skip to the next asset, or open Batch Manager for queue export.
## Web Sources
@@ -48,8 +49,9 @@ Download the `.exe`, run it, then import files from the top `Import` menu or use
Batch Manager processes selected workspace assets with a separate batch workflow so normal preview/edit state does not get tangled with queue export.
-- Copy current edits when needed.
-- Apply a chosen preset or smart preset rules.
+- Choose exactly one edit source: keep each asset's controls, apply one preset, copy the active asset, or smart-match each asset.
+- Chosen presets start from every asset's detected baseline; smart matching applies at most one preset and never stacks hidden rules.
+- Workspace, Preset Studio, and Batch share one merged preset library. Bundled presets live in one engine catalog; user presets are stored separately and override by name.
- Override background-removal behavior for the batch.
- Keep source names or use batch naming rules.
- Save files after processing and review failures clearly.
diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md
index 4f4b684..1a22db6 100644
--- a/docs/PROJECT_STRUCTURE.md
+++ b/docs/PROJECT_STRUCTURE.md
@@ -6,7 +6,8 @@ This document describes the intended repository layout for Sprite Factory.
- `image_engine_app/` — current production application code.
- `app/` — startup, controller wiring, settings, persistence, audit.
- - `engine/` — ingest, processing, analysis, batch, export, models.
+ - `assets/` — packaged static assets such as app icons and UI images.
+ - `engine/` — ingest, processing, analysis, batch, export, models, and the single bundled preset catalog under `engine/presets/`.
- `ui/` — PySide6 windows, coordinators, controls, and dialogs.
- `tests/` — unit and smoke coverage for the active app.
- `docs/` — package-specific reference notes and default data files.
@@ -23,6 +24,7 @@ This document describes the intended repository layout for Sprite Factory.
- `build_exe.ps1` / `build_exe_onefile.ps1` — Windows packaging helpers.
- `RUN_AUDIT.ps1` — repository audit entry point.
- `BUILD_LOCK.md` — locked build-next checklist that intentionally stays at repo root.
+- App icon assets live under `image_engine_app/assets/icons/` and are bundled by the PyInstaller specs.
## Generated or local-only paths
@@ -42,5 +44,6 @@ These should not be treated as source structure and should stay out of version c
- The active application path is `image_engine_app`, not `image_engine_v3`.
- `image_engine_v3` is still important because the active code and tests depend on it.
- Imports should be package-qualified (`image_engine_app.*`, `image_engine_v3.*`) rather than relying on `PYTHONPATH=image_engine_app`.
+- Output-size choices are defined once in `engine/process/output_size.py`; the UI maps them onto the existing resize/width/height controls rather than maintaining duplicate size state.
- `python -m image_engine_app` is the preferred development entrypoint; the repo-root `main.py` wrapper is compatibility sugar only.
- Legacy repo-root `_runtime_data/` may still exist on a local machine, but it is not part of the intended source layout.
diff --git a/docs/RELEASE_1.2.2.md b/docs/RELEASE_1.2.2.md
index 17e203e..f3c8637 100644
--- a/docs/RELEASE_1.2.2.md
+++ b/docs/RELEASE_1.2.2.md
@@ -5,8 +5,10 @@ Sprite Factory Pro 1.2.2 completes the preview-first redesign and focuses on rel
## Highlights
- Added a compact preset selector directly to the editing tools. It only offers compatible presets for the active asset and updates the final preview immediately.
-- Kept Preset Manager as the advanced workspace for creating, duplicating, editing, and saving user presets.
-- Simplified Batch Manager with compact `Queue` and `Options` menus while retaining isolated batch edits, presets, background rules, naming, and export behavior.
+- Rebuilt Preset Manager around `Use Active Controls`; it saves only changes from the active asset's detected baseline and keeps JSON under optional Advanced controls.
+- Consolidated bundled presets into one validated engine catalog shared by Workspace, Preset Studio, recommendations, and Batch; removed overlapping starter variants.
+- Added real output-size choices for 2x/3x/4x/8x sprite scaling and 240p through 2160p standard heights without duplicating the underlying pixel settings.
+- Rebuilt Batch Manager around one explicit edit source so copied controls, chosen presets, and smart matching cannot stack accidentally.
- Reworked Web Sources for saved pages, one-page or multi-page scanning, linked-page discovery, keyword and format filtering, scan limits, retries, and clearer failures.
- Tightened the editor shell, preview controls, workspace paging, export dock, settings cards, labels, and helper guidance.
- Removed obsolete design drafts, superseded UI/backend modules, generated files, and dead tests from the repository.
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
index b71b19b..1f35453 100644
--- a/docs/RELEASE_CHECKLIST.md
+++ b/docs/RELEASE_CHECKLIST.md
@@ -15,7 +15,7 @@ Run the commands from the repository root.
## 3) Version + icon
- [ ] Update `pyinstaller_version_info.py` version strings if needed
-- [ ] Confirm `spritefactory_pro.ico`, `spritefactory.ico`, and PNG icon assets are present and show the current app icon
+- [ ] Confirm `image_engine_app/assets/icons/` contains the current ICO and PNG app icon assets
## 4) Build one-file release (recommended)
- [ ] `powershell -ExecutionPolicy Bypass -File .\build_exe_onefile.ps1`
diff --git a/docs/WEB_SOURCES_README.md b/docs/WEB_SOURCES_README.md
index 184afa6..db1863f 100644
--- a/docs/WEB_SOURCES_README.md
+++ b/docs/WEB_SOURCES_README.md
@@ -1,21 +1,65 @@
-# Sprite Factory Web Sources Scaffold Pack
+# Web Sources
-This scaffold adds the Web Sources feature flow:
+Web Sources finds sprite and image files on public web pages and imports selected files into the Sprite Factory workspace.
-`Paste URL or choose a saved page` -> `Scan Page` -> select files -> `Download Selected`
+## Workflow
-It is designed to be:
-- **Beginner-friendly** with a simple UI flow
-- **Non-bloated** with optional smart features
-- **Patch-safe** because the UI stays thin and the controller owns the logic
+All page sources use the same scan request:
-## Where to drop these files
+`Entered URLs, Saved Pages, or Selected Linked Pages -> Page Scanner -> Found Files -> Download Selected -> Workspace`
-Copy the folders inside this pack into the repository root so paths line up, for example:
+The page scanner, linked-page discovery, saved-page registry, connection check, and downloader are separate operations. This prevents a menu action from accidentally calling the wrong behavior.
-- `image_engine_app/ui/main_window/web_sources_panel.py`
-- `image_engine_app/engine/ingest/web_sources_rules.py`
-- `image_engine_app/engine/ingest/zip_extract.py`
-- `image_engine_app/app/web_sources_models.py`
+## 1. Scan Pages
-Then follow `image_engine_app/docs/INTEGRATION_CHECKLIST.md`.
+- Paste one complete page URL per line.
+- URLs can come from different websites.
+- **Scan Pages** validates and deduplicates the list before scanning.
+- Scans over 100 pages require confirmation and are capped at 100 pages.
+- **More** contains Save Entered Pages, Check First URL, Include uncertain image links, and Clear Entered URLs.
+
+## 2. Saved Pages
+
+- **Save Entered Pages** groups exact page URLs under their website host.
+- Check one page, several pages, or an entire website row.
+- Checked pages from several websites can be scanned together.
+- **Scan Selected** uses the same scanner as entered and linked URLs.
+- **More** contains only saved-page selection, connection, and removal actions.
+- Saved-page changes do not clear Found Files.
+
+## 3. Find Linked Pages
+
+- Use this optional section for an index, category, or directory page.
+- **Discover from** explicitly shows which entered or saved page will be inspected.
+- **Find Pages** discovers page links but does not scan their files.
+- Search and select the discovered pages, then use **Scan Selected**.
+- **More** contains only selection and clear actions for linked pages.
+
+## 4. Found Files
+
+- Found Files persists across separate scans.
+- New scan results are merged by normalized file URL.
+- Failed scans and duplicate links do not remove successful earlier results.
+- Search matches filename, URL, and source page.
+- Hide words removes matching rows from view without deleting them.
+- File Types controls PNG, GIF, WEBP, JPG/JPEG, and ZIP visibility.
+- **More** contains Select All Visible Files, Clear File Selection, and Clear Found Files.
+
+## Download
+
+- **Download Options** controls skipping files already downloaded and ZIP extraction.
+- **Download Selected** imports selected files into the workspace.
+- Smart routing places assets into Main, Shiny, Animated, or Items.
+- Only **Clear Found Files** empties the persistent result basket.
+
+## Ownership
+
+- `web_sources_panel.py` renders state and emits typed user requests.
+- `web_sources_coordinator.py` validates requests, controls progress, and calls services.
+- `web_sources_service.py` scans pages, discovers links, downloads files, and creates assets.
+- `web_sources_models.py` defines request contracts and the Found Files store.
+- `settings_store.py` persists saved pages, the last selected page, and scan/download options.
+
+## Network Failures
+
+Website failures are reported per page when possible, while successful pages are retained. HTTP 403/429 normally means the remote website blocked or rate-limited the request. HTTP 500/502/503/504 means the remote server failed. Connection checks test one selected URL and never start a scan.
diff --git a/image_engine_app/app/audit.py b/image_engine_app/app/audit.py
index 8b82fea..b74f094 100644
--- a/image_engine_app/app/audit.py
+++ b/image_engine_app/app/audit.py
@@ -120,7 +120,7 @@ def _check_packaging_files(project_root: Path) -> tuple[bool, dict[str, Any]]:
"onedir_spec": project_root / "spritefactory.spec",
"onefile_spec": project_root / "spritefactory_onefile.spec",
"runtime_hook_dir": project_root / "pyinstaller_rthooks",
- "icon": project_root / "spritefactory.ico",
+ "icon": project_root / "image_engine_app" / "assets" / "icons" / "spritefactory_pro.ico",
"version_info": project_root / "pyinstaller_version_info.py",
}
missing = [k for k, p in paths.items() if not p.exists()]
diff --git a/image_engine_app/app/logging_config.py b/image_engine_app/app/logging_config.py
index dcf4f9e..6f9c4b0 100644
--- a/image_engine_app/app/logging_config.py
+++ b/image_engine_app/app/logging_config.py
@@ -3,19 +3,49 @@
from __future__ import annotations
import logging
+import os
from pathlib import Path
+import tempfile
+
+
+def _close_handlers(logger: logging.Logger) -> None:
+ for handler in list(logger.handlers):
+ logger.removeHandler(handler)
+ try:
+ handler.close()
+ except Exception:
+ pass
+
+
+def _file_handler(
+ path: Path,
+ *,
+ level: int,
+ formatter: logging.Formatter,
+) -> logging.FileHandler | None:
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ handler = logging.FileHandler(path, encoding="utf-8")
+ except OSError:
+ return None
+ handler.setFormatter(formatter)
+ handler.setLevel(level)
+ return handler
+
+
+def _fallback_log_dir() -> Path:
+ return Path(tempfile.gettempdir()) / "SpriteFactory" / "logs"
def configure_logging(log_dir: str | Path, *, level: int = logging.INFO) -> logging.Logger:
"""Configure app loggers and return the main app logger."""
target_dir = Path(log_dir)
- target_dir.mkdir(parents=True, exist_ok=True)
log_file = target_dir / "image_engine_app.log"
logger = logging.getLogger("image_engine_app")
logger.setLevel(level)
- logger.handlers.clear()
+ _close_handlers(logger)
formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
@@ -24,21 +54,33 @@ def configure_logging(log_dir: str | Path, *, level: int = logging.INFO) -> logg
stream_handler.setLevel(level)
logger.addHandler(stream_handler)
- file_handler = logging.FileHandler(log_file, encoding="utf-8")
- file_handler.setFormatter(formatter)
- file_handler.setLevel(level)
- logger.addHandler(file_handler)
+ active_log_dir = target_dir
+ file_handler = _file_handler(log_file, level=level, formatter=formatter)
+ if file_handler is None:
+ active_log_dir = _fallback_log_dir()
+ log_file = active_log_dir / f"image_engine_app_{os.getpid()}.log"
+ file_handler = _file_handler(log_file, level=level, formatter=formatter)
+ if file_handler is not None:
+ logger.addHandler(file_handler)
# Dedicated debug trace for batch workflows (UI + runner) to diagnose mid-run failures.
batch_logger = logging.getLogger("image_engine_app.batch")
batch_logger.setLevel(logging.DEBUG)
- batch_logger.handlers.clear()
- batch_file_handler = logging.FileHandler(target_dir / "batch_debug.log", encoding="utf-8")
- batch_file_handler.setFormatter(formatter)
- batch_file_handler.setLevel(logging.DEBUG)
- batch_logger.addHandler(batch_file_handler)
+ _close_handlers(batch_logger)
+ batch_file_handler = _file_handler(
+ active_log_dir / "batch_debug.log",
+ level=logging.DEBUG,
+ formatter=formatter,
+ )
+ if batch_file_handler is not None:
+ batch_logger.addHandler(batch_file_handler)
batch_logger.propagate = True
logger.propagate = False
- logger.debug("Logging configured at %s", log_file)
+ if file_handler is None:
+ logger.warning("File logging unavailable; continuing with console logging")
+ elif active_log_dir != target_dir:
+ logger.warning("Primary log unavailable; using fallback log: %s", log_file)
+ else:
+ logger.debug("Logging configured at %s", log_file)
return logger
diff --git a/image_engine_app/app/main.py b/image_engine_app/app/main.py
index 17c2ac7..d0afc1e 100644
--- a/image_engine_app/app/main.py
+++ b/image_engine_app/app/main.py
@@ -1,4 +1,4 @@
-"""App entrypoint (UI launch) for the Prompt 16 Qt shell."""
+"""Application entrypoint for the Sprite Factory Qt desktop UI."""
from __future__ import annotations
@@ -17,6 +17,9 @@
from image_engine_app.ui.common.shell_theme import build_app_stylesheet
+APP_ICON_NAMES = ("spritefactory_pro.ico", "spritefactory.ico", "spritefactory_pro.png", "spritefactory.png")
+
+
def build_startup_session() -> SessionState:
"""Create a clean empty startup session (no demo assets)."""
@@ -56,19 +59,19 @@ def _resolve_runtime_icon_candidates() -> list[Path]:
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
base = Path(str(meipass))
- candidates.append(base / "spritefactory_pro.ico")
- candidates.append(base / "spritefactory.ico")
- candidates.append(base / "spritefactory_pro.png")
- candidates.append(base / "spritefactory.png")
+ for icon_root in (base / "image_engine_app" / "assets" / "icons", base):
+ for icon_name in APP_ICON_NAMES:
+ candidates.append(icon_root / icon_name)
exe_path = Path(sys.executable)
candidates.append(exe_path)
else:
root = Path(__file__).resolve().parents[2]
- candidates.append(root / "spritefactory_pro.ico")
- candidates.append(root / "spritefactory.ico")
- candidates.append(root / "spritefactory_pro.png")
- candidates.append(root / "spritefactory.png")
+ icon_root = root / "image_engine_app" / "assets" / "icons"
+ for icon_name in APP_ICON_NAMES:
+ candidates.append(icon_root / icon_name)
+ for icon_name in APP_ICON_NAMES:
+ candidates.append(root / icon_name)
except Exception:
return []
@@ -104,12 +107,19 @@ def _apply_clean_pro_theme(app) -> None: # noqa: ANN001 - QApplication imported
app.setStyleSheet(build_app_stylesheet())
-def main(argv: list[str] | None = None) -> int:
- """Launch the Sprite Factory UI shell."""
+def _parse_startup_args(argv: list[str] | None) -> tuple[argparse.Namespace, list[str]]:
+ """Parse Sprite Factory options and leave unrelated options for Qt."""
parser = argparse.ArgumentParser(description="Launch the Sprite Factory UI shell")
parser.add_argument("--app-data-dir", default=None, help="Override app data directory (for local testing)")
- args = parser.parse_args(argv)
+ raw_args = list(sys.argv[1:] if argv is None else argv)
+ return parser.parse_known_args(raw_args)
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Launch the Sprite Factory UI shell."""
+
+ args, qt_args = _parse_startup_args(argv)
paths = ensure_app_paths(base_dir=args.app_data_dir)
logger = configure_logging(paths.logs)
@@ -126,7 +136,7 @@ def main(argv: list[str] | None = None) -> int:
_set_windows_app_user_model_id("Marcus.SpriteFactory.Windows.PythonV2", logger)
- app = QApplication(argv or sys.argv)
+ app = QApplication([sys.argv[0], *qt_args])
# Prefer native Windows style so min/max/titlebar behavior matches user expectations.
try:
@@ -193,6 +203,6 @@ def _on_about_to_quit() -> None:
if __name__ == "__main__":
- raise SystemExit(main())
-
-
+ raise SystemExit(main())
+
+
diff --git a/image_engine_app/app/services/__init__.py b/image_engine_app/app/services/__init__.py
index 24ffd80..94d6528 100644
--- a/image_engine_app/app/services/__init__.py
+++ b/image_engine_app/app/services/__init__.py
@@ -1,7 +1,7 @@
"""Application services layer."""
from .asset_profile_service import AssetProfileService
-from .batch_preset_rules import build_batch_auto_preset_rules, build_batch_per_source_preset_rules
+from .batch_preset_rules import build_batch_auto_preset_rules
from .export_workflow import export_asset, format_asset_export_prediction, predict_asset_export
from .preset_library import PresetLibrary, PresetLibraryState
@@ -10,7 +10,6 @@
"PresetLibrary",
"PresetLibraryState",
"build_batch_auto_preset_rules",
- "build_batch_per_source_preset_rules",
"export_asset",
"format_asset_export_prediction",
"predict_asset_export",
diff --git a/image_engine_app/app/services/batch_preset_rules.py b/image_engine_app/app/services/batch_preset_rules.py
index 5fd835f..3b02af6 100644
--- a/image_engine_app/app/services/batch_preset_rules.py
+++ b/image_engine_app/app/services/batch_preset_rules.py
@@ -3,7 +3,13 @@
from __future__ import annotations
from image_engine_app.app.services.preset_library import PresetLibrary
-from image_engine_app.engine.models import EditMode, ExportFormat, ExportProfile, PresetModel
+from image_engine_app.engine.models import PresetModel
+from image_engine_app.engine.presets import (
+ ARTIFACT_CLEANUP,
+ GIF_SAFE_CLEANUP,
+ PHOTO_RECOVER,
+ PIXEL_CLEAN_UPSCALE,
+)
def build_batch_auto_preset_rules(library: PresetLibrary, *, enabled: bool) -> dict[str, list[PresetModel]]:
@@ -11,58 +17,15 @@ def build_batch_auto_preset_rules(library: PresetLibrary, *, enabled: bool) -> d
if not enabled:
return auto_preset_rules
- if library.has_preset("Pixel Clean Upscale"):
- pixel_cleanup = library.get("Pixel Clean Upscale")
+ if library.has_preset(PIXEL_CLEAN_UPSCALE):
+ pixel_cleanup = library.get(PIXEL_CLEAN_UPSCALE)
auto_preset_rules["pixel_art"] = [pixel_cleanup]
auto_preset_rules["sprite_sheet"] = [pixel_cleanup]
- if library.has_preset("Photo Recover"):
- auto_preset_rules["photo"] = [library.get("Photo Recover")]
- if library.has_preset("GIF Safe Cleanup"):
- auto_preset_rules["animation"] = [library.get("GIF Safe Cleanup")]
- if library.has_preset("Artifact Cleanup"):
- auto_preset_rules.setdefault("artwork", []).append(library.get("Artifact Cleanup"))
+ if library.has_preset(PHOTO_RECOVER):
+ auto_preset_rules["photo"] = [library.get(PHOTO_RECOVER)]
+ if library.has_preset(GIF_SAFE_CLEANUP):
+ auto_preset_rules["animation"] = [library.get(GIF_SAFE_CLEANUP)]
+ if library.has_preset(ARTIFACT_CLEANUP):
+ auto_preset_rules.setdefault("artwork", []).append(library.get(ARTIFACT_CLEANUP))
return auto_preset_rules
-
-
-def build_batch_per_source_preset_rules(*, enabled: bool) -> dict[str, list[PresetModel]]:
- if not enabled:
- return {}
-
- return {
- "gif": [
- PresetModel(
- name="Batch GIF Export",
- description="Batch export rule for animated sources",
- settings_delta={"export": {"format": ExportFormat.GIF.value, "palette_limit": 256}},
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- )
- ],
- "png": [
- PresetModel(
- name="Batch PNG Export",
- description="Batch export rule for PNG sources",
- settings_delta={"export": {"format": ExportFormat.PNG.value}},
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- )
- ],
- "spritesheet": [
- PresetModel(
- name="Batch Spritesheet Export",
- description="Batch export rule for spritesheets",
- settings_delta={
- "export": {
- "export_profile": ExportProfile.APP_ASSET.value,
- "format": ExportFormat.PNG.value,
- }
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- )
- ],
- }
diff --git a/image_engine_app/app/services/export_workflow.py b/image_engine_app/app/services/export_workflow.py
index bb98358..b3286c1 100644
--- a/image_engine_app/app/services/export_workflow.py
+++ b/image_engine_app/app/services/export_workflow.py
@@ -32,6 +32,7 @@ def predict_asset_export(asset: object) -> ExportPredictorResult:
width=width,
height=height,
export_settings=export_settings,
+ gif_settings=getattr(settings, "gif", None),
has_alpha=has_alpha,
is_animated=is_animated,
frame_count=8 if is_animated else 1,
@@ -82,6 +83,8 @@ def export_asset(
width=width,
height=height,
export_settings=export_settings,
+ gif_settings=getattr(settings, "gif", None),
+ dpi=int(getattr(getattr(settings, "pixel", None), "dpi", 72) or 72),
asset_id=asset_id,
frame_count=8 if is_animated else 1,
has_alpha=has_alpha,
diff --git a/image_engine_app/app/services/web_sources_service.py b/image_engine_app/app/services/web_sources_service.py
index d3f3b6c..d6a167f 100644
--- a/image_engine_app/app/services/web_sources_service.py
+++ b/image_engine_app/app/services/web_sources_service.py
@@ -24,7 +24,6 @@
WebIndexLink,
WebItem,
coerce_smart_options,
- coerce_web_item,
)
from image_engine_app.engine.ingest.url_ingest import DownloadCancelledError, DownloadGuards, UrlIngestError, validate_url
from image_engine_app.engine.ingest.web_sources_rules import (
@@ -385,18 +384,17 @@ def _is_cancel_requested() -> bool:
return False
def _looks_like_cancel_error(exc: Exception) -> bool:
- return isinstance(exc, DownloadCancelledError)
+ return isinstance(exc, (DownloadCancelledError, WebpageScanCancelledError))
_emit_progress(0, "Preparing downloads...")
- for index, raw_item in enumerate(items, start=1):
+ for index, item in enumerate(items, start=1):
if _is_cancel_requested():
cancelled = True
_emit_progress(max(0, index - 1), "Download cancelled by user")
break
- item = coerce_web_item(raw_item)
- if item is None or not item.url.strip():
+ if not isinstance(item, WebItem) or not item.url.strip():
_emit_progress(index, f"Skipped invalid item ({index}/{total_items})")
continue
@@ -404,13 +402,22 @@ def _looks_like_cancel_error(exc: Exception) -> bool:
canonical_url = self.canonicalize_download_url(item.url) or item.url
source_page = str(item.source_page or item.url or canonical_url).strip()
name = self.resolve_web_item_name(item.name, item.url)
- download_url = self.resolve_download_url(
- item=item,
- canonical_url=canonical_url,
- source_page=source_page,
- opener=opener,
- cancel_requested=cancel_requested,
- )
+ try:
+ download_url = self.resolve_download_url(
+ item=item,
+ canonical_url=canonical_url,
+ source_page=source_page,
+ opener=opener,
+ cancel_requested=cancel_requested,
+ )
+ except Exception as exc:
+ if _looks_like_cancel_error(exc) or _is_cancel_requested():
+ cancelled = True
+ _emit_progress(max(0, index - 1), "Download cancelled by user")
+ break
+ failed.append(f"{name}: {exc}")
+ _emit_progress(index, f"Failed: {name}")
+ continue
_emit_progress(index - 1, f"Downloading {index}/{total_items}: {name}")
item_key = dedupe_key(f"{effective_target.value}:{name}:{canonical_url}")
@@ -1120,4 +1127,4 @@ def sanitize_registry(raw: object) -> list[dict]:
-
+
diff --git a/image_engine_app/app/ui_controller.py b/image_engine_app/app/ui_controller.py
index 8cf7a6a..19b6ad7 100644
--- a/image_engine_app/app/ui_controller.py
+++ b/image_engine_app/app/ui_controller.py
@@ -1,8 +1,7 @@
-"""UI action controller that bridges the Prompt 16 shell to engine modules."""
+"""Application controller bridging the Qt shell to engine services."""
from __future__ import annotations
-from copy import deepcopy
from dataclasses import dataclass
import re
from pathlib import Path
@@ -13,11 +12,11 @@
PresetCatalogEntry,
preset_matches_asset,
)
+from image_engine_app.engine.presets import build_builtin_presets
from image_engine_app.app.services import (
AssetProfileService,
PresetLibrary,
build_batch_auto_preset_rules,
- build_batch_per_source_preset_rules,
export_asset,
format_asset_export_prediction,
predict_asset_export,
@@ -57,29 +56,32 @@
)
from image_engine_app.engine.models import (
AssetRecord,
- AssetFormat,
EditMode,
EditState,
- ExportFormat,
- ExportProfile,
HeavyJobSpec,
HeavyTool,
PresetModel,
QueueItem,
QueueItemStatus,
- ScaleMethod,
SourceType,
- SettingsState,
normalize_edit_mode,
)
+from image_engine_app.engine.process.edit_baseline import (
+ CapturedControlSettings,
+ capture_control_settings,
+ capture_detected_settings,
+ clear_generated_outputs,
+ edit_state_from_detected_settings,
+ implied_heavy_jobs,
+ restore_detected_settings,
+)
from image_engine_app.engine.process.heavy_queue import HeavyQueueEngine
from image_engine_app.engine.process.heavy_runtime import execute_heavy_job
from image_engine_app.engine.process.light_steps import LightProcessError
from image_engine_app.engine.process.preview_support import render_light_pipeline_preview, select_export_source_path
from image_engine_app.engine.process.presets_apply import (
PresetApplyError,
- ViewEditStates,
- apply_preset_stack,
+ apply_preset_to_edit_state,
)
@@ -118,7 +120,7 @@ class UrlImportSummary:
class ImageEngineUIController:
- """Small orchestration layer for main-window actions."""
+ """Orchestrate main-window workflows through focused application services."""
AUTO_DETECTED_PRESET_MIN_CONFIDENCE = 0.6
@@ -132,7 +134,7 @@ def __init__(
self._heavy_queue_factory = heavy_queue_factory or (lambda: HeavyQueueEngine())
self._preset_library = PresetLibrary(
- system_presets=self._build_default_preset_library(),
+ system_presets=build_builtin_presets(),
app_paths=self.app_paths,
)
self._asset_profiles = AssetProfileService()
@@ -177,15 +179,21 @@ def delete_user_preset(self, name: str) -> bool:
return self._preset_library.delete_user_preset(name)
@staticmethod
- def _determine_mode_for_preset(asset: AssetRecord, preset: PresetModel) -> None:
+ def capture_preset_controls(asset: AssetRecord) -> CapturedControlSettings:
+ """Capture only the active asset controls changed after detection."""
+
+ return capture_control_settings(asset)
+
+ @staticmethod
+ def _determine_mode_for_preset(edit_state: EditState, preset: PresetModel) -> None:
mode_rank = {
EditMode.ADVANCED: 0,
EditMode.EXPERT: 1,
}
- current_mode = normalize_edit_mode(asset.edit_state.mode)
+ current_mode = normalize_edit_mode(edit_state.mode)
preset_mode = normalize_edit_mode(preset.mode_min)
if mode_rank[current_mode] < mode_rank[preset_mode]:
- asset.edit_state.mode = preset_mode
+ edit_state.mode = preset_mode
def apply_named_preset(self, asset: AssetRecord, preset_name: str) -> PresetApplySummary:
"""Apply a named preset to the active asset and queue any implied heavy jobs."""
@@ -194,43 +202,27 @@ def apply_named_preset(self, asset: AssetRecord, preset_name: str) -> PresetAppl
compatible, reason = preset_matches_asset(preset, asset)
if not compatible:
raise PresetApplyError(reason)
- # Keep preset clicks reliable from the chip bar: if a preset requires a higher mode,
- # auto-upgrade the asset mode before applying instead of failing.
- self._determine_mode_for_preset(asset, preset)
+ baseline_state = edit_state_from_detected_settings(asset)
+ self._determine_mode_for_preset(baseline_state, preset)
+ updated_state = apply_preset_to_edit_state(preset, baseline_state)
- states = ViewEditStates(current=deepcopy(asset.edit_state), final=deepcopy(asset.edit_state))
- report = apply_preset_stack([preset], states=states)
-
- # Persist canonical state back to the asset (favor final when target affects final/both).
- if report.effective_target.value in {"final", "both"} or report.sync_applied:
- asset.edit_state = report.states.final
- else:
- asset.edit_state = report.states.current
+ asset.edit_state = updated_state
+ clear_generated_outputs(asset)
if preset.uses_heavy_tools:
self._queue_implied_heavy_jobs(asset, preset)
return PresetApplySummary(
preset_name=preset.name,
- requires_apply=report.requires_apply,
+ requires_apply=(preset.requires_apply or preset.uses_heavy_tools),
queued_heavy_jobs=len(asset.edit_state.queued_heavy_jobs),
)
- def reset_asset_settings_to_defaults(self, asset: AssetRecord) -> None:
- """Reset only the active asset settings back to default values."""
-
- asset.edit_state.settings = SettingsState()
- asset.edit_state.queued_heavy_jobs.clear()
- asset.derived_current_path = None
- asset.derived_final_path = None
+ @staticmethod
+ def restore_asset_detected_settings(asset: AssetRecord) -> None:
+ """Restore the controls detected for this asset at import time."""
- original = getattr(asset, "dimensions_original", (0, 0))
- if isinstance(original, tuple) and len(original) == 2:
- ow = int(original[0] or 0)
- oh = int(original[1] or 0)
- if ow > 0 and oh > 0:
- asset.dimensions_current = (ow, oh)
- asset.dimensions_final = (ow, oh)
+ restore_detected_settings(asset)
def apply_light_pipeline(self, asset: AssetRecord) -> bool:
"""Apply the non-AI light pipeline and write derived Current/Final outputs.
@@ -512,25 +504,6 @@ def load_web_sources_registry(self, registry: list[dict] | None = None) -> list[
registry = []
return self._web_sources_service.load_registry(registry)
- def scan_web_sources_area(
- self,
- area_url: str,
- *,
- allowed_exts: set[str] | None = None,
- show_likely: bool = False,
- opener=None,
- cancel_requested=None,
- ) -> ScanResults:
- """Scan one area URL and shape results for the Web Sources UI."""
-
- return self._web_sources_service.scan_area(
- area_url,
- allowed_exts=allowed_exts,
- show_likely=show_likely,
- opener=opener,
- cancel_requested=cancel_requested,
- )
-
def discover_web_source_index_links(
self,
index_url: str,
@@ -594,66 +567,6 @@ def download_web_sources_items(
def _resolve_web_item_name(candidate_name: str | None, url: str) -> str:
return WebSourcesService.resolve_web_item_name(candidate_name, url)
- @staticmethod
- def _name_from_query(query: str) -> str:
- return WebSourcesService.name_from_query(query)
-
- @staticmethod
- def _clean_web_name(value: str | None) -> str:
- return WebSourcesService.clean_web_name(value)
-
- @staticmethod
- def _is_generic_web_name(name: str) -> bool:
- return WebSourcesService.is_generic_web_name(name)
-
- @staticmethod
- def _url_indicates_shiny(url: str) -> bool:
- return WebSourcesService.url_indicates_shiny(url)
-
- @staticmethod
- def _resolve_web_import_target(
- *,
- default_target: ImportTarget,
- item: WebItem,
- smart: SmartOptions,
- ) -> ImportTarget:
- return WebSourcesService.resolve_web_import_target(
- default_target=default_target,
- item=item,
- smart=smart,
- )
-
- def _web_target_cache_subdir(self, target: ImportTarget) -> str:
- return self._web_sources_service.web_target_cache_subdir(target)
-
- def _web_target_cache_dir(self, target: ImportTarget) -> Path:
- return self._web_sources_service.web_target_cache_dir(target)
-
- def _find_cached_web_file(self, url: str, target: ImportTarget) -> Path | None:
- return self._web_sources_service.find_cached_web_file(url, target)
-
- def _is_cached_web_url(self, url: str, target: ImportTarget) -> bool:
- return self._web_sources_service.is_cached_web_url(url, target)
-
- def _download_zip_to_cache(
- self,
- url: str,
- cache_dir: Path,
- *,
- max_bytes: int | None,
- timeout: float = 20.0,
- opener=None,
- cancel_requested=None,
- ) -> Path:
- return self._web_sources_service.download_zip_to_cache(
- url,
- cache_dir,
- max_bytes=max_bytes,
- timeout=timeout,
- opener=opener,
- cancel_requested=cancel_requested,
- )
-
def _build_web_asset_from_file(
self,
*,
@@ -680,18 +593,6 @@ def _build_web_asset_from_file(
self._hydrate_imported_asset(asset)
return asset
- @staticmethod
- def _extract_archive_urls(html: str, *, base_url: str, allowed_archives: set[str]) -> list[str]:
- return WebSourcesService.extract_archive_urls(
- html,
- base_url=base_url,
- allowed_archives=allowed_archives,
- )
-
- @staticmethod
- def _sanitize_web_sources_registry(raw: object) -> list[dict]:
- return WebSourcesService.sanitize_registry(raw)
-
def run_batch(
self,
assets: list[AssetRecord],
@@ -704,25 +605,29 @@ def run_batch(
export_dir: str | Path | None = None,
event_callback=None,
cancel_requested=None,
- ) -> BatchRunReport:
+ ) -> BatchRunReport:
"""Run the engine batch runner over a list of assets."""
auto_preset_rules = build_batch_auto_preset_rules(self._preset_library, enabled=auto_preset)
- per_source_preset_rules = build_batch_per_source_preset_rules(enabled=auto_preset)
+ resolved_export_dir: str | Path | None = None
+ if auto_export:
+ if export_dir is not None:
+ resolved_export_dir = export_dir
+ elif self.app_paths is not None:
+ resolved_export_dir = self.app_paths.exports
config = BatchRunnerConfig(
preview_skip_mode=preview_skip_mode,
auto_export=auto_export,
- export_dir=(export_dir if (auto_export and export_dir is not None) else (self.app_paths.exports if (auto_export and self.app_paths is not None) else None)),
+ export_dir=resolved_export_dir,
derived_cache_dir=((self.app_paths.cache / "batch_runs") if self.app_paths is not None else None),
auto_preset_rules=auto_preset_rules,
- per_source_preset_rules=per_source_preset_rules,
export_name_template=(export_name_template or "{stem}"),
overwrite_existing_exports=(not avoid_overwrite),
heavy_progress_steps=2,
heavy_step_delay_seconds=0.0,
)
- runner = BatchRunner(config)
+ runner = BatchRunner(config, heavy_queue_factory=self._heavy_queue_factory)
work_items = [
BatchWorkItem(
asset=asset,
@@ -813,39 +718,12 @@ def _select_export_source_path(self, asset: AssetRecord) -> str | None:
"""
return select_export_source_path(asset)
- @staticmethod
- def _reset_new_asset_to_default_size(asset: AssetRecord) -> None:
- """Ensure newly imported assets start at 100% before user resizing."""
-
- settings = getattr(getattr(asset, "edit_state", None), "settings", None)
- pixel = getattr(settings, "pixel", None)
- if pixel is not None:
- pixel.resize_percent = 100.0
- pixel.width = None
- pixel.height = None
-
- asset.derived_current_path = None
- asset.derived_final_path = None
-
- original = getattr(asset, "dimensions_original", (0, 0))
- if isinstance(original, tuple) and len(original) == 2:
- ow = int(original[0] or 0)
- oh = int(original[1] or 0)
- if ow > 0 and oh > 0:
- asset.dimensions_current = (ow, oh)
- asset.dimensions_final = (ow, oh)
-
def _hydrate_imported_asset(self, asset: AssetRecord) -> None:
self._asset_profiles.hydrate_imported_asset(
asset,
apply_baseline_preset=self._apply_detected_baseline_preset,
)
-
- def _analyze_asset_profile(self, asset: AssetRecord) -> None:
- self._asset_profiles.analyze_asset_profile(asset)
-
- def _build_quality_input_for_asset(self, asset: AssetRecord):
- return self._asset_profiles.build_quality_input_for_asset(asset)
+ capture_detected_settings(asset)
def _apply_detected_baseline_preset(self, asset: AssetRecord) -> None:
recs = getattr(asset, "recommendations", None)
@@ -867,346 +745,23 @@ def _apply_detected_baseline_preset(self, asset: AssetRecord) -> None:
return
preset = self.get_preset(suggestion.preset_name)
- self._determine_mode_for_preset(asset, preset)
- states = ViewEditStates(current=deepcopy(asset.edit_state), final=deepcopy(asset.edit_state))
- report = apply_preset_stack([preset], states=states)
-
- if report.effective_target.value in {"final", "both"} or report.sync_applied:
- asset.edit_state = report.states.final
- else:
- asset.edit_state = report.states.current
+ self._determine_mode_for_preset(asset.edit_state, preset)
+ asset.edit_state = apply_preset_to_edit_state(preset, asset.edit_state)
# Detection should not enqueue heavy work before the user explicitly applies.
asset.edit_state.queued_heavy_jobs.clear()
-
- @staticmethod
- def _clamp01(value: object, *, default: float = 0.0) -> float:
- return AssetProfileService.clamp01(value, default=default)
-
- def _apply_analysis_inferred_control_defaults(self, asset: AssetRecord) -> None:
- self._asset_profiles.apply_analysis_inferred_control_defaults(asset)
-
- def _apply_recommended_export_defaults(self, asset: AssetRecord) -> None:
- self._asset_profiles.apply_recommended_export_defaults(asset)
def _hydrate_local_assets(self, assets: list[AssetRecord]) -> None:
self._asset_profiles.hydrate_local_assets(assets, hydrate_asset=self._hydrate_imported_asset)
- def _probe_image_metadata(self, asset: AssetRecord) -> None:
- self._asset_profiles.probe_image_metadata(asset)
-
def _queue_implied_heavy_jobs(self, asset: AssetRecord, preset: PresetModel) -> None:
- name = preset.name.lower()
- if "upscale" in name:
- factor = max(2.0, float(asset.edit_state.settings.ai.upscale_factor))
- asset.edit_state.settings.ai.upscale_factor = factor
- self.queue_heavy_job(asset, tool=HeavyTool.AI_UPSCALE, params={"factor": factor, "preset": preset.name})
- elif "photo recover" in name:
+ for job in implied_heavy_jobs(preset, asset.edit_state):
self.queue_heavy_job(
asset,
- tool=HeavyTool.AI_DEBLUR,
- params={"strength": max(0.2, float(asset.edit_state.settings.ai.deblur_strength)), "preset": preset.name},
+ tool=job.tool,
+ params=job.params,
+ job_id=job.id,
)
- @staticmethod
- def _extension_for_format(fmt_value: str) -> str:
- return AssetProfileService.extension_for_format(fmt_value)
-
-
- @staticmethod
- def _asset_format_from_extension(ext: str) -> AssetFormat:
- return AssetProfileService.asset_format_from_extension(ext)
-
- @staticmethod
- def _asset_format_from_detected(detected_format: str) -> AssetFormat:
- return AssetProfileService.asset_format_from_detected(detected_format)
-
- @staticmethod
- def _build_default_preset_library() -> dict[str, PresetModel]:
- # Preset library is intentionally in-code + user-store for now; extend with additional providers when needed.
- return {
- "Pixel Clean Upscale": PresetModel(
- name="Pixel Clean Upscale",
- description="Pixel-safe cleanup with upscale prep",
- applies_to_formats=["png", "webp", "bmp"],
- applies_to_tags=["pixel_art", "sprite_sheet", "ui"],
- settings_delta={
- "cleanup": {"denoise": 0.22, "artifact_removal": 0.28, "halo_cleanup": 0.08},
- "detail": {"sharpen_amount": 0.42, "clarity": 0.20, "texture": 0.10},
- "ai": {"upscale_factor": 4.0, "deblur_strength": 0.15, "detail_reconstruct": 0.10},
- "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.PNG.value},
- },
- uses_heavy_tools=True,
- requires_apply=True,
- mode_min=EditMode.ADVANCED,
- ),
- "Artifact Cleanup": PresetModel(
- name="Artifact Cleanup",
- description="Reduce compression artifacts and noise",
- applies_to_formats=["jpg", "png", "webp", "bmp", "tiff"],
- applies_to_tags=["photo", "artwork", "texture"],
- settings_delta={
- "cleanup": {"denoise": 0.45, "artifact_removal": 0.55, "halo_cleanup": 0.24, "banding_removal": 0.28},
- "detail": {"sharpen_amount": 0.06, "clarity": -0.05},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "Photo Recover": PresetModel(
- name="Photo Recover",
- description="Photo deblur/recover preset",
- applies_to_formats=["jpg", "png", "webp", "bmp", "tiff"],
- applies_to_tags=["photo"],
- settings_delta={
- "detail": {"sharpen_amount": 0.46, "clarity": 0.34, "texture": 0.16},
- "cleanup": {"denoise": 0.26, "artifact_removal": 0.18},
- "ai": {"deblur_strength": 0.72, "detail_reconstruct": 0.38},
- "export": {"export_profile": ExportProfile.WEB.value, "format": ExportFormat.WEBP.value, "quality": 88},
- },
- uses_heavy_tools=True,
- requires_apply=True,
- mode_min=EditMode.ADVANCED,
- ),
- "Edge Repair": PresetModel(
- name="Edge Repair",
- description="Refine edges and cleanup halos",
- applies_to_formats=["png", "webp", "ico", "bmp"],
- applies_to_tags=["artwork", "ui", "logo", "icon", "pixel_art"],
- settings_delta={
- "edges": {"edge_refine": 0.55, "antialias": 0.28, "feather_px": 0.35, "grow_shrink_px": 0.0},
- "cleanup": {"halo_cleanup": 0.48},
- "alpha": {"alpha_smooth": 0.16},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "Starter Pixel Crisp": PresetModel(
- name="Starter Pixel Crisp",
- description="Stronger pixel-art preset for crisp upscale + cleanup",
- applies_to_formats=["png", "webp", "bmp"],
- applies_to_tags=["pixel_art", "sprite_sheet", "ui", "icon"],
- settings_delta={
- "pixel": {"resize_percent": 240.0, "pixel_snap": True, "scale_method": ScaleMethod.NEAREST.value},
- "detail": {"sharpen_amount": 0.62, "clarity": 0.30, "texture": 0.22},
- "cleanup": {"denoise": 0.10, "artifact_removal": 0.24, "halo_cleanup": 0.10},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "Starter Detail Boost": PresetModel(
- name="Starter Detail Boost",
- description="Stronger detail preset for readable sprite features",
- applies_to_formats=["png", "webp", "bmp", "tiff"],
- applies_to_tags=["pixel_art", "artwork", "ui"],
- settings_delta={
- "detail": {"sharpen_amount": 0.74, "clarity": 0.42, "texture": 0.32, "sharpen_threshold": 0.16},
- "cleanup": {"artifact_removal": 0.20, "halo_cleanup": 0.08},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "Starter Cleanup Smooth": PresetModel(
- name="Starter Cleanup Smooth",
- description="Stronger cleanup preset that keeps detail readable",
- applies_to_formats=["jpg", "png", "webp", "bmp", "tiff"],
- applies_to_tags=["photo", "artwork", "texture"],
- settings_delta={
- "cleanup": {"denoise": 0.42, "artifact_removal": 0.40, "halo_cleanup": 0.24, "banding_removal": 0.28},
- "detail": {"sharpen_amount": 0.08, "clarity": -0.08, "texture": -0.05},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "Starter Edges Clean": PresetModel(
- name="Starter Edges Clean",
- description="Stronger edge cleanup for cleaner outlines",
- applies_to_formats=["png", "webp", "ico"],
- applies_to_tags=["pixel_art", "artwork", "ui", "icon"],
- settings_delta={
- "edges": {"antialias": 0.38, "edge_refine": 0.56, "feather_px": 0.30, "grow_shrink_px": 0.0},
- "alpha": {"alpha_smooth": 0.20, "matte_fix": 0.14},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "Starter AI Recover": PresetModel(
- name="Starter AI Recover",
- description="Stronger AI-style recovery preset",
- applies_to_formats=["jpg", "png", "webp", "bmp", "tiff"],
- applies_to_tags=["photo", "artwork"],
- settings_delta={
- "ai": {"upscale_factor": 3.0, "deblur_strength": 0.52, "detail_reconstruct": 0.46},
- "detail": {"sharpen_amount": 0.22, "clarity": 0.18, "texture": 0.12},
- "cleanup": {"denoise": 0.16, "artifact_removal": 0.12},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "GIF Safe Cleanup": PresetModel(
- name="GIF Safe Cleanup",
- description="Animated-safe cleanup for GIF sprites and loops",
- applies_to_formats=["gif"],
- applies_to_tags=["animation", "pixel_art", "artwork", "ui"],
- settings_delta={
- "cleanup": {"denoise": 0.12, "artifact_removal": 0.18, "halo_cleanup": 0.06},
- "detail": {"sharpen_amount": 0.10, "clarity": 0.06, "texture": 0.04},
- "alpha": {"alpha_smooth": 0.04, "matte_fix": 0.06},
- "gif": {"dither_strength": 0.08, "palette_size": 256, "frame_optimize": True},
- "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.GIF.value, "palette_limit": 256},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "Sprite Sheet Prep": PresetModel(
- name="Sprite Sheet Prep",
- description="Sprite-sheet-safe prep for sheets, strips, and packed sprite atlases",
- applies_to_formats=["png", "gif", "webp", "bmp"],
- applies_to_tags=["sprite_sheet", "pixel_art", "ui"],
- settings_delta={
- "pixel": {"resize_percent": 200.0, "pixel_snap": True, "scale_method": ScaleMethod.NEAREST.value},
- "cleanup": {"artifact_removal": 0.16, "halo_cleanup": 0.06},
- "detail": {"sharpen_amount": 0.16, "clarity": 0.10},
- "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.PNG.value},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "GIF Outline Safe": PresetModel(
- name="GIF Outline Safe",
- description="Animated-safe edge cleanup for outlined GIF sprites",
- applies_to_formats=["gif"],
- applies_to_tags=["animation", "pixel_art", "ui"],
- settings_delta={
- "cleanup": {"halo_cleanup": 0.10, "artifact_removal": 0.12},
- "edges": {"edge_refine": 0.18, "antialias": 0.08},
- "alpha": {"alpha_smooth": 0.08, "matte_fix": 0.08},
- "gif": {"dither_strength": 0.05, "palette_size": 256, "frame_optimize": True},
- "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.GIF.value, "palette_limit": 256},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "PNG Alpha Clean": PresetModel(
- name="PNG Alpha Clean",
- description="Transparency-safe cleanup for PNG sprites, UI, and logos",
- applies_to_formats=["png", "webp"],
- applies_to_tags=["transparent", "ui", "logo", "icon", "pixel_art", "artwork"],
- settings_delta={
- "cleanup": {"artifact_removal": 0.18, "halo_cleanup": 0.10},
- "edges": {"edge_refine": 0.18, "antialias": 0.12},
- "alpha": {"alpha_smooth": 0.10, "matte_fix": 0.12},
- "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.PNG.value},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "Logo Alpha Clean": PresetModel(
- name="Logo Alpha Clean",
- description="Transparent-logo cleanup for crisp edges and matte cleanup",
- applies_to_formats=["png", "webp", "ico"],
- applies_to_tags=["logo", "transparent", "ui", "icon"],
- settings_delta={
- "cleanup": {"artifact_removal": 0.12, "halo_cleanup": 0.18},
- "edges": {"edge_refine": 0.22, "antialias": 0.14},
- "alpha": {"alpha_smooth": 0.12, "matte_fix": 0.18},
- "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.PNG.value},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "ICO Icon Polish": PresetModel(
- name="ICO Icon Polish",
- description="Icon-safe polish for ICO and small transparent app assets",
- applies_to_formats=["ico", "png"],
- applies_to_tags=["icon", "ui", "transparent"],
- settings_delta={
- "detail": {"sharpen_amount": 0.22, "clarity": 0.14},
- "cleanup": {"artifact_removal": 0.12, "halo_cleanup": 0.10},
- "alpha": {"alpha_smooth": 0.08, "matte_fix": 0.12},
- "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.ICO.value},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "Texture Repair": PresetModel(
- name="Texture Repair",
- description="Texture-friendly cleanup that smooths artifacts without flattening surfaces",
- applies_to_formats=["jpg", "png", "webp", "bmp", "tiff"],
- applies_to_tags=["texture", "artwork"],
- settings_delta={
- "cleanup": {"denoise": 0.26, "artifact_removal": 0.24, "banding_removal": 0.18},
- "detail": {"clarity": 0.12, "texture": 0.18, "sharpen_amount": 0.10},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "TIFF Print Clean": PresetModel(
- name="TIFF Print Clean",
- description="Print-oriented cleanup for TIFF artwork and scanned photos",
- applies_to_formats=["tiff", "png"],
- applies_to_tags=["photo", "artwork"],
- settings_delta={
- "cleanup": {"denoise": 0.18, "artifact_removal": 0.14},
- "detail": {"clarity": 0.10, "sharpen_amount": 0.16},
- "export": {"export_profile": ExportProfile.PRINT.value, "format": ExportFormat.TIFF.value},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "WEBP Photo Finish": PresetModel(
- name="WEBP Photo Finish",
- description="Photo cleanup and export tuned for lightweight WEBP delivery",
- applies_to_formats=["jpg", "png", "webp"],
- applies_to_tags=["photo", "artwork"],
- settings_delta={
- "cleanup": {"denoise": 0.16, "artifact_removal": 0.14},
- "detail": {"clarity": 0.12, "sharpen_amount": 0.18},
- "export": {"export_profile": ExportProfile.WEB.value, "format": ExportFormat.WEBP.value, "quality": 90},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- "Web Quick Export": PresetModel(
- name="Web Quick Export",
- description="Web export defaults",
- applies_to_formats=["jpg", "png", "webp", "gif", "bmp", "tiff", "ico"],
- applies_to_tags=["*"],
- settings_delta={
- "export": {"export_profile": ExportProfile.WEB.value, "format": ExportFormat.WEBP.value, "quality": 84, "strip_metadata": True},
- },
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- ),
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/image_engine_app/app/web_sources_models.py b/image_engine_app/app/web_sources_models.py
index 33a1708..9312fc4 100644
--- a/image_engine_app/app/web_sources_models.py
+++ b/image_engine_app/app/web_sources_models.py
@@ -5,11 +5,10 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from enum import Enum
-from pathlib import Path
from typing import TYPE_CHECKING
-from urllib.parse import parse_qs, unquote, urlparse
+from urllib.parse import urlparse, urlunparse
if TYPE_CHECKING:
from image_engine_app.engine.models import AssetRecord
@@ -28,6 +27,12 @@ class ImportTarget(str, Enum):
ITEMS = "items"
+class ScanOrigin(str, Enum):
+ ENTERED = "entered"
+ SAVED = "saved"
+ LINKED = "linked"
+
+
@dataclass(frozen=True)
class WebItem:
url: str
@@ -53,6 +58,38 @@ class SmartOptions:
allow_zip: bool = True
+@dataclass(frozen=True)
+class WebScanRequest:
+ """One scan contract shared by entered, saved, and discovered page URLs."""
+
+ urls: tuple[str, ...]
+ smart: SmartOptions = field(default_factory=SmartOptions)
+ origin: ScanOrigin = ScanOrigin.ENTERED
+ website_id: str | None = None
+ area_id: str | None = None
+
+
+@dataclass(frozen=True)
+class WebLinkDiscoveryRequest:
+ url: str
+ website_id: str | None = None
+ area_id: str | None = None
+
+
+@dataclass(frozen=True)
+class WebDownloadRequest:
+ items: tuple[WebItem, ...]
+ smart: SmartOptions = field(default_factory=SmartOptions)
+ target: ImportTarget = ImportTarget.NORMAL
+ website_id: str | None = None
+ area_id: str | None = None
+
+
+@dataclass(frozen=True)
+class WebDiagnosticsRequest:
+ url: str
+
+
@dataclass(frozen=True)
class ScanResults:
items: tuple[WebItem, ...]
@@ -60,6 +97,69 @@ class ScanResults:
failed_pages: tuple[str, ...] = ()
+@dataclass(frozen=True)
+class ScanMergeResult:
+ """Outcome of adding one scan into the persistent Found Files basket."""
+
+ results: ScanResults
+ added_count: int = 0
+ duplicate_count: int = 0
+
+
+def merge_scan_results(existing: ScanResults, incoming: ScanResults) -> ScanMergeResult:
+ """Merge scan items by normalized URL while preserving discovery order."""
+
+ merged_items = list(existing.items)
+ seen = {_scan_item_key(item) for item in merged_items}
+ added_count = 0
+ duplicate_count = 0
+
+ for item in incoming.items:
+ key = _scan_item_key(item)
+ if key in seen:
+ duplicate_count += 1
+ continue
+ seen.add(key)
+ merged_items.append(item)
+ added_count += 1
+
+ return ScanMergeResult(
+ results=ScanResults(
+ items=tuple(merged_items),
+ filtered_count=int(incoming.filtered_count or 0),
+ failed_pages=tuple(incoming.failed_pages or ()),
+ ),
+ added_count=added_count,
+ duplicate_count=duplicate_count,
+ )
+
+
+class FoundFilesStore:
+ """Single source of truth for the persistent Found Files basket."""
+
+ def __init__(self) -> None:
+ self._results = ScanResults(items=())
+
+ @property
+ def results(self) -> ScanResults:
+ return self._results
+
+ @property
+ def items(self) -> tuple[WebItem, ...]:
+ return self._results.items
+
+ def replace(self, results: ScanResults) -> None:
+ self._results = results
+
+ def add(self, results: ScanResults) -> ScanMergeResult:
+ outcome = merge_scan_results(self._results, results)
+ self._results = outcome.results
+ return outcome
+
+ def clear(self) -> None:
+ self._results = ScanResults(items=())
+
+
@dataclass(frozen=True)
class DownloadReport:
downloaded: tuple[str, ...]
@@ -69,15 +169,6 @@ class DownloadReport:
cancelled: bool = False
-def coerce_import_target(value: object, *, default: ImportTarget = ImportTarget.NORMAL) -> ImportTarget:
- if isinstance(value, ImportTarget):
- return value
- try:
- return ImportTarget(str(value))
- except Exception:
- return default
-
-
def coerce_smart_options(value: object) -> SmartOptions:
if isinstance(value, SmartOptions):
return value
@@ -91,94 +182,18 @@ def coerce_smart_options(value: object) -> SmartOptions:
return SmartOptions()
-def coerce_web_item(value: object) -> WebItem | None:
- if isinstance(value, WebItem):
- return value
- if not isinstance(value, dict):
- return None
-
- url = str(value.get("url", "")).strip()
- if not url:
- return None
-
- confidence_raw = str(value.get("confidence", Confidence.DIRECT.value)).strip().lower()
- try:
- confidence = Confidence(confidence_raw)
- except Exception:
- confidence = Confidence.DIRECT
-
- base_name = _name_from_url(url)
- ext = str(value.get("ext", "")).strip().lower() or _ext_from_url(url)
-
- return WebItem(
- url=url,
- name=str(value.get("name", "")).strip() or base_name,
- ext=ext,
- confidence=confidence,
- preview_url=(str(value.get("preview_url")) if value.get("preview_url") else None),
- source_page=(str(value.get("source_page")) if value.get("source_page") else None),
+def _scan_item_key(item: WebItem) -> str:
+ raw_url = str(item.url or "").strip()
+ parsed = urlparse(raw_url)
+ if not parsed.scheme or not parsed.netloc:
+ return raw_url.casefold()
+ return urlunparse(
+ (
+ parsed.scheme.lower(),
+ parsed.netloc.lower(),
+ parsed.path,
+ parsed.params,
+ parsed.query,
+ "",
+ )
)
-
-
-def coerce_web_items(raw: object) -> list[WebItem]:
- if not isinstance(raw, list):
- return []
-
- out: list[WebItem] = []
- for entry in raw:
- item = coerce_web_item(entry)
- if item is not None:
- out.append(item)
- return out
-
-
-def coerce_web_index_link(value: object) -> WebIndexLink | None:
- if isinstance(value, WebIndexLink):
- return value
- if not isinstance(value, dict):
- return None
-
- url = str(value.get("url", "")).strip()
- if not url:
- return None
-
- label = str(value.get("label", "")).strip() or _name_from_url(url)
- source_page = str(value.get("source_page", "")).strip() or None
- return WebIndexLink(label=label, url=url, source_page=source_page)
-
-
-def coerce_web_index_links(raw: object) -> list[WebIndexLink]:
- if not isinstance(raw, list):
- return []
-
- out: list[WebIndexLink] = []
- for entry in raw:
- link = coerce_web_index_link(entry)
- if link is not None:
- out.append(link)
- return out
-
-
-def _name_from_url(url: str) -> str:
- parsed = urlparse(url)
- path_name = unquote(Path(parsed.path).name).strip()
- if path_name:
- return path_name
-
- params = parse_qs(parsed.query or "", keep_blank_values=False)
- for key in ("filename", "file", "name", "download", "image", "img", "asset", "sprite"):
- for value in params.get(key, ()):
- decoded = unquote(str(value or "")).strip()
- if decoded:
- return decoded
-
- return "download"
-
-def _ext_from_url(url: str) -> str:
- base = url.split("?")[0].split("#")[0]
- return Path(base).suffix.lower()
-
-
-
-
-
diff --git a/image_engine_app/assets/__init__.py b/image_engine_app/assets/__init__.py
new file mode 100644
index 0000000..3bc28a5
--- /dev/null
+++ b/image_engine_app/assets/__init__.py
@@ -0,0 +1 @@
+"""Packaged static assets for Sprite Factory."""
diff --git a/image_engine_app/assets/icons/__init__.py b/image_engine_app/assets/icons/__init__.py
new file mode 100644
index 0000000..befc3d1
--- /dev/null
+++ b/image_engine_app/assets/icons/__init__.py
@@ -0,0 +1 @@
+"""Application icon assets."""
diff --git a/spritefactory.ico b/image_engine_app/assets/icons/spritefactory.ico
similarity index 100%
rename from spritefactory.ico
rename to image_engine_app/assets/icons/spritefactory.ico
diff --git a/spritefactory.png b/image_engine_app/assets/icons/spritefactory.png
similarity index 100%
rename from spritefactory.png
rename to image_engine_app/assets/icons/spritefactory.png
diff --git a/spritefactory_pro.ico b/image_engine_app/assets/icons/spritefactory_pro.ico
similarity index 100%
rename from spritefactory_pro.ico
rename to image_engine_app/assets/icons/spritefactory_pro.ico
diff --git a/spritefactory_pro.png b/image_engine_app/assets/icons/spritefactory_pro.png
similarity index 100%
rename from spritefactory_pro.png
rename to image_engine_app/assets/icons/spritefactory_pro.png
diff --git a/image_engine_app/docs/WEB_SOURCES_SMART_LAYER_FULL_BREAKDOWN.md b/image_engine_app/docs/WEB_SOURCES_SMART_LAYER_FULL_BREAKDOWN.md
deleted file mode 100644
index 186fae9..0000000
--- a/image_engine_app/docs/WEB_SOURCES_SMART_LAYER_FULL_BREAKDOWN.md
+++ /dev/null
@@ -1,129 +0,0 @@
-# Web Sources + Smart Layer
-## Full Breakdown (Ready To Start)
-
-Date: 2026-03-02
-Repo: SpriteFactory_Windows_Python_V2
-
-## 1) Current State (Already Implemented)
-- Core ingest pipeline exists for URL + webpage scan + local import.
-- Smart-layer scaffold files exist:
- - `image_engine_app/ui/main_window/web_sources_panel.py`
- - `image_engine_app/app/web_sources_models.py`
- - `image_engine_app/engine/ingest/web_sources_rules.py`
- - `image_engine_app/engine/ingest/zip_extract.py`
- - `image_engine_app/docs/WEB_SOURCES_SPEC.md`
- - `image_engine_app/docs/INTEGRATION_CHECKLIST.md`
- - `image_engine_app/docs/DEFAULT_WEB_SOURCES.json`
-
-## 2) What Is Missing vs Lock-In Spec
-
-### A. UI wiring missing
-- `WebSourcesPanel` is present but not mounted in `main_window.py`.
-- No signal hookups from panel to controller (`scan_requested`, `download_requested`).
-
-### B. Controller orchestration missing
-- `ui_controller.py` does not yet expose dedicated Web Sources flows:
- - source registry load/save
- - scan + confidence shaping
- - download/import report with counters
- - ZIP branch handling + safe extraction path
- - auto-sort + dedupe behavior.
-
-### C. Persistence missing
-- `settings_store.py` currently has no structured `web_sources_*` settings contract.
-- Missing persisted toggles:
- - `show_likely`
- - `auto_sort`
- - `skip_duplicates`
- - `allow_zip`
- - selected website/area.
-
-### D. Smart feature gaps
-- Confidence is not currently surfaced as badges in integrated workflow.
-- Auto bucket routing exists as rules but not yet used in import orchestration.
-- ZIP wizard path not connected to runtime flow.
-- Duplicate handling summary (skipped/renamed/failed counters) not integrated in UI status.
-- Preview pane is currently URL/text-centric; no enlarged image preview rendering in Web Sources panel flow.
-- Quick resize post-import toggle is not yet wired.
-
-### E. Tests missing
-- No dedicated tests for Web Sources panel orchestration path.
-- Missing end-to-end behavior tests for:
- - confidence filtering
- - dedupe outcomes
- - zip extraction/import path
- - auto-sort bucket routing.
-
-## 3) File Ownership Plan (Exact)
-
-### `image_engine_app/ui/main_window/main_window.py`
-Add:
-- `self.web_sources_panel = WebSourcesPanel(self)`
-- tab insertion in center layout (new tab or dock section)
-- signal connections:
- - `self.web_sources_panel.scan_requested.connect(self._on_web_sources_scan_requested)`
- - `self.web_sources_panel.download_requested.connect(self._on_web_sources_download_requested)`
-- handlers:
- - `_on_web_sources_scan_requested(payload: object) -> None`
- - `_on_web_sources_download_requested(payload: object) -> None`
-
-### `image_engine_app/app/ui_controller.py`
-Add:
-- `load_web_sources_registry(...)`
-- `scan_web_sources_area(...) -> ScanResults`
-- `download_web_sources_items(...) -> DownloadReport`
-- helper methods:
- - `_classify_web_item_confidence(...)`
- - `_resolve_web_import_target(...)`
- - `_dedupe_destination_path(...)`
- - `_import_downloaded_file(...)`
- - `_import_extracted_zip_images(...)`
-
-### `image_engine_app/app/settings_store.py`
-Add schema section:
-- `settings['web_sources'] = { ... }`
-Keys:
-- `registry`
-- `last_selected`
-- `options`
-
-### `image_engine_app/ui/main_window/web_sources_panel.py`
-Keep existing scaffold and add:
-- confidence badge rendering in list labels
-- optional thumbnail/preview pixmap load
-- progress line updates from controller reports
-
-### `image_engine_app/engine/ingest/web_sources_rules.py`
-Already present. Use directly in controller for:
-- `confidence_for`
-- `guess_import_target`
-- `dedupe_key`
-
-### `image_engine_app/engine/ingest/zip_extract.py`
-Already present. Use directly in controller for ZIP branch.
-
-## 4) Execution Order (Low-Risk)
-1. Wire panel into main window (no behavior change yet).
-2. Add controller methods with pure data returns (ScanResults/DownloadReport).
-3. Connect panel scan flow -> results render.
-4. Connect panel download flow -> import + counters.
-5. Add dedupe + auto-sort options.
-6. Add ZIP extraction branch.
-7. Add settings persistence.
-8. Add quick resize toggle (default OFF).
-9. Add tests and finalize.
-
-## 5) Smart Defaults (Beginner Safe)
-- Show likely links: OFF
-- Auto-sort downloads: OFF
-- Skip duplicates: ON
-- ZIP extract wizard: ON
-- Auto-resize after import: OFF
-
-## 6) Definition of Done
-- User can: Website -> Area -> Scan -> Select -> Download -> items appear in workspace/library.
-- Confidence filter works with OFF-by-default likely links.
-- Dedupe report visible (`downloaded/skipped/failed`).
-- ZIP import accepts image files only and blocks unsafe entries.
-- Settings persist across relaunch.
-- Unit tests cover smart path and pass in CI/local.
diff --git a/image_engine_app/engine/analyze/recommend.py b/image_engine_app/engine/analyze/recommend.py
index d82567f..73336ef 100644
--- a/image_engine_app/engine/analyze/recommend.py
+++ b/image_engine_app/engine/analyze/recommend.py
@@ -12,6 +12,13 @@
PresetSuggestion,
RecommendationsSummary,
)
+from image_engine_app.engine.presets import (
+ ARTIFACT_CLEANUP,
+ EDGE_REPAIR,
+ GIF_SAFE_CLEANUP,
+ PHOTO_RECOVER,
+ PIXEL_CLEAN_UPSCALE,
+)
@dataclass
@@ -36,7 +43,7 @@ def build_recommendations(context: RecommendationInput) -> RecommendationsSummar
confidence = clamp01(0.62 + (0.35 * analysis.resolution_need_score))
suggestions.append(
PresetSuggestion(
- preset_name="Pixel Clean Upscale",
+ preset_name=PIXEL_CLEAN_UPSCALE,
confidence=confidence,
reason="Pixel/sprite content benefits from clean scaling and edge-safe cleanup.",
)
@@ -46,7 +53,7 @@ def build_recommendations(context: RecommendationInput) -> RecommendationsSummar
confidence = clamp01(0.66 + (0.18 * (analysis.compression_score or 0.0)) + (0.12 * (analysis.noise_score or 0.0)))
suggestions.append(
PresetSuggestion(
- preset_name="GIF Safe Cleanup",
+ preset_name=GIF_SAFE_CLEANUP,
confidence=confidence,
reason="Animated content benefits from lighter frame-safe cleanup and GIF-tuned export settings.",
)
@@ -56,7 +63,7 @@ def build_recommendations(context: RecommendationInput) -> RecommendationsSummar
confidence = clamp01(0.58 + (0.22 * analysis.compression_score) + (0.2 * analysis.noise_score))
suggestions.append(
PresetSuggestion(
- preset_name="Artifact Cleanup",
+ preset_name=ARTIFACT_CLEANUP,
confidence=confidence,
reason="Compression/noise indicators suggest cleanup before export.",
)
@@ -66,7 +73,7 @@ def build_recommendations(context: RecommendationInput) -> RecommendationsSummar
confidence = clamp01(0.5 + (0.35 * analysis.blur_score))
suggestions.append(
PresetSuggestion(
- preset_name="Photo Recover",
+ preset_name=PHOTO_RECOVER,
confidence=confidence,
reason="Photo-like content with blur indicators may benefit from deblur/sharpen workflow.",
)
@@ -76,7 +83,7 @@ def build_recommendations(context: RecommendationInput) -> RecommendationsSummar
confidence = clamp01(0.48 + (0.4 * (1.0 - analysis.edge_integrity_score)))
suggestions.append(
PresetSuggestion(
- preset_name="Edge Repair",
+ preset_name=EDGE_REPAIR,
confidence=confidence,
reason="Weak edge integrity suggests edge refinement and halo cleanup.",
)
diff --git a/image_engine_app/engine/batch/batch_runner.py b/image_engine_app/engine/batch/batch_runner.py
index 29bd09a..a5c5b49 100644
--- a/image_engine_app/engine/batch/batch_runner.py
+++ b/image_engine_app/engine/batch/batch_runner.py
@@ -2,8 +2,8 @@
from __future__ import annotations
-from copy import deepcopy
-import logging
+from copy import deepcopy
+import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
@@ -26,16 +26,17 @@
)
from image_engine_app.engine.process.heavy_queue import HeavyQueueEngine
from image_engine_app.engine.process.heavy_runtime import execute_heavy_job
+from image_engine_app.engine.process.edit_baseline import edit_state_from_detected_settings, implied_heavy_jobs
from image_engine_app.engine.process.light_steps import LightProcessError
from image_engine_app.engine.process.pipeline import PipelinePhase, ProcessingPlan, ProcessingStep, build_processing_plan
from image_engine_app.engine.process.preset_compat import preset_matches_asset
-from image_engine_app.engine.process.presets_apply import ViewEditStates, apply_preset_stack
+from image_engine_app.engine.process.presets_apply import apply_preset_to_edit_state
from image_engine_app.engine.process.preview_support import render_light_pipeline_preview, resolve_export_source, select_export_source_path
-
-
-LOGGER = logging.getLogger("image_engine_app.batch.runner")
-
-
+
+
+LOGGER = logging.getLogger("image_engine_app.batch.runner")
+
+
@dataclass
class BatchWorkItem:
"""Batch input item tying a QueueItem to an AssetRecord."""
@@ -53,7 +54,6 @@ class BatchRunnerConfig:
export_dir: str | Path | None = None
derived_cache_dir: str | Path | None = None
auto_preset_rules: dict[str, list[PresetModel]] = field(default_factory=dict)
- per_source_preset_rules: dict[str, list[PresetModel]] = field(default_factory=dict)
group_outputs: bool = True
group_outputs_by: str = "source"
export_name_template: str = "{index:03d}_{stem}"
@@ -359,7 +359,7 @@ def mark(progress: float, stage: str, message: str | None = None) -> None:
result.processing_plan = None if self.config.preview_skip_mode else self._build_processing_plan(asset)
mark(0.55, "presets")
- applied_presets = self._apply_auto_presets_if_any(asset)
+ applied_presets = self._apply_smart_preset_if_any(asset)
result.applied_preset_names = applied_presets
mark(0.63, "light_pipeline")
@@ -519,25 +519,6 @@ def _build_processing_plan(self, asset: AssetRecord) -> ProcessingPlan:
return build_processing_plan(steps)
- def _source_rule_keys(self, asset: AssetRecord) -> list[str]:
- """Return normalized source keys used for per-source preset rules and export grouping."""
- keys: list[str] = []
- if asset.capabilities.is_animated or asset.format is AssetFormat.GIF:
- keys.append("gif")
- if asset.capabilities.is_sheet:
- keys.append("spritesheet")
- if asset.format is AssetFormat.PNG:
- keys.append("png")
- elif asset.format is AssetFormat.JPG:
- keys.append("jpg")
- elif asset.format is AssetFormat.WEBP:
- keys.append("webp")
- elif asset.format is AssetFormat.ICO:
- keys.append("ico")
- if not keys:
- keys.append("other")
- return keys
-
def _export_group_folder(self, asset: AssetRecord, predicted_format: str | None) -> str:
"""Choose a folder name for grouping batch exports."""
if asset.capabilities.is_animated or asset.format is AssetFormat.GIF or (predicted_format or "").lower() == "gif":
@@ -555,49 +536,41 @@ def _export_group_folder(self, asset: AssetRecord, predicted_format: str | None)
return "gifs" if val == "gif" else val
return "other"
- def _apply_auto_presets_if_any(self, asset: AssetRecord) -> list[str]:
- if not self.config.auto_preset_rules and not self.config.per_source_preset_rules:
+ def _apply_smart_preset_if_any(self, asset: AssetRecord) -> list[str]:
+ """Choose one compatible smart preset and apply it from the detected baseline."""
+
+ if not self.config.auto_preset_rules:
return []
- presets_to_apply: list[PresetModel] = []
+ rule_keys: list[str] = []
+ if asset.capabilities.is_animated or asset.format is AssetFormat.GIF:
+ rule_keys.append("animation")
+ if asset.capabilities.is_sheet:
+ rule_keys.append("sprite_sheet")
+ rule_keys.extend(str(tag) for tag in asset.classification_tags)
+
+ selected: PresetModel | None = None
seen_names: set[str] = set()
- # Apply per-source rules first (file type / spritesheet / animation)
- for key in self._source_rule_keys(asset):
- for preset in self.config.per_source_preset_rules.get(key, []):
- compatible, _reason = preset_matches_asset(preset, asset)
- if not compatible:
- continue
+ for key in dict.fromkeys(rule_keys):
+ for preset in self.config.auto_preset_rules.get(key, []):
if preset.name in seen_names:
continue
seen_names.add(preset.name)
- presets_to_apply.append(preset)
-
- for tag in asset.classification_tags:
- for preset in self.config.auto_preset_rules.get(tag, []):
compatible, _reason = preset_matches_asset(preset, asset)
if not compatible:
continue
- if preset.name in seen_names:
- continue
- seen_names.add(preset.name)
- presets_to_apply.append(preset)
+ selected = preset
+ break
+ if selected is not None:
+ break
- if not presets_to_apply:
+ if selected is None:
return []
- # The schema keeps one canonical EditState; we emulate current/final views for apply-target rules
- # and persist the resulting active state back onto the asset.
- states = ViewEditStates(current=deepcopy(asset.edit_state), final=deepcopy(asset.edit_state))
- report = apply_preset_stack(presets_to_apply, states=states)
-
- if report.effective_target is not None and (
- report.effective_target.value in {"final", "both"} or report.sync_applied
- ):
- asset.edit_state = report.states.final
- else:
- asset.edit_state = report.states.current
-
- return report.applied_preset_names
+ baseline_state = edit_state_from_detected_settings(asset)
+ asset.edit_state = apply_preset_to_edit_state(selected, baseline_state)
+ asset.edit_state.queued_heavy_jobs = implied_heavy_jobs(selected, asset.edit_state)
+ return [selected.name]
def _run_light_pipeline(self, asset: AssetRecord) -> None:
"""Render the light (non-AI) pipeline to a derived Final output, if configured."""
@@ -637,6 +610,7 @@ def _predict_export(self, asset: AssetRecord) -> ExportPredictorResult:
width=max(1, width),
height=max(1, height),
export_settings=asset.edit_state.settings.export,
+ gif_settings=asset.edit_state.settings.gif,
has_alpha=asset.capabilities.has_alpha,
is_animated=asset.capabilities.is_animated,
frame_count=8 if asset.capabilities.is_animated else 1,
@@ -690,6 +664,8 @@ def _auto_export(
width=max(1, width),
height=max(1, height),
export_settings=asset.edit_state.settings.export,
+ gif_settings=asset.edit_state.settings.gif,
+ dpi=int(asset.edit_state.settings.pixel.dpi or 72),
asset_id=asset.id,
frame_count=8 if asset.capabilities.is_animated else 1,
has_alpha=asset.capabilities.has_alpha,
@@ -745,11 +721,11 @@ def _extension_for_export_format_string(fmt: str) -> str:
return mapping.get(fmt, ".bin")
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/image_engine_app/engine/export/exporters.py b/image_engine_app/engine/export/exporters.py
index 1a9d28f..095c521 100644
--- a/image_engine_app/engine/export/exporters.py
+++ b/image_engine_app/engine/export/exporters.py
@@ -8,7 +8,7 @@
from pathlib import Path
from typing import Callable
-from image_engine_app.engine.models import ExportFormat, ExportSettings, SettingsState
+from image_engine_app.engine.models import ExportFormat, ExportSettings, GifSettings, SettingsState
from image_engine_app.engine.process.light_steps import render_light_image
@@ -38,6 +38,8 @@ class ExportRequest:
has_alpha: bool = False
source_path: str | Path | None = None
light_settings: SettingsState | None = None
+ gif_settings: GifSettings | None = None
+ dpi: int = 72
@dataclass
@@ -96,16 +98,15 @@ def export_image(request: ExportRequest) -> ExportResult:
try:
with Image.open(src) as im:
+ source_metadata = _metadata_save_kwargs(im, fmt, request.export_settings)
im.load()
is_animated = bool(getattr(im, "is_animated", False)) and int(getattr(im, "n_frames", 1) or 1) > 1
if fmt is ExportFormat.GIF and is_animated:
- kwargs = _build_save_kwargs(fmt, request.export_settings)
_save_gif_animated(
im,
out_path,
- request.export_settings,
- kwargs,
+ gif_settings=request.gif_settings,
target_size=(None if request.light_settings is not None else (target_w, target_h)),
light_settings=request.light_settings,
)
@@ -132,12 +133,20 @@ def export_image(request: ExportRequest) -> ExportResult:
)
save_kwargs = _build_save_kwargs(fmt, request.export_settings)
+ save_kwargs.update(source_metadata)
- dpi = getattr(request.export_settings, "dpi", None)
- if dpi is None:
- dpi = 72
if fmt in (ExportFormat.PNG, ExportFormat.JPG, ExportFormat.TIFF):
- save_kwargs.setdefault("dpi", (int(dpi), int(dpi)))
+ dpi = max(1, int(request.dpi or 72))
+ save_kwargs.setdefault("dpi", (dpi, dpi))
+
+ if fmt is ExportFormat.GIF:
+ gif = request.gif_settings or GifSettings()
+ im = _quantize_gif_frame(
+ im.convert("RGBA"),
+ palette_limit=int(gif.palette_size),
+ transparency_threshold=24,
+ dither_strength=float(gif.dither_strength),
+ )
_save_image(im, out_path, fmt, save_kwargs)
@@ -222,31 +231,36 @@ def export_generated_placeholder(
try:
if resolved_fmt is ExportFormat.GIF and int(request.frame_count or 1) > 1:
+ gif = request.gif_settings or GifSettings()
frames = [
_build_placeholder_image(request, size=size, frame_variant=0),
_build_placeholder_image(request, size=size, frame_variant=1),
]
save_kwargs = _build_save_kwargs(resolved_fmt, request.export_settings)
adaptive_palette = getattr(Image, "ADAPTIVE", 1)
- first = frames[0].convert("P", palette=adaptive_palette, colors=256)
- rest = [frame.convert("P", palette=adaptive_palette, colors=256) for frame in frames[1:]]
+ palette_size = max(2, min(256, int(gif.palette_size or 256)))
+ first = frames[0].convert("P", palette=adaptive_palette, colors=palette_size)
+ rest = [frame.convert("P", palette=adaptive_palette, colors=palette_size) for frame in frames[1:]]
+ delay = max(20, int(gif.frame_delay_ms or 120))
+ gif_kwargs = {
+ "save_all": True,
+ "append_images": rest,
+ "duration": [delay for _ in frames],
+ "optimize": bool(gif.frame_optimize),
+ }
+ if bool(gif.loop):
+ gif_kwargs["loop"] = 0
first.save(
out_path,
format="GIF",
- save_all=True,
- append_images=rest,
- duration=[120 for _ in frames],
- loop=0,
- optimize=bool(save_kwargs.get("optimize", True)),
+ **gif_kwargs,
)
else:
image = _build_placeholder_image(request, size=size, frame_variant=0)
image = _normalize_image_for_format(image, resolved_fmt, prefer_alpha=request.has_alpha)
save_kwargs = _build_save_kwargs(resolved_fmt, request.export_settings)
if resolved_fmt in (ExportFormat.PNG, ExportFormat.JPG, ExportFormat.TIFF):
- dpi = getattr(request.export_settings, "dpi", None)
- if dpi is None:
- dpi = 72
+ dpi = max(1, int(request.dpi or 72))
save_kwargs.setdefault("dpi", (int(dpi), int(dpi)))
_save_image(image, out_path, resolved_fmt, save_kwargs)
except Exception:
@@ -332,6 +346,11 @@ def _build_save_kwargs(fmt: ExportFormat, settings: ExportSettings) -> dict:
kwargs["quality"] = int(settings.quality)
kwargs["optimize"] = True
kwargs["progressive"] = True
+ kwargs["subsampling"] = {
+ "444": 0,
+ "422": 1,
+ "420": 2,
+ }.get(getattr(settings.chroma_subsampling, "value", "auto"), -1)
elif fmt is ExportFormat.PNG:
kwargs["compress_level"] = int(settings.compression_level)
kwargs["optimize"] = True
@@ -347,6 +366,25 @@ def _build_save_kwargs(fmt: ExportFormat, settings: ExportSettings) -> dict:
return kwargs
+def _metadata_save_kwargs(image, fmt: ExportFormat, settings: ExportSettings) -> dict:
+ """Preserve supported source metadata only when the user asks to keep it."""
+
+ if bool(settings.strip_metadata):
+ return {}
+
+ info = dict(getattr(image, "info", {}) or {})
+ kwargs: dict = {}
+ icc_profile = info.get("icc_profile")
+ if isinstance(icc_profile, (bytes, bytearray)) and icc_profile:
+ kwargs["icc_profile"] = bytes(icc_profile)
+
+ exif = info.get("exif")
+ if fmt in {ExportFormat.JPG, ExportFormat.WEBP, ExportFormat.PNG, ExportFormat.TIFF}:
+ if isinstance(exif, (bytes, bytearray)) and exif:
+ kwargs["exif"] = bytes(exif)
+ return kwargs
+
+
def _map_resample(settings: ExportSettings, fallback):
# ExportSettings currently doesn't include scale method; use fallback.
_ = settings
@@ -484,9 +522,8 @@ def _format_supports_alpha(fmt: ExportFormat) -> bool:
def _save_gif_animated(
im,
out_path: Path,
- settings: ExportSettings,
- kwargs: dict,
*,
+ gif_settings: GifSettings | None = None,
target_size: tuple[int, int] | None = None,
light_settings: SettingsState | None = None,
) -> None:
@@ -503,9 +540,10 @@ def _save_gif_animated(
frames = []
durations: list[int] = []
base_duration = int(getattr(im, "info", {}).get("duration", 40) or 40)
- loop = int(getattr(im, "info", {}).get("loop", 0) or 0)
disposal = getattr(im, "info", {}).get("disposal", None)
- palette_limit = max(2, min(256, int(getattr(settings, "palette_limit", 256) or 256)))
+ gif = gif_settings or getattr(light_settings, "gif", None) or GifSettings()
+ palette_limit = max(2, min(256, int(gif.palette_size or 256)))
+ delay_override = max(0, int(gif.frame_delay_ms or 0))
for frame in ImageSequence.Iterator(im):
rgba = frame.convert("RGBA")
@@ -513,11 +551,13 @@ def _save_gif_animated(
rgba = render_light_image(rgba, light_settings, target_size=target)
elif target is not None and rgba.size != target:
rgba = rgba.resize(target, resample=getattr(Image.Resampling, "NEAREST", Image.NEAREST))
- durations.append(int(getattr(frame, "info", {}).get("duration", base_duration) or base_duration))
+ source_duration = int(getattr(frame, "info", {}).get("duration", base_duration) or base_duration)
+ durations.append(delay_override or source_duration)
quantized = _quantize_gif_frame(
rgba,
palette_limit=palette_limit,
transparency_threshold=24,
+ dither_strength=float(gif.dither_strength),
)
frames.append(quantized)
@@ -526,16 +566,19 @@ def _save_gif_animated(
first = frames[0]
rest = frames[1:]
+ if not bool(gif.loop):
+ for frame in frames:
+ frame.info.pop("loop", None)
save_kwargs: dict = {
"save_all": True,
"append_images": rest,
"duration": durations if len(durations) > 1 else durations[0],
- "loop": loop,
}
+ if bool(gif.loop):
+ save_kwargs["loop"] = 0
if disposal is not None:
save_kwargs["disposal"] = disposal
- if "optimize" in kwargs:
- save_kwargs["optimize"] = bool(kwargs.get("optimize"))
+ save_kwargs["optimize"] = bool(gif.frame_optimize)
save_kwargs.setdefault("disposal", 2)
first.save(out_path, format="GIF", **save_kwargs)
@@ -546,12 +589,14 @@ def _quantize_gif_frame(
*,
palette_limit: int,
transparency_threshold: int,
+ dither_strength: float = 0.0,
):
"""Quantize an RGBA frame for GIF while preserving binary transparency."""
from PIL import Image # type: ignore
frame = rgba.convert("RGBA")
+ dither = Image.Dither.FLOYDSTEINBERG if float(dither_strength) > 0.0 else Image.Dither.NONE
alpha = frame.getchannel("A")
transparent_mask = alpha.point(
lambda value: 255 if int(value) <= int(transparency_threshold) else 0,
@@ -559,10 +604,18 @@ def _quantize_gif_frame(
)
if transparent_mask.getbbox() is None:
- return frame.quantize(colors=max(2, min(256, int(palette_limit or 256))), method=getattr(Image, "FASTOCTREE", 2))
+ return frame.quantize(
+ colors=max(2, min(256, int(palette_limit or 256))),
+ method=getattr(Image, "FASTOCTREE", 2),
+ dither=dither,
+ )
rgb = frame.convert("RGB")
- quantized = rgb.quantize(colors=max(2, min(255, int(palette_limit or 256) - 1)), method=getattr(Image, "FASTOCTREE", 2))
+ quantized = rgb.quantize(
+ colors=max(2, min(255, int(palette_limit or 256) - 1)),
+ method=getattr(Image, "FASTOCTREE", 2),
+ dither=dither,
+ )
palette = list(quantized.getpalette() or [])
if len(palette) < 768:
diff --git a/image_engine_app/engine/export/size_predictor.py b/image_engine_app/engine/export/size_predictor.py
index 4aedca9..f6a8db2 100644
--- a/image_engine_app/engine/export/size_predictor.py
+++ b/image_engine_app/engine/export/size_predictor.py
@@ -14,6 +14,7 @@
ExportPrediction,
ExportProfile,
ExportSettings,
+ GifSettings,
)
@@ -24,6 +25,7 @@ class ExportPredictorInput:
width: int
height: int
export_settings: ExportSettings
+ gif_settings: GifSettings | None = None
has_alpha: bool = False
is_animated: bool = False
frame_count: int = 1
@@ -64,6 +66,7 @@ def predict_export_size(
has_alpha=request.has_alpha,
color_count_estimate=request.color_count_estimate,
settings=request.export_settings,
+ gif_settings=request.gif_settings,
)
formats_to_compare = compare_formats or list(
@@ -84,6 +87,7 @@ def predict_export_size(
has_alpha=request.has_alpha,
color_count_estimate=request.color_count_estimate,
settings=request.export_settings,
+ gif_settings=request.gif_settings,
),
)
for fmt in _dedupe_preserve_order(formats_to_compare)
@@ -141,18 +145,16 @@ def _estimate_bytes_for_format(
has_alpha: bool,
color_count_estimate: int | None,
settings: ExportSettings,
+ gif_settings: GifSettings | None,
) -> int:
raw_frame_bytes = width * height * (4 if has_alpha else 3)
raw_total = raw_frame_bytes * frame_count
complexity = clamp01(complexity)
quality = max(1, min(100, int(settings.quality)))
compression_level = max(0, min(9, int(settings.compression_level)))
- palette_limit = settings.palette_limit
if file_format is ExportFormat.PNG:
factor = 0.10 + (0.42 * complexity) + (0.06 if has_alpha else 0.0) - (0.02 * (compression_level / 9))
- if palette_limit:
- factor *= max(0.2, min(1.0, palette_limit / 256))
return max(128, int(raw_total * factor))
if file_format is ExportFormat.JPG:
@@ -173,9 +175,10 @@ def _estimate_bytes_for_format(
return max(96, int(raw_total * factor))
if file_format is ExportFormat.GIF:
- palette = palette_limit or max(2, min(256, settings.palette_limit or 256))
+ gif = gif_settings or GifSettings()
+ palette = max(2, min(256, int(gif.palette_size or 256)))
palette_factor = max(0.08, min(1.0, palette / 256))
- dither_factor = 1.0 + (0.25 * max(0.0, min(1.0, settings.palette_limit / 256 if settings.palette_limit else 0.0)))
+ dither_factor = 1.0 + (0.25 * max(0.0, min(1.0, float(gif.dither_strength))))
anim_factor = 1.0 + (0.65 * math.log2(max(1, frame_count)))
base = width * height * max(1, frame_count)
return max(256, int(base * (0.12 + 0.35 * complexity) * palette_factor * dither_factor * anim_factor))
@@ -194,6 +197,7 @@ def _estimate_bytes_for_format(
has_alpha=True,
color_count_estimate=color_count_estimate,
settings=settings,
+ gif_settings=gif_settings,
)
return max(512, total + (16 * len(sizes)) + 6)
diff --git a/image_engine_app/engine/models/__init__.py b/image_engine_app/engine/models/__init__.py
index f21303c..a5cabbf 100644
--- a/image_engine_app/engine/models/__init__.py
+++ b/image_engine_app/engine/models/__init__.py
@@ -33,7 +33,7 @@
SourceType,
)
from .presets import ExportComparisonEntry, ExportPrediction, ExportProfileModel, PresetModel
-from .queue_models import JobState, ProgressEvent, QueueItem, QueueItemStatus
+from .queue_models import BatchEditSource, JobState, ProgressEvent, QueueItem, QueueItemStatus
from .session_state import HistoryAffectsView, HistoryState, HistoryStep, SessionState, TabState
__all__ = [
@@ -44,6 +44,7 @@
"AssetFormat",
"AssetRecord",
"BackgroundRemovalMode",
+ "BatchEditSource",
"Capabilities",
"ChromaSubsampling",
"CleanupSettings",
diff --git a/image_engine_app/engine/models/asset_record.py b/image_engine_app/engine/models/asset_record.py
index 1007d54..12df064 100644
--- a/image_engine_app/engine/models/asset_record.py
+++ b/image_engine_app/engine/models/asset_record.py
@@ -230,7 +230,7 @@ class AISettings(SerializableDataclass):
@dataclass
class GifSettings(SerializableDataclass):
- frame_delay_ms: int = 100
+ frame_delay_ms: int = 0
loop: bool = True
palette_size: int = 256
dither_strength: float = 0.0
@@ -244,7 +244,6 @@ class ExportSettings(SerializableDataclass):
quality: int = 90
compression_level: int = 6
chroma_subsampling: ChromaSubsampling = ChromaSubsampling.AUTO
- palette_limit: int | None = None
ico_sizes: list[int] = field(default_factory=lambda: [16, 32, 48, 64, 128, 256])
strip_metadata: bool = True
@@ -279,6 +278,7 @@ class EditState(SerializableDataclass):
mode: EditMode = EditMode.ADVANCED
sync_current_final: bool = True
apply_target: ApplyTarget = ApplyTarget.BOTH
+ # Kept for loading older workspaces; current UI always refreshes Final.
auto_apply_light: bool = True
queued_heavy_jobs: list[HeavyJobSpec] = field(default_factory=list)
settings: SettingsState = field(default_factory=SettingsState)
@@ -305,7 +305,8 @@ class AssetRecord(SerializableDataclass):
analysis: AnalysisSummary = field(default_factory=AnalysisSummary)
recommendations: RecommendationsSummary = field(default_factory=RecommendationsSummary)
edit_state: EditState = field(default_factory=EditState)
+ detected_settings: SettingsState | None = None
history: HistoryState = field(default_factory=HistoryState)
-
-
+
+
diff --git a/image_engine_app/engine/models/queue_models.py b/image_engine_app/engine/models/queue_models.py
index 8b7a81a..74929bf 100644
--- a/image_engine_app/engine/models/queue_models.py
+++ b/image_engine_app/engine/models/queue_models.py
@@ -19,6 +19,15 @@ class QueueItemStatus(str, Enum):
SKIPPED = "skipped"
+class BatchEditSource(str, Enum):
+ """Exactly one source of edit settings for a batch run."""
+
+ KEEP_EACH = "keep_each"
+ CHOSEN_PRESET = "chosen_preset"
+ COPY_ACTIVE = "copy_active"
+ SMART_MATCH = "smart_match"
+
+
@dataclass
class QueueItem(SerializableDataclass):
"""Batch queue item from the v1.0.1 schema."""
diff --git a/image_engine_app/engine/presets/__init__.py b/image_engine_app/engine/presets/__init__.py
new file mode 100644
index 0000000..a29605c
--- /dev/null
+++ b/image_engine_app/engine/presets/__init__.py
@@ -0,0 +1,19 @@
+"""Bundled preset catalog."""
+
+from .builtin import (
+ ARTIFACT_CLEANUP,
+ EDGE_REPAIR,
+ GIF_SAFE_CLEANUP,
+ PHOTO_RECOVER,
+ PIXEL_CLEAN_UPSCALE,
+ build_builtin_presets,
+)
+
+__all__ = [
+ "ARTIFACT_CLEANUP",
+ "EDGE_REPAIR",
+ "GIF_SAFE_CLEANUP",
+ "PHOTO_RECOVER",
+ "PIXEL_CLEAN_UPSCALE",
+ "build_builtin_presets",
+]
diff --git a/image_engine_app/engine/presets/builtin.py b/image_engine_app/engine/presets/builtin.py
new file mode 100644
index 0000000..b5d7321
--- /dev/null
+++ b/image_engine_app/engine/presets/builtin.py
@@ -0,0 +1,237 @@
+"""Curated system presets shared by Workspace, Preset Studio, and Batch."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from image_engine_app.engine.models import (
+ EditMode,
+ ExportFormat,
+ ExportProfile,
+ PresetModel,
+ ScaleMethod,
+)
+
+
+PIXEL_CLEAN_UPSCALE = "Pixel Clean Upscale"
+ARTIFACT_CLEANUP = "Artifact Cleanup"
+PHOTO_RECOVER = "Photo Recover"
+EDGE_REPAIR = "Edge Repair"
+GIF_SAFE_CLEANUP = "GIF Safe Cleanup"
+
+
+def _preset(
+ name: str,
+ description: str,
+ *,
+ formats: list[str],
+ tags: list[str],
+ settings: dict[str, Any],
+ heavy: bool = False,
+) -> PresetModel:
+ return PresetModel(
+ name=name,
+ description=description,
+ applies_to_formats=formats,
+ applies_to_tags=tags,
+ settings_delta=settings,
+ uses_heavy_tools=heavy,
+ requires_apply=heavy,
+ mode_min=EditMode.ADVANCED,
+ )
+
+
+def build_builtin_presets() -> dict[str, PresetModel]:
+ """Return one ordered, non-overlapping catalog of bundled presets."""
+
+ presets = [
+ _preset(
+ PIXEL_CLEAN_UPSCALE,
+ "Four-times recovery for small sprites using nearest-neighbour scaling.",
+ formats=["png", "webp", "bmp"],
+ tags=["pixel_art", "sprite_sheet", "ui", "icon"],
+ settings={
+ "pixel": {"pixel_snap": True, "scale_method": ScaleMethod.NEAREST.value},
+ "cleanup": {"denoise": 0.12, "artifact_removal": 0.20, "halo_cleanup": 0.06},
+ "detail": {"sharpen_amount": 0.20, "clarity": 0.10, "texture": 0.06},
+ "ai": {"upscale_factor": 4.0, "deblur_strength": 0.10, "detail_reconstruct": 0.12},
+ "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.PNG.value},
+ },
+ heavy=True,
+ ),
+ _preset(
+ "Sprite Crisp 2x",
+ "Double sprite dimensions with hard pixel edges and light cleanup.",
+ formats=["png", "webp", "bmp"],
+ tags=["pixel_art", "ui", "icon"],
+ settings={
+ "pixel": {"resize_percent": 200.0, "pixel_snap": True, "scale_method": ScaleMethod.NEAREST.value},
+ "detail": {"sharpen_amount": 0.16, "clarity": 0.08, "texture": 0.04},
+ "cleanup": {"artifact_removal": 0.08, "halo_cleanup": 0.04},
+ },
+ ),
+ _preset(
+ "Sprite Crisp 4x",
+ "Quadruple sprite dimensions with hard pixel edges and restrained sharpening.",
+ formats=["png", "webp", "bmp"],
+ tags=["pixel_art", "ui", "icon"],
+ settings={
+ "pixel": {"resize_percent": 400.0, "pixel_snap": True, "scale_method": ScaleMethod.NEAREST.value},
+ "detail": {"sharpen_amount": 0.18, "clarity": 0.10, "texture": 0.05},
+ "cleanup": {"artifact_removal": 0.10, "halo_cleanup": 0.04},
+ },
+ ),
+ _preset(
+ "Sprite Detail Boost",
+ "Increase sprite readability without changing output dimensions.",
+ formats=["png", "webp", "bmp", "tiff"],
+ tags=["pixel_art", "artwork", "ui"],
+ settings={
+ "detail": {"sharpen_amount": 0.42, "clarity": 0.24, "texture": 0.16, "sharpen_threshold": 0.10},
+ "cleanup": {"artifact_removal": 0.12, "halo_cleanup": 0.05},
+ },
+ ),
+ _preset(
+ "Sprite Sheet Prep",
+ "Double a sprite sheet cleanly and keep PNG export settings.",
+ formats=["png", "webp", "bmp"],
+ tags=["sprite_sheet", "pixel_art", "ui"],
+ settings={
+ "pixel": {"resize_percent": 200.0, "pixel_snap": True, "scale_method": ScaleMethod.NEAREST.value},
+ "cleanup": {"artifact_removal": 0.10, "halo_cleanup": 0.04},
+ "detail": {"sharpen_amount": 0.12, "clarity": 0.06},
+ "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.PNG.value},
+ },
+ ),
+ _preset(
+ GIF_SAFE_CLEANUP,
+ "Light frame-safe cleanup that keeps GIF timing and animation intact.",
+ formats=["gif"],
+ tags=["animation", "pixel_art", "artwork", "ui"],
+ settings={
+ "cleanup": {"denoise": 0.10, "artifact_removal": 0.14, "halo_cleanup": 0.05},
+ "detail": {"sharpen_amount": 0.08, "clarity": 0.04, "texture": 0.02},
+ "alpha": {"alpha_smooth": 0.04, "matte_fix": 0.05},
+ "gif": {"dither_strength": 0.06, "palette_size": 256, "frame_optimize": True},
+ "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.GIF.value},
+ },
+ ),
+ _preset(
+ "GIF Crisp 2x",
+ "Double an animated sprite with nearest-neighbour scaling and GIF-safe cleanup.",
+ formats=["gif"],
+ tags=["animation", "pixel_art", "ui"],
+ settings={
+ "pixel": {"resize_percent": 200.0, "pixel_snap": True, "scale_method": ScaleMethod.NEAREST.value},
+ "cleanup": {"artifact_removal": 0.10, "halo_cleanup": 0.04},
+ "detail": {"sharpen_amount": 0.08, "clarity": 0.04},
+ "gif": {"dither_strength": 0.04, "palette_size": 256, "frame_optimize": True},
+ "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.GIF.value},
+ },
+ ),
+ _preset(
+ "PNG Alpha Clean",
+ "Clean transparent sprite, artwork, logo, and UI edges without removing the background automatically.",
+ formats=["png", "webp"],
+ tags=["transparent", "ui", "logo", "icon", "pixel_art", "artwork"],
+ settings={
+ "cleanup": {"artifact_removal": 0.14, "halo_cleanup": 0.10},
+ "edges": {"edge_refine": 0.16, "antialias": 0.10},
+ "alpha": {"alpha_smooth": 0.08, "matte_fix": 0.10},
+ "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.PNG.value},
+ },
+ ),
+ _preset(
+ "ICO Icon Polish",
+ "Polish small icon edges and retain an ICO-ready output profile.",
+ formats=["ico", "png"],
+ tags=["icon", "ui", "transparent"],
+ settings={
+ "detail": {"sharpen_amount": 0.18, "clarity": 0.10},
+ "cleanup": {"artifact_removal": 0.10, "halo_cleanup": 0.08},
+ "alpha": {"alpha_smooth": 0.06, "matte_fix": 0.10},
+ "export": {"export_profile": ExportProfile.APP_ASSET.value, "format": ExportFormat.ICO.value},
+ },
+ ),
+ _preset(
+ ARTIFACT_CLEANUP,
+ "Reduce compression blocks, color banding, and noise while protecting detail.",
+ formats=["jpg", "png", "webp", "bmp", "tiff"],
+ tags=["photo", "artwork", "texture"],
+ settings={
+ "cleanup": {"denoise": 0.34, "artifact_removal": 0.42, "halo_cleanup": 0.16, "banding_removal": 0.22},
+ "detail": {"sharpen_amount": 0.06, "clarity": -0.04},
+ },
+ ),
+ _preset(
+ PHOTO_RECOVER,
+ "Recover a blurred photo with deblur, reconstruction, and balanced cleanup.",
+ formats=["jpg", "png", "webp", "bmp", "tiff"],
+ tags=["photo"],
+ settings={
+ "detail": {"sharpen_amount": 0.34, "clarity": 0.24, "texture": 0.10},
+ "cleanup": {"denoise": 0.22, "artifact_removal": 0.14},
+ "ai": {"deblur_strength": 0.64, "detail_reconstruct": 0.34},
+ "export": {"export_profile": ExportProfile.WEB.value, "format": ExportFormat.WEBP.value, "quality": 90},
+ },
+ heavy=True,
+ ),
+ _preset(
+ EDGE_REPAIR,
+ "Refine artwork and UI edges while reducing halos and rough alpha transitions.",
+ formats=["png", "webp", "ico", "bmp"],
+ tags=["artwork", "ui", "logo", "icon", "pixel_art"],
+ settings={
+ "edges": {"edge_refine": 0.40, "antialias": 0.20, "feather_px": 0.20, "grow_shrink_px": 0.0},
+ "cleanup": {"halo_cleanup": 0.32},
+ "alpha": {"alpha_smooth": 0.12},
+ },
+ ),
+ _preset(
+ "Texture Repair",
+ "Smooth texture artifacts without flattening useful surface detail.",
+ formats=["jpg", "png", "webp", "bmp", "tiff"],
+ tags=["texture", "artwork"],
+ settings={
+ "cleanup": {"denoise": 0.22, "artifact_removal": 0.22, "banding_removal": 0.14},
+ "detail": {"clarity": 0.10, "texture": 0.16, "sharpen_amount": 0.08},
+ },
+ ),
+ _preset(
+ "TIFF Print Clean",
+ "Prepare TIFF artwork and scans for a clean print-oriented export.",
+ formats=["tiff", "png"],
+ tags=["photo", "artwork"],
+ settings={
+ "cleanup": {"denoise": 0.16, "artifact_removal": 0.12},
+ "detail": {"clarity": 0.08, "sharpen_amount": 0.14},
+ "export": {"export_profile": ExportProfile.PRINT.value, "format": ExportFormat.TIFF.value},
+ },
+ ),
+ _preset(
+ "WEBP Photo Finish",
+ "Apply a restrained photo finish and efficient WEBP output settings.",
+ formats=["jpg", "png", "webp"],
+ tags=["photo", "artwork"],
+ settings={
+ "cleanup": {"denoise": 0.14, "artifact_removal": 0.12},
+ "detail": {"clarity": 0.10, "sharpen_amount": 0.16},
+ "export": {"export_profile": ExportProfile.WEB.value, "format": ExportFormat.WEBP.value, "quality": 90},
+ },
+ ),
+ _preset(
+ "Web Quick Export",
+ "Use lightweight WEBP export defaults without adding visual edits.",
+ formats=["jpg", "png", "webp", "bmp", "tiff"],
+ tags=["*"],
+ settings={
+ "export": {
+ "export_profile": ExportProfile.WEB.value,
+ "format": ExportFormat.WEBP.value,
+ "quality": 84,
+ "strip_metadata": True,
+ }
+ },
+ ),
+ ]
+ return {preset.name: preset for preset in presets}
diff --git a/image_engine_app/engine/process/bounds.py b/image_engine_app/engine/process/bounds.py
index a34eb35..a9927a1 100644
--- a/image_engine_app/engine/process/bounds.py
+++ b/image_engine_app/engine/process/bounds.py
@@ -275,8 +275,8 @@ def clamp_settings_for_mode(
clamped = deepcopy(settings)
for path, bounds in (bounds_map or DEFAULT_SETTING_BOUNDS).items():
current_value = _get_path(clamped, path)
- if current_value is None and path.rsplit(".", 1)[-1] in {"width", "height", "palette_limit"}:
- # Optional dimensions/palette limits may legitimately be None.
+ if current_value is None and path.rsplit(".", 1)[-1] in {"width", "height"}:
+ # Optional dimensions may legitimately be None.
continue
_set_path(clamped, path, bounds.clamp(current_value, mode=mode))
return clamped
@@ -320,4 +320,4 @@ def _set_path(root: SettingsState, dotted_path: str, value: Any) -> None:
current = getattr(current, part)
setattr(current, parts[-1], value)
-
+
diff --git a/image_engine_app/engine/process/edit_baseline.py b/image_engine_app/engine/process/edit_baseline.py
new file mode 100644
index 0000000..f93218d
--- /dev/null
+++ b/image_engine_app/engine/process/edit_baseline.py
@@ -0,0 +1,211 @@
+"""Detected asset baselines and control-captured preset helpers."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from dataclasses import dataclass
+import re
+from typing import Any
+
+from image_engine_app.engine.models import (
+ AssetFormat,
+ AssetRecord,
+ EditState,
+ HeavyJobSpec,
+ HeavyTool,
+ PresetModel,
+ SettingsState,
+)
+
+
+CONTROL_GROUP_ORDER = (
+ "pixel",
+ "color",
+ "detail",
+ "cleanup",
+ "edges",
+ "alpha",
+ "ai",
+ "gif",
+ "export",
+)
+_NO_CHANGE = object()
+_STABLE_TAG = re.compile(r"^[a-z0-9][a-z0-9_-]{0,39}$")
+
+
+@dataclass(frozen=True)
+class CapturedControlSettings:
+ """Sparse preset data captured from one asset's edited controls."""
+
+ settings_delta: dict[str, Any]
+ changed_groups: tuple[str, ...]
+ applies_to_formats: tuple[str, ...]
+ applies_to_tags: tuple[str, ...]
+ uses_heavy_tools: bool
+ requires_apply: bool
+
+
+def capture_detected_settings(asset: AssetRecord) -> SettingsState:
+ """Store the asset-specific settings produced by import detection."""
+
+ detected = deepcopy(asset.edit_state.settings)
+ asset.detected_settings = detected
+ return deepcopy(detected)
+
+
+def ensure_detected_settings(asset: AssetRecord) -> SettingsState:
+ """Return a baseline, safely adopting current controls for legacy assets."""
+
+ baseline = getattr(asset, "detected_settings", None)
+ if baseline is None:
+ baseline = capture_detected_settings(asset)
+ return deepcopy(baseline)
+
+
+def edit_state_from_detected_settings(asset: AssetRecord) -> EditState:
+ """Build a clean edit state while retaining the asset's UI behavior choices."""
+
+ state = deepcopy(asset.edit_state)
+ state.settings = ensure_detected_settings(asset)
+ state.queued_heavy_jobs.clear()
+ return state
+
+
+def restore_detected_settings(asset: AssetRecord) -> None:
+ """Restore the detected controls and discard generated edit output."""
+
+ asset.edit_state = edit_state_from_detected_settings(asset)
+ clear_generated_outputs(asset)
+
+
+def clear_generated_outputs(asset: AssetRecord) -> None:
+ """Return preview dimensions to the source after settings are replaced."""
+
+ asset.derived_current_path = None
+ asset.derived_final_path = None
+
+ original = getattr(asset, "dimensions_original", (0, 0))
+ if isinstance(original, tuple) and len(original) == 2:
+ width = int(original[0] or 0)
+ height = int(original[1] or 0)
+ if width > 0 and height > 0:
+ asset.dimensions_current = (width, height)
+ asset.dimensions_final = (width, height)
+
+
+def settings_delta_from_detected(asset: AssetRecord) -> dict[str, Any]:
+ """Return only control values that differ from the detected baseline."""
+
+ current = asset.edit_state.settings.to_dict()
+ baseline = ensure_detected_settings(asset).to_dict()
+ result = _sparse_difference(current, baseline)
+ return result if isinstance(result, dict) else {}
+
+
+def capture_control_settings(asset: AssetRecord) -> CapturedControlSettings:
+ """Capture reusable preset metadata from the active asset's controls."""
+
+ delta = settings_delta_from_detected(asset)
+ changed_groups = tuple(group for group in CONTROL_GROUP_ORDER if group in delta)
+ ai_delta = delta.get("ai") if isinstance(delta.get("ai"), dict) else {}
+ uses_heavy_tools = _has_heavy_ai_change(ai_delta)
+
+ format_value = getattr(getattr(asset, "format", None), "value", None)
+ formats = (str(format_value),) if format_value and asset.format is not AssetFormat.UNKNOWN else ("*",)
+ tags = tuple(_stable_asset_tags(asset)) or ("*",)
+
+ return CapturedControlSettings(
+ settings_delta=delta,
+ changed_groups=changed_groups,
+ applies_to_formats=formats,
+ applies_to_tags=tags,
+ uses_heavy_tools=uses_heavy_tools,
+ requires_apply=uses_heavy_tools,
+ )
+
+
+def implied_heavy_jobs(preset: PresetModel, edit_state: EditState) -> list[HeavyJobSpec]:
+ """Build the single heavy step implied by a preset's AI controls."""
+
+ if not preset.uses_heavy_tools or not isinstance(preset.settings_delta, dict):
+ return []
+ ai_delta = preset.settings_delta.get("ai")
+ if not isinstance(ai_delta, dict):
+ return []
+
+ factor = _safe_float(ai_delta.get("upscale_factor"), default=1.0)
+ deblur = _safe_float(ai_delta.get("deblur_strength"), default=0.0)
+ background = _safe_float(ai_delta.get("bg_remove_strength"), default=0.0)
+
+ if factor > 1.0:
+ resolved_factor = max(2.0, float(edit_state.settings.ai.upscale_factor))
+ return [
+ HeavyJobSpec(
+ tool=HeavyTool.AI_UPSCALE,
+ params={"factor": resolved_factor, "preset": preset.name},
+ )
+ ]
+ if deblur > 0.0:
+ return [
+ HeavyJobSpec(
+ tool=HeavyTool.AI_DEBLUR,
+ params={
+ "strength": max(0.2, float(edit_state.settings.ai.deblur_strength)),
+ "detail_reconstruct": float(edit_state.settings.ai.detail_reconstruct),
+ "preset": preset.name,
+ },
+ )
+ ]
+ if background > 0.0:
+ return [
+ HeavyJobSpec(
+ tool=HeavyTool.BG_REMOVE,
+ params={"strength": background, "preset": preset.name},
+ )
+ ]
+ return []
+
+
+def _sparse_difference(current: Any, baseline: Any) -> Any:
+ if isinstance(current, dict) and isinstance(baseline, dict):
+ changed: dict[str, Any] = {}
+ for key, current_value in current.items():
+ difference = _sparse_difference(current_value, baseline.get(key, _NO_CHANGE))
+ if difference is not _NO_CHANGE:
+ changed[key] = difference
+ return changed if changed else _NO_CHANGE
+ return _NO_CHANGE if current == baseline else deepcopy(current)
+
+
+def _stable_asset_tags(asset: AssetRecord) -> list[str]:
+ stable: list[str] = []
+ for raw_tag in asset.classification_tags or []:
+ tag = str(raw_tag).strip().lower()
+ if not _STABLE_TAG.fullmatch(tag) or tag in stable:
+ continue
+ stable.append(tag)
+ return stable
+
+
+def _has_heavy_ai_change(ai_delta: dict[str, Any]) -> bool:
+ thresholds = {
+ "upscale_factor": 1.0,
+ "deblur_strength": 0.0,
+ "bg_remove_strength": 0.0,
+ }
+ for key, threshold in thresholds.items():
+ if key not in ai_delta:
+ continue
+ try:
+ if float(ai_delta[key]) > threshold:
+ return True
+ except (TypeError, ValueError):
+ continue
+ return False
+
+
+def _safe_float(value: object, *, default: float) -> float:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return float(default)
diff --git a/image_engine_app/engine/process/light_steps.py b/image_engine_app/engine/process/light_steps.py
index 97e024c..f7d3a61 100644
--- a/image_engine_app/engine/process/light_steps.py
+++ b/image_engine_app/engine/process/light_steps.py
@@ -280,35 +280,41 @@ def _clamp_channel(value: float | int) -> int:
def _apply_cleanup(img, settings: SettingsState):
- _, _, ImageFilter, _ = _require_pillow()
+ Image, _, ImageFilter, _ = _require_pillow()
s = settings.cleanup
- # Denoise in [0,1] -> radius in [0,2]
denoise = max(0.0, float(s.denoise))
artifact = max(0.0, float(s.artifact_removal))
banding = max(0.0, float(s.banding_removal))
halo = max(0.0, float(s.halo_cleanup))
- strength = max(denoise, artifact, banding, halo)
- if strength <= 1e-6:
+ if max(denoise, artifact, banding, halo) <= 1e-6:
return img
- # Conservative blend: median filter for small impulse noise + light gaussian blur.
- median_size = 3 if strength < 0.35 else 5
- blurred = img.filter(ImageFilter.MedianFilter(size=median_size))
+ out = img
- radius = min(2.0, 0.5 + (strength * 1.5))
- blurred2 = blurred.filter(ImageFilter.GaussianBlur(radius=radius))
+ # Denoise targets isolated pixel noise with a median filter.
+ if denoise > 1e-6:
+ size = 3 if denoise < 0.8 else 5
+ layer = out.filter(ImageFilter.MedianFilter(size=size))
+ out = Image.blend(out, layer, min(0.7, 0.12 + (denoise * 0.28)))
- # Blend back toward original so we don't destroy edges.
- blend_alpha = min(0.65, 0.15 + (strength * 0.5))
- try:
- from PIL import ImageChops # type: ignore
+ # Artifact cleanup softens block/compression boundaries without using the denoise path.
+ if artifact > 1e-6:
+ layer = out.filter(ImageFilter.BoxBlur(radius=min(2.0, 0.35 + (artifact * 0.75))))
+ out = Image.blend(out, layer, min(0.52, 0.08 + (artifact * 0.22)))
- out = ImageChops.blend(img, blurred2, blend_alpha)
- return out
- except Exception:
- return blurred2
+ # Banding cleanup uses a wider low-strength blur to smooth gradual tone steps.
+ if banding > 1e-6:
+ layer = out.filter(ImageFilter.GaussianBlur(radius=min(3.0, 0.7 + (banding * 1.1))))
+ out = Image.blend(out, layer, min(0.38, 0.05 + (banding * 0.16)))
+
+ # Halo cleanup applies a conservative local smooth pass around high-contrast transitions.
+ if halo > 1e-6:
+ layer = out.filter(ImageFilter.SMOOTH_MORE)
+ out = Image.blend(out, layer, min(0.42, 0.06 + (halo * 0.18)))
+
+ return out
def _apply_detail(img, settings: SettingsState):
@@ -644,8 +650,9 @@ def _save_light_processed_gif_preview(im, out: Path, settings: SettingsState) ->
frames: list = []
durations: list[int] = []
- loop = int(getattr(im, "info", {}).get("loop", 0) or 0)
disposal = getattr(im, "info", {}).get("disposal", 2)
+ gif = settings.gif
+ delay_override = max(0, int(getattr(gif, "frame_delay_ms", 0) or 0))
source_frame_size = (int(im.size[0]), int(im.size[1]))
logical_size: tuple[int, int] | None = None
resample = _resample_from_method(
@@ -662,22 +669,33 @@ def _save_light_processed_gif_preview(im, out: Path, settings: SettingsState) ->
# balloon the on-screen preview while export still honors the logical output size.
if processed.size != source_frame_size:
processed = processed.resize(source_frame_size, resample=resample)
- durations.append(_gif_delay_or_default(getattr(frame, "info", {}).get("duration", 100)))
- frames.append(_quantize_preview_gif_frame(processed))
+ source_delay = _gif_delay_or_default(getattr(frame, "info", {}).get("duration", 100))
+ durations.append(delay_override or source_delay)
+ frames.append(
+ _quantize_preview_gif_frame(
+ processed,
+ palette_size=int(getattr(gif, "palette_size", 256) or 256),
+ dither_strength=float(getattr(gif, "dither_strength", 0.0) or 0.0),
+ )
+ )
if not frames or logical_size is None:
raise LightProcessError("Animated GIF preview had no frames.")
first = frames[0]
rest = frames[1:]
+ if not bool(getattr(gif, "loop", True)):
+ for frame in frames:
+ frame.info.pop("loop", None)
save_kwargs: dict[str, object] = {
"save_all": True,
"append_images": rest,
"duration": durations if len(durations) > 1 else durations[0],
- "loop": loop,
- "optimize": False,
+ "optimize": bool(getattr(gif, "frame_optimize", True)),
"disposal": int(disposal) if isinstance(disposal, int) else 2,
}
+ if bool(getattr(gif, "loop", True)):
+ save_kwargs["loop"] = 0
first.save(out, format="GIF", **save_kwargs)
return logical_size
@@ -690,10 +708,12 @@ def _gif_delay_or_default(raw_delay: object) -> int:
return max(20, value if value > 0 else 100)
-def _quantize_preview_gif_frame(rgba):
+def _quantize_preview_gif_frame(rgba, *, palette_size: int = 256, dither_strength: float = 0.0):
from PIL import Image # type: ignore
frame = rgba.convert("RGBA")
+ colors = max(2, min(256, int(palette_size or 256)))
+ dither = Image.Dither.FLOYDSTEINBERG if float(dither_strength) > 0.0 else Image.Dither.NONE
alpha = frame.getchannel("A")
transparent_mask = alpha.point(
lambda value: 255 if int(value) <= 24 else 0,
@@ -701,10 +721,14 @@ def _quantize_preview_gif_frame(rgba):
)
if transparent_mask.getbbox() is None:
- return frame.quantize(colors=256, method=getattr(Image, "FASTOCTREE", 2))
+ return frame.quantize(colors=colors, method=getattr(Image, "FASTOCTREE", 2), dither=dither)
rgb = frame.convert("RGB")
- quantized = rgb.quantize(colors=255, method=getattr(Image, "FASTOCTREE", 2))
+ quantized = rgb.quantize(
+ colors=max(2, min(255, colors - 1)),
+ method=getattr(Image, "FASTOCTREE", 2),
+ dither=dither,
+ )
palette = list(quantized.getpalette() or [])
if len(palette) < 768:
diff --git a/image_engine_app/engine/process/output_size.py b/image_engine_app/engine/process/output_size.py
new file mode 100644
index 0000000..1e2947b
--- /dev/null
+++ b/image_engine_app/engine/process/output_size.py
@@ -0,0 +1,73 @@
+"""Shared output-size choices backed by the existing pixel controls."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from image_engine_app.engine.models import PixelSettings
+
+
+@dataclass(frozen=True)
+class OutputSizeChoice:
+ key: str
+ label: str
+ resize_percent: float = 100.0
+ target_height: int | None = None
+
+
+ORIGINAL_SIZE = "original"
+CUSTOM_SIZE = "custom"
+
+OUTPUT_SIZE_CHOICES: tuple[OutputSizeChoice, ...] = (
+ OutputSizeChoice(ORIGINAL_SIZE, "Original dimensions"),
+ OutputSizeChoice("scale_2x", "Scale 2x", resize_percent=200.0),
+ OutputSizeChoice("scale_3x", "Scale 3x", resize_percent=300.0),
+ OutputSizeChoice("scale_4x", "Scale 4x", resize_percent=400.0),
+ OutputSizeChoice("scale_8x", "Scale 8x", resize_percent=800.0),
+ OutputSizeChoice("height_240", "Height 240p", target_height=240),
+ OutputSizeChoice("height_360", "Height 360p", target_height=360),
+ OutputSizeChoice("height_480", "Height 480p", target_height=480),
+ OutputSizeChoice("height_720", "Height 720p (HD)", target_height=720),
+ OutputSizeChoice("height_1080", "Height 1080p (Full HD)", target_height=1080),
+ OutputSizeChoice("height_1440", "Height 1440p (QHD)", target_height=1440),
+ OutputSizeChoice("height_2160", "Height 2160p (4K)", target_height=2160),
+)
+
+_CHOICES_BY_KEY = {choice.key: choice for choice in OUTPUT_SIZE_CHOICES}
+
+
+def apply_output_size_choice(pixel: PixelSettings, key: str) -> bool:
+ """Apply one convenience choice to the real resize/width/height settings."""
+
+ choice = _CHOICES_BY_KEY.get(str(key or ""))
+ if choice is None:
+ return False
+
+ before = (float(pixel.resize_percent), pixel.width, pixel.height)
+ pixel.resize_percent = float(choice.resize_percent)
+ pixel.width = None
+ pixel.height = choice.target_height
+ return before != (float(pixel.resize_percent), pixel.width, pixel.height)
+
+
+def output_size_choice_for(pixel: PixelSettings) -> str:
+ """Return the matching chooser key, or custom for manually entered dimensions."""
+
+ resize_percent = float(pixel.resize_percent or 100.0)
+ width = pixel.width
+ height = pixel.height
+
+ for choice in OUTPUT_SIZE_CHOICES:
+ if choice.target_height is None:
+ if width is None and height is None and abs(resize_percent - choice.resize_percent) < 0.001:
+ return choice.key
+ continue
+
+ if (
+ width is None
+ and height == choice.target_height
+ and abs(resize_percent - choice.resize_percent) < 0.001
+ ):
+ return choice.key
+
+ return CUSTOM_SIZE
diff --git a/image_engine_app/engine/process/presets_apply.py b/image_engine_app/engine/process/presets_apply.py
index 39f968e..4b7700c 100644
--- a/image_engine_app/engine/process/presets_apply.py
+++ b/image_engine_app/engine/process/presets_apply.py
@@ -47,7 +47,7 @@ def apply_preset_to_edit_state(
)
updated = deepcopy(edit_state)
- _apply_settings_delta(updated.settings, preset.settings_delta)
+ _apply_settings_delta(updated.settings, _normalize_legacy_settings_delta(preset.settings_delta))
return clamp_edit_state_for_mode(updated)
@@ -148,3 +148,18 @@ def _apply_settings_delta(settings_obj: Any, delta: dict[str, Any]) -> None:
setattr(settings_obj, key, value)
+
+def _normalize_legacy_settings_delta(delta: dict[str, Any]) -> dict[str, Any]:
+ """Map retired preset fields onto their single active control."""
+
+ normalized = deepcopy(delta)
+ export_delta = normalized.get("export")
+ if not isinstance(export_delta, dict) or "palette_limit" not in export_delta:
+ return normalized
+
+ palette_size = export_delta.pop("palette_limit")
+ gif_delta = normalized.setdefault("gif", {})
+ if isinstance(gif_delta, dict):
+ gif_delta.setdefault("palette_size", palette_size)
+ return normalized
+
diff --git a/image_engine_app/engine/process/preview_support.py b/image_engine_app/engine/process/preview_support.py
index 0ef001a..549d379 100644
--- a/image_engine_app/engine/process/preview_support.py
+++ b/image_engine_app/engine/process/preview_support.py
@@ -3,10 +3,9 @@
from __future__ import annotations
from dataclasses import dataclass
-import shutil
from pathlib import Path
-from image_engine_app.engine.models import ApplyTarget, AssetFormat, ExportFormat, SettingsState
+from image_engine_app.engine.models import AssetFormat, ExportFormat, SettingsState
from image_engine_app.engine.process.light_steps import LightProcessError, LightStepUnavailable, apply_light_processing
@@ -49,49 +48,16 @@ def render_light_pipeline_preview(
return False
derived_dir = base_dir / "derived" / str(getattr(asset, "id", "asset"))
- current_out = derived_dir / f"current{preview_extension_for_asset(asset)}"
final_out = derived_dir / f"final{preview_extension_for_asset(asset)}"
edit_state = getattr(asset, "edit_state", None)
settings = getattr(edit_state, "settings", None)
- target = getattr(edit_state, "apply_target", ApplyTarget.BOTH)
- target_value = getattr(target, "value", ApplyTarget.BOTH.value)
- sync_enabled = bool(getattr(edit_state, "sync_current_final", True))
-
try:
- if final_only:
- result = apply_light_processing(source_path=source, output_path=final_out, settings=settings)
- setattr(asset, "derived_final_path", str(result.output_path))
- setattr(asset, "dimensions_final", result.size)
- return True
-
- if sync_enabled or target_value == ApplyTarget.BOTH.value:
- result = apply_light_processing(source_path=source, output_path=final_out, settings=settings)
- setattr(asset, "derived_final_path", str(result.output_path))
-
- current_out.parent.mkdir(parents=True, exist_ok=True)
- mirrored_current = final_out
- if final_out.exists():
- try:
- shutil.copy2(final_out, current_out)
- mirrored_current = current_out
- except Exception:
- mirrored_current = final_out
-
- setattr(asset, "derived_current_path", str(mirrored_current))
- setattr(asset, "dimensions_current", result.size)
- setattr(asset, "dimensions_final", result.size)
- return True
-
- if target_value == ApplyTarget.FINAL.value:
- result = apply_light_processing(source_path=source, output_path=final_out, settings=settings)
- setattr(asset, "derived_final_path", str(result.output_path))
- setattr(asset, "dimensions_final", result.size)
- return True
-
- result = apply_light_processing(source_path=source, output_path=current_out, settings=settings)
- setattr(asset, "derived_current_path", str(result.output_path))
- setattr(asset, "dimensions_current", result.size)
+ _ = final_only
+ result = apply_light_processing(source_path=source, output_path=final_out, settings=settings)
+ setattr(asset, "derived_current_path", None)
+ setattr(asset, "derived_final_path", str(result.output_path))
+ setattr(asset, "dimensions_final", result.size)
return True
except LightStepUnavailable:
diff --git a/image_engine_app/tests/test_asset_tabs.py b/image_engine_app/tests/test_asset_tabs.py
index a78c546..283748e 100644
--- a/image_engine_app/tests/test_asset_tabs.py
+++ b/image_engine_app/tests/test_asset_tabs.py
@@ -122,5 +122,5 @@ def test_set_active_asset_selects_matching_list_row(self) -> None:
if __name__ == "__main__":
unittest.main()
-
-
+
+
diff --git a/image_engine_app/tests/test_batch_coordinator.py b/image_engine_app/tests/test_batch_coordinator.py
index e553323..1ed7b12 100644
--- a/image_engine_app/tests/test_batch_coordinator.py
+++ b/image_engine_app/tests/test_batch_coordinator.py
@@ -13,7 +13,7 @@
except Exception: # pragma: no cover - optional dependency in some environments
QCoreApplication = None # type: ignore[assignment]
-from image_engine_app.engine.models import AssetRecord, ExportFormat # noqa: E402
+from image_engine_app.engine.models import AssetRecord, BatchEditSource, ExportFormat # noqa: E402
from image_engine_app.engine.process.presets_apply import PresetApplyError # noqa: E402
from image_engine_app.ui.main_window.batch_coordinator import BatchCoordinator, _BatchRunWorker # noqa: E402
@@ -144,7 +144,7 @@ def _capture_start_worker(**kwargs): # noqa: ANN003
coordinator.on_run_requested(
SimpleNamespace(
- auto_preset=True,
+ edit_source=BatchEditSource.KEEP_EACH,
auto_export=True,
preview_skip_mode=True,
export_name_template="{stem}",
@@ -159,13 +159,13 @@ def _capture_start_worker(**kwargs): # noqa: ANN003
self.assertEqual([asset.id for asset in selected_assets], ["asset-b"])
self.assertIsNot(selected_assets[0], window._workspace_assets[1])
self.assertEqual(captured["export_dir"], "C:/Exports/ChosenInBatch")
- self.assertEqual(selected_assets[0].edit_state.settings.export.format, ExportFormat.PNG)
+ self.assertEqual(selected_assets[0].edit_state.settings.export.format, ExportFormat.JPG)
self.assertEqual(window._workspace_assets[1].edit_state.settings.export.format, ExportFormat.JPG)
self.assertEqual(window._export_coordinator.remembered[-1], "C:/Exports/ChosenInBatch")
self.assertTrue(window.batch_manager_dialog.running_states)
self.assertIn("Batch run started", window.status_messages[-1])
- def test_on_run_requested_can_apply_active_edits_and_selected_preset(self) -> None:
+ def test_on_run_requested_can_copy_active_controls(self) -> None:
window = self._FakeWindow()
active = window._workspace_assets[0]
target = window._workspace_assets[1]
@@ -182,16 +182,14 @@ def _capture_start_worker(**kwargs): # noqa: ANN003
coordinator.start_worker = _capture_start_worker # type: ignore[method-assign]
coordinator.on_run_requested(
SimpleNamespace(
- auto_preset=False,
+ edit_source=BatchEditSource.COPY_ACTIVE,
auto_export=False,
preview_skip_mode=True,
export_name_template="{stem}",
avoid_overwrite=True,
export_directory=None,
selected_asset_ids=("asset-b",),
- apply_active_edits=True,
- apply_selected_preset=True,
- selected_preset_name="Photo Recover",
+ selected_preset_name="",
)
)
@@ -201,7 +199,7 @@ def _capture_start_worker(**kwargs): # noqa: ANN003
self.assertIsNot(batch_assets[0], target)
self.assertAlmostEqual(batch_assets[0].edit_state.settings.cleanup.denoise, 0.37, places=3)
self.assertAlmostEqual(target.edit_state.settings.cleanup.denoise, 0.0, places=3)
- self.assertIn(("asset-b", "Photo Recover"), window.controller.applied_presets)
+ self.assertEqual(window.controller.applied_presets, [])
def test_on_run_requested_skips_incompatible_selected_preset_items(self) -> None:
window = self._FakeWindow()
@@ -222,15 +220,13 @@ def _capture_start_worker(**kwargs): # noqa: ANN003
coordinator.start_worker = _capture_start_worker # type: ignore[method-assign]
coordinator.on_run_requested(
SimpleNamespace(
- auto_preset=False,
+ edit_source=BatchEditSource.CHOSEN_PRESET,
auto_export=False,
preview_skip_mode=True,
export_name_template="{stem}",
avoid_overwrite=True,
export_directory=None,
selected_asset_ids=("asset-a", "asset-b"),
- apply_active_edits=False,
- apply_selected_preset=True,
selected_preset_name="Photo Recover",
)
)
@@ -255,15 +251,13 @@ def _capture_start_worker(**kwargs): # noqa: ANN003
coordinator.start_worker = _capture_start_worker # type: ignore[method-assign]
coordinator.on_run_requested(
SimpleNamespace(
- auto_preset=False,
+ edit_source=BatchEditSource.KEEP_EACH,
auto_export=False,
preview_skip_mode=True,
export_name_template="{stem}",
avoid_overwrite=True,
export_directory=None,
selected_asset_ids=("asset-b",),
- apply_active_edits=False,
- apply_selected_preset=False,
selected_preset_name="",
background_removal_override="black",
)
@@ -288,15 +282,13 @@ def _capture_start_worker(**_kwargs): # noqa: ANN003
coordinator.start_worker = _capture_start_worker # type: ignore[method-assign]
coordinator.on_run_requested(
SimpleNamespace(
- auto_preset=False,
+ edit_source=BatchEditSource.CHOSEN_PRESET,
auto_export=False,
preview_skip_mode=True,
export_name_template="{stem}",
avoid_overwrite=True,
export_directory=None,
selected_asset_ids=("asset-b",),
- apply_active_edits=False,
- apply_selected_preset=True,
selected_preset_name="",
)
)
@@ -315,7 +307,7 @@ def _capture_start_worker(**kwargs): # noqa: ANN003
coordinator.start_worker = _capture_start_worker # type: ignore[method-assign]
coordinator.on_run_requested(
SimpleNamespace(
- auto_preset=True,
+ edit_source=BatchEditSource.SMART_MATCH,
auto_export=True,
preview_skip_mode=True,
export_name_template="{stem}",
@@ -338,7 +330,7 @@ def _capture_start_worker(**_kwargs): # noqa: ANN003
coordinator.start_worker = _capture_start_worker # type: ignore[method-assign]
coordinator.on_run_requested(
SimpleNamespace(
- auto_preset=True,
+ edit_source=BatchEditSource.KEEP_EACH,
auto_export=False,
preview_skip_mode=True,
export_name_template="{stem}",
@@ -392,9 +384,9 @@ def test_ui_update_errors_are_caught_and_reported(self) -> None:
if __name__ == "__main__":
- unittest.main()
-
-
-
-
-
+ unittest.main()
+
+
+
+
+
diff --git a/image_engine_app/tests/test_batch_manager_dialog.py b/image_engine_app/tests/test_batch_manager_dialog.py
index 8b9f037..fd460d3 100644
--- a/image_engine_app/tests/test_batch_manager_dialog.py
+++ b/image_engine_app/tests/test_batch_manager_dialog.py
@@ -15,6 +15,7 @@
QProgressBar = None # type: ignore[assignment]
from image_engine_app.ui.windows.batch_manager import BatchManagerDialog # noqa: E402
+from image_engine_app.engine.models import BatchEditSource # noqa: E402
@unittest.skipIf(QApplication is None, "PySide6 not installed")
@@ -44,8 +45,7 @@ def test_current_options_defaults_to_source_name_and_selects_all(self) -> None:
self.assertEqual(options.export_name_template, "{stem}")
self.assertEqual(options.export_directory, "C:/Exports/Dialog")
self.assertEqual(options.selected_asset_ids, ("asset-1", "asset-2"))
- self.assertFalse(options.apply_active_edits)
- self.assertFalse(options.apply_selected_preset)
+ self.assertEqual(options.edit_source, BatchEditSource.KEEP_EACH)
self.assertIsNone(options.selected_preset_name)
self.assertIsNone(options.background_removal_override)
@@ -66,24 +66,24 @@ def test_run_button_tracks_selection_when_idle(self) -> None:
self.assertTrue(dialog._queue_more_btn.isEnabled())
self.assertTrue(dialog._run_options_btn.isEnabled())
- def test_compact_menus_keep_batch_options_wired(self) -> None:
+ def test_run_options_and_edit_source_are_independent(self) -> None:
dialog = BatchManagerDialog()
dialog.set_queue_assets([
("asset-1", "sprite_a.png"),
])
- self.assertTrue(dialog.current_options().auto_preset)
self.assertTrue(dialog.current_options().auto_export)
self.assertTrue(dialog.current_options().preview_skip_mode)
+ self.assertEqual(dialog.current_options().edit_source, BatchEditSource.KEEP_EACH)
- dialog._auto_preset_action.setChecked(False)
dialog._auto_export_action.setChecked(False)
dialog._preview_skip_action.setChecked(False)
+ dialog._edit_source_combo.setCurrentIndex(3)
options = dialog.current_options()
- self.assertFalse(options.auto_preset)
self.assertFalse(options.auto_export)
self.assertFalse(options.preview_skip_mode)
+ self.assertEqual(options.edit_source, BatchEditSource.SMART_MATCH)
def test_run_button_requires_preset_choice_when_enabled(self) -> None:
dialog = BatchManagerDialog()
@@ -94,14 +94,14 @@ def test_run_button_requires_preset_choice_when_enabled(self) -> None:
self.assertTrue(dialog._run_btn.isEnabled())
- dialog._apply_preset_check.setChecked(True)
+ dialog._edit_source_combo.setCurrentIndex(1)
self.assertFalse(dialog._run_btn.isEnabled())
dialog._batch_preset_combo.setCurrentIndex(1)
self.assertTrue(dialog._run_btn.isEnabled())
options = dialog.current_options()
- self.assertTrue(options.apply_selected_preset)
+ self.assertEqual(options.edit_source, BatchEditSource.CHOSEN_PRESET)
self.assertEqual(options.selected_preset_name, "Pixel Clean Upscale")
dialog.clear_selection()
self.assertFalse(dialog._run_btn.isEnabled())
@@ -322,7 +322,7 @@ def test_update_from_report_shows_latest_issue_detail(self) -> None:
if __name__ == "__main__":
unittest.main()
-
-
-
-
+
+
+
+
diff --git a/image_engine_app/tests/test_batch_preset_rules_service.py b/image_engine_app/tests/test_batch_preset_rules_service.py
index 54234c2..bd01ab6 100644
--- a/image_engine_app/tests/test_batch_preset_rules_service.py
+++ b/image_engine_app/tests/test_batch_preset_rules_service.py
@@ -7,7 +7,6 @@
from image_engine_app.app.services import (
PresetLibrary,
build_batch_auto_preset_rules,
- build_batch_per_source_preset_rules,
)
from image_engine_app.engine.models import EditMode, PresetModel
@@ -30,5 +29,6 @@ def test_auto_rules_pick_known_system_presets(self) -> None:
self.assertEqual(rules["photo"][0].name, "Photo Recover")
self.assertEqual(rules["animation"][0].name, "GIF Safe Cleanup")
- def test_per_source_rules_can_be_disabled(self) -> None:
- self.assertEqual(build_batch_per_source_preset_rules(enabled=False), {})
+ def test_auto_rules_can_be_disabled(self) -> None:
+ library = PresetLibrary(system_presets={})
+ self.assertEqual(build_batch_auto_preset_rules(library, enabled=False), {})
diff --git a/image_engine_app/tests/test_batch_run_prep.py b/image_engine_app/tests/test_batch_run_prep.py
index 4361bb0..7285ea4 100644
--- a/image_engine_app/tests/test_batch_run_prep.py
+++ b/image_engine_app/tests/test_batch_run_prep.py
@@ -4,7 +4,7 @@
import unittest
-from image_engine_app.engine.models import AssetFormat, AssetRecord, EditMode, HeavyJobSpec, HeavyTool
+from image_engine_app.engine.models import AssetFormat, AssetRecord, BatchEditSource, EditMode, HeavyJobSpec, HeavyTool
from image_engine_app.ui.main_window.batch_run_prep import prepare_batch_assets
@@ -41,9 +41,7 @@ def test_prepare_batch_assets_copies_active_heavy_jobs_and_clears_derived_output
selected_assets=[target],
active_asset=active,
controller=_FakeController(),
- auto_export=False,
- apply_active_edits=True,
- apply_selected_preset=False,
+ edit_source=BatchEditSource.COPY_ACTIVE,
selected_preset_name="",
background_override=None,
)
diff --git a/image_engine_app/tests/test_batch_runner.py b/image_engine_app/tests/test_batch_runner.py
index 88f0311..3aeac15 100644
--- a/image_engine_app/tests/test_batch_runner.py
+++ b/image_engine_app/tests/test_batch_runner.py
@@ -116,6 +116,44 @@ def _write_source_fixture(path: Path, *, fmt: AssetFormat, animated: bool = Fals
class BatchRunnerTests(unittest.TestCase):
+ def test_smart_batch_applies_only_first_compatible_preset(self) -> None:
+ asset = _make_asset(
+ asset_id="asset-smart-one",
+ name="enemy_sprite.png",
+ fmt=AssetFormat.PNG,
+ dims=(32, 32),
+ has_alpha=True,
+ )
+ first = PresetModel(
+ name="First Smart",
+ description="",
+ applies_to_formats=["png"],
+ applies_to_tags=["pixel_art"],
+ settings_delta={"cleanup": {"denoise": 0.22}},
+ mode_min=EditMode.ADVANCED,
+ )
+ second = PresetModel(
+ name="Second Smart",
+ description="",
+ applies_to_formats=["png"],
+ applies_to_tags=["pixel_art"],
+ settings_delta={"color": {"brightness": 0.3}},
+ mode_min=EditMode.ADVANCED,
+ )
+
+ runner = BatchRunner(
+ BatchRunnerConfig(
+ preview_skip_mode=True,
+ auto_export=False,
+ auto_preset_rules={"pixel_art": [first, second]},
+ )
+ )
+ report = runner.run([BatchWorkItem(asset=asset, queue_item=_make_queue(asset, 1))])
+
+ self.assertEqual(report.items[0].applied_preset_names, ["First Smart"])
+ self.assertEqual(asset.edit_state.settings.cleanup.denoise, 0.22)
+ self.assertEqual(asset.edit_state.settings.color.brightness, 0.0)
+
def test_batch_runner_sequential_auto_preset_heavy_and_export(self) -> None:
pixel_asset = _make_asset(
asset_id="asset-1",
@@ -124,10 +162,6 @@ def test_batch_runner_sequential_auto_preset_heavy_and_export(self) -> None:
dims=(64, 64),
has_alpha=True,
)
- pixel_asset.edit_state.queued_heavy_jobs = [
- HeavyJobSpec(id="job-upscale-1", tool=HeavyTool.AI_UPSCALE, params={"factor": 4})
- ]
-
photo_asset = _make_asset(
asset_id="asset-2",
name="portrait.jpg",
@@ -140,7 +174,13 @@ def test_batch_runner_sequential_auto_preset_heavy_and_export(self) -> None:
pixel_preset = PresetModel(
name="Pixel Batch Boost",
description="Boost pixel assets in batch",
- settings_delta={"color": {"brightness": 0.9}, "cleanup": {"denoise": 0.3}},
+ settings_delta={
+ "color": {"brightness": 0.9},
+ "cleanup": {"denoise": 0.3},
+ "ai": {"upscale_factor": 4.0},
+ },
+ uses_heavy_tools=True,
+ requires_apply=True,
mode_min=EditMode.ADVANCED,
)
@@ -408,7 +448,7 @@ def test_batch_runner_skips_incompatible_auto_presets_for_animated_gif(self) ->
BatchRunnerConfig(
preview_skip_mode=True,
auto_export=False,
- per_source_preset_rules={"gif": [incompatible, gif_safe]},
+ auto_preset_rules={"animation": [incompatible, gif_safe]},
)
)
report = runner.run([BatchWorkItem(asset=asset, queue_item=_make_queue(asset, 31))])
@@ -418,6 +458,7 @@ def test_batch_runner_skips_incompatible_auto_presets_for_animated_gif(self) ->
self.assertAlmostEqual(asset.edit_state.settings.cleanup.denoise, 0.0, places=6)
self.assertAlmostEqual(asset.edit_state.settings.cleanup.artifact_removal, 0.14, places=6)
self.assertEqual(asset.edit_state.settings.export.format, ExportFormat.GIF)
+ self.assertEqual(asset.edit_state.settings.gif.palette_size, 256)
@unittest.skipUnless(_pillow_available(), "Pillow required for static batch export test.")
def test_batch_runner_auto_export_applies_background_removal_when_no_derived_preview_exists(self) -> None:
@@ -672,5 +713,5 @@ def test_batch_runner_mixed_supported_sources_export_without_failure(self) -> No
if __name__ == "__main__":
unittest.main()
-
-
+
+
diff --git a/image_engine_app/tests/test_builtin_presets.py b/image_engine_app/tests/test_builtin_presets.py
new file mode 100644
index 0000000..ea288bc
--- /dev/null
+++ b/image_engine_app/tests/test_builtin_presets.py
@@ -0,0 +1,39 @@
+"""Bundled preset catalog tests."""
+
+from __future__ import annotations
+
+import unittest
+
+from image_engine_app.engine.models import EditState
+from image_engine_app.engine.presets import build_builtin_presets
+from image_engine_app.engine.process.presets_apply import apply_preset_to_edit_state
+
+
+class BuiltinPresetTests(unittest.TestCase):
+ def test_catalog_is_unique_and_every_delta_is_valid(self) -> None:
+ presets = build_builtin_presets()
+
+ self.assertEqual(16, len(presets))
+ self.assertEqual(len(presets), len(set(presets)))
+ for name, preset in presets.items():
+ self.assertEqual(name, preset.name)
+ apply_preset_to_edit_state(preset, EditState(mode=preset.mode_min))
+
+ def test_catalog_removes_overlapping_starter_variants(self) -> None:
+ presets = build_builtin_presets()
+
+ self.assertNotIn("Starter Cleanup Smooth", presets)
+ self.assertNotIn("Starter Edges Clean", presets)
+ self.assertNotIn("Starter AI Recover", presets)
+ self.assertNotIn("Logo Alpha Clean", presets)
+
+ def test_sprite_and_gif_size_presets_use_real_pixel_controls(self) -> None:
+ presets = build_builtin_presets()
+
+ self.assertEqual(200.0, presets["Sprite Crisp 2x"].settings_delta["pixel"]["resize_percent"])
+ self.assertEqual(400.0, presets["Sprite Crisp 4x"].settings_delta["pixel"]["resize_percent"])
+ self.assertEqual(200.0, presets["GIF Crisp 2x"].settings_delta["pixel"]["resize_percent"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/image_engine_app/tests/test_control_strip.py b/image_engine_app/tests/test_control_strip.py
index 2b17f7f..a435591 100644
--- a/image_engine_app/tests/test_control_strip.py
+++ b/image_engine_app/tests/test_control_strip.py
@@ -10,11 +10,10 @@
try:
- from PySide6.QtCore import Qt
- from PySide6.QtWidgets import QApplication
+ from PySide6.QtWidgets import QApplication, QLabel
except Exception: # pragma: no cover - optional dependency in some environments
QApplication = None # type: ignore[assignment]
- Qt = None # type: ignore[assignment]
+ QLabel = None # type: ignore[assignment]
from image_engine_app.engine.models import AssetRecord, BackgroundRemovalMode # noqa: E402
from image_engine_app.ui.common.state_bindings import EngineUIState # noqa: E402
@@ -35,28 +34,28 @@ def tearDownClass(cls) -> None:
if getattr(cls, "_owns_app", False) and getattr(cls, "_app", None) is not None:
cls._app.quit()
- def test_apply_button_text_tracks_heavy_queue_state(self) -> None:
+ def test_run_button_tracks_heavy_queue_state(self) -> None:
ui_state = EngineUIState()
strip = ControlStrip()
strip.bind_state(ui_state)
try:
- self.assertFalse(strip._apply_button.isEnabled())
+ self.assertFalse(strip._run_button.isEnabled())
self.assertIn("Select an asset", strip._header_summary.text())
asset = AssetRecord(id="asset-1", original_name="sprite.png")
ui_state.set_active_asset(asset)
- self.assertTrue(strip._apply_button.isEnabled())
- self.assertEqual("Apply", strip._apply_button.text())
+ self.assertTrue(strip._run_button.isEnabled())
+ self.assertEqual("Refresh Final", strip._run_button.text())
self.assertEqual("Ready", strip._queue_badge.text())
ui_state.set_heavy_queue_counts(queued_count=2, running_count=0)
- self.assertEqual("Run 2 Heavy", strip._apply_button.text())
+ self.assertEqual("Run 2 Heavy", strip._run_button.text())
self.assertIn("queued", strip._queue_badge.text().lower())
finally:
strip.close()
- def test_preview_button_emits_light_preview_request(self) -> None:
+ def test_run_button_emits_light_preview_request_without_heavy_queue(self) -> None:
ui_state = EngineUIState()
strip = ControlStrip()
strip.bind_state(ui_state)
@@ -67,7 +66,7 @@ def test_preview_button_emits_light_preview_request(self) -> None:
ui_state.light_preview_requested.connect(lambda: calls.append("preview"))
try:
- strip._preview_button.click()
+ strip._run_button.click()
self.assertEqual(["preview"], calls)
finally:
strip.close()
@@ -101,8 +100,7 @@ def test_menu_buttons_use_dedicated_menu_style_width(self) -> None:
try:
self.assertEqual("controlStripMenuAction", strip._background_button.objectName())
self.assertEqual("controlStripMenuAction", strip._options_button.objectName())
- self.assertEqual("Link", strip._sync_button.text())
- self.assertEqual("Auto", strip._auto_apply_button.text())
+ self.assertEqual("FINAL", strip._actions_group.findChild(QLabel, "controlStripSectionLabel").text())
self.assertGreaterEqual(strip._background_button.minimumWidth(), 84)
self.assertGreaterEqual(strip._options_button.minimumWidth(), 72)
finally:
@@ -146,7 +144,7 @@ def test_preset_menu_keeps_manager_available_without_asset(self) -> None:
finally:
strip.close()
- def test_target_badge_uses_centered_header_badge_layout(self) -> None:
+ def test_header_uses_preset_background_and_queue_controls(self) -> None:
ui_state = EngineUIState()
strip = ControlStrip()
strip.bind_state(ui_state)
@@ -154,13 +152,13 @@ def test_target_badge_uses_centered_header_badge_layout(self) -> None:
ui_state.set_active_asset(asset)
try:
- self.assertGreaterEqual(strip._target_badge.minimumHeight(), 24)
- self.assertEqual(int(strip._target_badge.alignment()), int(Qt.AlignmentFlag.AlignCenter))
- self.assertIn("Target:", strip._target_badge.text())
+ self.assertEqual(strip._preset_button.size(), strip._background_button.size())
+ self.assertEqual(strip._background_button.height(), strip._queue_badge.height())
+ self.assertIn("Current is the source", strip._header_summary.text())
finally:
strip.close()
- def test_background_mode_change_requests_preview_even_when_auto_preview_is_off(self) -> None:
+ def test_background_mode_change_always_requests_final_refresh(self) -> None:
ui_state = EngineUIState()
strip = ControlStrip()
strip.bind_state(ui_state)
@@ -181,5 +179,5 @@ def test_background_mode_change_requests_preview_even_when_auto_preview_is_off(s
if __name__ == "__main__":
unittest.main()
-
-
+
+
diff --git a/image_engine_app/tests/test_control_strip_state.py b/image_engine_app/tests/test_control_strip_state.py
index ecd16a6..bf4eb4f 100644
--- a/image_engine_app/tests/test_control_strip_state.py
+++ b/image_engine_app/tests/test_control_strip_state.py
@@ -8,7 +8,6 @@
import unittest
-from image_engine_app.engine.models import ApplyTarget # noqa: E402
from image_engine_app.ui.main_window.control_strip_state import build_control_strip_view_state # noqa: E402
@@ -17,36 +16,24 @@ def test_missing_asset_defaults_to_disabled_controls(self) -> None:
state = build_control_strip_view_state(None)
self.assertFalse(state.has_asset)
- self.assertEqual(ApplyTarget.BOTH.value, state.apply_target)
- self.assertTrue(state.sync_current_final)
- self.assertTrue(state.auto_apply_light)
self.assertEqual("No asset", state.queue_badge_text)
- def test_asset_state_uses_edit_state_values_and_normalizes_unknown_target(self) -> None:
+ def test_asset_state_reports_source_final_workflow(self) -> None:
asset = SimpleNamespace(
- edit_state=SimpleNamespace(
- apply_target=SimpleNamespace(value="unknown"),
- sync_current_final=False,
- auto_apply_light=False,
- )
+ edit_state=SimpleNamespace()
)
state = build_control_strip_view_state(asset)
self.assertTrue(state.has_asset)
- self.assertEqual(ApplyTarget.BOTH.value, state.apply_target)
- self.assertFalse(state.sync_current_final)
- self.assertFalse(state.auto_apply_light)
- self.assertEqual("Apply", state.apply_button_text)
- self.assertIn("Views split", state.summary_text)
- self.assertIn("Auto preview off", state.summary_text)
-
- def test_heavy_queue_state_changes_apply_copy_and_badge(self) -> None:
+ self.assertEqual("Refresh Final", state.run_button_text)
+ self.assertFalse(state.run_heavy)
+ self.assertIn("Current is the source", state.summary_text)
+ self.assertIn("Final updates automatically", state.summary_text)
+
+ def test_heavy_queue_state_changes_run_copy_and_badge(self) -> None:
asset = SimpleNamespace(
edit_state=SimpleNamespace(
- apply_target=SimpleNamespace(value=ApplyTarget.CURRENT.value),
- sync_current_final=True,
- auto_apply_light=True,
queued_heavy_jobs=[object()],
)
)
@@ -54,7 +41,8 @@ def test_heavy_queue_state_changes_apply_copy_and_badge(self) -> None:
state = build_control_strip_view_state(asset, heavy_state)
- self.assertEqual("Run 2 Heavy", state.apply_button_text)
+ self.assertEqual("Run 2 Heavy", state.run_button_text)
+ self.assertTrue(state.run_heavy)
self.assertEqual("Queued: 2", state.queue_badge_text)
self.assertEqual("queued", state.queue_badge_tone)
diff --git a/image_engine_app/tests/test_export_bar.py b/image_engine_app/tests/test_export_bar.py
index 9c74108..f3dabcd 100644
--- a/image_engine_app/tests/test_export_bar.py
+++ b/image_engine_app/tests/test_export_bar.py
@@ -13,7 +13,7 @@
QApplication = None # type: ignore[assignment]
Qt = None # type: ignore[assignment]
-from image_engine_app.engine.models import AssetRecord # noqa: E402
+from image_engine_app.engine.models import AssetRecord, ExportFormat, ExportProfile # noqa: E402
from image_engine_app.ui.common.state_bindings import EngineUIState # noqa: E402
from image_engine_app.ui.main_window.export_bar import ExportBar # noqa: E402
@@ -79,6 +79,27 @@ def test_folder_actions_are_grouped_in_menu(self) -> None:
finally:
bar.close()
+ def test_profile_control_applies_real_export_defaults(self) -> None:
+ bar = ExportBar()
+ ui_state = EngineUIState()
+ bar.bind_state(ui_state)
+ asset = AssetRecord(id="asset-3", original_name="print.png")
+ ui_state.set_active_asset(asset)
+
+ try:
+ print_index = bar._profile_combo.findData(ExportProfile.PRINT.value)
+ self.assertGreaterEqual(print_index, 0)
+ bar._profile_combo.setCurrentIndex(print_index)
+
+ export = asset.edit_state.settings.export
+ self.assertEqual(ExportProfile.PRINT, export.export_profile)
+ self.assertEqual(ExportFormat.TIFF, export.format)
+ self.assertEqual(100, export.quality)
+ self.assertEqual(0, export.compression_level)
+ self.assertFalse(export.strip_metadata)
+ finally:
+ bar.close()
+
if __name__ == "__main__":
unittest.main()
diff --git a/image_engine_app/tests/test_export_controls.py b/image_engine_app/tests/test_export_controls.py
new file mode 100644
index 0000000..6d24a6c
--- /dev/null
+++ b/image_engine_app/tests/test_export_controls.py
@@ -0,0 +1,125 @@
+"""Behavior tests for controls that directly affect encoded output."""
+
+from __future__ import annotations
+
+from pathlib import Path
+import tempfile
+import unittest
+
+from PIL import Image, ImageSequence
+
+from image_engine_app.engine.export.exporters import ExportRequest, _build_save_kwargs, export_image
+from image_engine_app.engine.models import (
+ ChromaSubsampling,
+ ExportFormat,
+ ExportSettings,
+ GifSettings,
+)
+
+
+class ExportControlBehaviorTests(unittest.TestCase):
+ def test_png_compression_and_jpeg_chroma_map_to_encoder_options(self) -> None:
+ png = ExportSettings(format=ExportFormat.PNG, compression_level=4)
+ jpeg = ExportSettings(
+ format=ExportFormat.JPG,
+ chroma_subsampling=ChromaSubsampling.CS_444,
+ )
+
+ self.assertEqual(4, _build_save_kwargs(ExportFormat.PNG, png)["compress_level"])
+ self.assertEqual(0, _build_save_kwargs(ExportFormat.JPG, jpeg)["subsampling"])
+
+ def test_dpi_and_metadata_controls_change_jpeg_output(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source = root / "source.jpg"
+ kept = root / "kept.jpg"
+ stripped = root / "stripped.jpg"
+
+ exif = Image.Exif()
+ exif[270] = "Sprite Factory metadata test"
+ Image.new("RGB", (18, 14), (40, 120, 210)).save(source, format="JPEG", exif=exif)
+
+ keep_result = export_image(
+ ExportRequest(
+ output_path=kept,
+ source_path=source,
+ width=18,
+ height=14,
+ export_settings=ExportSettings(format=ExportFormat.JPG, strip_metadata=False),
+ dpi=144,
+ )
+ )
+ strip_result = export_image(
+ ExportRequest(
+ output_path=stripped,
+ source_path=source,
+ width=18,
+ height=14,
+ export_settings=ExportSettings(format=ExportFormat.JPG, strip_metadata=True),
+ dpi=96,
+ )
+ )
+
+ self.assertTrue(keep_result.success)
+ self.assertTrue(strip_result.success)
+ with Image.open(kept) as image:
+ self.assertEqual("Sprite Factory metadata test", image.getexif().get(270))
+ self.assertAlmostEqual(144.0, float(image.info["dpi"][0]), delta=1.0)
+ with Image.open(stripped) as image:
+ self.assertIsNone(image.getexif().get(270))
+ self.assertAlmostEqual(96.0, float(image.info["dpi"][0]), delta=1.0)
+
+ def test_gif_controls_set_timing_loop_and_palette(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source = root / "source.gif"
+ output = root / "output.gif"
+
+ frames: list[Image.Image] = []
+ for offset in (0, 37):
+ frame = Image.new("RGB", (16, 16))
+ for y in range(16):
+ for x in range(16):
+ frame.putpixel((x, y), ((x * 17 + offset) % 256, (y * 17) % 256, ((x + y) * 9) % 256))
+ frames.append(frame)
+ frames[0].save(
+ source,
+ format="GIF",
+ save_all=True,
+ append_images=frames[1:],
+ duration=[80, 120],
+ loop=0,
+ )
+
+ result = export_image(
+ ExportRequest(
+ output_path=output,
+ source_path=source,
+ width=16,
+ height=16,
+ export_settings=ExportSettings(format=ExportFormat.GIF),
+ gif_settings=GifSettings(
+ frame_delay_ms=150,
+ loop=False,
+ palette_size=16,
+ dither_strength=0.0,
+ frame_optimize=False,
+ ),
+ frame_count=2,
+ )
+ )
+
+ self.assertTrue(result.success)
+ with Image.open(output) as image:
+ self.assertTrue(bool(getattr(image, "is_animated", False)))
+ self.assertIsNone(image.info.get("loop"))
+ exported_frames = list(ImageSequence.Iterator(image))
+ self.assertEqual([150, 150], [int(frame.info.get("duration", 0)) for frame in exported_frames])
+ for frame in exported_frames:
+ colors = frame.convert("RGB").getcolors(maxcolors=256)
+ self.assertIsNotNone(colors)
+ self.assertLessEqual(len(colors or []), 16)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/image_engine_app/tests/test_launcher.py b/image_engine_app/tests/test_launcher.py
index 3140991..3cc129b 100644
--- a/image_engine_app/tests/test_launcher.py
+++ b/image_engine_app/tests/test_launcher.py
@@ -13,6 +13,14 @@
class LauncherTests(unittest.TestCase):
+ def test_app_startup_arguments_do_not_leak_into_qt_arguments(self) -> None:
+ args, qt_args = app_main._parse_startup_args(
+ ["--app-data-dir", ".\\runtime", "-platform", "offscreen"]
+ )
+
+ self.assertEqual(Path(args.app_data_dir), Path(".\\runtime"))
+ self.assertEqual(qt_args, ["-platform", "offscreen"])
+
def test_extract_cli_app_data_dir_supports_separate_value(self) -> None:
target = launcher._extract_cli_app_data_dir(["--app-data-dir", ".\\runtime"])
self.assertEqual(Path(".\\runtime").expanduser().resolve(), target)
diff --git a/image_engine_app/tests/test_light_steps.py b/image_engine_app/tests/test_light_steps.py
index d7691a5..48f9050 100644
--- a/image_engine_app/tests/test_light_steps.py
+++ b/image_engine_app/tests/test_light_steps.py
@@ -10,7 +10,14 @@
from PIL import Image, ImageChops, ImageSequence, ImageStat # noqa: E402
from image_engine_app.engine.models import SettingsState # noqa: E402
-from image_engine_app.engine.process.light_steps import _apply_alpha_rules, _apply_color_adjust, _apply_detail, _apply_edges, apply_light_processing # noqa: E402
+from image_engine_app.engine.process.light_steps import ( # noqa: E402
+ _apply_alpha_rules,
+ _apply_cleanup,
+ _apply_color_adjust,
+ _apply_detail,
+ _apply_edges,
+ apply_light_processing,
+)
class LightDetailTests(unittest.TestCase):
@@ -112,6 +119,25 @@ def test_curves_adjust_lifts_midtones(self) -> None:
self.assertGreater(out.getpixel((0, 0))[2], 128)
+class LightCleanupTests(unittest.TestCase):
+ def test_cleanup_controls_use_distinct_processing_paths(self) -> None:
+ source = Image.new("RGB", (24, 24))
+ for y in range(24):
+ for x in range(24):
+ base = 35 if ((x // 4) + (y // 4)) % 2 else 220
+ source.putpixel((x, y), (base, (base + x * 7) % 256, (base + y * 9) % 256))
+
+ outputs: list[bytes] = []
+ for field_name in ("denoise", "artifact_removal", "banding_removal", "halo_cleanup"):
+ settings = SettingsState()
+ setattr(settings.cleanup, field_name, 0.8)
+ output = _apply_cleanup(source, settings)
+ self.assertNotEqual(source.tobytes(), output.tobytes())
+ outputs.append(output.tobytes())
+
+ self.assertEqual(4, len(set(outputs)))
+
+
class LightAlphaTests(unittest.TestCase):
def test_edge_controls_keep_rgba_processing_alive(self) -> None:
src = Image.new("RGBA", (8, 8), (10, 20, 30, 0))
@@ -206,6 +232,7 @@ def test_apply_light_processing_preserves_animation_when_output_is_gif(self) ->
settings = SettingsState()
settings.alpha.background_removal_mode = "white"
+ settings.gif.loop = False
result = apply_light_processing(source_path=src, output_path=out, settings=settings)
@@ -215,6 +242,7 @@ def test_apply_light_processing_preserves_animation_when_output_is_gif(self) ->
with Image.open(out) as image:
self.assertTrue(bool(getattr(image, "is_animated", False)))
self.assertGreaterEqual(int(getattr(image, "n_frames", 1)), 2)
+ self.assertIsNone(image.info.get("loop"))
frames = [frame.convert("RGBA") for frame in ImageSequence.Iterator(image)]
self.assertEqual(0, frames[0].getpixel((0, 0))[3])
self.assertNotEqual(frames[0].getpixel((5, 5)), frames[1].getpixel((5, 5)))
diff --git a/image_engine_app/tests/test_local_import_coordinator.py b/image_engine_app/tests/test_local_import_coordinator.py
index 9f507b4..1fbe5dc 100644
--- a/image_engine_app/tests/test_local_import_coordinator.py
+++ b/image_engine_app/tests/test_local_import_coordinator.py
@@ -52,8 +52,8 @@ def _supported_local_extensions() -> list[str]:
return [".png", ".jpg", ".gif", ".webp", ".bmp", ".ico", ".tif", ".tiff"]
@staticmethod
- def _local_file_dialog_filter() -> str:
- return "Supported Images (*.png *.jpg);;All Files (*)"
+ def _local_import_dialog_filter() -> str:
+ return "Supported Images and ZIPs (*.png *.jpg *.zip);;All Files (*)"
def _register_assets(self, assets: list[AssetRecord], *, set_active: bool) -> None:
self.registered_assets.append((list(assets), bool(set_active)))
@@ -102,11 +102,40 @@ def test_import_files_registers_assets_and_reports_summary(self) -> None:
self.assertEqual(True, kwargs.get("flatten"))
self.assertEqual(True, kwargs.get("dedupe_by_hash"))
self.assertEqual(1, len(window.registered_assets))
- self.assertIn("Imported files: 1 asset(s)", window.status_messages[-1])
+ self.assertIn("Added files: 1 asset(s)", window.status_messages[-1])
self.assertIn("1 duplicate(s) skipped", window.status_messages[-1])
self.assertIn("1 unsupported file(s) skipped", window.status_messages[-1])
- def test_import_zip_archive_reports_extract_error(self) -> None:
+ def test_import_files_expands_zip_and_regular_images_through_one_call(self) -> None:
+ summary = LocalImportSummary(
+ assets=[_asset("asset-a")],
+ duplicates=[],
+ unsupported=[],
+ raw_result=LocalIngestResult(),
+ )
+ controller = _FakeController(summary)
+ window = _FakeWindow(controller=controller)
+ coordinator = LocalImportCoordinator(window)
+
+ with patch(
+ "image_engine_app.ui.main_window.local_import_coordinator.QFileDialog.getOpenFileNames",
+ return_value=(["C:/images/direct.png", "C:/images/bundle.zip"], ""),
+ ):
+ with patch.object(
+ coordinator,
+ "_extract_zip_images",
+ return_value=["C:/cache/unpacked-a.png", "C:/cache/unpacked-b.gif"],
+ ):
+ coordinator.import_files()
+
+ self.assertEqual(1, len(controller.calls))
+ imported_sources, _kwargs = controller.calls[0]
+ self.assertEqual(
+ ["C:/images/direct.png", "C:/cache/unpacked-a.png", "C:/cache/unpacked-b.gif"],
+ imported_sources,
+ )
+
+ def test_import_files_reports_zip_extract_error(self) -> None:
summary = LocalImportSummary(
assets=[],
duplicates=[],
@@ -118,19 +147,20 @@ def test_import_zip_archive_reports_extract_error(self) -> None:
coordinator = LocalImportCoordinator(window)
with patch(
- "image_engine_app.ui.main_window.local_import_coordinator.QFileDialog.getOpenFileName",
- return_value=("C:/images/broken.zip", ""),
+ "image_engine_app.ui.main_window.local_import_coordinator.QFileDialog.getOpenFileNames",
+ return_value=(["C:/images/broken.zip"], ""),
):
with patch(
"image_engine_app.ui.main_window.local_import_coordinator.extract_images_only",
side_effect=ZipExtractError("Bad ZIP file: C:/images/broken.zip"),
):
- coordinator.import_zip_archive()
+ coordinator.import_files()
self.assertEqual([], controller.calls)
self.assertEqual([], window.registered_assets)
- self.assertEqual("ZIP Import Failed", window.error_messages[-1][0])
- self.assertIn("ZIP import failed", window.status_messages[-1])
+ self.assertEqual("Some Files Could Not Be Added", window.error_messages[-1][0])
+ self.assertIn("broken.zip", window.error_messages[-1][1])
+ self.assertEqual("Add files failed: no supported images were found", window.status_messages[-1])
def test_import_without_controller_shows_status(self) -> None:
window = _FakeWindow(controller=None)
diff --git a/image_engine_app/tests/test_logging_config.py b/image_engine_app/tests/test_logging_config.py
new file mode 100644
index 0000000..58b0e44
--- /dev/null
+++ b/image_engine_app/tests/test_logging_config.py
@@ -0,0 +1,61 @@
+"""Logging startup resilience tests."""
+
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from image_engine_app.app.logging_config import _close_handlers, configure_logging
+
+
+class LoggingConfigTests(unittest.TestCase):
+ def tearDown(self) -> None:
+ _close_handlers(logging.getLogger("image_engine_app.batch"))
+ _close_handlers(logging.getLogger("image_engine_app"))
+
+ def test_configure_logging_replaces_handlers_and_writes_expected_files(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ try:
+ log_dir = Path(temp_dir) / "logs"
+ first_logger = configure_logging(log_dir)
+ second_logger = configure_logging(log_dir)
+ second_logger.info("logging test")
+ for handler in second_logger.handlers:
+ handler.flush()
+
+ self.assertIs(first_logger, second_logger)
+ self.assertEqual(2, len(second_logger.handlers))
+ self.assertTrue((log_dir / "image_engine_app.log").exists())
+ self.assertTrue((log_dir / "batch_debug.log").exists())
+ finally:
+ self.tearDown()
+
+ def test_unwritable_primary_log_uses_process_specific_fallback(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ try:
+ root = Path(temp_dir)
+ blocked_log_dir = root / "blocked"
+ blocked_log_dir.write_text("not a directory", encoding="utf-8")
+ fallback_dir = root / "fallback"
+
+ with patch(
+ "image_engine_app.app.logging_config._fallback_log_dir",
+ return_value=fallback_dir,
+ ):
+ logger = configure_logging(blocked_log_dir)
+ logger.info("fallback test")
+ for handler in logger.handlers:
+ handler.flush()
+
+ self.assertTrue((fallback_dir / f"image_engine_app_{os.getpid()}.log").exists())
+ self.assertTrue((fallback_dir / "batch_debug.log").exists())
+ finally:
+ self.tearDown()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/image_engine_app/tests/test_main_window_smoke.py b/image_engine_app/tests/test_main_window_smoke.py
index 42ebbbb..fec9bc0 100644
--- a/image_engine_app/tests/test_main_window_smoke.py
+++ b/image_engine_app/tests/test_main_window_smoke.py
@@ -20,7 +20,8 @@
from image_engine_app.app.paths import ensure_app_paths # noqa: E402
from image_engine_app.app.settings_store import save_path_preferences # noqa: E402
from image_engine_app.app.ui_controller import ImageEngineUIController # noqa: E402
-from image_engine_app.engine.models import AssetRecord, EditMode, SessionState # noqa: E402
+from image_engine_app.engine.models import AssetFormat, AssetRecord, SessionState # noqa: E402
+from image_engine_app.engine.process.edit_baseline import capture_detected_settings # noqa: E402
from image_engine_app.ui.main_window.main_window import ImageEngineMainWindow # noqa: E402
@@ -59,6 +60,26 @@ def test_main_window_construction_and_show_offscreen(self) -> None:
self.assertTrue(hasattr(window.web_sources_panel, "set_sources"))
self.assertEqual(3, len(window._page_nav_buttons))
self.assertTrue(window.findChildren(QToolButton, "shellPageRailButton"))
+ toolbar_buttons = window.findChildren(QToolButton, "toolbarMenuButton")
+ toolbar_labels = [button.text() for button in toolbar_buttons]
+ self.assertEqual(1, toolbar_labels.count("File"))
+ self.assertNotIn("Session", toolbar_labels)
+ self.assertNotIn("Import", toolbar_labels)
+ self.assertNotIn("Presets", toolbar_labels)
+ preset_actions = [action.text() for action in window.control_strip._preset_menu.actions()]
+ self.assertIn("Manage Presets...", preset_actions)
+ file_button = next(button for button in toolbar_buttons if button.text() == "File")
+ file_actions = [action.text() for action in file_button.menu().actions() if not action.isSeparator()]
+ self.assertEqual(
+ [
+ "New Workspace",
+ "Open Workspace...",
+ "Save Workspace...",
+ "Add Files...",
+ "Add Folder...",
+ ],
+ file_actions,
+ )
self.assertFalse(hasattr(window.asset_tabs, "_import_button"))
self.assertIsNotNone(window._workspace_splitter)
self.assertIsNotNone(window._workspace_editor_splitter)
@@ -71,8 +92,16 @@ def test_main_window_construction_and_show_offscreen(self) -> None:
self.assertTrue(window._workspace_inspector_panel.isVisible())
guide = window.findChild(QTextBrowser, "shellGuideBrowser")
self.assertIsNotNone(guide)
- self.assertIn("Skip", guide.toPlainText())
- self.assertIn("remove black", guide.toPlainText().lower())
+ guide_text = guide.toPlainText()
+ self.assertIn("Skip", guide_text)
+ self.assertIn("remove black", guide_text.lower())
+ self.assertIn("1. Scan Pages", guide_text)
+ self.assertIn("2. Saved Pages", guide_text)
+ self.assertIn("3. Find Linked Pages", guide_text)
+ self.assertIn("4. Found Files and Download", guide_text)
+ self.assertIn("Only Clear Found Files empties the result basket", guide_text)
+ self.assertIn("New Workspace", guide_text)
+ self.assertIn("Add Files", guide_text)
window._set_preview_view_mode(window.preview_panel.VIEW_FINAL)
self.assertEqual(window.preview_panel.preview_view_mode(), window.preview_panel.VIEW_FINAL)
self.assertFalse(window.preview_panel._pane_containers["current"].isVisible())
@@ -86,7 +115,7 @@ def test_main_window_construction_and_show_offscreen(self) -> None:
if owns_app and app is not None:
app.quit()
- def test_new_session_updates_status_bar_without_error(self) -> None:
+ def test_new_workspace_updates_status_bar_without_error(self) -> None:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
app = QApplication.instance()
owns_app = app is None
@@ -97,14 +126,47 @@ def test_new_session_updates_status_bar_without_error(self) -> None:
window = ImageEngineMainWindow(controller=controller)
try:
- asset = AssetRecord(id="asset-reset-1", original_name="sprite.png")
- window.set_active_asset(asset)
- window.ui_state.set_mode(EditMode.EXPERT)
- window._new_session()
+ window._new_workspace()
self.assertIsNotNone(window.ui_state.session)
self.assertIsNone(window.ui_state.active_asset)
self.assertEqual([], window.workspace_assets)
- self.assertEqual(window.statusBar().currentMessage(), "New session created")
+ self.assertEqual(window.statusBar().currentMessage(), "New workspace created")
+ finally:
+ window.close()
+ if owns_app and app is not None:
+ app.quit()
+
+ def test_preset_updates_visible_controls_and_reset_restores_detected_baseline(self) -> None:
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ app = QApplication.instance()
+ owns_app = app is None
+ if app is None:
+ app = QApplication([])
+
+ controller = ImageEngineUIController()
+ window = ImageEngineMainWindow(controller=controller)
+ asset = AssetRecord(id="preset-flow", original_name="hero.png", format=AssetFormat.PNG)
+ asset.classification_tags = ["pixel_art"]
+ asset.edit_state.settings.detail.sharpen_amount = 0.07
+ capture_detected_settings(asset)
+
+ try:
+ window.set_active_asset(asset)
+ window._on_control_preset_selected("Sprite Crisp 4x")
+ app.processEvents()
+
+ self.assertEqual(400.0, asset.edit_state.settings.pixel.resize_percent)
+ self.assertEqual(400.0, window.settings_panel._resize_percent.value())
+ self.assertEqual("scale_4x", window.settings_panel._output_size.currentData())
+ self.assertNotEqual(0.07, asset.edit_state.settings.detail.sharpen_amount)
+
+ window._on_global_reset_requested()
+ app.processEvents()
+
+ self.assertEqual(100.0, asset.edit_state.settings.pixel.resize_percent)
+ self.assertEqual(100.0, window.settings_panel._resize_percent.value())
+ self.assertEqual("original", window.settings_panel._output_size.currentData())
+ self.assertEqual(0.07, asset.edit_state.settings.detail.sharpen_amount)
finally:
window.close()
if owns_app and app is not None:
@@ -142,4 +204,4 @@ def test_export_directory_restores_default_then_remembered_then_session_specific
if __name__ == "__main__":
unittest.main()
-
+
diff --git a/image_engine_app/tests/test_models.py b/image_engine_app/tests/test_models.py
index dd63629..819fdc7 100644
--- a/image_engine_app/tests/test_models.py
+++ b/image_engine_app/tests/test_models.py
@@ -172,7 +172,6 @@ def test_asset_record_round_trip(self) -> None:
quality=100,
compression_level=3,
chroma_subsampling=ChromaSubsampling.AUTO,
- palette_limit=None,
ico_sizes=[16, 32, 64, 128, 256],
strip_metadata=True,
),
@@ -194,6 +193,10 @@ def test_asset_record_round_trip(self) -> None:
pointer=0,
),
)
+ asset.detected_settings = SettingsState(
+ pixel=PixelSettings(scale_method=ScaleMethod.NEAREST, pixel_snap=True),
+ export=ExportSettings(export_profile=ExportProfile.APP_ASSET, format=ExportFormat.PNG),
+ )
payload = asset.to_dict()
json.dumps(payload) # JSON-safe check
@@ -203,6 +206,7 @@ def test_asset_record_round_trip(self) -> None:
self.assertIsInstance(restored.dimensions_original, tuple)
self.assertIsInstance(restored.created_at, datetime)
self.assertIsInstance(restored.edit_state.queued_heavy_jobs[0].tool, HeavyTool)
+ self.assertEqual(restored.detected_settings.pixel.scale_method, ScaleMethod.NEAREST)
def test_session_state_round_trip(self) -> None:
session = SessionState(
@@ -330,6 +334,6 @@ def test_extended_models_round_trip(self) -> None:
if __name__ == "__main__":
unittest.main()
-
-
-
+
+
+
diff --git a/image_engine_app/tests/test_output_size.py b/image_engine_app/tests/test_output_size.py
new file mode 100644
index 0000000..f7a18a8
--- /dev/null
+++ b/image_engine_app/tests/test_output_size.py
@@ -0,0 +1,54 @@
+"""Output-size chooser behavior tests."""
+
+from __future__ import annotations
+
+import unittest
+
+from image_engine_app.engine.models import PixelSettings
+from image_engine_app.engine.process.output_size import (
+ CUSTOM_SIZE,
+ ORIGINAL_SIZE,
+ apply_output_size_choice,
+ output_size_choice_for,
+)
+
+
+class OutputSizeTests(unittest.TestCase):
+ def test_integer_scale_uses_real_resize_percent(self) -> None:
+ pixel = PixelSettings(width=320, height=240)
+
+ self.assertTrue(apply_output_size_choice(pixel, "scale_4x"))
+
+ self.assertEqual(400.0, pixel.resize_percent)
+ self.assertIsNone(pixel.width)
+ self.assertIsNone(pixel.height)
+ self.assertEqual("scale_4x", output_size_choice_for(pixel))
+
+ def test_standard_height_preserves_ratio_by_leaving_width_auto(self) -> None:
+ pixel = PixelSettings(resize_percent=275.0, width=640, height=480)
+
+ self.assertTrue(apply_output_size_choice(pixel, "height_1080"))
+
+ self.assertEqual(100.0, pixel.resize_percent)
+ self.assertIsNone(pixel.width)
+ self.assertEqual(1080, pixel.height)
+ self.assertEqual("height_1080", output_size_choice_for(pixel))
+
+ def test_original_clears_target_dimensions(self) -> None:
+ pixel = PixelSettings(resize_percent=200.0, width=640, height=480)
+
+ apply_output_size_choice(pixel, ORIGINAL_SIZE)
+
+ self.assertEqual(100.0, pixel.resize_percent)
+ self.assertIsNone(pixel.width)
+ self.assertIsNone(pixel.height)
+ self.assertEqual(ORIGINAL_SIZE, output_size_choice_for(pixel))
+
+ def test_manual_dimensions_are_reported_as_custom(self) -> None:
+ pixel = PixelSettings(width=512, height=None)
+
+ self.assertEqual(CUSTOM_SIZE, output_size_choice_for(pixel))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/image_engine_app/tests/test_preset_manager_dialog.py b/image_engine_app/tests/test_preset_manager_dialog.py
index 91eab31..d78abd9 100644
--- a/image_engine_app/tests/test_preset_manager_dialog.py
+++ b/image_engine_app/tests/test_preset_manager_dialog.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import json
import os
import unittest
@@ -11,81 +12,95 @@
QApplication = None # type: ignore[assignment]
from image_engine_app.app.ui_controller import ImageEngineUIController # noqa: E402
+from image_engine_app.engine.models import AssetFormat, AssetRecord # noqa: E402
+from image_engine_app.engine.process.edit_baseline import capture_detected_settings # noqa: E402
from image_engine_app.ui.windows.preset_manager import PresetManagerDialog # noqa: E402
@unittest.skipIf(QApplication is None, "PySide6 not installed")
class PresetManagerDialogTests(unittest.TestCase):
- def _setup_dialog(self) -> tuple[QApplication, bool, ImageEngineUIController, PresetManagerDialog]:
+ def _setup_dialog(
+ self,
+ ) -> tuple[QApplication, bool, ImageEngineUIController, AssetRecord, PresetManagerDialog]:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
app = QApplication.instance()
owns_app = app is None
if app is None:
app = QApplication([])
+
controller = ImageEngineUIController()
- dialog = PresetManagerDialog(controller)
+ asset = AssetRecord(id="active", original_name="hero.png", format=AssetFormat.PNG)
+ asset.classification_tags = ["pixel_art", "transparent"]
+ asset.edit_state.settings.cleanup.denoise = 0.12
+ capture_detected_settings(asset)
+ asset.edit_state.settings.cleanup.denoise = 0.28
+
+ dialog = PresetManagerDialog(controller, active_asset_provider=lambda: asset)
dialog.show()
app.processEvents()
- return app, owns_app, controller, dialog
+ return app, owns_app, controller, asset, dialog
def test_system_preset_selection_shows_template_guidance(self) -> None:
- app, owns_app, controller, dialog = self._setup_dialog()
+ app, owns_app, controller, _asset, dialog = self._setup_dialog()
try:
- self.assertGreater(dialog._list.count(), 0)
selected_name = dialog._selected_preset_name()
self.assertIsNotNone(selected_name)
self.assertFalse(controller.is_user_preset(selected_name))
self.assertIn("System", dialog._preset_kind.text())
- self.assertIn("override", dialog._save_hint.text().lower())
+ self.assertIn("duplicate", dialog._save_hint.text().lower())
self.assertFalse(dialog._btn_delete.isEnabled())
finally:
dialog.close()
- if owns_app and app is not None:
+ if owns_app:
app.quit()
- def test_duplicate_selected_creates_editable_user_draft(self) -> None:
- app, owns_app, _controller, dialog = self._setup_dialog()
+ def test_new_from_active_captures_sparse_controls_and_scope(self) -> None:
+ app, owns_app, _controller, _asset, dialog = self._setup_dialog()
try:
- dialog._duplicate_selected()
- self.assertTrue(dialog._name.text().endswith(" Copy"))
- self.assertIn("new user preset", dialog._preset_kind.text().lower())
- self.assertFalse(dialog._btn_delete.isEnabled())
+ dialog._new_preset()
+ delta = json.loads(dialog._delta.toPlainText())
+
+ self.assertEqual(delta, {"cleanup": {"denoise": 0.28}})
+ self.assertEqual(dialog._formats.text(), "png")
+ self.assertIn("pixel_art", dialog._tags.text())
+ self.assertIn("Cleanup", dialog._capture_status.text())
+ self.assertTrue(dialog._name.text().endswith("Polish"))
finally:
dialog.close()
- if owns_app and app is not None:
+ if owns_app:
app.quit()
- def test_insert_example_and_format_json_roundtrip(self) -> None:
- app, owns_app, _controller, dialog = self._setup_dialog()
+ def test_advanced_editor_is_hidden_by_default_and_formats_json(self) -> None:
+ app, owns_app, _controller, _asset, dialog = self._setup_dialog()
try:
- dialog._insert_example_delta()
- self.assertIn('"cleanup"', dialog._delta.toPlainText())
+ self.assertFalse(dialog._advanced_panel.isVisible())
+ dialog._advanced_toggle.setChecked(True)
+ app.processEvents()
+ self.assertTrue(dialog._advanced_panel.isVisible())
+
dialog._delta.setPlainText('{"b":1,"a":{"z":2}}')
dialog._format_delta_json()
- formatted = dialog._delta.toPlainText()
- self.assertIn('"a"', formatted)
- self.assertIn('\n "a"', formatted)
+ self.assertIn('\n "a"', dialog._delta.toPlainText())
finally:
dialog.close()
- if owns_app and app is not None:
+ if owns_app:
app.quit()
- def test_save_current_creates_user_preset(self) -> None:
- app, owns_app, controller, dialog = self._setup_dialog()
+ def test_save_captured_controls_creates_user_preset(self) -> None:
+ app, owns_app, controller, _asset, dialog = self._setup_dialog()
try:
+ changes: list[str] = []
+ dialog.presets_changed.connect(lambda: changes.append("changed"))
dialog._new_preset()
- dialog._name.setText("My Advanced Preset")
- dialog._desc.setText("Custom tuning")
- dialog._formats.setText("png, webp")
- dialog._tags.setText("sprite_sheet, icon")
- dialog._delta.setPlainText('{"cleanup": {"denoise": 0.25}}')
+ dialog._name.setText("My Sprite Polish")
dialog._save_current()
- self.assertTrue(controller.is_user_preset("My Advanced Preset"))
- self.assertEqual("My Advanced Preset", dialog._selected_preset_name())
- self.assertIn("User", dialog._preset_kind.text())
+
+ self.assertTrue(controller.is_user_preset("My Sprite Polish"))
+ self.assertEqual("My Sprite Polish", dialog._selected_preset_name())
+ self.assertEqual(changes, ["changed"])
finally:
dialog.close()
- if owns_app and app is not None:
+ if owns_app:
app.quit()
diff --git a/image_engine_app/tests/test_preset_store.py b/image_engine_app/tests/test_preset_store.py
index 29481d9..8859baa 100644
--- a/image_engine_app/tests/test_preset_store.py
+++ b/image_engine_app/tests/test_preset_store.py
@@ -45,5 +45,5 @@ def test_roundtrip_user_presets(self) -> None:
self.assertEqual(loaded[0].settings_delta["cleanup"]["denoise"], 0.2)
self.assertTrue(loaded[1].uses_heavy_tools)
self.assertEqual(loaded[1].mode_min, EditMode.ADVANCED)
-
-
+
+
diff --git a/image_engine_app/tests/test_presets.py b/image_engine_app/tests/test_presets.py
index 6779ca9..6c47862 100644
--- a/image_engine_app/tests/test_presets.py
+++ b/image_engine_app/tests/test_presets.py
@@ -139,5 +139,5 @@ def test_mode_min_enforcement_raises(self) -> None:
if __name__ == "__main__":
unittest.main()
-
-
+
+
diff --git a/image_engine_app/tests/test_session_coordinator.py b/image_engine_app/tests/test_session_coordinator.py
index fdf1e1e..a0bd94a 100644
--- a/image_engine_app/tests/test_session_coordinator.py
+++ b/image_engine_app/tests/test_session_coordinator.py
@@ -1,4 +1,4 @@
-"""Session coordinator tests for clear/save workflows."""
+"""Session coordinator tests for safe workspace replacement workflows."""
from __future__ import annotations
@@ -76,7 +76,7 @@ def _asset(asset_id: str = "asset-a") -> AssetRecord:
@unittest.skipIf(QMessageBox is None, "PySide6 not installed")
class SessionCoordinatorTests(unittest.TestCase):
- def test_clear_session_discard_clears_workspace(self) -> None:
+ def test_new_workspace_discard_replaces_workspace(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
paths = ensure_app_paths(base_dir=temp_dir)
store = SessionStore(paths)
@@ -87,15 +87,16 @@ def test_clear_session_discard_clears_workspace(self) -> None:
"image_engine_app.ui.main_window.session_coordinator.QMessageBox.question",
return_value=QMessageBox.StandardButton.Discard,
):
- coordinator.clear_session()
+ created = coordinator.new_workspace()
+ self.assertTrue(created)
self.assertEqual(1, len(window.loaded_states))
loaded_session, loaded_assets = window.loaded_states[0]
self.assertTrue(loaded_session.session_id.startswith("session-"))
self.assertEqual([], loaded_assets)
- self.assertEqual("Session cleared", window.status_messages[-1])
+ self.assertEqual("New workspace created", window.status_messages[-1])
- def test_clear_session_save_then_cancel_save_dialog_does_not_clear(self) -> None:
+ def test_new_workspace_save_then_cancel_save_dialog_keeps_workspace(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
paths = ensure_app_paths(base_dir=temp_dir)
store = SessionStore(paths)
@@ -107,12 +108,13 @@ def test_clear_session_save_then_cancel_save_dialog_does_not_clear(self) -> None
return_value=QMessageBox.StandardButton.Save,
):
with patch("image_engine_app.ui.main_window.session_coordinator.QFileDialog.getSaveFileName", return_value=("", "")):
- coordinator.clear_session()
+ created = coordinator.new_workspace()
+ self.assertFalse(created)
self.assertEqual([], window.loaded_states)
- self.assertEqual("Clear session canceled", window.status_messages[-1])
+ self.assertEqual("New workspace canceled", window.status_messages[-1])
- def test_clear_session_without_content_skips_prompt(self) -> None:
+ def test_new_workspace_without_content_skips_prompt(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
paths = ensure_app_paths(base_dir=temp_dir)
store = SessionStore(paths)
@@ -130,11 +132,33 @@ def test_clear_session_without_content_skips_prompt(self) -> None:
coordinator = SessionCoordinator(window)
with patch("image_engine_app.ui.main_window.session_coordinator.QMessageBox.question") as mocked_prompt:
- coordinator.clear_session()
+ created = coordinator.new_workspace()
mocked_prompt.assert_not_called()
+ self.assertTrue(created)
self.assertEqual(1, len(window.loaded_states))
- self.assertEqual("Session cleared", window.status_messages[-1])
+ self.assertEqual("New workspace created", window.status_messages[-1])
+
+ def test_open_workspace_cancel_keeps_current_workspace(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ store = SessionStore(paths)
+ window = _FakeWindow(session_store=store, session=_session(), assets=[_asset()])
+ coordinator = SessionCoordinator(window)
+ target = Path(temp_dir) / "another-workspace.json"
+
+ with patch(
+ "image_engine_app.ui.main_window.session_coordinator.QFileDialog.getOpenFileName",
+ return_value=(str(target), "JSON Files (*.json)"),
+ ):
+ with patch(
+ "image_engine_app.ui.main_window.session_coordinator.QMessageBox.question",
+ return_value=QMessageBox.StandardButton.Cancel,
+ ):
+ coordinator.open_workspace_file()
+
+ self.assertEqual([], window.loaded_states)
+ self.assertEqual("Open workspace canceled", window.status_messages[-1])
if __name__ == "__main__":
diff --git a/image_engine_app/tests/test_settings_panel.py b/image_engine_app/tests/test_settings_panel.py
index bc9ca58..9bdebfe 100644
--- a/image_engine_app/tests/test_settings_panel.py
+++ b/image_engine_app/tests/test_settings_panel.py
@@ -40,15 +40,11 @@ def test_recent_controls_disable_without_active_asset(self) -> None:
try:
self.assertIsNotNone(panel._temperature)
- self.assertIsNotNone(panel._ai_bg_remove)
self.assertIsNotNone(panel._export_format)
- self.assertIsNotNone(panel._export_profile)
self.assertIsNotNone(panel._ico_sizes)
self.assertFalse(panel._temperature.isEnabled())
- self.assertFalse(panel._ai_bg_remove.isEnabled())
self.assertFalse(panel._export_format.isEnabled())
- self.assertFalse(panel._export_profile.isEnabled())
self.assertFalse(panel._ico_sizes.isEnabled())
finally:
panel.close()
@@ -64,10 +60,8 @@ def test_recent_controls_enable_with_active_asset(self) -> None:
ui_state.set_active_asset(asset)
self.assertTrue(panel._temperature.isEnabled())
- self.assertTrue(panel._ai_bg_remove.isEnabled())
self.assertTrue(panel._export_format.isEnabled())
- self.assertTrue(panel._export_profile.isEnabled())
- self.assertTrue(panel._ico_sizes.isEnabled())
+ self.assertFalse(panel._ico_sizes.isEnabled())
finally:
panel.close()
if owns_app and app is not None:
@@ -90,7 +84,7 @@ def test_ico_sizes_editor_normalizes_and_updates_export_state(self) -> None:
if owns_app and app is not None:
app.quit()
- def test_resize_change_scales_dpi(self) -> None:
+ def test_resize_change_does_not_change_metadata_dpi(self) -> None:
app, owns_app, panel, ui_state = self._setup_panel()
try:
@@ -99,15 +93,58 @@ def test_resize_change_scales_dpi(self) -> None:
panel._resize_percent.setValue(200.0)
- self.assertEqual(144, panel._dpi.value())
+ self.assertEqual(72, panel._dpi.value())
self.assertEqual(200.0, float(asset.edit_state.settings.pixel.resize_percent))
- self.assertEqual(144, int(asset.edit_state.settings.pixel.dpi))
+ self.assertEqual(72, int(asset.edit_state.settings.pixel.dpi))
finally:
panel.close()
if owns_app and app is not None:
app.quit()
- def test_dpi_change_scales_resize_percent(self) -> None:
+ def test_output_size_choice_updates_real_pixel_controls(self) -> None:
+ app, owns_app, panel, ui_state = self._setup_panel()
+
+ try:
+ asset = AssetRecord(id="asset-size", original_name="sprite.png")
+ ui_state.set_active_asset(asset)
+
+ index = panel._output_size.findData("height_720")
+ panel._output_size.setCurrentIndex(index)
+
+ self.assertEqual(100.0, asset.edit_state.settings.pixel.resize_percent)
+ self.assertIsNone(asset.edit_state.settings.pixel.width)
+ self.assertEqual(720, asset.edit_state.settings.pixel.height)
+ self.assertEqual(720, panel._target_height.value())
+
+ index = panel._output_size.findData("scale_4x")
+ panel._output_size.setCurrentIndex(index)
+
+ self.assertEqual(400.0, asset.edit_state.settings.pixel.resize_percent)
+ self.assertIsNone(asset.edit_state.settings.pixel.width)
+ self.assertIsNone(asset.edit_state.settings.pixel.height)
+ self.assertEqual(400.0, panel._resize_percent.value())
+ finally:
+ panel.close()
+ if owns_app and app is not None:
+ app.quit()
+
+ def test_manual_size_controls_switch_chooser_to_custom(self) -> None:
+ app, owns_app, panel, ui_state = self._setup_panel()
+
+ try:
+ asset = AssetRecord(id="asset-custom-size", original_name="sprite.png")
+ ui_state.set_active_asset(asset)
+
+ panel._target_width.setValue(512)
+
+ self.assertEqual("custom", panel._output_size.currentData())
+ self.assertEqual(512, asset.edit_state.settings.pixel.width)
+ finally:
+ panel.close()
+ if owns_app and app is not None:
+ app.quit()
+
+ def test_dpi_change_does_not_resize_pixels(self) -> None:
app, owns_app, panel, ui_state = self._setup_panel()
try:
@@ -116,14 +153,33 @@ def test_dpi_change_scales_resize_percent(self) -> None:
panel._dpi.setValue(144)
- self.assertEqual(200.0, float(panel._resize_percent.value()))
- self.assertEqual(200.0, float(asset.edit_state.settings.pixel.resize_percent))
+ self.assertEqual(100.0, float(panel._resize_percent.value()))
+ self.assertEqual(100.0, float(asset.edit_state.settings.pixel.resize_percent))
self.assertEqual(144, int(asset.edit_state.settings.pixel.dpi))
finally:
panel.close()
if owns_app and app is not None:
app.quit()
+ def test_visual_control_always_requests_final_refresh_for_legacy_sessions(self) -> None:
+ app, owns_app, panel, ui_state = self._setup_panel()
+
+ try:
+ asset = AssetRecord(id="asset-live-final", original_name="sprite.png")
+ asset.edit_state.auto_apply_light = False
+ ui_state.set_active_asset(asset)
+ calls: list[str] = []
+ ui_state.light_preview_requested.connect(lambda: calls.append("refresh"))
+
+ panel._brightness.setValue(0.25)
+
+ self.assertEqual(["refresh"], calls)
+ self.assertEqual(0.25, asset.edit_state.settings.color.brightness)
+ finally:
+ panel.close()
+ if owns_app and app is not None:
+ app.quit()
+
def test_white_background_mode_updates_alpha_settings(self) -> None:
app, owns_app, panel, ui_state = self._setup_panel()
@@ -150,26 +206,6 @@ def test_white_background_mode_updates_alpha_settings(self) -> None:
if owns_app and app is not None:
app.quit()
- def test_open_encoding_button_emits_signal(self) -> None:
- app, owns_app, panel, ui_state = self._setup_panel()
-
- try:
- asset = AssetRecord(id="asset-6", original_name="expert.png")
- asset.edit_state.mode = EditMode.EXPERT
- ui_state.set_active_asset(asset)
-
- self.assertIsNotNone(panel._open_encoding_window_btn)
- fired: list[str] = []
- panel.open_encoding_window_requested.connect(lambda: fired.append("open"))
-
- panel._open_encoding_window_btn.click()
-
- self.assertEqual(["open"], fired)
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
def test_header_updates_with_active_asset(self) -> None:
app, owns_app, panel, ui_state = self._setup_panel()
@@ -241,7 +277,7 @@ def test_settings_group_navigator_uses_tile_picker(self) -> None:
nav_buttons = panel._toolbox.findChildren(QToolButton, "settingsGroupNavButton")
self.assertEqual(len(SettingsPanel.GROUP_SPECS), len(nav_buttons))
- self.assertEqual(9, len(nav_buttons))
+ self.assertEqual(8, len(nav_buttons))
self.assertTrue(all(button.minimumHeight() == 76 for button in nav_buttons))
self.assertTrue(all(button.minimumWidth() == 84 for button in nav_buttons))
self.assertEqual("Pixel", nav_buttons[0].text())
@@ -313,5 +349,5 @@ def test_background_status_warns_when_gif_cutout_is_likely_wrong(self) -> None:
if __name__ == "__main__":
unittest.main()
-
-
+
+
diff --git a/image_engine_app/tests/test_settings_panel_state.py b/image_engine_app/tests/test_settings_panel_state.py
index cc8d534..1b9e0da 100644
--- a/image_engine_app/tests/test_settings_panel_state.py
+++ b/image_engine_app/tests/test_settings_panel_state.py
@@ -47,4 +47,4 @@ def test_header_state_describes_active_asset(self) -> None:
if __name__ == "__main__":
unittest.main()
-
+
diff --git a/image_engine_app/tests/test_ui_controller.py b/image_engine_app/tests/test_ui_controller.py
index e5a6ee8..4d6407d 100644
--- a/image_engine_app/tests/test_ui_controller.py
+++ b/image_engine_app/tests/test_ui_controller.py
@@ -1,7 +1,7 @@
-"""Tests for the UI action controller used by the Prompt 16 main window shell."""
-
-from __future__ import annotations
-
+"""Tests for the UI action controller used by the Prompt 16 main window shell."""
+
+from __future__ import annotations
+
import io
from pathlib import Path
import sys
@@ -10,8 +10,8 @@
import unittest
from unittest.mock import patch
import zipfile
-from urllib.error import URLError
-
+from urllib.error import URLError
+
from image_engine_app.app.paths import ensure_app_paths # noqa: E402
from image_engine_app.app.ui_controller import ImageEngineUIController # noqa: E402
from image_engine_app.app.web_sources_models import Confidence, ImportTarget, SmartOptions, WebItem # noqa: E402
@@ -22,50 +22,51 @@
ApplyTarget,
AssetFormat,
AssetRecord,
- AnalysisSummary,
- Capabilities,
- EditMode,
- ExportProfile,
- ExportFormat,
- HeavyJobSpec,
- HeavyJobStatus,
- HeavyTool,
- SourceType,
- ScaleMethod,
-)
+ AnalysisSummary,
+ Capabilities,
+ EditMode,
+ ExportProfile,
+ ExportFormat,
+ HeavyJobSpec,
+ HeavyJobStatus,
+ HeavyTool,
+ SourceType,
+ ScaleMethod,
+)
from image_engine_app.engine.process.presets_apply import PresetApplyError # noqa: E402
-
-
-def _fake_png(width: int, height: int, *, payload: bytes = b"DATA") -> bytes:
- signature = b"\x89PNG\r\n\x1a\n"
- ihdr_data = width.to_bytes(4, "big") + height.to_bytes(4, "big") + b"\x08\x06\x00\x00\x00"
- ihdr = b"\x00\x00\x00\rIHDR" + ihdr_data + b"\x00\x00\x00\x00"
- idat = len(payload).to_bytes(4, "big") + b"IDAT" + payload + b"\x00\x00\x00\x00"
- iend = b"\x00\x00\x00\x00IEND\x00\x00\x00\x00"
- return signature + ihdr + idat + iend
-
-
-
-def _fake_jpg(payload: bytes = b"DATA") -> bytes:
- return b"\xff\xd8\xff\xe0" + payload + b"\xff\xd9"
-
-
+from image_engine_app.engine.process.edit_baseline import capture_detected_settings # noqa: E402
+
+
+def _fake_png(width: int, height: int, *, payload: bytes = b"DATA") -> bytes:
+ signature = b"\x89PNG\r\n\x1a\n"
+ ihdr_data = width.to_bytes(4, "big") + height.to_bytes(4, "big") + b"\x08\x06\x00\x00\x00"
+ ihdr = b"\x00\x00\x00\rIHDR" + ihdr_data + b"\x00\x00\x00\x00"
+ idat = len(payload).to_bytes(4, "big") + b"IDAT" + payload + b"\x00\x00\x00\x00"
+ iend = b"\x00\x00\x00\x00IEND\x00\x00\x00\x00"
+ return signature + ihdr + idat + iend
+
+
+
+def _fake_jpg(payload: bytes = b"DATA") -> bytes:
+ return b"\xff\xd8\xff\xe0" + payload + b"\xff\xd9"
+
+
def _fake_gif_animated() -> bytes:
from PIL import Image # local import keeps baseline dependencies stable for non-GIF tests
-
- frames = [
- Image.new("RGBA", (12, 10), (250, 30, 30, 255)),
- Image.new("RGBA", (12, 10), (30, 250, 30, 255)),
- ]
- buffer = io.BytesIO()
- frames[0].save(
- buffer,
- format="GIF",
- save_all=True,
- append_images=frames[1:],
- duration=[80, 120],
- loop=0,
- )
+
+ frames = [
+ Image.new("RGBA", (12, 10), (250, 30, 30, 255)),
+ Image.new("RGBA", (12, 10), (30, 250, 30, 255)),
+ ]
+ buffer = io.BytesIO()
+ frames[0].save(
+ buffer,
+ format="GIF",
+ save_all=True,
+ append_images=frames[1:],
+ duration=[80, 120],
+ loop=0,
+ )
return buffer.getvalue()
@@ -75,138 +76,138 @@ def _pillow_available() -> bool:
return True
except Exception:
return False
-
-
-class _FakeResponse:
- def __init__(self, data: bytes, content_type: str) -> None:
- self._data = data
- self._offset = 0
- self.headers = {"Content-Type": content_type}
-
- def read(self, size: int = -1) -> bytes:
- if size is None or size < 0:
- size = len(self._data) - self._offset
- chunk = self._data[self._offset : self._offset + size]
- self._offset += len(chunk)
- return chunk
-
- def __enter__(self) -> _FakeResponse:
- return self
-
- def __exit__(self, exc_type, exc, tb) -> None:
- return None
-
-
+
+
+class _FakeResponse:
+ def __init__(self, data: bytes, content_type: str) -> None:
+ self._data = data
+ self._offset = 0
+ self.headers = {"Content-Type": content_type}
+
+ def read(self, size: int = -1) -> bytes:
+ if size is None or size < 0:
+ size = len(self._data) - self._offset
+ chunk = self._data[self._offset : self._offset + size]
+ self._offset += len(chunk)
+ return chunk
+
+ def __enter__(self) -> _FakeResponse:
+ return self
+
+ def __exit__(self, exc_type, exc, tb) -> None:
+ return None
+
+
def _asset(*, mode: EditMode = EditMode.ADVANCED) -> AssetRecord:
- asset = AssetRecord(
- id="asset-ui-001",
- source_type=SourceType.FILE,
- source_uri="C:/demo/sprite.png",
- original_name="sprite.png",
- format=AssetFormat.PNG,
- capabilities=Capabilities(has_alpha=True, is_animated=False, is_sheet=False, is_ico_bundle=False),
- dimensions_original=(64, 64),
- dimensions_current=(128, 128),
- dimensions_final=(256, 256),
- )
- asset.edit_state.mode = mode
- asset.edit_state.apply_target = ApplyTarget.BOTH
- asset.edit_state.sync_current_final = True
- asset.edit_state.settings.export.export_profile = ExportProfile.APP_ASSET
+ asset = AssetRecord(
+ id="asset-ui-001",
+ source_type=SourceType.FILE,
+ source_uri="C:/demo/sprite.png",
+ original_name="sprite.png",
+ format=AssetFormat.PNG,
+ capabilities=Capabilities(has_alpha=True, is_animated=False, is_sheet=False, is_ico_bundle=False),
+ dimensions_original=(64, 64),
+ dimensions_current=(128, 128),
+ dimensions_final=(256, 256),
+ )
+ asset.edit_state.mode = mode
+ asset.edit_state.apply_target = ApplyTarget.BOTH
+ asset.edit_state.sync_current_final = True
+ asset.edit_state.settings.export.export_profile = ExportProfile.APP_ASSET
return asset
class UIControllerTests(unittest.TestCase):
-
- def test_user_presets_load_upsert_delete(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- store = PresetStore(paths)
- from image_engine_app.engine.models import PresetModel, EditMode # local import keeps file tidy
-
- store.save_user_presets(
- [
- PresetModel(
- name="User A",
- description="demo",
- settings_delta={"cleanup": {"denoise": 0.12}},
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- )
- ]
- )
-
- controller = ImageEngineUIController(app_paths=paths)
- self.assertIn("User A", controller.available_preset_names())
- self.assertTrue(controller.is_user_preset("User A"))
-
- # Upsert override and delete.
- controller.upsert_user_preset(
- PresetModel(
- name="User A",
- description="updated",
- settings_delta={"detail": {"sharpen_amount": 0.25}},
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- )
- )
- self.assertEqual(controller.get_preset("User A").description, "updated")
- self.assertTrue(controller.delete_user_preset("User A"))
- self.assertNotIn("User A", controller.available_preset_names())
-
- def test_import_local_sources_returns_assets_and_filters_duplicates(self) -> None:
- controller = ImageEngineUIController()
- with tempfile.TemporaryDirectory() as temp_dir:
- root = Path(temp_dir)
- (root / "a.png").write_bytes(_fake_png(8, 8, payload=b"same"))
- (root / "b.png").write_bytes(_fake_png(8, 8, payload=b"same"))
- (root / "c.jpg").write_bytes(_fake_jpg(b"jpg"))
- (root / "note.txt").write_text("x", encoding="utf-8")
-
- summary = controller.import_local_sources([root], preserve_structure=False)
-
- self.assertEqual(len(summary.assets), 2)
- self.assertEqual(len(summary.duplicates), 1)
- self.assertEqual(len(summary.unsupported), 1)
- self.assertEqual({asset.format for asset in summary.assets}, {AssetFormat.PNG, AssetFormat.JPG})
-
- def test_import_url_source_and_webpage_scan_with_mocked_openers(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- controller = ImageEngineUIController(app_paths=paths)
-
- png_bytes = _fake_png(32, 16, payload=b"hello")
- url_summary = controller.import_url_source(
- "https://example.com/sprite.png",
- opener=lambda request, timeout=0: _FakeResponse(png_bytes, "image/png"),
- )
- self.assertEqual(url_summary.asset.source_type, SourceType.URL)
- self.assertEqual(url_summary.asset.format, AssetFormat.PNG)
- self.assertEqual(url_summary.asset.dimensions_original, (32, 16))
- self.assertEqual(url_summary.preview_detected_format, "png")
- self.assertEqual(url_summary.preview_dimensions, (32, 16))
- self.assertIsNotNone(url_summary.preview_bytes_sampled)
- self.assertTrue(Path(url_summary.asset.cache_path).exists())
-
- html = (
- "
"
- "
"
- "webp"
- ""
- ).encode("utf-8")
- scan = controller.scan_webpage_images(
- "https://example.com/gallery",
- filters=WebpageScanFilters(allowed_extensions={".png", ".webp"}),
- opener=lambda request, timeout=0: _FakeResponse(html, "text/html; charset=utf-8"),
- )
- self.assertEqual({item.url for item in scan.images}, {
- "https://example.com/a.png",
- "https://example.com/c.webp",
- })
- self.assertEqual(len(scan.filtered_out), 1)
-
+
+ def test_user_presets_load_upsert_delete(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ store = PresetStore(paths)
+ from image_engine_app.engine.models import PresetModel, EditMode # local import keeps file tidy
+
+ store.save_user_presets(
+ [
+ PresetModel(
+ name="User A",
+ description="demo",
+ settings_delta={"cleanup": {"denoise": 0.12}},
+ uses_heavy_tools=False,
+ requires_apply=False,
+ mode_min=EditMode.ADVANCED,
+ )
+ ]
+ )
+
+ controller = ImageEngineUIController(app_paths=paths)
+ self.assertIn("User A", controller.available_preset_names())
+ self.assertTrue(controller.is_user_preset("User A"))
+
+ # Upsert override and delete.
+ controller.upsert_user_preset(
+ PresetModel(
+ name="User A",
+ description="updated",
+ settings_delta={"detail": {"sharpen_amount": 0.25}},
+ uses_heavy_tools=False,
+ requires_apply=False,
+ mode_min=EditMode.ADVANCED,
+ )
+ )
+ self.assertEqual(controller.get_preset("User A").description, "updated")
+ self.assertTrue(controller.delete_user_preset("User A"))
+ self.assertNotIn("User A", controller.available_preset_names())
+
+ def test_import_local_sources_returns_assets_and_filters_duplicates(self) -> None:
+ controller = ImageEngineUIController()
+ with tempfile.TemporaryDirectory() as temp_dir:
+ root = Path(temp_dir)
+ (root / "a.png").write_bytes(_fake_png(8, 8, payload=b"same"))
+ (root / "b.png").write_bytes(_fake_png(8, 8, payload=b"same"))
+ (root / "c.jpg").write_bytes(_fake_jpg(b"jpg"))
+ (root / "note.txt").write_text("x", encoding="utf-8")
+
+ summary = controller.import_local_sources([root], preserve_structure=False)
+
+ self.assertEqual(len(summary.assets), 2)
+ self.assertEqual(len(summary.duplicates), 1)
+ self.assertEqual(len(summary.unsupported), 1)
+ self.assertEqual({asset.format for asset in summary.assets}, {AssetFormat.PNG, AssetFormat.JPG})
+
+ def test_import_url_source_and_webpage_scan_with_mocked_openers(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ controller = ImageEngineUIController(app_paths=paths)
+
+ png_bytes = _fake_png(32, 16, payload=b"hello")
+ url_summary = controller.import_url_source(
+ "https://example.com/sprite.png",
+ opener=lambda request, timeout=0: _FakeResponse(png_bytes, "image/png"),
+ )
+ self.assertEqual(url_summary.asset.source_type, SourceType.URL)
+ self.assertEqual(url_summary.asset.format, AssetFormat.PNG)
+ self.assertEqual(url_summary.asset.dimensions_original, (32, 16))
+ self.assertEqual(url_summary.preview_detected_format, "png")
+ self.assertEqual(url_summary.preview_dimensions, (32, 16))
+ self.assertIsNotNone(url_summary.preview_bytes_sampled)
+ self.assertTrue(Path(url_summary.asset.cache_path).exists())
+
+ html = (
+ ""
+ "
"
+ "webp"
+ ""
+ ).encode("utf-8")
+ scan = controller.scan_webpage_images(
+ "https://example.com/gallery",
+ filters=WebpageScanFilters(allowed_extensions={".png", ".webp"}),
+ opener=lambda request, timeout=0: _FakeResponse(html, "text/html; charset=utf-8"),
+ )
+ self.assertEqual({item.url for item in scan.images}, {
+ "https://example.com/a.png",
+ "https://example.com/c.webp",
+ })
+ self.assertEqual(len(scan.filtered_out), 1)
+
def test_import_url_source_prefers_decoded_file_dimensions_over_download_hints(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
paths = ensure_app_paths(base_dir=temp_dir)
@@ -236,435 +237,435 @@ def test_import_url_source_prefers_decoded_file_dimensions_over_download_hints(s
self.assertEqual(summary.asset.dimensions_current, (96, 64))
self.assertEqual(summary.asset.dimensions_final, (96, 64))
self.assertEqual(summary.dimensions, (96, 64))
-
-
- def test_import_url_source_falls_back_when_stream_preview_fails(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- controller = ImageEngineUIController(app_paths=paths)
- png_bytes = _fake_png(20, 10, payload=b"fallback")
-
- call_count = {"value": 0}
-
- def opener(request, timeout=0): # noqa: ANN001
- _ = (request, timeout)
- call_count["value"] += 1
- if call_count["value"] == 1:
- raise URLError("preview failed")
- return _FakeResponse(png_bytes, "image/png")
-
- summary = controller.import_url_source(
- "https://example.com/fallback.png",
- opener=opener,
- )
-
- self.assertEqual(summary.asset.format, AssetFormat.PNG)
- self.assertEqual(summary.dimensions, (20, 10))
- self.assertIsNone(summary.preview_detected_format)
- self.assertIsNone(summary.preview_dimensions)
-
- def test_import_url_source_can_disable_stream_preview(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- controller = ImageEngineUIController(app_paths=paths)
- png_bytes = _fake_png(24, 12, payload=b"no-preview")
- call_count = {"value": 0}
-
- def opener(request, timeout=0): # noqa: ANN001
- _ = (request, timeout)
- call_count["value"] += 1
- return _FakeResponse(png_bytes, "image/png")
-
- summary = controller.import_url_source(
- "https://example.com/no-preview.png",
- opener=opener,
- stream_preview=False,
- )
-
- self.assertEqual(call_count["value"], 1)
- self.assertEqual(summary.asset.format, AssetFormat.PNG)
- self.assertEqual(summary.dimensions, (24, 12))
- self.assertIsNone(summary.preview_detected_format)
- self.assertIsNone(summary.preview_dimensions)
- self.assertIsNone(summary.preview_bytes_sampled)
- self.assertIsNone(summary.preview_truncated)
-
- def test_import_url_source_sets_gif_animated_capability(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- controller = ImageEngineUIController(app_paths=paths)
- gif_bytes = _fake_gif_animated()
-
- summary = controller.import_url_source(
- "https://example.com/anim.gif",
- opener=lambda request, timeout=0: _FakeResponse(gif_bytes, "image/gif"),
- stream_preview=False,
- )
-
- self.assertEqual(summary.asset.format, AssetFormat.GIF)
- self.assertTrue(summary.asset.capabilities.is_animated)
- self.assertEqual(summary.asset.dimensions_original, (12, 10))
-
- def test_import_url_source_falls_back_from_webpage_to_first_image(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- controller = ImageEngineUIController(app_paths=paths)
-
- page_html = (
- ""
- "Sprite Page
"
- "
"
- ""
- ).encode("utf-8")
- png_bytes = _fake_png(14, 9, payload=b"page-fallback")
-
- def opener(request, timeout=0): # noqa: ANN001
- _ = timeout
- url = str(getattr(request, "full_url", request))
- if url == "https://example.com/gallery/one":
- return _FakeResponse(page_html, "text/html; charset=utf-8")
- if url == "https://example.com/sprite.png":
- return _FakeResponse(png_bytes, "image/png")
- raise URLError(f"unexpected URL: {url}")
-
- summary = controller.import_url_source(
- "https://example.com/gallery/one",
- opener=opener,
- stream_preview=False,
- allow_webpage_fallback=True,
- )
-
- self.assertEqual(summary.asset.source_uri, "https://example.com/sprite.png")
- self.assertEqual(summary.asset.format, AssetFormat.PNG)
- self.assertEqual(summary.asset.dimensions_original, (14, 9))
- self.assertIn("url_fallback:webpage_first_image", summary.asset.classification_tags)
-
- def test_load_web_sources_registry_sanitizes_missing_ids(self) -> None:
- controller = ImageEngineUIController()
- registry = controller.load_web_sources_registry(
- [
- {
- "name": "Demo Site",
- "areas": [{"label": "Main Area", "url": "https://example.com/gallery"}],
- }
- ]
- )
-
- self.assertEqual(len(registry), 1)
- self.assertEqual(registry[0]["id"], "demo_site")
- self.assertEqual(registry[0]["areas"][0]["id"], "main_area")
-
- def test_scan_web_sources_area_filters_likely_and_detects_zip_links(self) -> None:
- controller = ImageEngineUIController()
- html = (
- ""
- "
"
- "
"
- "ZIP"
- ""
- ).encode("utf-8")
-
- def opener(request, timeout=0): # noqa: ANN001
- _ = (request, timeout)
- return _FakeResponse(html, "text/html; charset=utf-8")
-
- strict = controller.scan_web_sources_area(
- "https://example.com/gallery",
- show_likely=False,
- opener=opener,
- )
- strict_urls = {item.url for item in strict.items}
- self.assertIn("https://cdn.example.com/a.png", strict_urls)
- self.assertIn("https://example.com/pack.zip", strict_urls)
- self.assertNotIn("https://cdn.example.com/image?id=42", strict_urls)
-
- likely = controller.scan_web_sources_area(
- "https://example.com/gallery",
- show_likely=True,
- opener=opener,
- )
- likely_urls = {item.url for item in likely.items}
- self.assertIn("https://cdn.example.com/image?id=42", likely_urls)
-
- def test_scan_web_sources_area_falls_back_to_likely_when_no_direct_links(self) -> None:
- controller = ImageEngineUIController()
- html = "
".encode("utf-8")
-
- def opener(request, timeout=0): # noqa: ANN001
- _ = (request, timeout)
- return _FakeResponse(html, "text/html; charset=utf-8")
-
- results = controller.scan_web_sources_area(
- "https://example.com/gallery",
- show_likely=False,
- opener=opener,
- )
-
- self.assertEqual(1, len(results.items))
- self.assertEqual("https://cdn.example.com/image?id=42", results.items[0].url)
- self.assertEqual(Confidence.LIKELY, results.items[0].confidence)
-
- def test_scan_web_sources_area_accepts_direct_url_without_html_scan(self) -> None:
- controller = ImageEngineUIController()
-
- def opener(request, timeout=0): # noqa: ANN001
- raise AssertionError(f"HTML scan opener should not run for direct URLs: {request} {timeout}")
-
- results = controller.scan_web_sources_area(
- "https://cdn.example.com/sprite.png",
- show_likely=False,
- opener=opener,
- )
-
- self.assertEqual(1, len(results.items))
- self.assertEqual("https://cdn.example.com/sprite.png", results.items[0].url)
- self.assertEqual(Confidence.DIRECT, results.items[0].confidence)
-
- def test_scan_web_sources_area_can_be_cancelled(self) -> None:
- controller = ImageEngineUIController()
-
- with self.assertRaises(WebpageScanCancelledError):
- controller.scan_web_sources_area(
- "https://example.com/gallery",
- cancel_requested=lambda: True,
- opener=lambda request, timeout=0: _FakeResponse(b"", "text/html; charset=utf-8"),
- )
-
- def test_download_web_sources_items_supports_png_zip_and_dedupe(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- controller = ImageEngineUIController(app_paths=paths)
-
- png_bytes = _fake_png(18, 18, payload=b"web")
- zip_buffer = io.BytesIO()
- with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
- archive.writestr("sprite_zip.png", _fake_png(10, 6, payload=b"zip"))
- archive.writestr("readme.txt", b"ignore")
- zip_bytes = zip_buffer.getvalue()
-
- def opener(request, timeout=0): # noqa: ANN001
- _ = timeout
- url = str(getattr(request, "full_url", request))
- if url.endswith("sprite.png"):
- return _FakeResponse(png_bytes, "image/png")
- if url.endswith("pack.zip"):
- return _FakeResponse(zip_bytes, "application/zip")
- raise URLError(f"unexpected URL: {url}")
-
- items = [
- WebItem(
- url="https://example.com/sprite.png",
- name="sprite.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- ),
- WebItem(
- url="https://example.com/pack.zip",
- name="pack.zip",
- ext=".zip",
- confidence=Confidence.DIRECT,
- ),
- ]
-
- progress_events: list[tuple[int, int, str]] = []
-
- report = controller.download_web_sources_items(
- items,
- ImportTarget.NORMAL,
- smart=SmartOptions(show_likely=False, auto_sort=False, skip_duplicates=True, allow_zip=True),
- opener=opener,
- progress_callback=lambda done, total, msg: progress_events.append((int(done), int(total), str(msg))),
- )
-
- self.assertEqual(len(report.failed), 0)
- self.assertEqual(len(report.skipped), 0)
- self.assertGreaterEqual(len(report.downloaded), 2)
- self.assertEqual(len(report.assets), 2)
- self.assertTrue(progress_events)
- self.assertEqual(progress_events[-1][0], progress_events[-1][1])
- self.assertIn("Download complete", progress_events[-1][2])
- report_second = controller.download_web_sources_items(
- items,
- ImportTarget.NORMAL,
- smart=SmartOptions(show_likely=False, auto_sort=False, skip_duplicates=True, allow_zip=True),
- opener=opener,
- )
- self.assertEqual(len(report_second.downloaded), 0)
- self.assertGreaterEqual(len(report_second.skipped), 2)
- cached_names = {asset.original_name for asset in report_second.assets}
- self.assertIn("sprite.png", cached_names)
-
- def test_download_web_sources_items_auto_sort_splits_gif_by_extension(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- controller = ImageEngineUIController(app_paths=paths)
-
- png_bytes = _fake_png(8, 8, payload=b"png")
- gif_bytes = _fake_gif_animated()
-
- def opener(request, timeout=0): # noqa: ANN001
- _ = timeout
- url = str(getattr(request, "full_url", request))
- if url.endswith("sprite_no_ext"):
- return _FakeResponse(gif_bytes, "image/gif")
- if url.endswith("sprite.png"):
- return _FakeResponse(png_bytes, "image/png")
- raise URLError(f"unexpected URL: {url}")
-
- items = [
- WebItem(
- url="https://example.com/sprite.png",
- name="sprite.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- ),
- WebItem(
- url="https://example.com/sprite_no_ext",
- name="sprite",
- ext=".gif",
- confidence=Confidence.DIRECT,
- ),
- ]
-
- report = controller.download_web_sources_items(
- items,
- ImportTarget.NORMAL,
- smart=SmartOptions(show_likely=False, auto_sort=True, skip_duplicates=False, allow_zip=True),
- opener=opener,
- )
-
- self.assertEqual(len(report.failed), 0)
- self.assertEqual(len(report.assets), 2)
-
- by_name = {asset.original_name: asset for asset in report.assets}
- self.assertIn("sprite.png", by_name)
- self.assertIn("sprite_no_ext", by_name)
-
- normal_tags = set(by_name["sprite.png"].classification_tags)
- gif_tags = set(by_name["sprite_no_ext"].classification_tags)
- self.assertIn("web_target:normal", normal_tags)
- self.assertIn("web_target:animated", gif_tags)
-
- def test_resolve_web_item_name_decodes_and_marks_shiny(self) -> None:
- name = ImageEngineUIController._resolve_web_item_name(
- "Enlarge image",
- "https://projectpokemon.org/images/shiny-sprite/venusaur-f.gif",
- )
- self.assertEqual(name, "venusaur-f_shiny.gif")
-
- query_name = ImageEngineUIController._resolve_web_item_name(
- None,
- "https://example.com/cdn/image?filename=charizard%20mega%20x%20shiny.gif",
- )
- self.assertEqual(query_name, "charizard mega x shiny.gif")
-
- def test_download_web_sources_items_skip_duplicates_keeps_distinct_urls_same_name(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- controller = ImageEngineUIController(app_paths=paths)
-
- png_a = _fake_png(10, 10, payload=b"a")
- png_b = _fake_png(10, 10, payload=b"b")
-
- def opener(request, timeout=0): # noqa: ANN001
- _ = timeout
- url = str(getattr(request, "full_url", request))
- if url.endswith("sprite_a.png"):
- return _FakeResponse(png_a, "image/png")
- if url.endswith("sprite_b.png"):
- return _FakeResponse(png_b, "image/png")
- raise URLError(f"unexpected URL: {url}")
-
- items = [
- WebItem(
- url="https://example.com/sprite_a.png",
- name="download.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- ),
- WebItem(
- url="https://example.com/sprite_b.png",
- name="download.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- ),
- ]
-
- report = controller.download_web_sources_items(
- items,
- ImportTarget.NORMAL,
- smart=SmartOptions(show_likely=False, auto_sort=False, skip_duplicates=True, allow_zip=True),
- opener=opener,
- )
-
- self.assertEqual(len(report.failed), 0)
- self.assertEqual(len(report.assets), 2)
- self.assertEqual(len(report.skipped), 0)
-
- def test_download_web_sources_items_can_cancel_mid_batch(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- controller = ImageEngineUIController(app_paths=paths)
-
- png_a = _fake_png(12, 12, payload=b"a")
- png_b = _fake_png(12, 12, payload=b"b")
-
- def opener(request, timeout=0): # noqa: ANN001
- _ = timeout
- url = str(getattr(request, "full_url", request))
- if url.endswith("sprite_a.png"):
- return _FakeResponse(png_a, "image/png")
- if url.endswith("sprite_b.png"):
- return _FakeResponse(png_b, "image/png")
- raise URLError(f"unexpected URL: {url}")
-
- items = [
- WebItem(
- url="https://example.com/sprite_a.png",
- name="sprite_a.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- ),
- WebItem(
- url="https://example.com/sprite_b.png",
- name="sprite_b.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- ),
- ]
-
- cancel_state = {"requested": False}
-
- def progress_callback(done, total, message): # noqa: ANN001
- _ = (done, total)
- if str(message).startswith("Imported:"):
- cancel_state["requested"] = True
-
- report = controller.download_web_sources_items(
- items,
- ImportTarget.NORMAL,
- smart=SmartOptions(show_likely=False, auto_sort=False, skip_duplicates=False, allow_zip=True),
- opener=opener,
- progress_callback=progress_callback,
- cancel_requested=lambda: bool(cancel_state["requested"]),
- )
-
- self.assertTrue(report.cancelled)
- self.assertEqual(len(report.assets), 1)
- self.assertEqual(len(report.downloaded), 1)
- self.assertEqual(len(report.failed), 0)
-
+
+
+ def test_import_url_source_falls_back_when_stream_preview_fails(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ controller = ImageEngineUIController(app_paths=paths)
+ png_bytes = _fake_png(20, 10, payload=b"fallback")
+
+ call_count = {"value": 0}
+
+ def opener(request, timeout=0): # noqa: ANN001
+ _ = (request, timeout)
+ call_count["value"] += 1
+ if call_count["value"] == 1:
+ raise URLError("preview failed")
+ return _FakeResponse(png_bytes, "image/png")
+
+ summary = controller.import_url_source(
+ "https://example.com/fallback.png",
+ opener=opener,
+ )
+
+ self.assertEqual(summary.asset.format, AssetFormat.PNG)
+ self.assertEqual(summary.dimensions, (20, 10))
+ self.assertIsNone(summary.preview_detected_format)
+ self.assertIsNone(summary.preview_dimensions)
+
+ def test_import_url_source_can_disable_stream_preview(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ controller = ImageEngineUIController(app_paths=paths)
+ png_bytes = _fake_png(24, 12, payload=b"no-preview")
+ call_count = {"value": 0}
+
+ def opener(request, timeout=0): # noqa: ANN001
+ _ = (request, timeout)
+ call_count["value"] += 1
+ return _FakeResponse(png_bytes, "image/png")
+
+ summary = controller.import_url_source(
+ "https://example.com/no-preview.png",
+ opener=opener,
+ stream_preview=False,
+ )
+
+ self.assertEqual(call_count["value"], 1)
+ self.assertEqual(summary.asset.format, AssetFormat.PNG)
+ self.assertEqual(summary.dimensions, (24, 12))
+ self.assertIsNone(summary.preview_detected_format)
+ self.assertIsNone(summary.preview_dimensions)
+ self.assertIsNone(summary.preview_bytes_sampled)
+ self.assertIsNone(summary.preview_truncated)
+
+ def test_import_url_source_sets_gif_animated_capability(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ controller = ImageEngineUIController(app_paths=paths)
+ gif_bytes = _fake_gif_animated()
+
+ summary = controller.import_url_source(
+ "https://example.com/anim.gif",
+ opener=lambda request, timeout=0: _FakeResponse(gif_bytes, "image/gif"),
+ stream_preview=False,
+ )
+
+ self.assertEqual(summary.asset.format, AssetFormat.GIF)
+ self.assertTrue(summary.asset.capabilities.is_animated)
+ self.assertEqual(summary.asset.dimensions_original, (12, 10))
+
+ def test_import_url_source_falls_back_from_webpage_to_first_image(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ controller = ImageEngineUIController(app_paths=paths)
+
+ page_html = (
+ ""
+ "Sprite Page
"
+ "
"
+ ""
+ ).encode("utf-8")
+ png_bytes = _fake_png(14, 9, payload=b"page-fallback")
+
+ def opener(request, timeout=0): # noqa: ANN001
+ _ = timeout
+ url = str(getattr(request, "full_url", request))
+ if url == "https://example.com/gallery/one":
+ return _FakeResponse(page_html, "text/html; charset=utf-8")
+ if url == "https://example.com/sprite.png":
+ return _FakeResponse(png_bytes, "image/png")
+ raise URLError(f"unexpected URL: {url}")
+
+ summary = controller.import_url_source(
+ "https://example.com/gallery/one",
+ opener=opener,
+ stream_preview=False,
+ allow_webpage_fallback=True,
+ )
+
+ self.assertEqual(summary.asset.source_uri, "https://example.com/sprite.png")
+ self.assertEqual(summary.asset.format, AssetFormat.PNG)
+ self.assertEqual(summary.asset.dimensions_original, (14, 9))
+ self.assertIn("url_fallback:webpage_first_image", summary.asset.classification_tags)
+
+ def test_load_web_sources_registry_sanitizes_missing_ids(self) -> None:
+ controller = ImageEngineUIController()
+ registry = controller.load_web_sources_registry(
+ [
+ {
+ "name": "Demo Site",
+ "areas": [{"label": "Main Area", "url": "https://example.com/gallery"}],
+ }
+ ]
+ )
+
+ self.assertEqual(len(registry), 1)
+ self.assertEqual(registry[0]["id"], "demo_site")
+ self.assertEqual(registry[0]["areas"][0]["id"], "main_area")
+
+ def test_scan_web_source_pages_filters_likely_and_detects_zip_links(self) -> None:
+ controller = ImageEngineUIController()
+ html = (
+ ""
+ "
"
+ "
"
+ "ZIP"
+ ""
+ ).encode("utf-8")
+
+ def opener(request, timeout=0): # noqa: ANN001
+ _ = (request, timeout)
+ return _FakeResponse(html, "text/html; charset=utf-8")
+
+ strict = controller.scan_web_source_pages(
+ ["https://example.com/gallery"],
+ show_likely=False,
+ opener=opener,
+ )
+ strict_urls = {item.url for item in strict.items}
+ self.assertIn("https://cdn.example.com/a.png", strict_urls)
+ self.assertIn("https://example.com/pack.zip", strict_urls)
+ self.assertNotIn("https://cdn.example.com/image?id=42", strict_urls)
+
+ likely = controller.scan_web_source_pages(
+ ["https://example.com/gallery"],
+ show_likely=True,
+ opener=opener,
+ )
+ likely_urls = {item.url for item in likely.items}
+ self.assertIn("https://cdn.example.com/image?id=42", likely_urls)
+
+ def test_scan_web_source_pages_falls_back_to_likely_when_no_direct_links(self) -> None:
+ controller = ImageEngineUIController()
+ html = "
".encode("utf-8")
+
+ def opener(request, timeout=0): # noqa: ANN001
+ _ = (request, timeout)
+ return _FakeResponse(html, "text/html; charset=utf-8")
+
+ results = controller.scan_web_source_pages(
+ ["https://example.com/gallery"],
+ show_likely=False,
+ opener=opener,
+ )
+
+ self.assertEqual(1, len(results.items))
+ self.assertEqual("https://cdn.example.com/image?id=42", results.items[0].url)
+ self.assertEqual(Confidence.LIKELY, results.items[0].confidence)
+
+ def test_scan_web_source_pages_accepts_direct_url_without_html_scan(self) -> None:
+ controller = ImageEngineUIController()
+
+ def opener(request, timeout=0): # noqa: ANN001
+ raise AssertionError(f"HTML scan opener should not run for direct URLs: {request} {timeout}")
+
+ results = controller.scan_web_source_pages(
+ ["https://cdn.example.com/sprite.png"],
+ show_likely=False,
+ opener=opener,
+ )
+
+ self.assertEqual(1, len(results.items))
+ self.assertEqual("https://cdn.example.com/sprite.png", results.items[0].url)
+ self.assertEqual(Confidence.DIRECT, results.items[0].confidence)
+
+ def test_scan_web_source_pages_can_be_cancelled(self) -> None:
+ controller = ImageEngineUIController()
+
+ with self.assertRaises(WebpageScanCancelledError):
+ controller.scan_web_source_pages(
+ ["https://example.com/gallery"],
+ cancel_requested=lambda: True,
+ opener=lambda request, timeout=0: _FakeResponse(b"", "text/html; charset=utf-8"),
+ )
+
+ def test_download_web_sources_items_supports_png_zip_and_dedupe(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ controller = ImageEngineUIController(app_paths=paths)
+
+ png_bytes = _fake_png(18, 18, payload=b"web")
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr("sprite_zip.png", _fake_png(10, 6, payload=b"zip"))
+ archive.writestr("readme.txt", b"ignore")
+ zip_bytes = zip_buffer.getvalue()
+
+ def opener(request, timeout=0): # noqa: ANN001
+ _ = timeout
+ url = str(getattr(request, "full_url", request))
+ if url.endswith("sprite.png"):
+ return _FakeResponse(png_bytes, "image/png")
+ if url.endswith("pack.zip"):
+ return _FakeResponse(zip_bytes, "application/zip")
+ raise URLError(f"unexpected URL: {url}")
+
+ items = [
+ WebItem(
+ url="https://example.com/sprite.png",
+ name="sprite.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ ),
+ WebItem(
+ url="https://example.com/pack.zip",
+ name="pack.zip",
+ ext=".zip",
+ confidence=Confidence.DIRECT,
+ ),
+ ]
+
+ progress_events: list[tuple[int, int, str]] = []
+
+ report = controller.download_web_sources_items(
+ items,
+ ImportTarget.NORMAL,
+ smart=SmartOptions(show_likely=False, auto_sort=False, skip_duplicates=True, allow_zip=True),
+ opener=opener,
+ progress_callback=lambda done, total, msg: progress_events.append((int(done), int(total), str(msg))),
+ )
+
+ self.assertEqual(len(report.failed), 0)
+ self.assertEqual(len(report.skipped), 0)
+ self.assertGreaterEqual(len(report.downloaded), 2)
+ self.assertEqual(len(report.assets), 2)
+ self.assertTrue(progress_events)
+ self.assertEqual(progress_events[-1][0], progress_events[-1][1])
+ self.assertIn("Download complete", progress_events[-1][2])
+ report_second = controller.download_web_sources_items(
+ items,
+ ImportTarget.NORMAL,
+ smart=SmartOptions(show_likely=False, auto_sort=False, skip_duplicates=True, allow_zip=True),
+ opener=opener,
+ )
+ self.assertEqual(len(report_second.downloaded), 0)
+ self.assertGreaterEqual(len(report_second.skipped), 2)
+ cached_names = {asset.original_name for asset in report_second.assets}
+ self.assertIn("sprite.png", cached_names)
+
+ def test_download_web_sources_items_auto_sort_splits_gif_by_extension(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ controller = ImageEngineUIController(app_paths=paths)
+
+ png_bytes = _fake_png(8, 8, payload=b"png")
+ gif_bytes = _fake_gif_animated()
+
+ def opener(request, timeout=0): # noqa: ANN001
+ _ = timeout
+ url = str(getattr(request, "full_url", request))
+ if url.endswith("sprite_no_ext"):
+ return _FakeResponse(gif_bytes, "image/gif")
+ if url.endswith("sprite.png"):
+ return _FakeResponse(png_bytes, "image/png")
+ raise URLError(f"unexpected URL: {url}")
+
+ items = [
+ WebItem(
+ url="https://example.com/sprite.png",
+ name="sprite.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ ),
+ WebItem(
+ url="https://example.com/sprite_no_ext",
+ name="sprite",
+ ext=".gif",
+ confidence=Confidence.DIRECT,
+ ),
+ ]
+
+ report = controller.download_web_sources_items(
+ items,
+ ImportTarget.NORMAL,
+ smart=SmartOptions(show_likely=False, auto_sort=True, skip_duplicates=False, allow_zip=True),
+ opener=opener,
+ )
+
+ self.assertEqual(len(report.failed), 0)
+ self.assertEqual(len(report.assets), 2)
+
+ by_name = {asset.original_name: asset for asset in report.assets}
+ self.assertIn("sprite.png", by_name)
+ self.assertIn("sprite_no_ext", by_name)
+
+ normal_tags = set(by_name["sprite.png"].classification_tags)
+ gif_tags = set(by_name["sprite_no_ext"].classification_tags)
+ self.assertIn("web_target:normal", normal_tags)
+ self.assertIn("web_target:animated", gif_tags)
+
+ def test_resolve_web_item_name_decodes_and_marks_shiny(self) -> None:
+ name = ImageEngineUIController._resolve_web_item_name(
+ "Enlarge image",
+ "https://projectpokemon.org/images/shiny-sprite/venusaur-f.gif",
+ )
+ self.assertEqual(name, "venusaur-f_shiny.gif")
+
+ query_name = ImageEngineUIController._resolve_web_item_name(
+ None,
+ "https://example.com/cdn/image?filename=charizard%20mega%20x%20shiny.gif",
+ )
+ self.assertEqual(query_name, "charizard mega x shiny.gif")
+
+ def test_download_web_sources_items_skip_duplicates_keeps_distinct_urls_same_name(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ controller = ImageEngineUIController(app_paths=paths)
+
+ png_a = _fake_png(10, 10, payload=b"a")
+ png_b = _fake_png(10, 10, payload=b"b")
+
+ def opener(request, timeout=0): # noqa: ANN001
+ _ = timeout
+ url = str(getattr(request, "full_url", request))
+ if url.endswith("sprite_a.png"):
+ return _FakeResponse(png_a, "image/png")
+ if url.endswith("sprite_b.png"):
+ return _FakeResponse(png_b, "image/png")
+ raise URLError(f"unexpected URL: {url}")
+
+ items = [
+ WebItem(
+ url="https://example.com/sprite_a.png",
+ name="download.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ ),
+ WebItem(
+ url="https://example.com/sprite_b.png",
+ name="download.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ ),
+ ]
+
+ report = controller.download_web_sources_items(
+ items,
+ ImportTarget.NORMAL,
+ smart=SmartOptions(show_likely=False, auto_sort=False, skip_duplicates=True, allow_zip=True),
+ opener=opener,
+ )
+
+ self.assertEqual(len(report.failed), 0)
+ self.assertEqual(len(report.assets), 2)
+ self.assertEqual(len(report.skipped), 0)
+
+ def test_download_web_sources_items_can_cancel_mid_batch(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ controller = ImageEngineUIController(app_paths=paths)
+
+ png_a = _fake_png(12, 12, payload=b"a")
+ png_b = _fake_png(12, 12, payload=b"b")
+
+ def opener(request, timeout=0): # noqa: ANN001
+ _ = timeout
+ url = str(getattr(request, "full_url", request))
+ if url.endswith("sprite_a.png"):
+ return _FakeResponse(png_a, "image/png")
+ if url.endswith("sprite_b.png"):
+ return _FakeResponse(png_b, "image/png")
+ raise URLError(f"unexpected URL: {url}")
+
+ items = [
+ WebItem(
+ url="https://example.com/sprite_a.png",
+ name="sprite_a.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ ),
+ WebItem(
+ url="https://example.com/sprite_b.png",
+ name="sprite_b.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ ),
+ ]
+
+ cancel_state = {"requested": False}
+
+ def progress_callback(done, total, message): # noqa: ANN001
+ _ = (done, total)
+ if str(message).startswith("Imported:"):
+ cancel_state["requested"] = True
+
+ report = controller.download_web_sources_items(
+ items,
+ ImportTarget.NORMAL,
+ smart=SmartOptions(show_likely=False, auto_sort=False, skip_duplicates=False, allow_zip=True),
+ opener=opener,
+ progress_callback=progress_callback,
+ cancel_requested=lambda: bool(cancel_state["requested"]),
+ )
+
+ self.assertTrue(report.cancelled)
+ self.assertEqual(len(report.assets), 1)
+ self.assertEqual(len(report.downloaded), 1)
+ self.assertEqual(len(report.failed), 0)
+
def test_apply_named_preset_clamps_and_queues_heavy_job(self) -> None:
controller = ImageEngineUIController()
asset = _asset(mode=EditMode.ADVANCED)
-
- summary = controller.apply_named_preset(asset, "Pixel Clean Upscale")
-
- self.assertEqual(summary.preset_name, "Pixel Clean Upscale")
- self.assertTrue(summary.requires_apply)
- self.assertEqual(summary.queued_heavy_jobs, 1)
+
+ summary = controller.apply_named_preset(asset, "Pixel Clean Upscale")
+
+ self.assertEqual(summary.preset_name, "Pixel Clean Upscale")
+ self.assertTrue(summary.requires_apply)
+ self.assertEqual(summary.queued_heavy_jobs, 1)
self.assertEqual(asset.edit_state.settings.ai.upscale_factor, 4.0) # advanced-mode clamp
- self.assertEqual(asset.edit_state.settings.export.export_profile.value, "app_asset")
+ self.assertEqual(asset.edit_state.settings.export.export_profile.value, "app_asset")
self.assertEqual(asset.edit_state.settings.export.format.value, "png")
self.assertEqual(asset.edit_state.queued_heavy_jobs[0].tool, HeavyTool.AI_UPSCALE)
self.assertEqual(asset.edit_state.queued_heavy_jobs[0].status, HeavyJobStatus.QUEUED)
@@ -692,7 +693,7 @@ def test_available_preset_entries_filter_to_compatible_asset_types(self) -> None
names = [entry.name for entry in entries]
self.assertIn("GIF Safe Cleanup", names)
- self.assertIn("GIF Outline Safe", names)
+ self.assertIn("GIF Crisp 2x", names)
self.assertNotIn("Sprite Sheet Prep", names)
self.assertNotIn("Photo Recover", names)
self.assertTrue(any("GIF" in entry.label for entry in entries if entry.name == "GIF Safe Cleanup"))
@@ -736,39 +737,77 @@ def test_detected_baseline_preset_skips_incompatible_animated_gif_suggestion(sel
controller._apply_detected_baseline_preset(asset)
self.assertEqual(asset.edit_state.settings.export.format, ExportFormat.GIF)
- self.assertEqual(asset.edit_state.settings.export.palette_limit, 256)
+ self.assertEqual(asset.edit_state.settings.gif.palette_size, 256)
self.assertEqual(len(asset.edit_state.queued_heavy_jobs), 0)
- def test_reset_asset_settings_to_defaults_clears_custom_edits(self) -> None:
+ def test_restore_asset_detected_settings_discards_only_custom_edits(self) -> None:
+ controller = ImageEngineUIController()
+ asset = _asset(mode=EditMode.ADVANCED)
+
+ asset.edit_state.settings.cleanup.denoise = 0.18
+ asset.edit_state.settings.detail.clarity = 0.12
+ capture_detected_settings(asset)
+
+ asset.edit_state.settings.pixel.resize_percent = 175.0
+ asset.edit_state.settings.detail.clarity = 0.7
+ asset.edit_state.settings.cleanup.denoise = 0.5
+ asset.edit_state.settings.ai.deblur_strength = 0.6
+ asset.dimensions_original = (48, 32)
+ asset.dimensions_current = (120, 90)
+ asset.dimensions_final = (120, 90)
+ asset.edit_state.queued_heavy_jobs.append(HeavyJobSpec(tool=HeavyTool.AI_UPSCALE))
+
+ controller.restore_asset_detected_settings(asset)
+
+ self.assertEqual(asset.edit_state.settings.pixel.resize_percent, 100.0)
+ self.assertEqual(asset.edit_state.settings.detail.clarity, 0.12)
+ self.assertEqual(asset.edit_state.settings.cleanup.denoise, 0.18)
+ self.assertEqual(asset.edit_state.settings.ai.deblur_strength, 0.0)
+ self.assertEqual(asset.dimensions_current, (48, 32))
+ self.assertEqual(asset.dimensions_final, (48, 32))
+ self.assertEqual(len(asset.edit_state.queued_heavy_jobs), 0)
+
+ def test_capture_preset_controls_saves_only_changes_from_detected_settings(self) -> None:
+ controller = ImageEngineUIController()
+ asset = _asset(mode=EditMode.ADVANCED)
+ asset.format = AssetFormat.PNG
+ asset.classification_tags = ["pixel_art", "web_source:https://example.com"]
+ asset.edit_state.settings.cleanup.denoise = 0.18
+ capture_detected_settings(asset)
+
+ asset.edit_state.settings.cleanup.denoise = 0.31
+ asset.edit_state.settings.color.contrast = 0.2
+ captured = controller.capture_preset_controls(asset)
+
+ self.assertEqual(captured.settings_delta["cleanup"], {"denoise": 0.31})
+ self.assertEqual(captured.settings_delta["color"], {"contrast": 0.2})
+ self.assertEqual(captured.changed_groups, ("color", "cleanup"))
+ self.assertEqual(captured.applies_to_formats, ("png",))
+ self.assertEqual(captured.applies_to_tags, ("pixel_art",))
+
+ def test_named_preset_replaces_stale_edits_but_keeps_detected_controls(self) -> None:
controller = ImageEngineUIController()
asset = _asset(mode=EditMode.ADVANCED)
-
- asset.edit_state.settings.pixel.resize_percent = 175.0
- asset.edit_state.settings.detail.clarity = 0.7
- asset.edit_state.settings.cleanup.denoise = 0.5
- asset.edit_state.settings.ai.deblur_strength = 0.6
- asset.dimensions_original = (48, 32)
- asset.dimensions_current = (120, 90)
- asset.dimensions_final = (120, 90)
- asset.edit_state.queued_heavy_jobs.append(HeavyJobSpec(tool=HeavyTool.AI_UPSCALE))
-
- controller.reset_asset_settings_to_defaults(asset)
-
- self.assertEqual(asset.edit_state.settings.pixel.resize_percent, 100.0)
- self.assertEqual(asset.edit_state.settings.detail.clarity, 0.0)
- self.assertEqual(asset.edit_state.settings.cleanup.denoise, 0.0)
- self.assertEqual(asset.edit_state.settings.ai.deblur_strength, 0.0)
- self.assertEqual(asset.dimensions_current, (48, 32))
- self.assertEqual(asset.dimensions_final, (48, 32))
- self.assertEqual(len(asset.edit_state.queued_heavy_jobs), 0)
-
+ asset.format = AssetFormat.PNG
+ asset.classification_tags = ["pixel_art"]
+ asset.edit_state.settings.cleanup.denoise = 0.16
+ capture_detected_settings(asset)
+
+ asset.edit_state.settings.cleanup.denoise = 0.88
+ asset.edit_state.settings.color.brightness = 0.4
+ controller.apply_named_preset(asset, "Web Quick Export")
+
+ self.assertEqual(asset.edit_state.settings.cleanup.denoise, 0.16)
+ self.assertEqual(asset.edit_state.settings.color.brightness, 0.0)
+ self.assertEqual(asset.edit_state.settings.export.format, ExportFormat.WEBP)
+
def test_apply_heavy_queue_runs_queued_jobs(self) -> None:
controller = ImageEngineUIController()
asset = _asset(mode=EditMode.ADVANCED)
controller.apply_named_preset(asset, "Pixel Clean Upscale")
-
- completed = controller.apply_heavy_queue(asset)
-
+
+ completed = controller.apply_heavy_queue(asset)
+
self.assertEqual(len(completed), 1)
self.assertEqual(completed[0].status, HeavyJobStatus.DONE)
self.assertEqual(asset.edit_state.queued_heavy_jobs[0].status, HeavyJobStatus.DONE)
@@ -801,21 +840,21 @@ def test_apply_heavy_queue_writes_real_derived_output(self) -> None:
self.assertEqual(len(completed), 1)
self.assertEqual(asset.edit_state.queued_heavy_jobs[0].status, HeavyJobStatus.DONE)
self.assertTrue(isinstance(asset.derived_final_path, str) and Path(asset.derived_final_path).exists())
- self.assertTrue(isinstance(asset.derived_current_path, str) and Path(asset.derived_current_path).exists())
+ self.assertIsNone(asset.derived_current_path)
self.assertGreaterEqual(asset.dimensions_final[0], 20)
self.assertGreaterEqual(asset.dimensions_final[1], 16)
-
+
def test_predict_and_export_active_asset(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
paths = ensure_app_paths(base_dir=temp_dir)
controller = ImageEngineUIController(app_paths=paths)
asset = _asset(mode=EditMode.ADVANCED)
- controller.apply_named_preset(asset, "Web Quick Export")
-
- prediction = controller.predict_export(asset)
- text = controller.format_prediction_text(asset)
- export_result = controller.export_active_asset(asset)
-
+ controller.apply_named_preset(asset, "Web Quick Export")
+
+ prediction = controller.predict_export(asset)
+ text = controller.format_prediction_text(asset)
+ export_result = controller.export_active_asset(asset)
+
self.assertGreater(prediction.prediction.predicted_bytes, 0)
self.assertIn(prediction.prediction.predicted_format.upper(), text)
self.assertTrue(export_result.success)
@@ -823,17 +862,17 @@ def test_predict_and_export_active_asset(self) -> None:
self.assertEqual(export_result.output_path.parent, paths.exports)
self.assertTrue(export_result.is_stub)
self.assertEqual("placeholder", export_result.fallback_kind)
-
+
def test_export_active_asset_accepts_custom_export_dir(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
paths = ensure_app_paths(base_dir=temp_dir)
controller = ImageEngineUIController(app_paths=paths)
asset = _asset(mode=EditMode.ADVANCED)
- custom_dir = Path(temp_dir) / "manual-export"
-
- export_result = controller.export_active_asset(asset, export_dir=custom_dir)
-
- self.assertTrue(export_result.success)
+ custom_dir = Path(temp_dir) / "manual-export"
+
+ export_result = controller.export_active_asset(asset, export_dir=custom_dir)
+
+ self.assertTrue(export_result.success)
self.assertTrue(export_result.output_path.exists())
self.assertEqual(export_result.output_path.parent, custom_dir)
@@ -958,7 +997,7 @@ def test_apply_light_pipeline_preserves_animated_gif_preview(self) -> None:
wrote = controller.apply_light_pipeline(asset)
self.assertTrue(wrote)
- self.assertTrue(str(asset.derived_current_path).endswith(".gif"))
+ self.assertIsNone(asset.derived_current_path)
self.assertTrue(str(asset.derived_final_path).endswith(".gif"))
with Image.open(asset.derived_final_path) as im:
@@ -971,8 +1010,8 @@ def test_select_export_source_prefers_cache_for_auto_animated_asset(self) -> Non
asset = _asset(mode=EditMode.ADVANCED)
asset.capabilities = Capabilities(has_alpha=True, is_animated=True, is_sheet=False, is_ico_bundle=False)
asset.cache_path = "C:/cache/source_anim.gif"
- asset.derived_final_path = "C:/cache/final_preview.png"
- asset.edit_state.settings.export.format = ExportFormat.AUTO
+ asset.derived_final_path = "C:/cache/final_preview.png"
+ asset.edit_state.settings.export.format = ExportFormat.AUTO
selected = controller._select_export_source_path(asset)
@@ -1020,63 +1059,75 @@ def test_export_active_asset_uses_source_uri_when_cache_missing(self) -> None:
self.assertTrue(export_result.success)
self.assertFalse(export_result.is_stub)
self.assertTrue(export_result.output_path.exists())
-
- def test_run_batch_via_controller(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- controller = ImageEngineUIController(app_paths=paths)
- a1 = _asset(mode=EditMode.ADVANCED)
- a1.id = "batch-a1"
- a1.original_name = "enemy_sprite.png"
- a1.dimensions_original = (64, 64)
- a1.dimensions_current = (64, 64)
- a1.dimensions_final = (64, 64)
- a2 = _asset(mode=EditMode.ADVANCED)
- a2.id = "batch-a2"
- a2.original_name = "photo.jpg"
- a2.format = AssetFormat.JPG
- a2.capabilities = Capabilities(has_alpha=False, is_animated=False, is_sheet=False, is_ico_bundle=False)
- a2.dimensions_original = (800, 600)
- a2.dimensions_current = (800, 600)
- a2.dimensions_final = (800, 600)
-
- events: list[object] = []
- report = controller.run_batch(
- [a1, a2],
- preview_skip_mode=True,
- auto_export=True,
- auto_preset=True,
- event_callback=events.append,
- )
-
- self.assertEqual(report.processed_count, 2)
- self.assertEqual(report.failed_count, 0)
- self.assertEqual(len(report.items), 2)
- self.assertTrue((paths.exports).exists())
- self.assertGreaterEqual(len(list(paths.exports.iterdir())), 2)
- self.assertEqual(getattr(events[0], "event_type", None), "batch_start")
- self.assertEqual(getattr(events[-1], "event_type", None), "batch_complete")
-
- def test_run_batch_via_controller_supports_cancel_callback(self) -> None:
- controller = ImageEngineUIController()
- asset = _asset(mode=EditMode.ADVANCED)
- asset.id = "batch-cancel-1"
-
- events: list[object] = []
- report = controller.run_batch(
- [asset],
- preview_skip_mode=True,
- auto_export=False,
- auto_preset=False,
- event_callback=events.append,
- cancel_requested=lambda: True,
- )
-
- self.assertTrue(report.cancelled)
- self.assertEqual(len(report.items), 0)
- self.assertEqual(getattr(events[0], "event_type", None), "batch_start")
- self.assertEqual(getattr(events[-1], "event_type", None), "batch_cancelled")
-
+
+ def test_run_batch_via_controller(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ controller = ImageEngineUIController(app_paths=paths)
+ a1 = _asset(mode=EditMode.ADVANCED)
+ a1.id = "batch-a1"
+ a1.original_name = "enemy_sprite.png"
+ a1.dimensions_original = (64, 64)
+ a1.dimensions_current = (64, 64)
+ a1.dimensions_final = (64, 64)
+ a2 = _asset(mode=EditMode.ADVANCED)
+ a2.id = "batch-a2"
+ a2.original_name = "photo.jpg"
+ a2.format = AssetFormat.JPG
+ a2.capabilities = Capabilities(has_alpha=False, is_animated=False, is_sheet=False, is_ico_bundle=False)
+ a2.dimensions_original = (800, 600)
+ a2.dimensions_current = (800, 600)
+ a2.dimensions_final = (800, 600)
+
+ events: list[object] = []
+ report = controller.run_batch(
+ [a1, a2],
+ preview_skip_mode=True,
+ auto_export=True,
+ auto_preset=True,
+ event_callback=events.append,
+ )
+
+ self.assertEqual(report.processed_count, 2)
+ self.assertEqual(report.failed_count, 0)
+ self.assertEqual(len(report.items), 2)
+ self.assertTrue((paths.exports).exists())
+ self.assertGreaterEqual(len(list(paths.exports.iterdir())), 2)
+ self.assertEqual(getattr(events[0], "event_type", None), "batch_start")
+ self.assertEqual(getattr(events[-1], "event_type", None), "batch_complete")
+
+ def test_run_batch_via_controller_supports_cancel_callback(self) -> None:
+ controller = ImageEngineUIController()
+ asset = _asset(mode=EditMode.ADVANCED)
+ asset.id = "batch-cancel-1"
+
+ events: list[object] = []
+ report = controller.run_batch(
+ [asset],
+ preview_skip_mode=True,
+ auto_export=False,
+ auto_preset=False,
+ event_callback=events.append,
+ cancel_requested=lambda: True,
+ )
+
+ self.assertTrue(report.cancelled)
+ self.assertEqual(len(report.items), 0)
+ self.assertEqual(getattr(events[0], "event_type", None), "batch_start")
+ self.assertEqual(getattr(events[-1], "event_type", None), "batch_cancelled")
+
+ def test_run_batch_uses_the_controllers_heavy_queue_factory(self) -> None:
+ heavy_queue_factory = object()
+ controller = ImageEngineUIController(heavy_queue_factory=heavy_queue_factory)
+ expected_report = SimpleNamespace(items=[])
+
+ with patch("image_engine_app.app.ui_controller.BatchRunner") as runner_class:
+ runner_class.return_value.run.return_value = expected_report
+ report = controller.run_batch([], auto_export=False, auto_preset=False)
+
+ self.assertIs(report, expected_report)
+ self.assertIs(runner_class.call_args.kwargs["heavy_queue_factory"], heavy_queue_factory)
+
def test_apply_named_preset_auto_upgrades_mode_when_required(self) -> None:
controller = ImageEngineUIController()
asset = _asset(mode=EditMode.ADVANCED)
@@ -1086,87 +1137,65 @@ def test_apply_named_preset_auto_upgrades_mode_when_required(self) -> None:
asset.classification_tags = ["photo"]
summary = controller.apply_named_preset(asset, "Photo Recover")
-
- self.assertEqual(summary.preset_name, "Photo Recover")
- self.assertEqual(asset.edit_state.mode, EditMode.ADVANCED)
-
- def test_import_url_source_hydrates_detected_baseline_controls(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- paths = ensure_app_paths(base_dir=temp_dir)
- controller = ImageEngineUIController(app_paths=paths)
- png_bytes = _fake_png(32, 16, payload=b"hydrate")
-
- summary = controller.import_url_source(
- "https://example.com/hero_sprite.png",
- opener=lambda request, timeout=0: _FakeResponse(png_bytes, "image/png"),
- stream_preview=False,
- )
-
- asset = summary.asset
- self.assertIn("pixel_art", set(asset.classification_tags))
- self.assertGreater(len(asset.recommendations.suggested_presets), 0)
- self.assertIn("Pixel Clean Upscale", [item.preset_name for item in asset.recommendations.suggested_presets])
+
+ self.assertEqual(summary.preset_name, "Photo Recover")
+ self.assertEqual(asset.edit_state.mode, EditMode.ADVANCED)
+
+ def test_import_url_source_hydrates_detected_baseline_controls(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ paths = ensure_app_paths(base_dir=temp_dir)
+ controller = ImageEngineUIController(app_paths=paths)
+ png_bytes = _fake_png(32, 16, payload=b"hydrate")
+
+ summary = controller.import_url_source(
+ "https://example.com/hero_sprite.png",
+ opener=lambda request, timeout=0: _FakeResponse(png_bytes, "image/png"),
+ stream_preview=False,
+ )
+
+ asset = summary.asset
+ self.assertIn("pixel_art", set(asset.classification_tags))
+ self.assertGreater(len(asset.recommendations.suggested_presets), 0)
+ self.assertIn("Pixel Clean Upscale", [item.preset_name for item in asset.recommendations.suggested_presets])
self.assertEqual(asset.edit_state.settings.ai.upscale_factor, 4.0)
- self.assertEqual(asset.edit_state.settings.export.export_profile, ExportProfile.APP_ASSET)
- self.assertEqual(asset.edit_state.settings.pixel.scale_method, ScaleMethod.NEAREST)
- self.assertTrue(asset.edit_state.settings.pixel.pixel_snap)
- self.assertGreater(asset.edit_state.settings.cleanup.denoise, 0.0)
- self.assertGreater(asset.edit_state.settings.detail.sharpen_amount, 0.0)
- self.assertEqual(len(asset.edit_state.queued_heavy_jobs), 0)
-
- def test_analysis_inference_clamps_and_prefills_controls(self) -> None:
- controller = ImageEngineUIController()
- asset = _asset(mode=EditMode.ADVANCED)
- asset.classification_tags = ["pixel_art"]
- asset.analysis = AnalysisSummary(
- blur_score=0.76,
- noise_score=0.62,
- compression_score=0.58,
- edge_integrity_score=0.48,
- resolution_need_score=0.95,
- gif_palette_stress=None,
- warnings=[],
- )
-
- controller._apply_analysis_inferred_control_defaults(asset)
-
- self.assertEqual(asset.edit_state.settings.pixel.scale_method, ScaleMethod.NEAREST)
- self.assertTrue(asset.edit_state.settings.pixel.pixel_snap)
- self.assertGreater(asset.edit_state.settings.cleanup.denoise, 0.0)
- self.assertGreater(asset.edit_state.settings.cleanup.artifact_removal, 0.0)
- self.assertGreater(asset.edit_state.settings.detail.sharpen_amount, 0.0)
- self.assertLessEqual(asset.edit_state.settings.ai.upscale_factor, 4.0)
- self.assertEqual(len(asset.edit_state.queued_heavy_jobs), 0)
-
- def test_upsert_user_preset_rejects_invalid_settings_delta(self) -> None:
- controller = ImageEngineUIController()
- from image_engine_app.engine.models import PresetModel, EditMode # local import keeps file tidy
-
- with self.assertRaises(ValueError):
- controller.upsert_user_preset(
- PresetModel(
- name="Broken Preset",
- description="invalid key",
- settings_delta={"not_a_real_group": {"value": 1}},
- uses_heavy_tools=False,
- requires_apply=False,
- mode_min=EditMode.ADVANCED,
- )
- )
-
-
-if __name__ == "__main__":
- unittest.main()
-
-
-
-
-
-
-
-
-
-
-
-
-
+ self.assertEqual(asset.edit_state.settings.export.export_profile, ExportProfile.APP_ASSET)
+ self.assertEqual(asset.edit_state.settings.pixel.scale_method, ScaleMethod.NEAREST)
+ self.assertTrue(asset.edit_state.settings.pixel.pixel_snap)
+ self.assertGreater(asset.edit_state.settings.cleanup.denoise, 0.0)
+ self.assertGreater(asset.edit_state.settings.detail.sharpen_amount, 0.0)
+ self.assertEqual(len(asset.edit_state.queued_heavy_jobs), 0)
+ self.assertIsNotNone(asset.detected_settings)
+ self.assertEqual(asset.detected_settings, asset.edit_state.settings)
+
+ def test_upsert_user_preset_rejects_invalid_settings_delta(self) -> None:
+ controller = ImageEngineUIController()
+ from image_engine_app.engine.models import PresetModel, EditMode # local import keeps file tidy
+
+ with self.assertRaises(ValueError):
+ controller.upsert_user_preset(
+ PresetModel(
+ name="Broken Preset",
+ description="invalid key",
+ settings_delta={"not_a_real_group": {"value": 1}},
+ uses_heavy_tools=False,
+ requires_apply=False,
+ mode_min=EditMode.ADVANCED,
+ )
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/image_engine_app/tests/test_web_sources_coordinator.py b/image_engine_app/tests/test_web_sources_coordinator.py
index ecf9ea0..39a6228 100644
--- a/image_engine_app/tests/test_web_sources_coordinator.py
+++ b/image_engine_app/tests/test_web_sources_coordinator.py
@@ -1,176 +1,157 @@
-"""Web Sources coordinator regression tests."""
-
-from __future__ import annotations
-
-from pathlib import Path
-import sys
-import unittest
-from unittest.mock import patch
-
-
-from image_engine_app.app.web_sources_models import Confidence, DownloadReport, ScanResults, WebIndexLink, WebItem # noqa: E402
-from image_engine_app.engine.models import AssetRecord, SourceType # noqa: E402
-from image_engine_app.ui.main_window import web_sources_coordinator as coordinator_module # noqa: E402
-from image_engine_app.ui.main_window.web_sources_coordinator import WebSourcesCoordinator # noqa: E402
-
-
-class _FakeSignal:
- def __init__(self) -> None:
- self._callbacks: list = []
-
- def connect(self, callback) -> None: # noqa: ANN001
- self._callbacks.append(callback)
-
- def disconnect(self, callback) -> None: # noqa: ANN001
- self._callbacks = [cb for cb in self._callbacks if cb is not callback]
-
- def emit(self) -> None:
- for callback in tuple(self._callbacks):
- callback()
-
-
-class _FakeProgressDialog:
- def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
- _ = (args, kwargs)
- self.canceled = _FakeSignal()
- self._max = 1
- self._value = 0
-
- def setWindowTitle(self, _title: str) -> None:
- return
-
- def setWindowModality(self, _modality) -> None: # noqa: ANN001
- return
-
- def setMinimumDuration(self, _ms: int) -> None:
- return
-
- def setAutoClose(self, _enabled: bool) -> None:
- return
-
- def setAutoReset(self, _enabled: bool) -> None:
- return
-
- def setLabelText(self, _text: str) -> None:
- return
-
- def setValue(self, value: int) -> None:
- self._value = int(value)
-
- def value(self) -> int:
- return self._value
-
- def setMaximum(self, value: int) -> None:
- self._max = int(value)
-
- def maximum(self) -> int:
- return self._max
-
- def show(self) -> None:
- return
-
- def hide(self) -> None:
- return
-
- def deleteLater(self) -> None:
- return
-
- def close(self) -> None:
- # Backward-compatible noop for older coordinator behavior.
- return
-
-
-class _FakeApp:
- @staticmethod
- def processEvents() -> None:
- return
-
-
+"""Web Sources coordinator contract tests."""
+
+from __future__ import annotations
+
+import unittest
+from unittest.mock import patch
+
+from image_engine_app.app.web_sources_models import (
+ Confidence,
+ DownloadReport,
+ FoundFilesStore,
+ ImportTarget,
+ ScanOrigin,
+ ScanResults,
+ SmartOptions,
+ WebDiagnosticsRequest,
+ WebDownloadRequest,
+ WebIndexLink,
+ WebItem,
+ WebLinkDiscoveryRequest,
+ WebScanRequest,
+)
+from image_engine_app.engine.models import AssetRecord, SourceType
+from image_engine_app.ui.main_window import web_sources_coordinator as coordinator_module
+from image_engine_app.ui.main_window.web_sources_coordinator import WebSourcesCoordinator
+
+
+class _FakeSignal:
+ def __init__(self) -> None:
+ self._callbacks: list = []
+
+ def connect(self, callback) -> None: # noqa: ANN001
+ self._callbacks.append(callback)
+
+ def disconnect(self, callback) -> None: # noqa: ANN001
+ self._callbacks = [entry for entry in self._callbacks if entry is not callback]
+
+
+class _FakeProgressDialog:
+ def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
+ _ = (args, kwargs)
+ self.canceled = _FakeSignal()
+ self._maximum = 1
+ self._value = 0
+
+ def setWindowTitle(self, _title: str) -> None:
+ return
+
+ def setWindowModality(self, _modality) -> None: # noqa: ANN001
+ return
+
+ def setMinimumDuration(self, _value: int) -> None:
+ return
+
+ def setAutoClose(self, _enabled: bool) -> None:
+ return
+
+ def setAutoReset(self, _enabled: bool) -> None:
+ return
+
+ def setLabelText(self, _text: str) -> None:
+ return
+
+ def setMaximum(self, value: int) -> None:
+ self._maximum = int(value)
+
+ def maximum(self) -> int:
+ return self._maximum
+
+ def setValue(self, value: int) -> None:
+ self._value = int(value)
+
+ def value(self) -> int:
+ return self._value
+
+ def show(self) -> None:
+ return
+
+ def hide(self) -> None:
+ return
+
+ def deleteLater(self) -> None:
+ return
+
+
+class _FakeApp:
+ @staticmethod
+ def processEvents() -> None:
+ return
+
+
class _FakePanel:
+ PAGE_SCAN_CAP = 100
+
def __init__(self) -> None:
- self.LINKED_PAGE_SCAN_CAP = 100
self.status_messages: list[str] = []
- self.results: ScanResults | None = None
+ self._store = FoundFilesStore()
self.index_links: tuple[WebIndexLink, ...] = ()
self.sources: list[dict] = []
- self.confirm_large_scan_result = True
- self.confirm_large_scan_calls: list[tuple[int, int]] = []
-
- def set_status(self, msg: str) -> None:
- self.status_messages.append(str(msg))
-
- def set_results(self, results: ScanResults) -> None:
- self.results = results
-
- def set_index_links(self, links: tuple[WebIndexLink, ...]) -> None:
+ self.smart = SmartOptions()
+ self.confirm_result = True
+ self.confirm_calls: list[tuple[int, int]] = []
+
+ def set_status(self, message: str) -> None:
+ self.status_messages.append(str(message))
+
+ def add_results(self, results: ScanResults): # noqa: ANN201
+ return self._store.add(results)
+
+ def found_items(self) -> tuple[WebItem, ...]:
+ return self._store.items
+
+ def set_index_links(self, links) -> None: # noqa: ANN001
self.index_links = tuple(links)
- def confirm_large_linked_page_scan(self, page_count: int, *, cap: int | None = None) -> bool:
- self.confirm_large_scan_calls.append((int(page_count), int(cap or 0)))
- return bool(self.confirm_large_scan_result)
-
- def set_sources(
- self,
- *,
- websites: list[dict],
- selected_website_id: str | None = None,
- selected_area_id: str | None = None,
- ) -> None:
- _ = (selected_website_id, selected_area_id)
- self.sources = list(websites)
-
- def selected_source_ids(self) -> tuple[str | None, str | None]:
- return None, None
-
- def smart_options(self): # noqa: ANN001
- raise AssertionError("smart_options should not be called in this test")
-
- def sources_registry(self) -> list[dict]:
- return list(self.sources)
-
-
+ def confirm_large_page_scan(self, count: int, *, cap: int | None = None) -> bool:
+ self.confirm_calls.append((int(count), int(cap or 0)))
+ return self.confirm_result
+
+ def set_sources(
+ self,
+ *,
+ websites: list[dict],
+ selected_website_id: str | None = None,
+ selected_area_id: str | None = None,
+ ) -> None:
+ _ = (selected_website_id, selected_area_id)
+ self.sources = list(websites)
+
+ def set_smart_options(self, smart: SmartOptions) -> None:
+ self.smart = smart
+
+ def smart_options(self) -> SmartOptions:
+ return self.smart
+
+ def sources_registry(self) -> list[dict]:
+ return list(self.sources)
+
+ def selected_source_ids(self) -> tuple[str | None, str | None]:
+ return None, None
+
+
class _FakeController:
app_paths = None
def __init__(self) -> None:
- self.scanned_page_urls: list[str] = []
-
- def load_web_sources_registry(self, registry=None): # noqa: ANN001
- if registry is None:
- return []
- if isinstance(registry, list):
- return list(registry)
- return []
-
- def scan_web_sources_area(self, *_args, **_kwargs) -> ScanResults:
- return ScanResults(
- items=(
- WebItem(
- url="https://cdn.example.com/a.png",
- name="a.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- ),
- ),
- filtered_count=0,
- )
+ self.scanned_urls: list[str] = []
+ self.discovery_url = ""
- def discover_web_source_index_links(self, *_args, **_kwargs) -> tuple[WebIndexLink, ...]:
- return (
- WebIndexLink(
- label="HOME Sprites: Gen 1",
- url="https://example.com/home-gen-1",
- source_page="https://example.com/index",
- ),
- WebIndexLink(
- label="Animations",
- url="https://example.com/animations",
- source_page="https://example.com/index",
- ),
- )
+ def load_web_sources_registry(self, registry=None): # noqa: ANN001
+ return list(registry) if isinstance(registry, list) else []
- def scan_web_source_pages(self, page_urls: list[str], *_args, **_kwargs) -> ScanResults:
- self.scanned_page_urls = list(page_urls)
+ def scan_web_source_pages(self, urls: list[str], **_kwargs) -> ScanResults:
+ self.scanned_urls = list(urls)
return ScanResults(
items=tuple(
WebItem(
@@ -180,501 +161,261 @@ def scan_web_source_pages(self, page_urls: list[str], *_args, **_kwargs) -> Scan
confidence=Confidence.DIRECT,
source_page=url,
)
- for index, url in enumerate(page_urls, start=1)
- ),
- filtered_count=0,
+ for index, url in enumerate(urls, start=1)
+ )
+ )
+
+ def discover_web_source_index_links(self, url: str, **_kwargs) -> tuple[WebIndexLink, ...]:
+ self.discovery_url = url
+ return (
+ WebIndexLink("Page One", f"{url.rstrip('/')}/one", source_page=url),
+ WebIndexLink("Page Two", f"{url.rstrip('/')}/two", source_page=url),
)
def download_web_sources_items(self, *_args, **_kwargs) -> DownloadReport:
asset = AssetRecord(
source_type=SourceType.WEBPAGE_ITEM,
- source_uri="https://cdn.example.com/a.png",
- cache_path="cache/a.png",
- original_name="a.png",
- )
- return DownloadReport(
- downloaded=("a.png",),
- skipped=(),
- failed=(),
- assets=(asset,),
- cancelled=False,
+ source_uri="https://cdn.example.com/a.png",
+ cache_path="cache/a.png",
+ original_name="a.png",
+ )
+ return DownloadReport(
+ downloaded=("a.png",),
+ skipped=(),
+ failed=(),
+ assets=(asset,),
)
-class _ManyLinksController(_FakeController):
- def discover_web_source_index_links(self, *_args, **_kwargs) -> tuple[WebIndexLink, ...]:
- return tuple(
- WebIndexLink(
- label=f"Page {index}",
- url=f"https://example.com/page-{index}",
- source_page="https://example.com/index",
- )
- for index in range(205)
+class _PartialFailureController(_FakeController):
+ def scan_web_source_pages(self, urls: list[str], **_kwargs) -> ScanResults:
+ self.scanned_urls = list(urls)
+ return ScanResults(
+ items=(
+ WebItem(
+ url="https://cdn.example.com/good.png",
+ name="good.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ ),
+ ),
+ failed_pages=(f"{urls[-1]}: HTTP Error 502: Bad Gateway",),
)
-class _Win10013Controller:
- app_paths = None
-
- def scan_web_sources_area(self, *_args, **_kwargs) -> ScanResults:
- raise RuntimeError("")
-
-
-class _Http403Controller:
- app_paths = None
-
- def scan_web_sources_area(self, *_args, **_kwargs) -> ScanResults:
+class _HardFailureController(_FakeController):
+ def scan_web_source_pages(self, _urls: list[str], **_kwargs) -> ScanResults:
raise RuntimeError("HTTP Error 403: Forbidden")
-class _Http502Controller:
- app_paths = None
+class _MalformedDownloadController(_FakeController):
+ def download_web_sources_items(self, *_args, **_kwargs) -> DownloadReport:
+ return DownloadReport(
+ downloaded=("a.png",),
+ skipped=(),
+ failed=(),
+ assets=("invalid",), # type: ignore[arg-type]
+ )
- def scan_web_sources_area(self, *_args, **_kwargs) -> ScanResults:
- raise RuntimeError("HTTP Error 502: Bad Gateway")
+class _FakeWindow:
+ def __init__(self, controller=None) -> None: # noqa: ANN001
+ self.controller = controller or _FakeController()
+ self.web_sources_panel = _FakePanel()
+ self.status_updates: list[str] = []
+ self.register_calls: list[tuple[list[AssetRecord], bool]] = []
-class _TimeoutController:
- app_paths = None
+ def _status(self, message: str) -> None:
+ self.status_updates.append(str(message))
- def scan_web_sources_area(self, *_args, **_kwargs) -> ScanResults:
- raise TimeoutError("timed out")
+ def _register_assets(self, assets: list[AssetRecord], *, set_active: bool) -> None:
+ self.register_calls.append((list(assets), bool(set_active)))
-class _MalformedAssetsController(_FakeController):
- def download_web_sources_items(self, *_args, **_kwargs) -> DownloadReport:
- return DownloadReport(
- downloaded=("a.png",),
- skipped=(),
- failed=(),
- assets=("not-an-asset",), # type: ignore[arg-type]
- cancelled=False,
- )
-
-
-class _FakeWindow:
- def __init__(self, controller: object | None = None) -> None:
- self.controller = controller if controller is not None else _FakeController()
- self.web_sources_panel = _FakePanel()
- self.status_updates: list[str] = []
- self.register_calls: list[tuple[list[AssetRecord], bool]] = []
-
- def _status(self, msg: str) -> None:
- self.status_updates.append(str(msg))
-
- def _register_assets(self, assets: list[AssetRecord], *, set_active: bool) -> None:
- self.register_calls.append((list(assets), bool(set_active)))
-
-
-class WebSourcesCoordinatorRegressionTests(unittest.TestCase):
- def test_scan_not_marked_cancelled_when_progress_close_emits_canceled_signal(self) -> None:
- window = _FakeWindow()
- coordinator = WebSourcesCoordinator(window)
-
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_scan_requested(
- {
- "area_url": "https://example.com/gallery",
- "website_id": None,
- "area_id": None,
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
- )
-
- self.assertIsNotNone(window.web_sources_panel.results)
- self.assertTrue(any("scan complete" in msg.lower() for msg in window.status_updates))
- self.assertFalse(any("scan cancelled" in msg.lower() for msg in window.status_updates))
-
- def test_index_scan_sets_discovered_links(self) -> None:
+class WebSourcesCoordinatorTests(unittest.TestCase):
+ def _progress_patches(self): # noqa: ANN201
+ return (
+ patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog),
+ patch.object(coordinator_module, "QApplication", _FakeApp),
+ )
+
+ @staticmethod
+ def _scan_request(*urls: str) -> WebScanRequest:
+ return WebScanRequest(
+ urls=tuple(urls),
+ smart=SmartOptions(show_likely=True),
+ origin=ScanOrigin.ENTERED,
+ )
+
+ def test_all_page_sources_use_the_one_scan_call_and_accumulate(self) -> None:
window = _FakeWindow()
coordinator = WebSourcesCoordinator(window)
-
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_index_links_requested(
- {
- "index_url": "https://example.com/index",
- "website_id": None,
- "area_id": None,
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
+ first_patch, second_patch = self._progress_patches()
+ with first_patch, second_patch:
+ coordinator.on_scan_requested(self._scan_request("https://example.com/one"))
+ coordinator.on_scan_requested(
+ WebScanRequest(
+ urls=("https://example.com/two", "https://example.com/three"),
+ origin=ScanOrigin.SAVED,
+ )
)
- self.assertEqual(2, len(window.web_sources_panel.index_links))
- self.assertTrue(any("index scan complete" in msg.lower() for msg in window.status_updates))
+ self.assertEqual(
+ ["https://example.com/two", "https://example.com/three"],
+ window.controller.scanned_urls,
+ )
+ self.assertEqual(2, len(window.web_sources_panel.found_items()))
+ self.assertTrue(any("scan complete" in message.lower() for message in window.status_updates))
- def test_multi_scan_sets_merged_results(self) -> None:
+ def test_scan_dedupes_request_urls_before_service_call(self) -> None:
window = _FakeWindow()
coordinator = WebSourcesCoordinator(window)
-
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_multi_scan_requested(
- {
- "pages": [
- {
- "label": "HOME Sprites: Gen 1",
- "url": "https://example.com/home-gen-1",
- "source_page": "https://example.com/index",
- },
- {
- "label": "Animations",
- "url": "https://example.com/animations",
- "source_page": "https://example.com/index",
- },
- ],
- "website_id": None,
- "area_id": None,
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
+ first_patch, second_patch = self._progress_patches()
+ with first_patch, second_patch:
+ coordinator.on_scan_requested(
+ self._scan_request(
+ "https://example.com/page",
+ "https://example.com/page",
+ )
)
+ self.assertEqual(["https://example.com/page"], window.controller.scanned_urls)
- self.assertIsNotNone(window.web_sources_panel.results)
- assert window.web_sources_panel.results is not None
- self.assertEqual(2, len(window.web_sources_panel.results.items))
- self.assertTrue(any("multi-page scan complete" in msg.lower() for msg in window.status_updates))
-
- def test_index_scan_all_discovers_links_and_scans_every_page(self) -> None:
+ def test_large_scan_uses_one_central_warning_and_cap(self) -> None:
+ window = _FakeWindow()
+ coordinator = WebSourcesCoordinator(window)
+ urls = tuple(f"https://example.com/{index}" for index in range(150))
+ first_patch, second_patch = self._progress_patches()
+ with first_patch, second_patch:
+ coordinator.on_scan_requested(self._scan_request(*urls))
+ self.assertEqual([(150, 100)], window.web_sources_panel.confirm_calls)
+ self.assertEqual(100, len(window.controller.scanned_urls))
+
+ def test_large_scan_can_be_cancelled_before_network_call(self) -> None:
window = _FakeWindow()
+ window.web_sources_panel.confirm_result = False
coordinator = WebSourcesCoordinator(window)
+ coordinator.on_scan_requested(
+ self._scan_request(*(f"https://example.com/{index}" for index in range(101)))
+ )
+ self.assertEqual([], window.controller.scanned_urls)
+ self.assertIn("cancelled before starting", window.web_sources_panel.status_messages[-1])
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_index_scan_all_requested(
- {
- "index_url": "https://example.com/index",
- "website_id": None,
- "area_id": None,
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
+ def test_partial_failures_keep_successes_and_receive_plain_error_text(self) -> None:
+ window = _FakeWindow(_PartialFailureController())
+ coordinator = WebSourcesCoordinator(window)
+ first_patch, second_patch = self._progress_patches()
+ with first_patch, second_patch:
+ coordinator.on_scan_requested(
+ self._scan_request("https://example.com/good", "https://example.com/bad")
)
-
- self.assertEqual(2, len(window.web_sources_panel.index_links))
- self.assertIsNotNone(window.web_sources_panel.results)
- assert window.web_sources_panel.results is not None
- self.assertEqual(2, len(window.web_sources_panel.results.items))
- self.assertTrue(any("linked-page scan complete" in msg.lower() for msg in window.status_updates))
-
- def test_index_scan_all_warns_and_caps_large_link_sets(self) -> None:
- controller = _ManyLinksController()
- window = _FakeWindow(controller=controller)
+ self.assertEqual(1, len(window.web_sources_panel.found_items()))
+ failure = window.web_sources_panel._store.results.failed_pages[0]
+ self.assertIn("HTTP 502", failure)
+ self.assertIn("scan fewer pages", failure)
+
+ def test_hard_scan_failure_is_mapped_without_clearing_existing_results(self) -> None:
+ window = _FakeWindow(_HardFailureController())
+ window.web_sources_panel._store.add(
+ ScanResults(
+ items=(
+ WebItem(
+ url="https://cdn.example.com/existing.png",
+ name="existing.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ ),
+ )
+ )
+ )
coordinator = WebSourcesCoordinator(window)
+ first_patch, second_patch = self._progress_patches()
+ with first_patch, second_patch:
+ coordinator.on_scan_requested(self._scan_request("https://example.com/blocked"))
+ self.assertEqual(1, len(window.web_sources_panel.found_items()))
+ self.assertIn("HTTP 403", window.web_sources_panel.status_messages[-1])
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_index_scan_all_requested(
- {
- "index_url": "https://example.com/index",
- "website_id": None,
- "area_id": None,
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
+ def test_discovery_has_its_own_call_and_only_updates_linked_pages(self) -> None:
+ window = _FakeWindow()
+ coordinator = WebSourcesCoordinator(window)
+ first_patch, second_patch = self._progress_patches()
+ with first_patch, second_patch:
+ coordinator.on_discover_links_requested(
+ WebLinkDiscoveryRequest(url="https://example.com/index")
)
+ self.assertEqual("https://example.com/index", window.controller.discovery_url)
+ self.assertEqual(2, len(window.web_sources_panel.index_links))
+ self.assertEqual(0, len(window.web_sources_panel.found_items()))
- self.assertEqual([(205, 100)], window.web_sources_panel.confirm_large_scan_calls)
- self.assertEqual(100, len(controller.scanned_page_urls))
- self.assertEqual("https://example.com/page-0", controller.scanned_page_urls[0])
- self.assertEqual("https://example.com/page-99", controller.scanned_page_urls[-1])
-
- def test_index_scan_all_can_be_cancelled_after_large_link_warning(self) -> None:
- controller = _ManyLinksController()
- window = _FakeWindow(controller=controller)
- window.web_sources_panel.confirm_large_scan_result = False
+ def test_download_registers_only_valid_assets_into_workspace(self) -> None:
+ window = _FakeWindow()
coordinator = WebSourcesCoordinator(window)
-
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_index_scan_all_requested(
- {
- "index_url": "https://example.com/index",
- "website_id": None,
- "area_id": None,
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
+ item = WebItem(
+ url="https://cdn.example.com/a.png",
+ name="a.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ )
+ first_patch, second_patch = self._progress_patches()
+ with first_patch, second_patch:
+ coordinator.on_download_requested(
+ WebDownloadRequest(items=(item,), target=ImportTarget.NORMAL)
)
+ self.assertEqual(1, len(window.register_calls))
+ self.assertEqual("a.png", window.register_calls[0][0][0].original_name)
- self.assertEqual([(205, 100)], window.web_sources_panel.confirm_large_scan_calls)
- self.assertEqual([], controller.scanned_page_urls)
- self.assertTrue(any("cancelled before starting" in msg.lower() for msg in window.status_updates))
-
- def test_scan_winerror_10013_maps_to_friendly_message(self) -> None:
- window = _FakeWindow(controller=_Win10013Controller())
- coordinator = WebSourcesCoordinator(window)
-
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_scan_requested(
- {
- "area_url": "https://example.com/gallery",
- "website_id": None,
- "area_id": None,
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
- )
-
- self.assertTrue(window.web_sources_panel.status_messages)
- latest = window.web_sources_panel.status_messages[-1]
- self.assertIn("Scan failed:", latest)
- self.assertIn("Windows blocked network access (WinError 10013)", latest)
-
- def test_scan_http_403_maps_to_friendly_message(self) -> None:
- window = _FakeWindow(controller=_Http403Controller())
- coordinator = WebSourcesCoordinator(window)
-
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_scan_requested(
- {
- "area_url": "https://example.com/gallery",
- "website_id": None,
- "area_id": None,
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
- )
-
- self.assertTrue(window.web_sources_panel.status_messages)
- latest = window.web_sources_panel.status_messages[-1]
- self.assertIn("Scan failed:", latest)
- self.assertIn("HTTP 403 (Forbidden)", latest)
-
- def test_scan_http_502_maps_to_server_failure_message(self) -> None:
- window = _FakeWindow(controller=_Http502Controller())
+ def test_download_ignores_malformed_report_assets_without_crashing(self) -> None:
+ window = _FakeWindow(_MalformedDownloadController())
coordinator = WebSourcesCoordinator(window)
+ item = WebItem(
+ url="https://cdn.example.com/a.png",
+ name="a.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ )
+ first_patch, second_patch = self._progress_patches()
+ with first_patch, second_patch:
+ coordinator.on_download_requested(WebDownloadRequest(items=(item,)))
+ self.assertEqual([], window.register_calls)
+ self.assertTrue(any("downloaded 1" in message.lower() for message in window.status_updates))
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_scan_requested(
- {
- "area_url": "https://example.com/gallery",
- "website_id": None,
- "area_id": None,
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
- )
-
- self.assertIsNotNone(window.web_sources_panel.results)
- assert window.web_sources_panel.results is not None
- self.assertEqual((), window.web_sources_panel.results.items)
- self.assertEqual(1, len(window.web_sources_panel.results.failed_pages))
- self.assertIn("https://example.com/gallery", window.web_sources_panel.results.failed_pages[0])
- self.assertIn("HTTP 502 (Bad Gateway)", window.web_sources_panel.results.failed_pages[0])
- self.assertIn("website/server failed", window.web_sources_panel.results.failed_pages[0])
- self.assertIn("failed page", window.status_updates[-1])
-
- def test_single_page_timeout_sets_failed_page_results_instead_of_hard_fail(self) -> None:
- window = _FakeWindow(controller=_TimeoutController())
+ def test_diagnostics_uses_only_the_diagnostics_call(self) -> None:
+ window = _FakeWindow()
coordinator = WebSourcesCoordinator(window)
+ with patch.object(coordinator, "_diagnostics_summary_for_url", return_value="Connection check passed"):
+ coordinator.on_diagnostics_requested(WebDiagnosticsRequest(url="https://example.com"))
+ self.assertEqual("Connection check passed", window.web_sources_panel.status_messages[-1])
+ self.assertEqual([], window.controller.scanned_urls)
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_scan_requested(
- {
- "area_url": "https://example.com/gallery",
- "website_id": None,
- "area_id": None,
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
+ def test_registry_update_accepts_empty_list_without_touching_results(self) -> None:
+ window = _FakeWindow()
+ window.web_sources_panel.sources = [{"id": "old"}]
+ window.web_sources_panel._store.add(
+ ScanResults(
+ items=(
+ WebItem(
+ url="https://cdn.example.com/a.png",
+ name="a.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ ),
+ )
)
+ )
+ coordinator = WebSourcesCoordinator(window)
+ coordinator.on_registry_changed([])
+ self.assertEqual([], window.web_sources_panel.sources)
+ self.assertEqual(1, len(window.web_sources_panel.found_items()))
- self.assertIsNotNone(window.web_sources_panel.results)
- assert window.web_sources_panel.results is not None
- self.assertEqual((), window.web_sources_panel.results.items)
- self.assertEqual(1, len(window.web_sources_panel.results.failed_pages))
- self.assertIn("https://example.com/gallery", window.web_sources_panel.results.failed_pages[0])
- self.assertIn("failed page", window.status_updates[-1])
-
- def test_download_registers_assets_into_workspace(self) -> None:
- window = _FakeWindow(controller=_FakeController())
+ def test_invalid_request_types_do_not_call_the_wrong_operation(self) -> None:
+ window = _FakeWindow()
coordinator = WebSourcesCoordinator(window)
-
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_download_requested(
- {
- "items": [
- {
- "url": "https://cdn.example.com/a.png",
- "name": "a.png",
- "ext": ".png",
- "confidence": "direct",
- }
- ],
- "target": "normal",
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
- )
-
- self.assertEqual(1, len(window.register_calls))
- assets, set_active = window.register_calls[0]
- self.assertTrue(set_active)
- self.assertEqual(1, len(assets))
- self.assertIn("workspace loaded 1", window.web_sources_panel.status_messages[-1].lower())
-
- def test_download_with_malformed_assets_does_not_crash(self) -> None:
- window = _FakeWindow(controller=_MalformedAssetsController())
- coordinator = WebSourcesCoordinator(window)
-
- with patch.object(coordinator_module, "QProgressDialog", _FakeProgressDialog), patch.object(
- coordinator_module,
- "QApplication",
- _FakeApp,
- ):
- coordinator.on_download_requested(
- {
- "items": [
- {
- "url": "https://cdn.example.com/a.png",
- "name": "a.png",
- "ext": ".png",
- "confidence": "direct",
- }
- ],
- "target": "normal",
- "smart": {
- "show_likely": False,
- "auto_sort": False,
- "skip_duplicates": True,
- "allow_zip": True,
- },
- }
- )
-
- self.assertEqual([], window.register_calls)
- self.assertIn("workspace loaded 0", window.web_sources_panel.status_messages[-1].lower())
-
- def test_network_diagnostics_updates_status(self) -> None:
- window = _FakeWindow()
- coordinator = WebSourcesCoordinator(window)
-
- with patch.object(
- WebSourcesCoordinator,
- "_diagnostics_summary_for_url",
- return_value="Network diagnostics OK: DNS + TCP + HTTP 200 for example.com:443",
- ):
- coordinator.on_network_diagnostics_requested({"area_url": "https://example.com/sprites"})
-
- self.assertTrue(window.web_sources_panel.status_messages)
- self.assertIn("Network diagnostics OK", window.web_sources_panel.status_messages[-1])
- self.assertTrue(window.status_updates)
- self.assertIn("Network diagnostics OK", window.status_updates[-1])
-
- def test_network_diagnostics_error_is_mapped(self) -> None:
- window = _FakeWindow()
- coordinator = WebSourcesCoordinator(window)
-
- with patch.object(
- WebSourcesCoordinator,
- "_diagnostics_summary_for_url",
- side_effect=RuntimeError(""),
- ):
- coordinator.on_network_diagnostics_requested({"area_url": "https://example.com/sprites"})
-
- self.assertTrue(window.web_sources_panel.status_messages)
- self.assertIn("Network diagnostics failed", window.web_sources_panel.status_messages[-1])
- self.assertIn("WinError 10013", window.web_sources_panel.status_messages[-1])
-
- def test_registry_changed_allows_empty_list_and_clears_sources(self) -> None:
- window = _FakeWindow()
- coordinator = WebSourcesCoordinator(window)
-
- coordinator.on_registry_changed([])
-
- self.assertEqual([], window.web_sources_panel.sources)
- self.assertFalse(
- any("empty after validation" in msg.lower() for msg in window.web_sources_panel.status_messages)
- )
-
-
-if __name__ == "__main__":
- unittest.main()
-
-
+ coordinator.on_scan_requested({"urls": ["https://example.com"]})
+ coordinator.on_discover_links_requested(self._scan_request("https://example.com"))
+ coordinator.on_download_requested(WebDiagnosticsRequest(url="https://example.com"))
+ self.assertEqual([], window.controller.scanned_urls)
+ self.assertEqual("", window.controller.discovery_url)
+ self.assertEqual(3, len(window.web_sources_panel.status_messages))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/image_engine_app/tests/test_web_sources_panel.py b/image_engine_app/tests/test_web_sources_panel.py
index c527338..3d5d3f3 100644
--- a/image_engine_app/tests/test_web_sources_panel.py
+++ b/image_engine_app/tests/test_web_sources_panel.py
@@ -1,23 +1,32 @@
-"""Web Sources panel behavior tests."""
+"""Web Sources panel workflow tests."""
from __future__ import annotations
import os
-from pathlib import Path
-import sys
import unittest
-
+from unittest.mock import patch
try:
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QMessageBox
-except Exception: # pragma: no cover - optional dependency in some environments
+except Exception: # pragma: no cover - optional UI dependency
Qt = None # type: ignore[assignment]
QApplication = None # type: ignore[assignment]
QMessageBox = None # type: ignore[assignment]
-from image_engine_app.app.web_sources_models import Confidence, ImportTarget, ScanResults, WebIndexLink, WebItem # noqa: E402
-from image_engine_app.ui.main_window.web_sources_panel import WebSourcesPanel # noqa: E402
+from image_engine_app.app.web_sources_models import (
+ Confidence,
+ ScanOrigin,
+ ScanResults,
+ SmartOptions,
+ WebDiagnosticsRequest,
+ WebDownloadRequest,
+ WebIndexLink,
+ WebItem,
+ WebLinkDiscoveryRequest,
+ WebScanRequest,
+)
+from image_engine_app.ui.main_window.web_sources_panel import WebSourcesPanel
@unittest.skipIf(QApplication is None, "PySide6 not installed")
@@ -28,984 +37,385 @@ def _setup_panel(self) -> tuple[QApplication, bool, WebSourcesPanel]:
owns_app = app is None
if app is None:
app = QApplication([])
-
- panel = WebSourcesPanel()
- return app, owns_app, panel
-
- def test_menu_only_actions_do_not_leave_floating_button_widgets(self) -> None:
- app, owns_app, panel = self._setup_panel()
- try:
- self.assertFalse(hasattr(panel, "_scan_selected_pages_btn"))
- self.assertFalse(hasattr(panel, "_clear_manual_links_btn"))
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
- def test_add_custom_website_url_saves_exact_page_and_dedupes(self) -> None:
- app, owns_app, panel = self._setup_panel()
- panel.set_sources(
- websites=[
- {
- "id": "pokemon_db",
- "name": "PokemonDB",
- "areas": [
- {
- "id": "sprites_root",
- "label": "Sprites (All Pokemon)",
- "url": "https://pokemondb.net/sprites",
- }
- ],
- }
- ]
+ return app, owns_app, WebSourcesPanel()
+
+ @staticmethod
+ def _close(app: QApplication, owns_app: bool, panel: WebSourcesPanel) -> None:
+ panel.close()
+ if owns_app:
+ app.quit()
+
+ @staticmethod
+ def _item(name: str, *, source: str = "https://example.com/page") -> WebItem:
+ return WebItem(
+ url=f"https://cdn.example.com/{name}",
+ name=name,
+ ext="." + name.rsplit(".", 1)[-1].lower(),
+ confidence=Confidence.DIRECT,
+ source_page=source,
)
- changes: list[list[dict]] = []
- panel.registry_changed.connect(lambda payload: changes.append(payload if isinstance(payload, list) else []))
-
- try:
- panel._custom_url.setText("https://example.com/sprites/pokemon/gen1?form=alt")
- panel._add_custom_website()
-
- registry = panel.sources_registry()
- example = next((entry for entry in registry if entry.get("name") == "example.com"), None)
- self.assertIsNotNone(example)
- areas = example.get("areas") if isinstance(example, dict) else []
- self.assertEqual(1, len(areas))
-
- area_urls = [str(area.get("url", "")) for area in areas if isinstance(area, dict)]
- self.assertEqual(["https://example.com/sprites/pokemon/gen1?form=alt"], area_urls)
-
- panel._custom_url.setText("https://example.com/sprites/pokemon/gen1?form=alt")
- panel._add_custom_website()
-
- registry_after = panel.sources_registry()
- example_after = next((entry for entry in registry_after if entry.get("name") == "example.com"), None)
- self.assertIsNotNone(example_after)
- areas_after = example_after.get("areas") if isinstance(example_after, dict) else []
- self.assertEqual(1, len(areas_after))
- self.assertGreaterEqual(len(changes), 2)
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
- def test_area_dropdown_uses_compact_decoded_labels_and_tooltips(self) -> None:
- app, owns_app, panel = self._setup_panel()
- try:
- panel.set_sources(
- websites=[
- {
- "id": "project_pokemon",
- "name": "Project Pokemon",
- "areas": [
- {
- "id": "gen1",
- "label": "Home / Docs / Spriteindex 148 / 3d Models Generation 1 Pok%C3%A9mon R90",
- "url": "https://example.com/home/docs/spriteindex_148/3d-models-generation-1-pok%C3%A9mon-r90",
- }
- ],
- }
- ]
- )
-
- self.assertEqual("... / Docs / Spriteindex 148 / 3d Models Generation 1 Pokemon R90", panel._area.itemText(0))
- self.assertEqual(
- "https://example.com/home/docs/spriteindex_148/3d-models-generation-1-pok%C3%A9mon-r90",
- panel._area.itemData(0, Qt.ItemDataRole.ToolTipRole),
- )
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
- def test_added_website_page_uses_generic_url_path_label(self) -> None:
- app, owns_app, panel = self._setup_panel()
- try:
- panel._custom_url.setText("https://example.com/sprites/pokemon/gen1?form=alt")
- panel._add_custom_website()
-
- labels = [panel._area.itemText(index) for index in range(panel._area.count())]
- self.assertEqual(["Sprites / Pokemon / Gen1 (Query)"], labels)
- self.assertIn("Selected page:", panel._selected_page_hint.text())
- self.assertIn("https://example.com/sprites/pokemon/gen1?form=alt", panel._selected_page_hint.text())
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
- def test_scan_all_saved_pages_emits_multi_scan_for_every_saved_page(self) -> None:
- app, owns_app, panel = self._setup_panel()
- scans: list[object] = []
- panel.multi_scan_requested.connect(scans.append)
-
- try:
- panel.set_sources(
- websites=[
- {
- "id": "example_com",
- "name": "example.com",
- "areas": [
- {"id": "root", "label": "Root", "url": "https://example.com/"},
- {"id": "sprites", "label": "Sprites", "url": "https://example.com/sprites"},
- ],
- },
- {
- "id": "other_com",
- "name": "other.com",
- "areas": [
- {"id": "free", "label": "Freebies", "url": "https://other.com/freebies"},
- ],
- },
- ]
- )
-
- panel._emit_saved_pages_scan()
-
- self.assertEqual(1, len(scans))
- payload = scans[0]
- self.assertIsInstance(payload, dict)
- pages = payload.get("pages") if isinstance(payload, dict) else []
- self.assertEqual(
- ["https://example.com/", "https://example.com/sprites", "https://other.com/freebies"],
- [page.get("url") for page in pages],
- )
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ @staticmethod
+ def _registry() -> list[dict]:
+ return [
+ {
+ "id": "site_a",
+ "name": "site-a.example",
+ "areas": [
+ {"id": "page_a", "label": "Sprites A", "url": "https://site-a.example/sprites"},
+ {"id": "page_b", "label": "Sprites B", "url": "https://site-a.example/other"},
+ ],
+ },
+ {
+ "id": "site_b",
+ "name": "site-b.example",
+ "areas": [
+ {"id": "page_c", "label": "Sprites C", "url": "https://site-b.example/sprites"},
+ ],
+ },
+ ]
- def test_saved_page_picker_selects_and_scans_saved_area(self) -> None:
+ def test_rebuild_has_no_retired_hidden_controls_or_scan_signals(self) -> None:
app, owns_app, panel = self._setup_panel()
- scans: list[object] = []
- panel.scan_requested.connect(scans.append)
-
try:
- panel.set_sources(
- websites=[
- {
- "id": "example_com",
- "name": "example.com",
- "areas": [
- {
- "id": "root",
- "label": "Root",
- "url": "https://example.com/",
- },
- {
- "id": "sprites_gen1",
- "label": "Sprites / Gen1",
- "url": "https://example.com/sprites/gen1",
- },
- ],
- },
- {
- "id": "archive_org",
- "name": "archive.example",
- "areas": [
- {
- "id": "animated",
- "label": "Animated",
- "url": "https://archive.example/animated",
- }
- ],
- },
- ]
- )
-
- for index in range(panel._saved_page.count()):
- if panel._saved_page.itemData(index, Qt.ItemDataRole.ToolTipRole) == "https://archive.example/animated":
- panel._saved_page.setCurrentIndex(index)
- break
-
- panel._scan_saved_btn.click()
-
- self.assertEqual(1, len(scans))
- payload = scans[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- self.assertEqual("https://archive.example/animated", payload.get("area_url"))
- self.assertEqual("archive_org", payload.get("website_id"))
- self.assertEqual("animated", payload.get("area_id"))
- self.assertIn("archive.example", panel._saved_page.currentText())
+ for name in ("_website", "_area", "_saved_page", "_manual_links"):
+ self.assertFalse(hasattr(panel, name))
+ for name in ("multi_scan_requested", "index_scan_all_requested", "index_links_requested"):
+ self.assertFalse(hasattr(panel, name))
+ self.assertIsNotNone(panel._saved_tree)
+ self.assertIsNotNone(panel._results)
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_network_diagnostics_emits_payload_for_custom_url(self) -> None:
+ def test_each_more_menu_owns_only_its_section_actions(self) -> None:
app, owns_app, panel = self._setup_panel()
- diagnostics: list[object] = []
- panel.network_diagnostics_requested.connect(diagnostics.append)
-
try:
- panel._custom_url.setText("example.com/sprites")
- panel._emit_custom_url_network_diagnostics()
-
- self.assertEqual(1, len(diagnostics))
- payload = diagnostics[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- self.assertEqual("https://example.com/sprites", payload.get("area_url"))
- self.assertIsNone(payload.get("website_id"))
- self.assertIsNone(payload.get("area_id"))
- self.assertIn("Running network diagnostics", panel._status.text())
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ entered = [action.text() for action in panel._entered_more_btn.menu().actions() if action.text()]
+ saved = [action.text() for action in panel._saved_more_btn.menu().actions() if action.text()]
+ linked = [action.text() for action in panel._links_more_btn.menu().actions() if action.text()]
+ found = [action.text() for action in panel._results_more_btn.menu().actions() if action.text()]
- def test_more_menus_have_section_specific_actions(self) -> None:
- app, owns_app, panel = self._setup_panel()
-
- def action_labels(button) -> list[str]:
- menu = button.menu()
- self.assertIsNotNone(menu)
- if menu is None:
- return []
- return [action.text() for action in menu.actions() if not action.isSeparator()]
-
- try:
- self.assertEqual(
- ["Save URL as Page", "Clear URL", "Clear Page List", "Check Pasted URL"],
- action_labels(panel._url_more_btn),
- )
self.assertEqual(
- ["Scan All Saved", "Remove Saved Page", "Remove Website", "Check Saved Page"],
- action_labels(panel._source_more_btn),
+ ["Save Entered Pages", "Check First URL", "Include uncertain image links", "Clear Entered URLs"],
+ entered,
)
self.assertEqual(
[
- "Scan Selected Links",
- "Find and Scan First 100",
- "Select Visible Links",
- "Clear Link Selection",
- "Clear Linked Pages",
+ "Check Current Page",
+ "Clear Checked Pages",
+ "Check Current Page Connection",
+ "Remove Current Page",
+ "Remove Current Website",
],
- action_labels(panel._index_more_btn),
+ saved,
)
self.assertEqual(
- ["Select All Results", "Clear Result Selection", "Clear Found Files"],
- action_labels(panel._download_more_btn),
+ ["Select Visible Pages", "Clear Page Selection", "Clear Linked Pages"],
+ linked,
)
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
- def test_saved_page_network_diagnostics_ignores_pasted_url(self) -> None:
- app, owns_app, panel = self._setup_panel()
- diagnostics: list[object] = []
- panel.network_diagnostics_requested.connect(diagnostics.append)
-
- try:
- panel.set_sources(
- websites=[
- {
- "id": "example_com",
- "name": "example.com",
- "areas": [
- {
- "id": "sprites",
- "label": "Sprites",
- "url": "https://example.com/sprites",
- }
- ],
- }
- ],
- selected_website_id="example_com",
- selected_area_id="sprites",
- )
- panel._custom_url.setText("https://other.example/manual")
- panel._emit_saved_page_network_diagnostics()
-
- self.assertEqual(1, len(diagnostics))
- payload = diagnostics[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- self.assertEqual("https://example.com/sprites", payload.get("area_url"))
- self.assertEqual("example_com", payload.get("website_id"))
- self.assertEqual("sprites", payload.get("area_id"))
- self.assertIn("saved page", panel._status.text())
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
- def test_jpg_filter_is_available_and_enabled_by_default(self) -> None:
- app, owns_app, panel = self._setup_panel()
- try:
- self.assertEqual("JPG", panel._filter_jpg.text())
- self.assertTrue(panel._filter_jpg.isChecked())
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
- def test_index_keyword_filter_limits_scan_selected_pages_payload(self) -> None:
- app, owns_app, panel = self._setup_panel()
- scans: list[object] = []
- panel.multi_scan_requested.connect(scans.append)
-
- try:
- panel.set_index_links(
- [
- WebIndexLink(
- label="HOME Sprites: Gen 1",
- url="https://example.com/home-gen-1",
- source_page="https://example.com/index",
- ),
- WebIndexLink(
- label="HOME Sprites: Gen 2",
- url="https://example.com/home-gen-2",
- source_page="https://example.com/index",
- ),
- ]
- )
- panel._index_keyword.setText("gen 1")
- panel._select_visible_index_links()
- panel._emit_multi_page_scan()
-
- self.assertEqual(1, len(scans))
- payload = scans[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- pages = payload.get("pages")
- self.assertIsInstance(pages, list)
- if isinstance(pages, list):
- self.assertEqual(1, len(pages))
- self.assertEqual("HOME Sprites: Gen 1", pages[0].get("label"))
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
- def test_found_index_links_are_selected_by_default_for_scanning(self) -> None:
- app, owns_app, panel = self._setup_panel()
- scans: list[object] = []
- panel.multi_scan_requested.connect(scans.append)
-
- try:
- panel.set_index_links(
- [
- WebIndexLink(
- label="HOME Sprites: Gen 1",
- url="https://example.com/home-gen-1",
- source_page="https://example.com/index",
- ),
- WebIndexLink(
- label="Animations",
- url="https://example.com/animations",
- source_page="https://example.com/index",
- ),
- ]
+ self.assertEqual(
+ ["Select All Visible Files", "Clear File Selection", "Clear Found Files"],
+ found,
)
- panel._emit_multi_page_scan()
-
- self.assertEqual(1, len(scans))
- payload = scans[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- pages = payload.get("pages")
- self.assertIsInstance(pages, list)
- if isinstance(pages, list):
- self.assertEqual(2, len(pages))
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_scan_selected_uses_visible_links_when_selection_was_cleared(self) -> None:
+ def test_entered_pages_accept_multiple_domains_and_emit_one_scan_request(self) -> None:
app, owns_app, panel = self._setup_panel()
- scans: list[object] = []
- panel.multi_scan_requested.connect(scans.append)
-
+ requests: list[object] = []
+ panel.scan_requested.connect(requests.append)
try:
- panel.set_index_links(
- [
- WebIndexLink(
- label="HOME Sprites: Gen 1",
- url="https://example.com/home-gen-1",
- source_page="https://example.com/index",
- ),
- WebIndexLink(
- label="HOME Sprites: Gen 2",
- url="https://example.com/home-gen-2",
- source_page="https://example.com/index",
- ),
- ]
+ panel._entered_urls.setPlainText(
+ "example.com/sprites\nhttps://other.example/art\nhttps://other.example/art#duplicate\nnot a url"
)
- panel._index_keyword.setText("gen 2")
- panel._index_links.clearSelection()
- panel._emit_multi_page_scan()
-
- self.assertEqual(1, len(scans))
- payload = scans[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- pages = payload.get("pages")
- self.assertIsInstance(pages, list)
- if isinstance(pages, list):
- self.assertEqual(1, len(pages))
- self.assertEqual("HOME Sprites: Gen 2", pages[0].get("label"))
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
- def test_large_selected_page_scan_warns_and_caps_payload(self) -> None:
- app, owns_app, panel = self._setup_panel()
- scans: list[object] = []
- panel.multi_scan_requested.connect(scans.append)
+ panel._emit_entered_scan()
- try:
- panel.set_index_links(
- [
- WebIndexLink(
- label=f"Page {index}",
- url=f"https://example.com/page-{index}",
- source_page="https://example.com/index",
- )
- for index in range(panel.LINKED_PAGE_SCAN_CAP + 5)
- ]
- )
- panel._select_visible_index_links()
-
- original_confirm = panel.confirm_large_linked_page_scan
- confirm_calls: list[tuple[int, int]] = []
-
- def confirm(page_count: int, *, cap: int | None = None) -> bool:
- confirm_calls.append((page_count, int(cap or 0)))
- return True
-
- panel.confirm_large_linked_page_scan = confirm # type: ignore[method-assign]
- try:
- panel._emit_multi_page_scan()
- finally:
- panel.confirm_large_linked_page_scan = original_confirm # type: ignore[method-assign]
-
- self.assertEqual([(panel.LINKED_PAGE_SCAN_CAP + 5, panel.LINKED_PAGE_SCAN_CAP)], confirm_calls)
- self.assertEqual(1, len(scans))
- payload = scans[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- pages = payload.get("pages")
- self.assertIsInstance(pages, list)
- if isinstance(pages, list):
- self.assertEqual(panel.LINKED_PAGE_SCAN_CAP, len(pages))
+ self.assertEqual(1, len(requests))
+ request = requests[0]
+ self.assertIsInstance(request, WebScanRequest)
+ if isinstance(request, WebScanRequest):
+ self.assertEqual(ScanOrigin.ENTERED, request.origin)
+ self.assertEqual(
+ ("https://example.com/sprites", "https://other.example/art"),
+ request.urls,
+ )
+ self.assertIn("2 valid URLs", panel._entered_count.text())
+ self.assertIn("1 invalid", panel._entered_count.text())
+ self.assertIn("1 duplicate", panel._entered_count.text())
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_large_selected_page_scan_can_be_cancelled_before_emit(self) -> None:
+ def test_save_entered_pages_groups_by_host_and_dedupes(self) -> None:
app, owns_app, panel = self._setup_panel()
- scans: list[object] = []
- panel.multi_scan_requested.connect(scans.append)
-
+ changes: list[object] = []
+ panel.registry_changed.connect(changes.append)
try:
- panel.set_index_links(
- [
- WebIndexLink(label=f"Page {index}", url=f"https://example.com/page-{index}")
- for index in range(panel.LINKED_PAGE_SCAN_CAP + 1)
- ]
+ panel._entered_urls.setPlainText(
+ "https://one.example/sprites/gen-1\nhttps://two.example/art\nhttps://one.example/sprites/gen-1"
)
- panel._select_visible_index_links()
- panel.confirm_large_linked_page_scan = lambda *_args, **_kwargs: False # type: ignore[method-assign]
- panel._emit_multi_page_scan()
-
- self.assertEqual([], scans)
- self.assertIn("cancelled", panel._status.text().lower())
- finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
- def test_manual_page_urls_emit_multi_scan_payload_with_dedupe(self) -> None:
- app, owns_app, panel = self._setup_panel()
- scans: list[object] = []
- panel.multi_scan_requested.connect(scans.append)
+ panel._save_entered_pages()
+ panel._save_entered_pages()
- try:
- panel._manual_links.setPlainText(
- "\n".join(
- [
- "example.com/sprites/gen-1",
- "https://example.com/sprites/gen-1",
- "ftp://example.com/bad",
- "https://example.com/sprites/gen-2",
- ]
- )
- )
- panel._scan_manual_links_btn.click()
-
- self.assertEqual(1, len(scans))
- payload = scans[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- pages = payload.get("pages")
- self.assertIsInstance(pages, list)
- if isinstance(pages, list):
- self.assertEqual(2, len(pages))
- self.assertEqual("https://example.com/sprites/gen-1", pages[0].get("url"))
- self.assertEqual("https://example.com/sprites/gen-2", pages[1].get("url"))
- self.assertIn("skipped", panel._status.text().lower())
- self.assertIn("invalid", panel._status.text().lower())
- self.assertIn("duplicate", panel._status.text().lower())
+ registry = panel.sources_registry()
+ self.assertEqual(2, len(registry))
+ self.assertEqual(2, sum(len(site["areas"]) for site in registry))
+ self.assertEqual(2, len(changes))
+ self.assertIn("skipped 2 already saved", panel._status.text())
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_large_manual_page_scan_warns_and_caps_payload(self) -> None:
+ def test_saved_tree_scans_checked_pages_across_websites_through_same_call(self) -> None:
app, owns_app, panel = self._setup_panel()
- scans: list[object] = []
- panel.multi_scan_requested.connect(scans.append)
-
+ requests: list[object] = []
+ panel.scan_requested.connect(requests.append)
try:
- panel._manual_links.setPlainText(
- "\n".join(
- f"https://example.com/page-{index}"
- for index in range(panel.LINKED_PAGE_SCAN_CAP + 3)
+ panel.set_sources(websites=self._registry())
+ panel._saved_tree.topLevelItem(0).child(0).setCheckState(0, Qt.CheckState.Checked)
+ panel._saved_tree.topLevelItem(1).child(0).setCheckState(0, Qt.CheckState.Checked)
+ panel._emit_saved_scan()
+
+ request = requests[0]
+ self.assertIsInstance(request, WebScanRequest)
+ if isinstance(request, WebScanRequest):
+ self.assertEqual(ScanOrigin.SAVED, request.origin)
+ self.assertEqual(
+ ("https://site-a.example/sprites", "https://site-b.example/sprites"),
+ request.urls,
)
- )
- confirm_calls: list[tuple[int, int]] = []
-
- def confirm(page_count: int, *, cap: int | None = None) -> bool:
- confirm_calls.append((page_count, int(cap or 0)))
- return True
-
- panel.confirm_large_linked_page_scan = confirm # type: ignore[method-assign]
- panel._scan_manual_links_btn.click()
-
- self.assertEqual([(panel.LINKED_PAGE_SCAN_CAP + 3, panel.LINKED_PAGE_SCAN_CAP)], confirm_calls)
- self.assertEqual(1, len(scans))
- payload = scans[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- pages = payload.get("pages")
- self.assertIsInstance(pages, list)
- if isinstance(pages, list):
- self.assertEqual(panel.LINKED_PAGE_SCAN_CAP, len(pages))
+ self.assertIsNone(request.website_id)
+ self.assertIsNone(request.area_id)
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_find_linked_pages_emits_custom_index_url_payload(self) -> None:
+ def test_saved_tree_falls_back_to_current_page_when_none_are_checked(self) -> None:
app, owns_app, panel = self._setup_panel()
requests: list[object] = []
- panel.index_links_requested.connect(requests.append)
-
+ panel.scan_requested.connect(requests.append)
try:
- panel._custom_url.setText("example.com/sprite-index")
- panel._find_index_links_btn.click()
-
- self.assertEqual(1, len(requests))
- payload = requests[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- self.assertEqual("https://example.com/sprite-index", payload.get("index_url"))
+ panel.set_sources(websites=self._registry(), selected_website_id="site_a", selected_area_id="page_b")
+ panel._emit_saved_scan()
+ request = requests[0]
+ self.assertIsInstance(request, WebScanRequest)
+ if isinstance(request, WebScanRequest):
+ self.assertEqual(("https://site-a.example/other",), request.urls)
+ self.assertEqual("site_a", request.website_id)
+ self.assertEqual("page_b", request.area_id)
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_find_and_scan_all_emits_custom_index_url_payload(self) -> None:
+ def test_discover_from_dropdown_exposes_entered_and_saved_pages(self) -> None:
app, owns_app, panel = self._setup_panel()
requests: list[object] = []
- panel.index_scan_all_requested.connect(requests.append)
-
+ panel.discover_links_requested.connect(requests.append)
try:
- panel._custom_url.setText("example.com/sprite-index")
- panel._emit_index_scan_all()
-
- self.assertEqual(1, len(requests))
- payload = requests[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- self.assertEqual("https://example.com/sprite-index", payload.get("index_url"))
+ panel.set_sources(websites=self._registry())
+ panel._entered_urls.setPlainText("https://entered.example/index")
+
+ self.assertEqual(4, panel._link_source.count())
+ self.assertTrue(panel._link_source.itemText(0).startswith("Entered:"))
+ self.assertTrue(panel._link_source.itemText(1).startswith("Saved:"))
+
+ panel._link_source.setCurrentIndex(2)
+ panel._emit_discover_links()
+ request = requests[0]
+ self.assertIsInstance(request, WebLinkDiscoveryRequest)
+ if isinstance(request, WebLinkDiscoveryRequest):
+ self.assertEqual("https://site-a.example/other", request.url)
+ self.assertEqual("site_a", request.website_id)
+ self.assertEqual("page_b", request.area_id)
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_result_search_matches_url_and_source_page(self) -> None:
+ def test_linked_pages_filter_and_selection_emit_unified_scan_request(self) -> None:
app, owns_app, panel = self._setup_panel()
+ requests: list[object] = []
+ panel.scan_requested.connect(requests.append)
try:
- panel.set_results(
- ScanResults(
- items=(
- WebItem(
- url="https://cdn.example.com/bulbasaur.png",
- name="001.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- source_page="https://example.com/home-gen-1",
- ),
- WebItem(
- url="https://cdn.example.com/charmander.png",
- name="004.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- source_page="https://example.com/home-gen-1",
- ),
- ),
- filtered_count=0,
+ panel._entered_urls.setPlainText("https://example.com/index")
+ panel.set_index_links(
+ (
+ WebIndexLink("Generation 1", "https://example.com/gen-1"),
+ WebIndexLink("Generation 2", "https://example.com/gen-2"),
)
)
- panel._search.setText("bulbasaur")
- self.assertEqual(1, panel._results.count())
- panel._search.setText("home-gen-1")
- self.assertEqual(2, panel._results.count())
+ panel._clear_link_selection()
+ panel._links_search.setText("gen-2")
+ panel._select_visible_links()
+ panel._emit_linked_scan()
+
+ request = requests[0]
+ self.assertIsInstance(request, WebScanRequest)
+ if isinstance(request, WebScanRequest):
+ self.assertEqual(ScanOrigin.LINKED, request.origin)
+ self.assertEqual(("https://example.com/gen-2",), request.urls)
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_result_exclude_keywords_hide_matching_names_urls_and_source_pages(self) -> None:
+ def test_linked_scan_requires_real_selection_instead_of_hidden_fallback(self) -> None:
app, owns_app, panel = self._setup_panel()
+ requests: list[object] = []
+ panel.scan_requested.connect(requests.append)
try:
- panel.set_results(
- ScanResults(
- items=(
- WebItem(
- url="https://cdn.example.com/bulbasaur.png",
- name="bulbasaur.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- source_page="https://example.com/home-gen-1",
- ),
- WebItem(
- url="https://cdn.example.com/bulbasaur_shiny.png",
- name="bulbasaur_shiny.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- source_page="https://example.com/home-gen-1",
- ),
- WebItem(
- url="https://cdn.example.com/charmander.png",
- name="charmander.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- source_page="https://example.com/shiny-index",
- ),
- ),
- filtered_count=0,
- )
- )
-
- panel._exclude_keywords.setText("shiny")
-
- self.assertEqual(1, panel._results.count())
- self.assertIn("bulbasaur.png", panel._results.item(0).text())
+ panel.set_index_links((WebIndexLink("Page", "https://example.com/page"),))
+ panel._clear_link_selection()
+ panel._emit_linked_scan()
+ self.assertEqual([], requests)
+ self.assertIn("Select one or more linked pages", panel._status.text())
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_set_results_explains_when_search_filters_hide_all_items(self) -> None:
+ def test_results_accumulate_dedupe_and_keep_selection(self) -> None:
app, owns_app, panel = self._setup_panel()
+ first = self._item("a.png")
+ second = self._item("b.png")
try:
- panel._search.setText("missing")
- panel.set_results(
+ panel.set_results(ScanResults(items=(first,)))
+ panel._results.topLevelItem(0).setSelected(True)
+ panel._on_result_selection_changed()
+ panel.add_results(
ScanResults(
items=(
WebItem(
- url="https://cdn.example.com/bulbasaur.png",
- name="bulbasaur.png",
+ url="HTTPS://CDN.EXAMPLE.COM/a.png#preview",
+ name="duplicate.png",
ext=".png",
confidence=Confidence.DIRECT,
- source_page="https://example.com/home-gen-1",
),
- ),
- filtered_count=0,
+ second,
+ )
)
)
- self.assertEqual(0, panel._results.count())
- self.assertIn("hide", panel._status.text().lower())
+ self.assertEqual((first, second), panel.found_items())
+ self.assertEqual([first], panel._selected_result_items())
+ self.assertIn("Added 1 new item", panel._status.text())
+ self.assertIn("ignored 1 duplicate", panel._status.text())
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_set_results_reports_failed_pages_from_multi_page_scan(self) -> None:
+ def test_failed_scan_keeps_previous_results_and_reports_details(self) -> None:
app, owns_app, panel = self._setup_panel()
+ first = self._item("a.png")
try:
- panel.set_results(
- ScanResults(
- items=(
- WebItem(
- url="https://cdn.example.com/bulbasaur.png",
- name="bulbasaur.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- source_page="https://example.com/good",
- ),
- ),
- filtered_count=0,
- failed_pages=("https://example.com/slow: timed out",),
- )
+ panel.add_results(ScanResults(items=(first,)))
+ panel.add_results(
+ ScanResults(items=(), failed_pages=("https://example.com/slow: Network timeout",))
)
-
- self.assertEqual(1, panel._results.count())
+ self.assertEqual((first,), panel.found_items())
self.assertIn("1 page failed", panel._status.text())
- self.assertIn("Hover for details", panel._status.text())
- self.assertIn("https://example.com/slow", panel._status.toolTip())
+ self.assertIn("example.com/slow", panel._status.toolTip())
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_set_results_reports_when_all_pages_failed(self) -> None:
+ def test_result_search_exclusion_and_file_type_filters_are_independent(self) -> None:
app, owns_app, panel = self._setup_panel()
try:
panel.set_results(
ScanResults(
- items=(),
- filtered_count=0,
- failed_pages=("https://example.com/slow: timed out",),
+ items=(
+ self._item("bulbasaur.png", source="https://example.com/gen-1"),
+ self._item("bulbasaur_shiny.gif", source="https://example.com/gen-1"),
+ self._item("charmander.png", source="https://example.com/gen-2"),
+ )
)
)
-
- self.assertEqual(0, panel._results.count())
- self.assertIn("Found 0 item", panel._status.text())
- self.assertIn("1 page failed", panel._status.text())
- self.assertIn("https://example.com/slow", panel._status.toolTip())
+ panel._results_search.setText("gen-1")
+ self.assertEqual(2, panel._results.topLevelItemCount())
+ panel._exclude_words.setText("shiny")
+ self.assertEqual(1, panel._results.topLevelItemCount())
+ panel._format_actions[".png"].setChecked(False)
+ self.assertEqual(0, panel._results.topLevelItemCount())
+ self.assertIn("hide all stored results", panel._status.text())
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_download_area_uses_auto_destination_hint_and_forces_smart_routing(self) -> None:
+ def test_download_emits_typed_request_with_download_options(self) -> None:
app, owns_app, panel = self._setup_panel()
- downloads: list[object] = []
- panel.download_requested.connect(downloads.append)
- panel.set_sources(
- websites=[
- {
- "id": "pokemon_fandom_com",
- "name": "pokemon.fandom.com",
- "areas": [
- {
- "id": "item_sprites",
- "label": "Wiki / Category:item Sprites (Query)",
- "url": "https://pokemon.fandom.com/wiki/Category:Item_sprites",
- }
- ],
- }
- ],
- selected_website_id="pokemon_fandom_com",
- selected_area_id="item_sprites",
- )
- panel.set_results(
- ScanResults(
- items=(
- WebItem(
- url="https://example.com/a.png",
- name="a.png",
- ext=".png",
- confidence=Confidence.DIRECT,
- source_page="https://pokemon.fandom.com/wiki/Category:Item_sprites",
- ),
- ),
- filtered_count=0,
- )
- )
- panel._select_all_visible()
-
+ requests: list[object] = []
+ panel.download_requested.connect(requests.append)
try:
- self.assertFalse(hasattr(panel, "_target"))
- self.assertIn("Sprite Factory routes downloads", panel._destination_hint.text())
- self.assertTrue(panel.smart_options().auto_sort)
-
+ panel.set_results(ScanResults(items=(self._item("a.png"),)))
+ panel._results.topLevelItem(0).setSelected(True)
+ panel._skip_downloaded_action.setChecked(False)
+ panel._allow_zip_action.setChecked(False)
panel._emit_download()
- self.assertEqual(1, len(downloads))
- payload = downloads[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- self.assertEqual(ImportTarget.NORMAL.value, payload.get("target"))
- self.assertEqual("https://pokemon.fandom.com/wiki/Category:Item_sprites", payload.get("area_url"))
- self.assertEqual("Wiki / Category:item Sprites (Query)", payload.get("area_label"))
- smart = payload.get("smart")
- self.assertIsInstance(smart, dict)
- if isinstance(smart, dict):
- self.assertTrue(smart.get("auto_sort"))
+
+ request = requests[0]
+ self.assertIsInstance(request, WebDownloadRequest)
+ if isinstance(request, WebDownloadRequest):
+ self.assertEqual(1, len(request.items))
+ self.assertFalse(request.smart.skip_duplicates)
+ self.assertFalse(request.smart.allow_zip)
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_scan_emits_payload_for_custom_url_without_adding_site(self) -> None:
+ def test_diagnostics_calls_are_specific_to_entered_or_saved_section(self) -> None:
app, owns_app, panel = self._setup_panel()
- scans: list[object] = []
- panel.scan_requested.connect(scans.append)
-
+ requests: list[object] = []
+ panel.diagnostics_requested.connect(requests.append)
try:
- panel._custom_url.setText("example.com/sprites")
- panel._scan_btn.click()
-
- self.assertEqual(1, len(scans))
- payload = scans[0]
- self.assertIsInstance(payload, dict)
- if isinstance(payload, dict):
- self.assertEqual("https://example.com/sprites", payload.get("area_url"))
- self.assertIsNone(payload.get("website_id"))
- self.assertIsNone(payload.get("area_id"))
- self.assertIn("Scanning URL", panel._status.text())
+ panel._entered_urls.setPlainText("https://entered.example/page")
+ panel._diagnose_first_entered_url()
+ panel.set_sources(websites=self._registry(), selected_website_id="site_b", selected_area_id="page_c")
+ panel._diagnose_current_saved_page()
+
+ self.assertEqual(2, len(requests))
+ self.assertEqual("https://entered.example/page", requests[0].url)
+ self.assertEqual("https://site-b.example/sprites", requests[1].url)
+ self.assertTrue(all(isinstance(request, WebDiagnosticsRequest) for request in requests))
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_scan_rejects_invalid_custom_url(self) -> None:
+ def test_removing_saved_data_does_not_clear_found_files(self) -> None:
app, owns_app, panel = self._setup_panel()
- scans: list[object] = []
- panel.scan_requested.connect(scans.append)
-
try:
- panel._custom_url.setText("ftp://example.com/sprites")
- panel._scan_btn.click()
-
- self.assertEqual(0, len(scans))
- self.assertEqual("Invalid URL. Use http(s)://domain/path.", panel._status.text())
+ panel.set_sources(websites=self._registry(), selected_website_id="site_a", selected_area_id="page_a")
+ panel.set_results(ScanResults(items=(self._item("a.png"),)))
+ panel._remove_current_saved_page()
+ self.assertEqual(1, len(panel.found_items()))
+ self.assertIn("Found Files were kept", panel._status.text())
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-
+ self._close(app, owns_app, panel)
-
- def test_remove_selected_custom_area_updates_registry_and_emits_change(self) -> None:
+ def test_clear_found_files_is_the_only_result_reset(self) -> None:
app, owns_app, panel = self._setup_panel()
- panel.set_sources(
- websites=[
- {
- "id": "project_pokemon",
- "name": "Project Pokemon",
- "areas": [
- {
- "id": "spriteindex_root",
- "label": "Sprite Index (root)",
- "url": "https://projectpokemon.org/home/docs/spriteindex_148/",
- },
- {
- "id": "custom_docs",
- "label": "Custom Docs",
- "url": "https://projectpokemon.org/home/docs/spriteindex_148/3d-models",
- },
- ],
- }
- ],
- selected_website_id="project_pokemon",
- selected_area_id="custom_docs",
- )
-
- changes: list[list[dict]] = []
- panel.registry_changed.connect(lambda payload: changes.append(payload if isinstance(payload, list) else []))
-
try:
- panel._remove_selected_area()
-
- registry = panel.sources_registry()
- self.assertEqual(1, len(registry))
- areas = registry[0].get("areas") if isinstance(registry[0], dict) else []
- urls = [str(area.get("url", "")) for area in areas if isinstance(area, dict)]
- self.assertIn("https://projectpokemon.org/home/docs/spriteindex_148/", urls)
- self.assertNotIn("https://projectpokemon.org/home/docs/spriteindex_148/3d-models", urls)
- self.assertTrue(changes)
- self.assertIn("Removed URL:", panel._status.text())
+ panel.add_results(ScanResults(items=(self._item("a.png"),)))
+ panel._clear_entered_urls()
+ panel._clear_linked_pages()
+ self.assertEqual(1, len(panel.found_items()))
+ panel._clear_found_files()
+ self.assertEqual((), panel.found_items())
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_remove_selected_area_is_allowed_without_builtins(self) -> None:
+ def test_smart_option_actions_emit_preferences_without_hidden_widgets(self) -> None:
app, owns_app, panel = self._setup_panel()
- panel.set_sources(
- websites=[
- {
- "id": "pokemon_db",
- "name": "PokemonDB",
- "areas": [
- {
- "id": "sprites_root",
- "label": "Sprites (All Pokemon)",
- "url": "https://pokemondb.net/sprites",
- }
- ],
- }
- ],
- selected_website_id="pokemon_db",
- selected_area_id="sprites_root",
- )
-
+ changes: list[object] = []
+ panel.preferences_changed.connect(changes.append)
try:
- panel._remove_selected_area()
- registry = panel.sources_registry()
-
- self.assertEqual([], registry)
- self.assertIn("Removed URL:", panel._status.text())
+ panel.set_smart_options(
+ SmartOptions(show_likely=False, skip_duplicates=True, allow_zip=True)
+ )
+ self.assertEqual([], changes)
+ panel._include_likely_action.setChecked(True)
+ self.assertEqual(1, len(changes))
+ self.assertIsInstance(changes[0], SmartOptions)
+ self.assertTrue(changes[0].show_likely)
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
+ self._close(app, owns_app, panel)
- def test_remove_selected_custom_website_updates_registry(self) -> None:
+ def test_large_scan_confirmation_is_capped_and_can_be_cancelled(self) -> None:
app, owns_app, panel = self._setup_panel()
- panel.set_sources(
- websites=[
- {
- "id": "pokemon_db",
- "name": "PokemonDB",
- "areas": [
- {
- "id": "sprites_root",
- "label": "Sprites (All Pokemon)",
- "url": "https://pokemondb.net/sprites",
- }
- ],
- },
- {
- "id": "example_com",
- "name": "example.com",
- "areas": [
- {
- "id": "root",
- "label": "Root",
- "url": "https://example.com/",
- }
- ],
- },
- ],
- selected_website_id="example_com",
- selected_area_id="root",
- )
-
- changes: list[list[dict]] = []
- panel.registry_changed.connect(lambda payload: changes.append(payload if isinstance(payload, list) else []))
-
try:
- panel._remove_selected_website()
-
- registry = panel.sources_registry()
- ids = [str(entry.get("id", "")) for entry in registry if isinstance(entry, dict)]
- self.assertIn("pokemon_db", ids)
- self.assertNotIn("example_com", ids)
- self.assertTrue(changes)
- self.assertIn("Removed website:", panel._status.text())
+ with patch.object(QMessageBox, "question", return_value=QMessageBox.StandardButton.No):
+ self.assertFalse(panel.confirm_large_page_scan(200, cap=100))
+ with patch.object(QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes):
+ self.assertTrue(panel.confirm_large_page_scan(200, cap=100))
+ self.assertTrue(panel.confirm_large_page_scan(100, cap=100))
finally:
- panel.close()
- if owns_app and app is not None:
- app.quit()
-if __name__ == "__main__":
- unittest.main()
+ self._close(app, owns_app, panel)
-
-
-
-
-
-
+if __name__ == "__main__":
+ unittest.main()
diff --git a/image_engine_app/tests/test_web_sources_service.py b/image_engine_app/tests/test_web_sources_service.py
index d9e9e90..23723b6 100644
--- a/image_engine_app/tests/test_web_sources_service.py
+++ b/image_engine_app/tests/test_web_sources_service.py
@@ -266,8 +266,62 @@ def import_url_source(url, **kwargs): # noqa: ANN001
headers["Referer"],
)
+ def test_download_items_keeps_going_when_media_resolution_fails(self) -> None:
+ imported_urls: list[str] = []
+
+ def import_url_source(url, **_kwargs): # noqa: ANN001
+ imported_urls.append(url)
+ return SimpleNamespace(
+ asset=AssetRecord(
+ source_type=SourceType.WEBPAGE_ITEM,
+ source_uri=url,
+ cache_path="good.png",
+ original_name="good.png",
+ format=AssetFormat.PNG,
+ )
+ )
+
+ service = WebSourcesService(
+ app_paths=None,
+ scan_webpage_images=lambda *_args, **_kwargs: None,
+ import_url_source=import_url_source,
+ build_web_asset_from_file=lambda **_kwargs: None,
+ )
+
+ def resolve_download_url(*, item, canonical_url, **_kwargs): # noqa: ANN001
+ if item.name == "bad.png":
+ raise TimeoutError("media lookup timed out")
+ return canonical_url
+
+ service.resolve_download_url = resolve_download_url # type: ignore[method-assign]
+ items = [
+ WebItem(
+ url="https://example.com/bad.png",
+ name="bad.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ ),
+ WebItem(
+ url="https://example.com/good.png",
+ name="good.png",
+ ext=".png",
+ confidence=Confidence.DIRECT,
+ ),
+ ]
+
+ report = service.download_items(
+ items,
+ ImportTarget.NORMAL,
+ smart=SmartOptions(show_likely=False, auto_sort=True, skip_duplicates=False, allow_zip=True),
+ )
+
+ self.assertEqual(["https://example.com/good.png"], imported_urls)
+ self.assertEqual(1, len(report.assets))
+ self.assertEqual(1, len(report.failed))
+ self.assertIn("bad.png: media lookup timed out", report.failed[0])
+
if __name__ == "__main__":
unittest.main()
-
-
+
+
diff --git a/image_engine_app/ui/common/shell_theme.py b/image_engine_app/ui/common/shell_theme.py
index 32d36a6..ff25e28 100644
--- a/image_engine_app/ui/common/shell_theme.py
+++ b/image_engine_app/ui/common/shell_theme.py
@@ -530,9 +530,19 @@ def build_app_stylesheet() -> str:
color: #285d5b;
font-weight: 800;
}
+ QLabel#webSourcesCountBadge {
+ border: 1px solid #d2d9d3;
+ border-radius: 8px;
+ background: #f5f3ed;
+ color: #5a6d72;
+ padding: 3px 7px;
+ font-size: 9px;
+ font-weight: 700;
+ }
QListWidget#webSourcesIndexList,
- QListWidget#webSourcesResultsList,
- QPlainTextEdit#webSourcesManualList {
+ QTreeWidget#webSourcesSavedTree,
+ QTreeWidget#webSourcesResultsTree,
+ QPlainTextEdit#webSourcesUrlList {
border: 1px solid #d2d9d3;
border-radius: 9px;
background: #fffdfa;
@@ -540,6 +550,28 @@ def build_app_stylesheet() -> str:
color: #243940;
font-size: 10px;
}
+ QListWidget#webSourcesIndexList::item,
+ QTreeWidget#webSourcesSavedTree::item,
+ QTreeWidget#webSourcesResultsTree::item {
+ min-height: 20px;
+ border-radius: 5px;
+ padding: 2px 5px;
+ }
+ QListWidget#webSourcesIndexList::item:selected,
+ QTreeWidget#webSourcesSavedTree::item:selected,
+ QTreeWidget#webSourcesResultsTree::item:selected {
+ background: #e8f2ee;
+ color: #254b4d;
+ }
+ QTreeWidget#webSourcesResultsTree QHeaderView::section {
+ background: #f2f0ea;
+ color: #465b61;
+ border: none;
+ border-bottom: 1px solid #d3dad4;
+ padding: 4px 6px;
+ font-size: 9px;
+ font-weight: 700;
+ }
QFrame#previewPanelFrame {
border: 1px solid #cfd8d4;
border-radius: 14px;
diff --git a/image_engine_app/ui/common/state_bindings.py b/image_engine_app/ui/common/state_bindings.py
index 60f264b..7e9d0d3 100644
--- a/image_engine_app/ui/common/state_bindings.py
+++ b/image_engine_app/ui/common/state_bindings.py
@@ -1,4 +1,4 @@
-"""Lightweight Qt state bindings for engine state -> UI integration (Prompt 16)."""
+"""Qt signals and state shared by the Sprite Factory workspace controls."""
from __future__ import annotations
@@ -8,7 +8,6 @@
from PySide6.QtCore import QObject, Signal
from image_engine_app.engine.models import (
- ApplyTarget,
AssetRecord,
BackgroundRemovalMode,
EditMode,
@@ -17,6 +16,7 @@
normalize_background_removal_mode,
normalize_edit_mode,
)
+from image_engine_app.engine.export.profiles import get_profile_rule
@dataclass(frozen=True)
@@ -28,20 +28,11 @@ class HeavyQueueState:
class EngineUIState(QObject):
- """
- Lightweight UI binding store.
-
- This intentionally does not implement the processing engine. It tracks active session/asset,
- UI selections, and emits signals so the Prompt 16 UI shell can stay decoupled from later engine
- wiring.
- """
+ """Track active UI state without taking ownership of engine processing."""
session_changed = Signal(object)
active_asset_changed = Signal(object)
mode_changed = Signal(str)
- apply_target_changed = Signal(str)
- sync_changed = Signal(bool)
- auto_apply_light_changed = Signal(bool)
background_removal_mode_changed = Signal(str)
heavy_queue_state_changed = Signal(object)
export_prediction_changed = Signal(str)
@@ -50,8 +41,6 @@ class EngineUIState(QObject):
apply_requested = Signal()
light_preview_requested = Signal()
export_requested = Signal()
- undo_requested = Signal()
- redo_requested = Signal()
global_reset_requested = Signal()
reset_view_requested = Signal()
@@ -87,9 +76,6 @@ def set_active_asset(self, asset: AssetRecord | None) -> None:
self.active_asset_changed.emit(asset)
if asset is None:
self.mode_changed.emit(EditMode.ADVANCED.value)
- self.apply_target_changed.emit(ApplyTarget.BOTH.value)
- self.sync_changed.emit(True)
- self.auto_apply_light_changed.emit(True)
self.background_removal_mode_changed.emit(BackgroundRemovalMode.OFF.value)
self.set_heavy_queue_counts(queued_count=0, running_count=0)
return
@@ -104,25 +90,6 @@ def set_mode(self, mode: EditMode | str) -> None:
asset.edit_state.mode = mode_enum
self.mode_changed.emit(mode_enum.value)
- def set_apply_target(self, target: ApplyTarget | str) -> None:
- asset = self._active_asset
- target_enum = target if isinstance(target, ApplyTarget) else ApplyTarget(str(target))
- if asset is not None:
- asset.edit_state.apply_target = target_enum
- self.apply_target_changed.emit(target_enum.value)
-
- def set_sync_current_final(self, enabled: bool) -> None:
- asset = self._active_asset
- if asset is not None:
- asset.edit_state.sync_current_final = bool(enabled)
- self.sync_changed.emit(bool(enabled))
-
- def set_auto_apply_light(self, enabled: bool) -> None:
- asset = self._active_asset
- if asset is not None:
- asset.edit_state.auto_apply_light = bool(enabled)
- self.auto_apply_light_changed.emit(bool(enabled))
-
def set_background_removal_mode(self, mode: BackgroundRemovalMode | str) -> None:
asset = self._active_asset
mode_value = normalize_background_removal_mode(mode).value
@@ -139,7 +106,13 @@ def set_export_profile(self, profile: ExportProfile | str) -> None:
if asset is None:
return
profile_enum = profile if isinstance(profile, ExportProfile) else ExportProfile(str(profile))
- asset.edit_state.settings.export.export_profile = profile_enum
+ export = asset.edit_state.settings.export
+ rule = get_profile_rule(profile_enum)
+ export.export_profile = profile_enum
+ export.format = rule.default_format
+ export.quality = rule.default_quality
+ export.compression_level = rule.default_compression_level
+ export.strip_metadata = rule.strip_metadata
self.export_profile_changed.emit(profile_enum.value)
self.status_message_changed.emit(f"Export profile set to {profile_enum.value}")
@@ -153,7 +126,7 @@ def set_heavy_queue_counts(self, *, queued_count: int, running_count: int) -> No
def request_apply(self) -> None:
self.apply_requested.emit()
- self.status_message_changed.emit("Apply requested.")
+ self.status_message_changed.emit("Heavy processing requested.")
def request_light_preview(self) -> None:
"""Request a light-only preview refresh without running heavy queues."""
@@ -163,14 +136,6 @@ def request_export(self) -> None:
self.export_requested.emit()
self.status_message_changed.emit("Export requested.")
- def request_undo(self) -> None:
- self.undo_requested.emit()
- self.status_message_changed.emit("Undo requested.")
-
- def request_redo(self) -> None:
- self.redo_requested.emit()
- self.status_message_changed.emit("Redo requested.")
-
def request_global_reset(self) -> None:
self.global_reset_requested.emit()
self.status_message_changed.emit("Global reset requested.")
@@ -184,15 +149,8 @@ def _emit_asset_mode_and_controls(self) -> None:
if asset is None:
return
self.mode_changed.emit(asset.edit_state.mode.value)
- self.apply_target_changed.emit(asset.edit_state.apply_target.value)
- self.sync_changed.emit(asset.edit_state.sync_current_final)
- self.auto_apply_light_changed.emit(asset.edit_state.auto_apply_light)
mode_value = normalize_background_removal_mode(
getattr(asset.edit_state.settings.alpha, "background_removal_mode", None),
remove_white_bg=bool(getattr(asset.edit_state.settings.alpha, "remove_white_bg", False)),
).value
self.background_removal_mode_changed.emit(mode_value)
-
-
-
-
diff --git a/image_engine_app/ui/main_window/__init__.py b/image_engine_app/ui/main_window/__init__.py
index 22f02b3..078b985 100644
--- a/image_engine_app/ui/main_window/__init__.py
+++ b/image_engine_app/ui/main_window/__init__.py
@@ -9,7 +9,6 @@
"ApplyCoordinator",
"BatchCoordinator",
"ControlStrip",
- "EncodingCoordinator",
"ExportBar",
"ExportCoordinator",
"ImageEngineMainWindow",
@@ -29,7 +28,6 @@
"WorkspaceAssetTabs": "asset_tabs",
"BatchCoordinator": "batch_coordinator",
"ControlStrip": "control_strip",
- "EncodingCoordinator": "encoding_coordinator",
"ExportBar": "export_bar",
"ExportCoordinator": "export_coordinator",
"ImageEngineMainWindow": "main_window",
@@ -59,7 +57,6 @@ def __getattr__(name: str) -> object:
from .asset_tabs import WorkspaceAssetTabs
from .batch_coordinator import BatchCoordinator
from .control_strip import ControlStrip
- from .encoding_coordinator import EncodingCoordinator
from .export_bar import ExportBar
from .export_coordinator import ExportCoordinator
from .local_import_coordinator import LocalImportCoordinator
@@ -70,4 +67,4 @@ def __getattr__(name: str) -> object:
from .shell_coordinator import ShellCoordinator
from .workspace_coordinator import WorkspaceCoordinator
from .web_sources_coordinator import WebSourcesCoordinator
- from .web_sources_panel import WebSourcesPanel
+ from .web_sources_panel import WebSourcesPanel
diff --git a/image_engine_app/ui/main_window/apply_coordinator.py b/image_engine_app/ui/main_window/apply_coordinator.py
index af62de2..93a4b11 100644
--- a/image_engine_app/ui/main_window/apply_coordinator.py
+++ b/image_engine_app/ui/main_window/apply_coordinator.py
@@ -82,7 +82,6 @@ def on_preset_requested(self, preset_name: str) -> None:
return
try:
- self._window.controller.reset_asset_settings_to_defaults(asset)
summary = self._window.controller.apply_named_preset(asset, preset_name)
except PresetApplyError as exc:
self._window._status(f"Preset skipped: {exc}")
@@ -123,7 +122,7 @@ def on_global_reset_requested(self) -> None:
return
try:
- self._window.controller.reset_asset_settings_to_defaults(asset)
+ self._window.controller.restore_asset_detected_settings(asset)
self._window.controller.apply_light_pipeline(asset)
self._window.ui_state.set_active_asset(asset)
self._window.ui_state.set_heavy_queue_counts(
@@ -131,7 +130,7 @@ def on_global_reset_requested(self) -> None:
running_count=0,
)
self._window._refresh_export_prediction()
- self._window._status("Reset active asset edits to defaults")
+ self._window._status("Reset active asset edits to detected controls")
except Exception as exc:
self._window._status(f"Reset failed: {exc}")
-
+
diff --git a/image_engine_app/ui/main_window/batch_coordinator.py b/image_engine_app/ui/main_window/batch_coordinator.py
index 53a74ef..912e47a 100644
--- a/image_engine_app/ui/main_window/batch_coordinator.py
+++ b/image_engine_app/ui/main_window/batch_coordinator.py
@@ -6,7 +6,7 @@
from typing import Any
from image_engine_app.app.ui_controller import ImageEngineUIController
-from image_engine_app.engine.models import AssetRecord, normalize_background_removal_mode
+from image_engine_app.engine.models import AssetRecord, BatchEditSource, normalize_background_removal_mode
from image_engine_app.ui.main_window.batch_run_prep import prepare_batch_assets
from PySide6.QtCore import QObject, Qt, QThread, Signal, Slot
@@ -131,13 +131,21 @@ def on_run_requested(self, options_obj: object) -> None:
self._window._status("Batch run already in progress")
return
- auto_preset = bool(getattr(options_obj, "auto_preset", True))
+ raw_edit_source = getattr(options_obj, "edit_source", BatchEditSource.KEEP_EACH)
+ try:
+ edit_source = (
+ raw_edit_source
+ if isinstance(raw_edit_source, BatchEditSource)
+ else BatchEditSource(str(raw_edit_source))
+ )
+ except ValueError:
+ self._window._status("Batch run skipped: choose a valid edit source")
+ return
+ auto_preset = edit_source is BatchEditSource.SMART_MATCH
auto_export = bool(getattr(options_obj, "auto_export", True))
preview_skip = bool(getattr(options_obj, "preview_skip_mode", True))
export_name_template = str(getattr(options_obj, "export_name_template", "{stem}"))
avoid_overwrite = bool(getattr(options_obj, "avoid_overwrite", True))
- apply_active_edits = bool(getattr(options_obj, "apply_active_edits", False))
- apply_selected_preset = bool(getattr(options_obj, "apply_selected_preset", False))
selected_preset_name = str(getattr(options_obj, "selected_preset_name", "") or "").strip()
background_override_raw = getattr(options_obj, "background_removal_override", None)
background_override = (
@@ -161,9 +169,7 @@ def on_run_requested(self, options_obj: object) -> None:
selected_assets=selected_assets,
active_asset=self._window.ui_state.active_asset,
controller=self._window.controller,
- auto_export=auto_export,
- apply_active_edits=apply_active_edits,
- apply_selected_preset=apply_selected_preset,
+ edit_source=edit_source,
selected_preset_name=selected_preset_name,
background_override=background_override,
)
@@ -181,15 +187,13 @@ def on_run_requested(self, options_obj: object) -> None:
)
LOGGER.info(
- "Batch run requested: selected=%s auto_export=%s auto_preset=%s preview_skip=%s export_dir=%s template=%s apply_active_edits=%s apply_selected_preset=%s preset=%s bg_override=%s",
+ "Batch run requested: selected=%s edit_source=%s auto_export=%s preview_skip=%s export_dir=%s template=%s preset=%s bg_override=%s",
len(batch_assets),
+ edit_source.value,
auto_export,
- auto_preset,
preview_skip,
export_dir,
export_name_template,
- apply_active_edits,
- apply_selected_preset,
selected_preset_name,
background_override,
)
@@ -317,7 +321,7 @@ def _resolve_export_directory(self, candidate: object) -> str | None:
if isinstance(fallback, str) and fallback.strip():
return fallback.strip()
return None
-
-
-
-
+
+
+
+
diff --git a/image_engine_app/ui/main_window/batch_run_prep.py b/image_engine_app/ui/main_window/batch_run_prep.py
index 099a4df..ae00754 100644
--- a/image_engine_app/ui/main_window/batch_run_prep.py
+++ b/image_engine_app/ui/main_window/batch_run_prep.py
@@ -6,7 +6,12 @@
from dataclasses import dataclass
from typing import Any
-from image_engine_app.engine.models import AssetRecord, BackgroundRemovalMode, normalize_background_removal_mode
+from image_engine_app.engine.models import (
+ AssetRecord,
+ BackgroundRemovalMode,
+ BatchEditSource,
+ normalize_background_removal_mode,
+)
from image_engine_app.engine.process.presets_apply import PresetApplyError
@@ -22,26 +27,23 @@ def prepare_batch_assets(
selected_assets: list[AssetRecord],
active_asset: AssetRecord | None,
controller: Any,
- auto_export: bool,
- apply_active_edits: bool,
- apply_selected_preset: bool,
+ edit_source: BatchEditSource,
selected_preset_name: str,
background_override: str | None,
) -> BatchPreparationResult:
assets = [deepcopy(asset) for asset in selected_assets]
_clear_derived_outputs(assets)
- if auto_export and active_asset is not None:
- _apply_active_export_settings(active_asset, assets)
-
- if apply_active_edits and active_asset is not None:
+ if edit_source is BatchEditSource.COPY_ACTIVE:
+ if active_asset is None:
+ raise ValueError("select an active asset before copying its controls")
_apply_active_edit_settings(active_asset, assets)
applied_preset_count = 0
skipped_preset_count = 0
- if apply_selected_preset:
+ if edit_source is BatchEditSource.CHOSEN_PRESET:
if not selected_preset_name:
- raise ValueError("choose a preset or disable preset apply")
+ raise ValueError("choose a preset for this batch")
applied_preset_count, skipped_preset_count = _apply_named_preset_to_assets(
controller,
assets,
@@ -58,23 +60,13 @@ def prepare_batch_assets(
)
-def _apply_active_export_settings(active_asset: AssetRecord, assets: list[AssetRecord]) -> None:
- template = deepcopy(active_asset.edit_state.settings.export)
- for asset in assets:
- asset.edit_state.settings.export = deepcopy(template)
-
-
def _apply_active_edit_settings(active_asset: AssetRecord, assets: list[AssetRecord]) -> None:
template_mode = deepcopy(active_asset.edit_state.mode)
- template_apply_target = deepcopy(active_asset.edit_state.apply_target)
- template_sync = bool(active_asset.edit_state.sync_current_final)
template_heavy_jobs = deepcopy(active_asset.edit_state.queued_heavy_jobs)
template_settings = deepcopy(active_asset.edit_state.settings)
for asset in assets:
asset.edit_state.mode = deepcopy(template_mode)
- asset.edit_state.apply_target = deepcopy(template_apply_target)
- asset.edit_state.sync_current_final = template_sync
asset.edit_state.queued_heavy_jobs = deepcopy(template_heavy_jobs)
asset.edit_state.settings = deepcopy(template_settings)
diff --git a/image_engine_app/ui/main_window/control_strip.py b/image_engine_app/ui/main_window/control_strip.py
index df75c41..e019a1f 100644
--- a/image_engine_app/ui/main_window/control_strip.py
+++ b/image_engine_app/ui/main_window/control_strip.py
@@ -1,13 +1,10 @@
-"""Preview control strip for apply targets, live behavior, and workspace actions."""
+"""Preview controls for presets, background handling, and processing actions."""
from __future__ import annotations
-from functools import partial
-
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import (
- QButtonGroup,
QFrame,
QHBoxLayout,
QLabel,
@@ -18,7 +15,7 @@
QWidget,
)
-from image_engine_app.engine.models import ApplyTarget, BackgroundRemovalMode
+from image_engine_app.engine.models import BackgroundRemovalMode
from image_engine_app.ui.common.icons import icon
from image_engine_app.ui.common.state_bindings import EngineUIState
from image_engine_app.ui.main_window.control_strip_state import ControlStripViewState, build_control_strip_view_state
@@ -33,21 +30,15 @@ class ControlStrip(QFrame):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._ui_state: EngineUIState | None = None
- self._apply_target_buttons: dict[str, QToolButton] = {}
- self._scope_group: QFrame | None = None
- self._behavior_group: QFrame | None = None
self._actions_group: QFrame | None = None
self._header_summary = QLabel(self)
self._queue_badge = QLabel(self)
- self._target_badge = QLabel(self)
self._preset_button = QToolButton(self)
self._preset_menu = QMenu(self._preset_button)
self._background_button = QToolButton(self)
self._background_mode_actions: dict[str, QAction] = {}
- self._sync_button = QToolButton(self)
- self._auto_apply_button = QToolButton(self)
- self._preview_button = QPushButton(self)
- self._apply_button = QPushButton(self)
+ self._run_button = QPushButton(self)
+ self._run_heavy = False
self._options_button = QToolButton(self)
self._reset_settings_action = QAction(icon("reset"), "Reset Edits", self)
self._reset_view_action = QAction(icon("reset"), "Reset View", self)
@@ -94,9 +85,6 @@ def set_preset_entries(self, entries: list[object], *, has_asset: bool) -> None:
def bind_state(self, ui_state: EngineUIState) -> None:
self._ui_state = ui_state
- ui_state.apply_target_changed.connect(self._on_apply_target_changed)
- ui_state.sync_changed.connect(self._on_sync_changed)
- ui_state.auto_apply_light_changed.connect(self._on_auto_apply_light_changed)
ui_state.background_removal_mode_changed.connect(self._on_background_mode_changed)
ui_state.active_asset_changed.connect(self._on_active_asset_changed)
ui_state.heavy_queue_state_changed.connect(self._on_heavy_queue_state_changed)
@@ -151,11 +139,6 @@ def _build_ui(self) -> None:
self._background_button.setFixedSize(88, 26)
header.addWidget(self._background_button, 0)
- self._target_badge.setObjectName("controlStripHeaderBadge")
- self._target_badge.setFixedSize(88, 26)
- self._target_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
- header.addWidget(self._target_badge, 0)
-
self._queue_badge.setObjectName("controlStripHeaderBadge")
self._queue_badge.setFixedSize(84, 26)
self._queue_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -166,54 +149,11 @@ def _build_ui(self) -> None:
body = QHBoxLayout()
body.setSpacing(8)
- self._scope_group, scope_layout = self._create_group("TARGET")
- apply_group = QButtonGroup(self)
- apply_group.setExclusive(True)
- for value, label_text in (
- (ApplyTarget.CURRENT.value, "Current"),
- (ApplyTarget.FINAL.value, "Final"),
- (ApplyTarget.BOTH.value, "Both"),
- ):
- btn = QToolButton(self)
- btn.setObjectName("controlStripChip")
- btn.setText(label_text)
- btn.setCheckable(True)
- btn.setToolTip(f"Apply to {value}")
- btn.clicked.connect(partial(self._emit_apply_target, value))
- apply_group.addButton(btn)
- self._apply_target_buttons[value] = btn
- scope_layout.addWidget(btn)
- scope_layout.addStretch(1)
- body.addWidget(self._scope_group, 1)
-
- self._behavior_group, behavior_layout = self._create_group("LIVE")
- self._sync_button.setObjectName("controlStripToggle")
- self._sync_button.setText("Link")
- self._sync_button.setToolTip("Keep current and final previews linked")
- self._sync_button.setCheckable(True)
- self._sync_button.clicked.connect(self._emit_sync_change)
- behavior_layout.addWidget(self._sync_button)
-
- self._auto_apply_button.setObjectName("controlStripToggle")
- self._auto_apply_button.setText("Auto")
- self._auto_apply_button.setToolTip("Auto-run light preview while editing")
- self._auto_apply_button.setCheckable(True)
- self._auto_apply_button.clicked.connect(self._emit_auto_apply_change)
- behavior_layout.addWidget(self._auto_apply_button)
- behavior_layout.addStretch(1)
- body.addWidget(self._behavior_group, 1)
-
- self._actions_group, action_layout = self._create_group("RUN")
- self._preview_button.setObjectName("controlStripSecondaryAction")
- self._preview_button.setText("Preview")
- self._preview_button.setIcon(icon("apply"))
- self._preview_button.clicked.connect(self._emit_light_preview_requested)
- action_layout.addWidget(self._preview_button, 0)
-
- self._apply_button.setObjectName("controlStripPrimaryAction")
- self._apply_button.setIcon(icon("apply"))
- self._apply_button.clicked.connect(self._emit_apply_requested)
- action_layout.addWidget(self._apply_button, 0)
+ self._actions_group, action_layout = self._create_group("FINAL")
+ self._run_button.setObjectName("controlStripPrimaryAction")
+ self._run_button.setIcon(icon("apply"))
+ self._run_button.clicked.connect(self._emit_run_requested)
+ action_layout.addWidget(self._run_button, 0)
options_menu = QMenu(self._options_button)
self._reset_settings_action.triggered.connect(self._emit_reset_settings)
@@ -227,7 +167,8 @@ def _build_ui(self) -> None:
self._options_button.setMenu(options_menu)
self._options_button.setMinimumWidth(72)
action_layout.addWidget(self._options_button, 0)
- body.addWidget(self._actions_group, 0)
+ action_layout.addStretch(1)
+ body.addWidget(self._actions_group, 1)
root.addLayout(body)
@@ -248,29 +189,16 @@ def _create_group(self, title: str) -> tuple[QFrame, QHBoxLayout]:
layout.addLayout(content)
return frame, content
- def _emit_apply_target(self, target_value: str) -> None:
- if self._ui_state is not None:
- self._ui_state.set_apply_target(target_value)
-
- def _emit_sync_change(self) -> None:
- if self._ui_state is not None:
- self._ui_state.set_sync_current_final(self._sync_button.isChecked())
-
- def _emit_auto_apply_change(self) -> None:
- if self._ui_state is not None:
- self._ui_state.set_auto_apply_light(self._auto_apply_button.isChecked())
-
def _emit_background_mode(self, mode_value: str) -> None:
if self._ui_state is not None:
self._ui_state.set_background_removal_mode(mode_value)
- def _emit_light_preview_requested(self) -> None:
+ def _emit_run_requested(self) -> None:
if self._ui_state is not None:
- self._ui_state.request_light_preview()
-
- def _emit_apply_requested(self) -> None:
- if self._ui_state is not None:
- self._ui_state.request_apply()
+ if self._run_heavy:
+ self._ui_state.request_apply()
+ else:
+ self._ui_state.request_light_preview()
def _emit_reset_view(self) -> None:
if self._ui_state is not None:
@@ -280,15 +208,6 @@ def _emit_reset_settings(self) -> None:
if self._ui_state is not None:
self._ui_state.request_global_reset()
- def _on_apply_target_changed(self, _target_value: str) -> None:
- self._sync_from_ui_state()
-
- def _on_sync_changed(self, _enabled: bool) -> None:
- self._sync_from_ui_state()
-
- def _on_auto_apply_light_changed(self, _enabled: bool) -> None:
- self._sync_from_ui_state()
-
def _on_background_mode_changed(self, _mode_value: str) -> None:
self._sync_from_ui_state()
@@ -310,11 +229,8 @@ def _sync_from_ui_state(self) -> None:
)
def _apply_view_state(self, state: ControlStripViewState) -> None:
- self._set_group_enabled(self._scope_group, state.has_asset)
- self._set_group_enabled(self._behavior_group, state.has_asset)
self._set_group_enabled(self._actions_group, state.has_asset)
- self._preview_button.setEnabled(state.has_asset)
- self._apply_button.setEnabled(state.has_asset)
+ self._run_button.setEnabled(state.has_asset and state.running_heavy_jobs == 0)
self._options_button.setEnabled(state.has_asset)
self._background_button.setEnabled(state.has_asset)
self._reset_settings_action.setEnabled(state.has_asset)
@@ -323,33 +239,15 @@ def _apply_view_state(self, state: ControlStripViewState) -> None:
self._header_summary.setText(state.summary_text)
self._background_button.setText(state.background_button_text)
self._background_button.setToolTip(state.background_button_tooltip)
- self._set_badge_text(self._target_badge, state.target_badge_text, tone="neutral")
self._set_badge_text(self._queue_badge, state.queue_badge_text, tone=state.queue_badge_tone)
for mode_value, action in self._background_mode_actions.items():
action.blockSignals(True)
action.setChecked(mode_value == state.background_mode)
action.blockSignals(False)
- button = self._apply_target_buttons.get(state.apply_target)
- if button is not None:
- for candidate in self._apply_target_buttons.values():
- candidate.blockSignals(True)
- button.setChecked(True)
- for candidate in self._apply_target_buttons.values():
- candidate.blockSignals(False)
-
- self._sync_button.blockSignals(True)
- self._sync_button.setChecked(state.sync_current_final)
- self._sync_button.blockSignals(False)
-
- self._auto_apply_button.blockSignals(True)
- self._auto_apply_button.setChecked(state.auto_apply_light)
- self._auto_apply_button.blockSignals(False)
-
- self._preview_button.setText(state.preview_button_text)
- self._preview_button.setToolTip(state.preview_button_tooltip)
- self._apply_button.setText(state.apply_button_text)
- self._apply_button.setToolTip(state.apply_button_tooltip)
+ self._run_heavy = state.run_heavy
+ self._run_button.setText(state.run_button_text)
+ self._run_button.setToolTip(state.run_button_tooltip)
@staticmethod
def _set_group_enabled(group: QFrame | None, enabled: bool) -> None:
diff --git a/image_engine_app/ui/main_window/control_strip_state.py b/image_engine_app/ui/main_window/control_strip_state.py
index d92af35..2ff92a7 100644
--- a/image_engine_app/ui/main_window/control_strip_state.py
+++ b/image_engine_app/ui/main_window/control_strip_state.py
@@ -4,7 +4,7 @@
from dataclasses import dataclass
-from image_engine_app.engine.models import ApplyTarget, BackgroundRemovalMode, normalize_background_removal_mode
+from image_engine_app.engine.models import BackgroundRemovalMode, normalize_background_removal_mode
@dataclass(frozen=True)
@@ -12,22 +12,17 @@ class ControlStripViewState:
"""Normalized control-strip state derived from the active asset."""
has_asset: bool
- apply_target: str = ApplyTarget.BOTH.value
- sync_current_final: bool = True
- auto_apply_light: bool = True
queued_heavy_jobs: int = 0
running_heavy_jobs: int = 0
summary_text: str = "Select an asset to start editing."
queue_badge_text: str = "No asset"
queue_badge_tone: str = "disabled"
- target_badge_text: str = "Target: --"
background_mode: str = BackgroundRemovalMode.OFF.value
background_button_text: str = "Keep BG"
background_button_tooltip: str = "Keep the current background settings for the active asset."
- apply_button_text: str = "Apply"
- apply_button_tooltip: str = "Select an asset before applying changes."
- preview_button_text: str = "Preview"
- preview_button_tooltip: str = "Render a light-only preview for the active asset."
+ run_button_text: str = "Refresh Final"
+ run_button_tooltip: str = "Select an asset before refreshing Final."
+ run_heavy: bool = False
def build_control_strip_view_state(asset: object | None, heavy_queue_state: object | None = None) -> ControlStripViewState:
@@ -35,14 +30,6 @@ def build_control_strip_view_state(asset: object | None, heavy_queue_state: obje
if edit_state is None:
return ControlStripViewState(has_asset=False)
- apply_target = getattr(getattr(edit_state, "apply_target", None), "value", ApplyTarget.BOTH.value)
- if apply_target not in {
- ApplyTarget.CURRENT.value,
- ApplyTarget.FINAL.value,
- ApplyTarget.BOTH.value,
- }:
- apply_target = ApplyTarget.BOTH.value
-
queued_heavy_jobs = _int_attr(
heavy_queue_state,
"queued_count",
@@ -51,14 +38,11 @@ def build_control_strip_view_state(asset: object | None, heavy_queue_state: obje
running_heavy_jobs = _int_attr(heavy_queue_state, "running_count", default=0)
alpha_settings = getattr(getattr(edit_state, "settings", None), "alpha", None)
- target_label = _TARGET_LABELS.get(apply_target, "Both")
- sync_text = "Views linked" if bool(getattr(edit_state, "sync_current_final", True)) else "Views split"
- preview_text = "Auto preview on" if bool(getattr(edit_state, "auto_apply_light", True)) else "Auto preview off"
-
queue_badge_text = "Ready"
queue_badge_tone = "ready"
- apply_button_text = "Apply"
- apply_button_tooltip = "Commit the light pipeline for the active asset."
+ run_button_text = "Refresh Final"
+ run_button_tooltip = "Rebuild the Final pane using the current edit settings."
+ run_heavy = False
background_mode = normalize_background_removal_mode(
getattr(alpha_settings, "background_removal_mode", None),
remove_white_bg=bool(getattr(alpha_settings, "remove_white_bg", False)),
@@ -67,39 +51,31 @@ def build_control_strip_view_state(asset: object | None, heavy_queue_state: obje
if running_heavy_jobs > 0:
queue_badge_text = f"Running: {running_heavy_jobs}"
queue_badge_tone = "running"
- apply_button_text = "Run Heavy"
- apply_button_tooltip = "Run the queued heavy steps for the active asset."
+ run_button_text = "Heavy Steps Running"
+ run_button_tooltip = "Heavy processing is currently running."
+ run_heavy = True
elif queued_heavy_jobs > 0:
queue_badge_text = f"Queued: {queued_heavy_jobs}"
queue_badge_tone = "queued"
- apply_button_text = f"Run {queued_heavy_jobs} Heavy"
- apply_button_tooltip = "Run the queued heavy steps for the active asset."
+ run_button_text = f"Run {queued_heavy_jobs} Heavy"
+ run_button_tooltip = "Run the queued heavy steps and refresh the Final pane."
+ run_heavy = True
return ControlStripViewState(
has_asset=True,
- apply_target=apply_target,
- sync_current_final=bool(getattr(edit_state, "sync_current_final", True)),
- auto_apply_light=bool(getattr(edit_state, "auto_apply_light", True)),
queued_heavy_jobs=max(0, queued_heavy_jobs),
running_heavy_jobs=max(0, running_heavy_jobs),
- summary_text=f"{sync_text} | {preview_text}",
+ summary_text="Current is the source | Final updates automatically",
queue_badge_text=queue_badge_text,
queue_badge_tone=queue_badge_tone,
- target_badge_text=f"Target: {target_label}",
background_mode=background_mode,
background_button_text=_BACKGROUND_BUTTON_TEXT[background_mode],
background_button_tooltip=_BACKGROUND_BUTTON_TOOLTIPS[background_mode],
- apply_button_text=apply_button_text,
- apply_button_tooltip=apply_button_tooltip,
+ run_button_text=run_button_text,
+ run_button_tooltip=run_button_tooltip,
+ run_heavy=run_heavy,
)
-
-_TARGET_LABELS: dict[str, str] = {
- ApplyTarget.CURRENT.value: "Current",
- ApplyTarget.FINAL.value: "Final",
- ApplyTarget.BOTH.value: "Both",
-}
-
_BACKGROUND_BUTTON_TEXT: dict[str, str] = {
BackgroundRemovalMode.OFF.value: "Keep BG",
BackgroundRemovalMode.WHITE.value: "Remove White",
@@ -120,4 +96,4 @@ def _int_attr(source: object | None, name: str, *, default: int) -> int:
return max(0, int(getattr(source, name, default)))
except Exception:
return max(0, int(default))
-
+
diff --git a/image_engine_app/ui/main_window/encoding_coordinator.py b/image_engine_app/ui/main_window/encoding_coordinator.py
deleted file mode 100644
index b7b45cb..0000000
--- a/image_engine_app/ui/main_window/encoding_coordinator.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""Encoding workflow coordinator for the main window."""
-
-from __future__ import annotations
-
-from typing import Any
-
-from image_engine_app.engine.models import ChromaSubsampling, ExportFormat
-
-
-class EncodingCoordinator:
- """Owns encoding dialog show/apply actions for the main window."""
-
- def __init__(self, window: Any) -> None:
- self._window = window
-
- def show_export_encoding_window(self) -> None:
- asset = self._window.ui_state.active_asset
- if asset is not None:
- self._window.export_encoding_dialog.load_from_asset(asset)
- self._window.export_encoding_dialog.show()
- self._window.export_encoding_dialog.raise_()
- self._window.export_encoding_dialog.activateWindow()
-
- def on_export_encoding_apply_requested(self, options_obj: object) -> None:
- asset = self._window.ui_state.active_asset
- if asset is None:
- self._window._status("Encoding apply skipped: no active asset")
- return
-
- export_settings = getattr(getattr(getattr(asset, "edit_state", None), "settings", None), "export", None)
- if export_settings is None:
- self._window._status("Encoding apply skipped: export settings unavailable")
- return
-
- try:
- fmt_raw = str(
- getattr(
- options_obj,
- "format",
- getattr(export_settings.format, "value", ExportFormat.AUTO.value),
- )
- ).lower()
- export_settings.format = ExportFormat(fmt_raw)
-
- quality = int(getattr(options_obj, "quality", export_settings.quality))
- export_settings.quality = max(1, min(100, quality))
-
- compression = int(getattr(options_obj, "compression_level", export_settings.compression_level))
- export_settings.compression_level = max(0, min(9, compression))
-
- chroma_raw = str(
- getattr(
- options_obj,
- "chroma_subsampling",
- getattr(export_settings.chroma_subsampling, "value", ChromaSubsampling.AUTO.value),
- )
- ).lower()
- export_settings.chroma_subsampling = ChromaSubsampling(chroma_raw)
-
- export_settings.strip_metadata = bool(getattr(options_obj, "strip_metadata", export_settings.strip_metadata))
-
- self._window.ui_state.set_active_asset(asset)
- self._window._refresh_export_prediction()
- self._window._status("Applied encoding settings to current asset")
- except Exception as exc:
- self._window._status(f"Encoding apply failed: {exc}")
-
diff --git a/image_engine_app/ui/main_window/export_bar.py b/image_engine_app/ui/main_window/export_bar.py
index 46da3bb..a52c373 100644
--- a/image_engine_app/ui/main_window/export_bar.py
+++ b/image_engine_app/ui/main_window/export_bar.py
@@ -165,6 +165,6 @@ def _on_active_asset_changed(self, asset: object) -> None:
self._profile_combo.blockSignals(True)
self._profile_combo.setCurrentIndex(idx)
self._profile_combo.blockSignals(False)
- break
-
-
+ break
+
+
diff --git a/image_engine_app/ui/main_window/local_import_coordinator.py b/image_engine_app/ui/main_window/local_import_coordinator.py
index 0066dd5..b17db2a 100644
--- a/image_engine_app/ui/main_window/local_import_coordinator.py
+++ b/image_engine_app/ui/main_window/local_import_coordinator.py
@@ -1,4 +1,4 @@
-"""Local file/folder/ZIP import coordinator for the main window."""
+"""Local file and folder import coordinator for the main window."""
from __future__ import annotations
@@ -12,7 +12,7 @@
class LocalImportCoordinator:
- """Owns local import actions exposed from the top toolbar."""
+ """Owns the File menu's two distinct add workflows."""
def __init__(self, window: Any) -> None:
self._window = window
@@ -23,18 +23,25 @@ def import_files(self) -> None:
paths, _selected = QFileDialog.getOpenFileNames(
self._window,
- "Import Image Files",
+ "Add Files to Workspace",
self._default_import_directory(),
- self._window._local_file_dialog_filter(),
+ self._window._local_import_dialog_filter(),
)
if not paths:
return
+ sources, issues = self._expand_selected_files(paths)
+ if issues:
+ self._window._show_error("Some Files Could Not Be Added", "\n".join(issues))
+ if not sources:
+ self._window._status("Add files failed: no supported images were found")
+ return
+
self._import_sources(
- paths,
+ sources,
recursive=False,
preserve_structure=False,
- source_label="Imported files",
+ source_label="Added files",
)
def import_folder(self) -> None:
@@ -56,38 +63,6 @@ def import_folder(self) -> None:
source_label="Imported folder",
)
- def import_zip_archive(self) -> None:
- if not self._has_controller():
- return
-
- path, _selected = QFileDialog.getOpenFileName(
- self._window,
- "Import ZIP Archive",
- self._default_import_directory(),
- "ZIP Files (*.zip);;All Files (*)",
- )
- if not path:
- return
-
- zip_path = Path(path)
- try:
- extracted = self._extract_zip_images(zip_path)
- except (ZipExtractError, OSError, ValueError) as exc:
- self._window._show_error("ZIP Import Failed", str(exc))
- self._window._status(f"ZIP import failed: {exc}")
- return
-
- if not extracted:
- self._window._status(f"ZIP import skipped: no supported images in {zip_path.name}")
- return
-
- self._import_sources(
- extracted,
- recursive=False,
- preserve_structure=False,
- source_label=f"Imported ZIP: {zip_path.name}",
- )
-
def _has_controller(self) -> bool:
if self._window.controller is not None:
return True
@@ -140,6 +115,25 @@ def _extract_zip_images(self, zip_path: Path) -> list[str]:
extract_dir = extract_root / f"{zip_path.stem}_{uuid4().hex[:8]}"
return extract_images_only(str(zip_path), str(extract_dir), allowed_exts=allowed_exts)
+ def _expand_selected_files(self, paths: list[str]) -> tuple[list[str], list[str]]:
+ sources: list[str] = []
+ issues: list[str] = []
+ for raw_path in paths:
+ path = Path(raw_path)
+ if path.suffix.lower() != ".zip":
+ sources.append(raw_path)
+ continue
+ try:
+ extracted = self._extract_zip_images(path)
+ except (ZipExtractError, OSError, ValueError) as exc:
+ issues.append(f"{path.name}: {exc}")
+ continue
+ if not extracted:
+ issues.append(f"{path.name}: no supported images found")
+ continue
+ sources.extend(extracted)
+ return sources, issues
+
def _zip_extract_root(self) -> Path:
controller = self._window.controller
app_paths = getattr(controller, "app_paths", None) if controller is not None else None
diff --git a/image_engine_app/ui/main_window/main_window.py b/image_engine_app/ui/main_window/main_window.py
index cc088bc..88d936e 100644
--- a/image_engine_app/ui/main_window/main_window.py
+++ b/image_engine_app/ui/main_window/main_window.py
@@ -2,8 +2,6 @@
from __future__ import annotations
-from pathlib import Path
-
from image_engine_app.app.settings_store import SessionStore
from image_engine_app.app.ui_controller import ImageEngineUIController
from image_engine_app.engine.models import AssetRecord, SessionState
@@ -37,7 +35,6 @@
from image_engine_app.ui.main_window.control_strip import ControlStrip
from image_engine_app.ui.main_window.export_bar import ExportBar
from image_engine_app.ui.main_window.export_coordinator import ExportCoordinator
-from image_engine_app.ui.main_window.encoding_coordinator import EncodingCoordinator
from image_engine_app.ui.main_window.preview_panel import PreviewPanel
from image_engine_app.ui.main_window.settings_panel import SettingsPanel
from image_engine_app.ui.main_window.shell_coordinator import ShellCoordinator
@@ -47,16 +44,13 @@
from image_engine_app.ui.main_window.web_sources_panel import WebSourcesPanel
from image_engine_app.ui.main_window.web_sources_coordinator import WebSourcesCoordinator
from image_engine_app.ui.windows.batch_manager import BatchManagerDialog
-from image_engine_app.ui.windows.export_encoding import ExportEncodingDialog
from image_engine_app.ui.windows.preset_manager import PresetManagerDialog
class ImageEngineMainWindow(QMainWindow):
- """Prompt 16 main window shell for the Sprite Factory app."""
+ """Main Sprite Factory window and coordinator wiring."""
MAX_RENDERED_WORKSPACE_TABS = 100
- DEFAULT_WORKSPACE_RAIL_WIDTH = 248
- DEFAULT_WORKSPACE_INSPECTOR_WIDTH = 398
MOCK_PAGE_RAIL_WIDTH = 86
MOCK_WORKSPACE_PANEL_WIDTH = 276
MOCK_INSPECTOR_PANEL_WIDTH = 398
@@ -117,9 +111,15 @@ def __init__(
self._session_coordinator = SessionCoordinator(self)
self._local_import_coordinator = LocalImportCoordinator(self)
self._export_coordinator = ExportCoordinator(self)
- self.export_encoding_dialog = ExportEncodingDialog(self)
- self._encoding_coordinator = EncodingCoordinator(self)
- self.preset_manager_dialog = PresetManagerDialog(self.controller, self) if self.controller is not None else None
+ self.preset_manager_dialog = (
+ PresetManagerDialog(
+ self.controller,
+ self,
+ active_asset_provider=lambda: self.ui_state.active_asset,
+ )
+ if self.controller is not None
+ else None
+ )
self._build_ui()
self._bind_state()
@@ -199,18 +199,12 @@ def _build_top_toolbar(self) -> None:
self._add_toolbar_brand_lockup(toolbar)
toolbar.addSeparator()
- # Session/import menus
- self._add_toolbar_menu_button(
- toolbar,
- text="Session",
- icon_name=None,
- actions=self._build_session_menu_actions(),
- )
- self._add_toolbar_menu_button(
+ # Workspace files and local imports share one conventional File menu.
+ self._add_toolbar_popup_menu_button(
toolbar,
- text="Import",
- icon_name=None,
- actions=self._build_import_menu_actions(),
+ text="File",
+ menu=self._build_file_menu(),
+ tooltip="Workspace and local file actions",
)
toolbar.addSeparator()
@@ -223,13 +217,6 @@ def _build_top_toolbar(self) -> None:
toolbar.addSeparator()
self._add_toolbar_command_button(toolbar, text="Batch", callback=self._show_batch_manager)
- self._add_toolbar_command_button(
- toolbar,
- text="Presets",
- callback=(self._show_preset_manager if self.preset_manager_dialog is not None else None),
- tooltip="Open preset manager",
- width=76,
- )
toolbar.addSeparator()
self._compact_ui_action = QAction("Compact UI", self, checkable=True)
@@ -299,37 +286,6 @@ def _add_toolbar_brand_lockup(self, toolbar: QToolBar) -> None:
toolbar.addWidget(brand)
- def _add_toolbar_menu_button(
- self,
- toolbar: QToolBar,
- *,
- text: str,
- icon_name: str | None,
- actions: list[QAction],
- tooltip: str | None = None,
- ) -> None:
- menu = QMenu(self)
- for action in actions:
- menu.addAction(action)
-
- button = QToolButton(toolbar)
- button.setObjectName("toolbarMenuButton")
- button.setText(text)
- if icon_name:
- button.setIcon(icon(icon_name))
- button.setToolButtonStyle(
- Qt.ToolButtonStyle.ToolButtonIconOnly
- if not text
- else Qt.ToolButtonStyle.ToolButtonTextBesideIcon
- )
- else:
- button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
- button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
- button.setMenu(menu)
- button.setToolTip(tooltip or text)
- button.setFixedSize(72, 30)
- toolbar.addWidget(button)
-
def _add_toolbar_popup_menu_button(
self,
toolbar: QToolBar,
@@ -368,35 +324,27 @@ def _add_toolbar_command_button(
button.clicked.connect(callback)
toolbar.addWidget(button)
- def _build_session_menu_actions(self) -> list[QAction]:
- new_action = QAction(icon("new"), "New Session", self)
- new_action.triggered.connect(self._new_session)
-
- open_action = QAction(icon("open"), "Open Session", self)
- open_action.triggered.connect(self._open_session_file)
-
- save_action = QAction(icon("save"), "Save Session", self)
- save_action.triggered.connect(self._save_session_file)
+ def _build_file_menu(self) -> QMenu:
+ menu = QMenu(self)
- clear_action = QAction("Clear Session", self)
- clear_action.triggered.connect(self._clear_session)
+ new_action = menu.addAction(icon("new"), "New Workspace")
+ new_action.triggered.connect(self._new_workspace)
- sessions_folder_action = QAction("Sessions Folder", self)
- sessions_folder_action.triggered.connect(self._open_sessions_folder)
+ open_action = menu.addAction(icon("open"), "Open Workspace...")
+ open_action.triggered.connect(self._open_workspace_file)
- return [new_action, open_action, save_action, clear_action, sessions_folder_action]
+ save_action = menu.addAction(icon("save"), "Save Workspace...")
+ save_action.triggered.connect(self._save_workspace_file)
- def _build_import_menu_actions(self) -> list[QAction]:
- import_files_action = QAction("Import File(s)...", self)
- import_files_action.triggered.connect(self._import_files)
+ menu.addSeparator()
- import_folder_action = QAction("Import Folder...", self)
- import_folder_action.triggered.connect(self._import_folder)
+ add_files_action = menu.addAction("Add Files...")
+ add_files_action.triggered.connect(self._import_files)
- import_zip_action = QAction("Import ZIP...", self)
- import_zip_action.triggered.connect(self._import_zip_archive)
+ add_folder_action = menu.addAction("Add Folder...")
+ add_folder_action.triggered.connect(self._import_folder)
- return [import_files_action, import_folder_action, import_zip_action]
+ return menu
def _build_center_shell(self) -> None:
central = QWidget(self)
@@ -544,13 +492,6 @@ def _set_page_index(self, index: int) -> None:
button.setChecked(button_index == index)
button.blockSignals(False)
- def _default_workspace_splitter_sizes(self) -> list[int]:
- return [
- int(self.DEFAULT_WORKSPACE_RAIL_WIDTH),
- 1180,
- int(self.DEFAULT_WORKSPACE_INSPECTOR_WIDTH),
- ]
-
def _build_helper_tab_page(self, parent: QWidget) -> QWidget:
page = QWidget(parent)
page.setObjectName("shellHelperPage")
@@ -577,78 +518,113 @@ def _build_helper_tab_page(self, parent: QWidget) -> QWidget:
guide.setHtml(
"Quick Start
"
""
- "- Start: use Session for New, Open, Save, Clear, or Sessions Folder.
"
- "- Add assets: use the top Import menu for files/folders/ZIPs, or Web Sources for website scans.
"
+ "- Start: open File to create, open, or save a workspace.
"
+ "- Add assets: use File for images, folders, or ZIPs, or Web Sources for website scans.
"
f"- Supported formats: {self._local_extensions_label()}.
"
"- Edit: use the center previews, bottom tools, and right-side edit settings.
"
- "- Finish: Preview, Apply, Skip, Export, or send selected assets through Batch.
"
+ "- Finish: review the automatically updated Final pane, then Export, Skip, or send selected assets through Batch.
"
"
"
"Studio Shell
"
""
"- Left rail: Workspace, Web Sources, and Helper.
"
"- Workspace panel: asset list, empty state, and 100-item queue paging.
"
"- Preview Studio: Current and Final stay central; use per-pane Reset to refit either side.
"
- "- Tools shelf: quick Preset, Target, Live, Run, BG handling, reset, and export workflow controls.
"
- "- Edit Settings: right-side tiles jump to Pixel, Color, Detail, Cleanup, Edges, Alpha, GIF, Export, and Encoding.
"
+ "- Tools shelf: compatible Presets, background handling, Final refresh, processing, and reset actions.
"
+ "- Edit Settings: right-side tiles jump to Pixel, Color, Detail, Cleanup, Edges, Alpha, GIF, and Export.
"
"
"
"Top Toolbar
"
""
- "- Session: New Session, Open Session, Save Session, Clear Session, and Sessions Folder.
"
- "- Import: the only local import control; use it for files, folders, or ZIPs.
"
+ "- File: New Workspace, Open Workspace, Save Workspace, Add Files, and Add Folder.
"
+ "- Add Files: choose one or more supported images or ZIP archives. ZIP images are safely extracted and added through the same import workflow.
"
+ "- New / Open Workspace: if the current workspace contains work, Sprite Factory asks whether to save, discard, or cancel before replacing it.
"
"- Format / Alpha / Frames: quick readout for the selected asset.
"
- "- Batch / Presets: open the queue manager and advanced preset editor directly.
"
+ "- Batch: open the queue manager for multi-asset editing and export.
"
"- View: Compare View, Current Only, Final Only, Compact UI, and Reset Shell live here.
"
"
"
"Workspace Flow
"
""
"- Import or download assets, then choose one from Workspace.
"
- "- Use Target to choose Current, Final, or Both for the next apply.
"
- "- Use Live to link views or turn Auto on/off for preview updates.
"
- "- Use BG for background handling: keep, remove white, or remove black.
"
- "- More keeps reset actions separate: Reset Edits changes image edits; Reset View changes zoom/pan.
"
+ "- Current always shows the untouched source; Final shows your active edits.
"
+ "- Visual control changes refresh Final automatically. Use Refresh Final to rebuild it manually whenever needed.
"
+ "- When a preset queues heavier processing, the same Run button changes to Run Heavy.
"
+ "- Use the background menu to keep the image background, remove white, or remove black.
"
+ "- Reset Edits restores the controls detected for that asset at import. Reset View changes zoom only.
"
"- The export footer has Profile, Export, Skip, folder output, Auto-next, and Estimate.
"
"
"
"Settings Groups
"
""
- "- Pixel and Resolution: resize %, DPI metadata, target width/height, scale method.
"
+ "- Pixel and Resolution: Output size offers 2x/3x/4x/8x sprite scaling and 240p-2160p output heights. Standard heights keep width on Auto to preserve proportions.
"
+ "- Resize % / target dimensions: these change real pixel dimensions. DPI changes print metadata only and does not create extra image detail.
"
"- Color and Light, Detail, Cleanup, Edges: core visual tuning controls.
"
"- Transparency: keep/remove backgrounds, refine alpha, and check matte handling.
"
- "- GIF Controls: only enabled for animated assets.
"
- "- Export: format/quality/metadata options.
"
- "- Export Encoding: compression, chroma, palette tuning, and Encoding Window launch.
"
+ "- GIF Controls: timing, looping, palette, dithering, and frame optimization for animated GIFs.
"
+ "- Export: profile, format, quality, metadata, PNG compression, JPEG chroma, and ICO sizes in one place.
"
"- Choose what to edit: tiles jump directly to the matching settings card.
"
"
"
"Presets
"
""
- "- Preset in the Tools shelf applies a compatible preset to the active asset for quick polish.
"
- "- Presets in the top toolbar opens the advanced manager where you can duplicate a system template, set compatible formats/tags, edit the JSON delta, then save a user preset.
"
- "- Presets are compatibility-aware; GIF-safe presets avoid breaking animated assets.
"
- "- Batch can copy current edits, apply a preset, apply background overrides, process, then export.
"
+ "- Each imported asset keeps its own detected control baseline. A preset starts from that baseline rather than blank defaults.
"
+ "- Preset in the Tools shelf applies one compatible preset to the active asset and refreshes Final.
"
+ "- Manage Presets... at the bottom of the Tools preset menu opens Preset Studio. Choose New from Active or Use Active Controls; only controls changed from the detected baseline are saved.
"
+ "- The Tools menu, Preset Studio, and Batch all use the same preset library. Bundled presets and your saved presets are merged once, then filtered for the active asset.
"
+ "- Format and asset-type scopes are filled from the active asset. The JSON editor is optional under Advanced.
"
+ "- User presets appear in Workspace and Batch immediately. Compatibility checks protect animated GIFs and format-specific assets. Reset Edits restores the detected controls from before the preset was chosen.
"
"
"
"Web Sources
"
- ""
- "- Use 1. Scan Pages to paste one page URL, or paste a list of page URLs and scan them together.
"
- "- Use Save URL as Page when you want to keep one page as a saved shortcut.
"
- "- Use 2. Saved Shortcuts only when you want to scan a saved shortcut again.
"
- "- Use 3. Find Linked Pages only when a page is an index/category page and you need to discover linked pages first.
"
- "- Large linked-page scans are capped at 100 pages unless you confirm, which helps avoid freezing on huge sites.
"
- "- Use 4. Found Files to filter by PNG/GIF/WEBP/JPG/ZIP, search names/URLs, and select files.
"
- "- Select files and click Download Selected; Sprite Factory routes them into the workspace automatically.
"
- "- Use More for Network Check or saved-page cleanup.
"
- "
"
+ "Web Sources uses one scan system for entered URLs, saved pages, and linked pages. Every successful scan adds unique files to the same Found Files list instead of replacing earlier results.
"
+ "1. Scan Pages
"
+ ""
+ "- Paste one full page URL per line. The list can contain pages from several different websites.
"
+ "- Scan Pages validates the list, removes repeated URLs, applies the safety limit, and sends the remaining pages through the same scan call.
"
+ "- More contains only entered-URL actions: Save Entered Pages, Check First URL, Include uncertain image links, and Clear Entered URLs.
"
+ "- Include uncertain image links is optional. Enable it when a website hides useful image links behind URLs that do not end with a normal file extension.
"
+ "
"
+ "2. Saved Pages
"
+ ""
+ "- Save Entered Pages groups entered URLs by website and stores each exact page once.
"
+ "- Expand a website and check individual pages, or check the website row to choose all of its pages. Pages from different websites can be checked together.
"
+ "- Scan Selected sends every checked page through the normal scan call. If nothing is checked, the currently highlighted page is used.
"
+ "- More contains saved-page actions only: check the current page, clear checked pages, check the current page connection, remove the current page, or remove the current website.
"
+ "- Changing or removing a saved shortcut never clears Found Files.
"
+ "
"
+ "3. Find Linked Pages
"
+ ""
+ "- This optional section is for index, category, or directory pages that link to many separate sprite pages.
"
+ "- Use Discover from to choose exactly which entered or saved page will be inspected, then click Find Pages.
"
+ "- Search the discovered page list, select the pages you need, then click Scan Selected. Finding pages does not download or scan files by itself.
"
+ "- More contains linked-page actions only: select visible pages, clear page selection, and clear linked pages.
"
+ "- Any scan above 100 pages asks for confirmation and scans only the first 100, reducing the chance of a freeze or website rate limit.
"
+ "
"
+ "4. Found Files and Download
"
+ ""
+ "- Found Files is a persistent basket. New scans add unique file URLs; repeated URLs and failed pages do not erase earlier results.
"
+ "- Search matches filenames, URLs, and source pages. Hide words excludes matching results. File Types controls PNG, GIF, WEBP, JPG/JPEG, and ZIP visibility.
"
+ "- More contains result actions only: select all visible files, clear file selection, and Clear Found Files.
"
+ "- Download Options controls skipping files already downloaded and whether ZIP extraction is allowed.
"
+ "- Download Selected imports selected files and automatically routes them into Main, Shiny, Animated, or Items in the workspace.
"
+ "- Only Clear Found Files empties the result basket. Search filters, URL clearing, saved-page changes, linked-page clearing, cancellation, and failed scans keep it intact.
"
+ "
"
+ "Web Source Status and Errors
"
+ ""
+ "- The status line reports new files, total stored files, duplicates, filtered links, and failed pages. Hover a failure message for page details.
"
+ "- A connection check tests the selected page but never starts a scan. HTTP 403/429 usually means the website blocked or limited automated requests; HTTP 5xx means the website server failed.
"
+ "
"
"Batch
"
""
- "- Select queue items, choose optional rules, then click Run Batch.
"
+ "- Select queue items, choose one edit source, then click Run Selected.
"
"- Use Queue for Select all, Select failed, and Clear selection.
"
- "- Use Options for Smart presets, Export after processing, and Fast run.
"
- "- Run order: copy current edits, apply chosen preset, apply background override, process, export.
"
+ "- Keep each asset's controls uses every file exactly as it is currently configured.
"
+ "- Apply one preset starts from each asset's detected baseline and applies the same compatible saved preset.
"
+ "- Copy active asset controls deliberately copies the selected Workspace asset's controls to the batch.
"
+ "- Smart match each asset chooses at most one compatible system preset per asset; it never stacks hidden presets.
"
+ "- Background override is applied after the chosen edit source. Use Options only for Export after processing and Fast run.
"
"- Use naming/output options for clean filenames and a consistent export folder.
"
"- Failed items can be selected again after the run.
"
"
"
"Troubleshooting
"
""
"- If Web Sources returns blocked errors such as WinError 10013, check firewall/VPN/proxy/antivirus web shield.
"
- "- If a site blocks scan requests (HTTP 403/429), try Network Check or use direct image URLs.
"
+ "- If a site blocks scan requests (HTTP 403/429), try the section's connection check or use direct image URLs.
"
"- Some settings groups lock automatically when the active asset does not support that feature.
"
"
"
)
@@ -664,16 +640,19 @@ def _local_extensions_label(cls) -> str:
return ", ".join(labels)
@classmethod
- def _local_file_dialog_filter(cls) -> str:
+ def _local_import_dialog_filter(cls) -> str:
patterns = " ".join(f"*{ext}" for ext in cls._supported_local_extensions())
- return f"Supported Images ({patterns});;All Files (*)"
+ return (
+ f"Supported Images and ZIPs ({patterns} *.zip);;"
+ f"Supported Images ({patterns});;"
+ "ZIP Archives (*.zip);;All Files (*)"
+ )
def _bind_state(self) -> None:
self.preview_panel.bind_state(self.ui_state)
self.control_strip.bind_state(self.ui_state)
self.export_bar.bind_state(self.ui_state)
self.settings_panel.bind_state(self.ui_state)
- self.settings_panel.open_encoding_window_requested.connect(self._show_export_encoding_window)
self.ui_state.status_message_changed.connect(self._status)
self.ui_state.active_asset_changed.connect(self._on_active_asset_changed)
@@ -695,20 +674,15 @@ def _bind_state(self) -> None:
self.asset_tabs.window_section_requested.connect(self._on_workspace_window_section_requested)
self.batch_manager_dialog.run_requested.connect(self._on_batch_run_requested)
self.batch_manager_dialog.cancel_run_requested.connect(self._on_batch_cancel_requested)
- self.export_encoding_dialog.apply_requested.connect(self._on_export_encoding_apply_requested)
self.web_sources_panel.registry_changed.connect(self._on_web_sources_registry_changed)
self.web_sources_panel.scan_requested.connect(self._on_web_sources_scan_requested)
- self.web_sources_panel.index_links_requested.connect(self._on_web_sources_index_links_requested)
- self.web_sources_panel.index_scan_all_requested.connect(self._on_web_sources_index_scan_all_requested)
- self.web_sources_panel.multi_scan_requested.connect(self._on_web_sources_multi_scan_requested)
+ self.web_sources_panel.discover_links_requested.connect(self._on_web_sources_discover_links_requested)
self.web_sources_panel.download_requested.connect(self._on_web_sources_download_requested)
- self.web_sources_panel.network_diagnostics_requested.connect(self._on_web_sources_network_diagnostics_requested)
+ self.web_sources_panel.diagnostics_requested.connect(self._on_web_sources_diagnostics_requested)
+ self.web_sources_panel.preferences_changed.connect(self._on_web_sources_preferences_changed)
if self.preset_manager_dialog is not None and self.controller is not None:
- # Keep batch preset choices fresh after saves/deletes in the manager.
- self.preset_manager_dialog.finished.connect(
- lambda _code=0: self._refresh_preset_surfaces()
- )
+ self.preset_manager_dialog.presets_changed.connect(self._refresh_preset_surfaces)
self._init_web_sources_panel()
self._refresh_preset_surfaces()
@@ -819,20 +793,14 @@ def _on_export_directory_browse_requested(self) -> None:
def _on_export_directory_open_requested(self) -> None:
self._export_coordinator.on_export_directory_open_requested()
- def _new_session(self) -> None:
- self._session_coordinator.new_session()
+ def _new_workspace(self) -> None:
+ self._session_coordinator.new_workspace()
- def _save_session_file(self) -> None:
- self._session_coordinator.save_session_file()
+ def _save_workspace_file(self) -> None:
+ self._session_coordinator.save_workspace_file()
- def _open_session_file(self) -> None:
- self._session_coordinator.open_session_file()
-
- def _open_sessions_folder(self) -> None:
- self._session_coordinator.open_sessions_folder()
-
- def _clear_session(self) -> None:
- self._session_coordinator.clear_session()
+ def _open_workspace_file(self) -> None:
+ self._session_coordinator.open_workspace_file()
def _import_files(self) -> None:
self._local_import_coordinator.import_files()
@@ -840,12 +808,6 @@ def _import_files(self) -> None:
def _import_folder(self) -> None:
self._local_import_coordinator.import_folder()
- def _import_zip_archive(self) -> None:
- self._local_import_coordinator.import_zip_archive()
-
- def _load_workspace_from_file(self, path: Path, *, source_label: str) -> None:
- self._session_coordinator.load_workspace_from_file(path, source_label=source_label)
-
def _show_batch_manager(self) -> None:
self._batch_coordinator.show_manager()
@@ -858,12 +820,6 @@ def _show_preset_manager(self) -> None:
self.preset_manager_dialog.raise_()
self.preset_manager_dialog.activateWindow()
- def _show_export_encoding_window(self) -> None:
- self._encoding_coordinator.show_export_encoding_window()
-
- def _on_export_encoding_apply_requested(self, options_obj: object) -> None:
- self._encoding_coordinator.on_export_encoding_apply_requested(options_obj)
-
def compact_ui_enabled(self) -> bool:
return self._shell_coordinator.compact_ui_enabled()
@@ -882,20 +838,17 @@ def _on_web_sources_registry_changed(self, payload: object) -> None:
def _on_web_sources_scan_requested(self, payload: object) -> None:
self._web_sources_coordinator.on_scan_requested(payload)
- def _on_web_sources_index_links_requested(self, payload: object) -> None:
- self._web_sources_coordinator.on_index_links_requested(payload)
-
- def _on_web_sources_index_scan_all_requested(self, payload: object) -> None:
- self._web_sources_coordinator.on_index_scan_all_requested(payload)
-
- def _on_web_sources_multi_scan_requested(self, payload: object) -> None:
- self._web_sources_coordinator.on_multi_scan_requested(payload)
+ def _on_web_sources_discover_links_requested(self, payload: object) -> None:
+ self._web_sources_coordinator.on_discover_links_requested(payload)
def _on_web_sources_download_requested(self, payload: object) -> None:
self._web_sources_coordinator.on_download_requested(payload)
- def _on_web_sources_network_diagnostics_requested(self, payload: object) -> None:
- self._web_sources_coordinator.on_network_diagnostics_requested(payload)
+ def _on_web_sources_diagnostics_requested(self, payload: object) -> None:
+ self._web_sources_coordinator.on_diagnostics_requested(payload)
+
+ def _on_web_sources_preferences_changed(self, payload: object) -> None:
+ self._web_sources_coordinator.on_preferences_changed(payload)
def _on_batch_run_requested(self, options_obj: object) -> None:
self._batch_coordinator.on_run_requested(options_obj)
@@ -1030,4 +983,4 @@ def _find_workspace_asset(self, asset_id: str) -> AssetRecord | None:
-
+
diff --git a/image_engine_app/ui/main_window/preview_panel.py b/image_engine_app/ui/main_window/preview_panel.py
index 5e6daa0..a8af8ff 100644
--- a/image_engine_app/ui/main_window/preview_panel.py
+++ b/image_engine_app/ui/main_window/preview_panel.py
@@ -93,9 +93,7 @@ def bind_state(self, ui_state: EngineUIState) -> None:
self._ui_state = ui_state
ui_state.active_asset_changed.connect(self._on_active_asset_changed)
- ui_state.sync_changed.connect(self._on_sync_changed)
ui_state.reset_view_requested.connect(self._on_reset_view_requested)
- self._on_sync_changed(ui_state.active_asset.edit_state.sync_current_final if ui_state.active_asset else True)
self._on_active_asset_changed(ui_state.active_asset)
def _build_ui(self) -> None:
@@ -259,11 +257,6 @@ def _update_pane_dividers(self, order: tuple[str, ...]) -> None:
f"QFrame#previewPaneContainer {{ border-right: {border}; }}"
)
- def _on_sync_changed(self, enabled: bool) -> None:
- _ = enabled
- # Sync still matters to edit behavior, but the preview layout is now fixed to Current + Final.
- self._layout_panes()
-
def _on_zoom_snap_toggled(self, enabled: bool) -> None:
self._zoom_snap_enabled = bool(enabled)
self._zoom_snap_button.setText("Crisp On" if self._zoom_snap_enabled else "Crisp Off")
@@ -685,15 +678,10 @@ def _resolve_preview_path_for_view(self, asset: object, view_key: str) -> Path |
source_path = self._resolve_preview_path(asset)
if view_key == "current":
- candidates = (
- getattr(asset, "derived_current_path", None),
- source_path,
- getattr(asset, "derived_final_path", None),
- )
+ candidates = (source_path,)
else:
candidates = (
getattr(asset, "derived_final_path", None),
- getattr(asset, "derived_current_path", None),
source_path,
)
@@ -734,4 +722,4 @@ def _empty_canvas_text() -> str:
-
+
diff --git a/image_engine_app/ui/main_window/session_coordinator.py b/image_engine_app/ui/main_window/session_coordinator.py
index cc871a8..53352ab 100644
--- a/image_engine_app/ui/main_window/session_coordinator.py
+++ b/image_engine_app/ui/main_window/session_coordinator.py
@@ -10,40 +10,46 @@
from image_engine_app.app.settings_store import load_path_preferences, save_path_preferences
from image_engine_app.engine.models import SessionState
-from PySide6.QtCore import QUrl
-from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QFileDialog, QMessageBox
class SessionCoordinator:
- """Owns new/open/save/clear session workflows for the main window."""
+ """Owns safe new/open/save workspace workflows for the main window."""
def __init__(self, window: Any) -> None:
self._window = window
- def new_session(self) -> None:
- """Start a fresh empty session and clear the workspace."""
+ def new_workspace(self) -> bool:
+ """Start an empty workspace without silently discarding current work."""
+
+ if not self._confirm_workspace_replacement(
+ title="New Workspace",
+ prompt="Save your current workspace before starting a new one?",
+ ):
+ self._window._status("New workspace canceled")
+ return False
self._window.load_workspace_state(self._create_empty_session(), [])
- self._window._status("New session created")
+ self._window._status("New workspace created")
+ return True
- def save_session_file(self) -> bool:
+ def save_workspace_file(self) -> bool:
"""Save the current workspace session + assets to a chosen JSON file."""
if self._window.session_store is None:
- self._window._status("Save session unavailable: session store not configured")
+ self._window._status("Save workspace unavailable: workspace store not configured")
return False
session = self._window.ui_state.session
if session is None:
- self._window._status("Save session skipped: no active session")
+ self._window._status("Save workspace skipped: no active workspace")
return False
- default_dir = self._default_session_directory()
+ default_dir = self._default_workspace_directory()
default_base = Path(default_dir) if default_dir else Path.home()
- default_name = f"session_{session.session_id}.json"
+ default_name = f"workspace_{session.session_id}.json"
path_str, _selected = QFileDialog.getSaveFileName(
self._window,
- "Save Session",
+ "Save Workspace",
str(default_base / default_name),
"JSON Files (*.json);;All Files (*)",
)
@@ -60,24 +66,24 @@ def save_session_file(self) -> bool:
session,
self._window.workspace_assets,
)
- self._remember_session_directory(result.path.parent)
- self._window._status(f"Session saved: {result.path.name} | folder: {result.path.parent}")
+ self._remember_workspace_directory(result.path.parent)
+ self._window._status(f"Workspace saved: {result.path.name} | folder: {result.path.parent}")
return True
except Exception as exc:
- self._window._show_error("Save Session Failed", str(exc))
+ self._window._show_error("Save Workspace Failed", str(exc))
return False
- def open_session_file(self) -> None:
+ def open_workspace_file(self) -> None:
"""Open a saved session/workspace JSON file."""
if self._window.session_store is None:
- self._window._status("Open session unavailable: session store not configured")
+ self._window._status("Open workspace unavailable: workspace store not configured")
return
- start_dir = self._default_session_directory()
+ start_dir = self._default_workspace_directory()
path_str, _selected = QFileDialog.getOpenFileName(
self._window,
- "Open Session",
+ "Open Workspace",
start_dir,
"JSON Files (*.json);;All Files (*)",
)
@@ -85,31 +91,14 @@ def open_session_file(self) -> None:
return
target = Path(path_str)
- if self.load_workspace_from_file(target, source_label="Session"):
- self._remember_session_directory(target.parent)
-
- def clear_session(self) -> None:
- """Clear the active workspace, prompting to save before discarding work."""
-
- if self._has_workspace_content():
- answer = QMessageBox.question(
- self._window,
- "Clear Session",
- "Save your current work before clearing the session?",
- QMessageBox.StandardButton.Save
- | QMessageBox.StandardButton.Discard
- | QMessageBox.StandardButton.Cancel,
- QMessageBox.StandardButton.Save,
- )
- if answer == QMessageBox.StandardButton.Cancel:
- self._window._status("Clear session canceled")
- return
- if answer == QMessageBox.StandardButton.Save and not self.save_session_file():
- self._window._status("Clear session canceled")
- return
-
- self._window.load_workspace_state(self._create_empty_session(), [])
- self._window._status("Session cleared")
+ if not self._confirm_workspace_replacement(
+ title="Open Workspace",
+ prompt="Save your current workspace before opening another one?",
+ ):
+ self._window._status("Open workspace canceled")
+ return
+ if self.load_workspace_from_file(target, source_label="Workspace"):
+ self._remember_workspace_directory(target.parent)
def load_workspace_from_file(self, path: Path, *, source_label: str) -> bool:
"""Load a workspace/session JSON file and replace current workspace state."""
@@ -128,20 +117,24 @@ def load_workspace_from_file(self, path: Path, *, source_label: str) -> bool:
self._window._status(f"{source_label} loaded: {loaded.path.name} ({len(loaded.assets)} asset(s))")
return True
- def open_sessions_folder(self) -> None:
- """Open the sessions storage directory in the system file manager."""
-
- if self._window.session_store is None:
- self._window._status("Open sessions folder unavailable: session store not configured")
- return
+ def _confirm_workspace_replacement(self, *, title: str, prompt: str) -> bool:
+ if not self._has_workspace_content():
+ return True
- default_dir = self._default_session_directory()
- folder = Path(default_dir) if default_dir else self._window.session_store.paths.sessions
- folder.mkdir(parents=True, exist_ok=True)
- if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(folder))):
- self._window._status(f"Open sessions folder failed: {folder}")
- return
- self._window._status(f"Opened sessions folder: {folder}")
+ answer = QMessageBox.question(
+ self._window,
+ title,
+ prompt,
+ QMessageBox.StandardButton.Save
+ | QMessageBox.StandardButton.Discard
+ | QMessageBox.StandardButton.Cancel,
+ QMessageBox.StandardButton.Save,
+ )
+ if answer == QMessageBox.StandardButton.Cancel:
+ return False
+ if answer == QMessageBox.StandardButton.Save:
+ return self.save_workspace_file()
+ return True
@staticmethod
def _create_empty_session() -> SessionState:
@@ -170,7 +163,7 @@ def _has_workspace_content(self) -> bool:
or session.macros
)
- def _default_session_directory(self) -> str:
+ def _default_workspace_directory(self) -> str:
if self._window.session_store is None:
return ""
try:
@@ -182,7 +175,7 @@ def _default_session_directory(self) -> str:
return preferred.strip()
return str(self._window.session_store.paths.sessions)
- def _remember_session_directory(self, path: Path) -> None:
+ def _remember_workspace_directory(self, path: Path) -> None:
if self._window.session_store is None:
return
try:
diff --git a/image_engine_app/ui/main_window/settings_group_builders.py b/image_engine_app/ui/main_window/settings_group_builders.py
index 0b0fa59..d667404 100644
--- a/image_engine_app/ui/main_window/settings_group_builders.py
+++ b/image_engine_app/ui/main_window/settings_group_builders.py
@@ -5,15 +5,15 @@
from functools import partial
from typing import Any, Callable
-from PySide6.QtWidgets import QCheckBox, QComboBox, QFormLayout, QLabel, QLineEdit, QPushButton, QSpinBox, QWidget
+from PySide6.QtWidgets import QCheckBox, QComboBox, QFormLayout, QLabel, QLineEdit, QSpinBox, QWidget
from image_engine_app.engine.models import (
BackgroundRemovalMode,
ChromaSubsampling,
ExportFormat,
- ExportProfile,
ScaleMethod,
)
+from image_engine_app.engine.process.output_size import CUSTOM_SIZE, OUTPUT_SIZE_CHOICES
def settings_group_builders(panel: Any) -> dict[str, Callable[[QFormLayout, QWidget], None]]:
@@ -24,14 +24,24 @@ def settings_group_builders(panel: Any) -> dict[str, Callable[[QFormLayout, QWid
"Cleanup": partial(build_cleanup_group, panel),
"Edges": partial(build_edges_group, panel),
"Transparency": partial(build_transparency_group, panel),
- "AI Enhance": partial(build_ai_enhance_group, panel),
"GIF Controls": partial(build_gif_controls_group, panel),
"Export": partial(build_export_group, panel),
- "Export Encoding": partial(build_export_encoding_group, panel),
}
def build_pixel_resolution_group(panel: Any, form: QFormLayout, page: QWidget) -> None:
+ output_size = QComboBox(page)
+ for choice in OUTPUT_SIZE_CHOICES:
+ output_size.addItem(choice.label, choice.key)
+ output_size.addItem("Custom dimensions", CUSTOM_SIZE)
+ output_size.setToolTip(
+ "Choose an integer scale for sprites or a standard output height. "
+ "Standard heights keep width on Auto so the image aspect ratio is preserved."
+ )
+ output_size.currentIndexChanged.connect(panel._on_output_size_changed)
+ panel._output_size = output_size
+ form.addRow("Output size", output_size)
+
resize = panel._new_float_spin(page, minimum=1.0, maximum=3200.0, step=10.0, default=100.0)
resize.setSuffix(" %")
resize.valueChanged.connect(panel._on_resize_percent_changed)
@@ -222,32 +232,11 @@ def build_transparency_group(panel: Any, form: QFormLayout, page: QWidget) -> No
form.addRow("Alpha threshold", threshold)
-def build_ai_enhance_group(panel: Any, form: QFormLayout, page: QWidget) -> None:
- upscale = panel._new_float_spin(page, minimum=1.0, maximum=16.0, step=0.5, default=1.0)
- panel._bind_float(upscale, "ai", "upscale_factor")
- panel._upscale_factor = upscale
- form.addRow("Upscale factor", upscale)
-
- deblur = panel._new_float_spin(page, minimum=0.0, maximum=2.0, step=0.05, default=0.0)
- panel._bind_float(deblur, "ai", "deblur_strength")
- panel._ai_deblur = deblur
- form.addRow("Deblur strength", deblur)
-
- reconstruct = panel._new_float_spin(page, minimum=0.0, maximum=2.0, step=0.05, default=0.0)
- panel._bind_float(reconstruct, "ai", "detail_reconstruct")
- panel._ai_detail_reconstruct = reconstruct
- form.addRow("Detail reconstruct", reconstruct)
-
- bg_remove = panel._new_float_spin(page, minimum=0.0, maximum=2.0, step=0.05, default=0.0)
- panel._bind_float(bg_remove, "ai", "bg_remove_strength")
- panel._ai_bg_remove = bg_remove
- form.addRow("BG remove strength", bg_remove)
-
-
def build_gif_controls_group(panel: Any, form: QFormLayout, page: QWidget) -> None:
frame_delay = QSpinBox(page)
- frame_delay.setRange(1, 5000)
- frame_delay.setValue(100)
+ frame_delay.setRange(0, 5000)
+ frame_delay.setValue(0)
+ frame_delay.setSpecialValueText("Keep original")
frame_delay.setSuffix(" ms")
panel._bind_int(frame_delay, "gif", "frame_delay_ms")
panel._frame_delay = frame_delay
@@ -283,14 +272,6 @@ def build_export_group(panel: Any, form: QFormLayout, page: QWidget) -> None:
panel._export_format_hint = format_hint
form.addRow("", format_hint)
- export_profile = QComboBox(page)
- export_profile.addItem("Web", ExportProfile.WEB)
- export_profile.addItem("App Asset", ExportProfile.APP_ASSET)
- export_profile.addItem("Print", ExportProfile.PRINT)
- export_profile.currentIndexChanged.connect(panel._on_export_profile_changed)
- panel._export_profile = export_profile
- form.addRow("Export profile", export_profile)
-
export_format = QComboBox(page)
export_format.addItem("Auto (profile/source)", ExportFormat.AUTO)
export_format.addItem("PNG", ExportFormat.PNG)
@@ -330,20 +311,12 @@ def build_export_group(panel: Any, form: QFormLayout, page: QWidget) -> None:
panel._strip_metadata = strip
form.addRow("", strip)
-
-def build_export_encoding_group(panel: Any, form: QFormLayout, page: QWidget) -> None:
- open_encoding = QPushButton("Open Encoding Window", page)
- open_encoding.setAutoDefault(False)
- open_encoding.clicked.connect(panel._emit_open_encoding_window)
- panel._open_encoding_window_btn = open_encoding
- form.addRow("", open_encoding)
-
compression = QSpinBox(page)
compression.setRange(0, 9)
compression.setValue(6)
panel._bind_int(compression, "export", "compression_level")
panel._compression_level = compression
- form.addRow("Compression level", compression)
+ form.addRow("PNG compression", compression)
chroma = QComboBox(page)
chroma.addItem("Auto", ChromaSubsampling.AUTO)
@@ -352,15 +325,7 @@ def build_export_encoding_group(panel: Any, form: QFormLayout, page: QWidget) ->
chroma.addItem("4:2:0", ChromaSubsampling.CS_420)
chroma.currentIndexChanged.connect(panel._on_chroma_subsampling_changed)
panel._chroma_subsampling = chroma
- form.addRow("Chroma subsampling", chroma)
-
- palette_limit = QSpinBox(page)
- palette_limit.setRange(0, 256)
- palette_limit.setValue(0)
- palette_limit.setSpecialValueText("Auto")
- palette_limit.valueChanged.connect(panel._on_palette_limit_changed)
- panel._palette_limit = palette_limit
- form.addRow("Palette Limit", palette_limit)
+ form.addRow("JPEG chroma", chroma)
ico_sizes = QLineEdit(page)
ico_sizes.setPlaceholderText("16, 32, 48, 64, 128, 256")
diff --git a/image_engine_app/ui/main_window/settings_panel.py b/image_engine_app/ui/main_window/settings_panel.py
index 81b0fd3..4b1461d 100644
--- a/image_engine_app/ui/main_window/settings_panel.py
+++ b/image_engine_app/ui/main_window/settings_panel.py
@@ -32,11 +32,15 @@
BackgroundRemovalMode,
ChromaSubsampling,
ExportFormat,
- ExportProfile,
ScaleMethod,
normalize_background_removal_mode,
)
from image_engine_app.engine.analyze.background_scan import BackgroundScanResult, inspect_background_state
+from image_engine_app.engine.process.output_size import (
+ CUSTOM_SIZE,
+ apply_output_size_choice,
+ output_size_choice_for,
+)
from image_engine_app.ui.common.icons import icon
from image_engine_app.ui.common.state_bindings import EngineUIState
from image_engine_app.ui.main_window.settings_group_builders import settings_group_builders
@@ -60,7 +64,6 @@ class _SettingsGroupNavigator(QFrame):
"Color and Light": "Color",
"Transparency": "Alpha",
"GIF Controls": "GIF",
- "Export Encoding": "Encoding",
}
TILE_ICONS = {
"Pixel": "settings-pixel",
@@ -71,7 +74,6 @@ class _SettingsGroupNavigator(QFrame):
"Alpha": "settings-alpha",
"GIF": "settings-gif",
"Export": "settings-export",
- "Encoding": "settings-encoding",
}
def __init__(self, parent: QWidget | None = None) -> None:
@@ -195,8 +197,6 @@ def widget(self, index: int) -> QWidget | None:
class SettingsPanel(QScrollArea):
"""Right-docked settings panel shell matching the spec layout."""
- open_encoding_window_requested = Signal()
-
GROUP_SPECS = [
_GroupSpec("Pixel and Resolution"),
_GroupSpec("Color and Light"),
@@ -206,7 +206,6 @@ class SettingsPanel(QScrollArea):
_GroupSpec("Transparency"),
_GroupSpec("GIF Controls", requires_gif=True),
_GroupSpec("Export"),
- _GroupSpec("Export Encoding"),
]
GROUP_HELP = {
"Pixel and Resolution": "Real output size, DPI, target width/height, and scale style.",
@@ -217,7 +216,6 @@ class SettingsPanel(QScrollArea):
"Transparency": "White/black cutout and alpha edge cleanup.",
"GIF Controls": "Animation timing, palette, and GIF output tuning.",
"Export": "Final output format, quality, and metadata settings.",
- "Export Encoding": "Compression, chroma, palette, and icon size tuning.",
}
_LOCK_THEME = {
@@ -240,11 +238,9 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._background_scan = BackgroundScanResult()
self._visible_group_count = 0
self._suspend_setting_events = False
- self._syncing_resize_dpi = False
- self._last_resize_percent_for_dpi_sync = 100.0
- self._last_dpi_for_resize_sync = 72
# Pixel
+ self._output_size: QComboBox | None = None
self._resize_percent: QDoubleSpinBox | None = None
self._dpi: QSpinBox | None = None
self._pixel_snap: QCheckBox | None = None
@@ -286,12 +282,6 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._matte_fix: QDoubleSpinBox | None = None
self._alpha_threshold: QSpinBox | None = None
- # AI
- self._upscale_factor: QDoubleSpinBox | None = None
- self._ai_deblur: QDoubleSpinBox | None = None
- self._ai_detail_reconstruct: QDoubleSpinBox | None = None
- self._ai_bg_remove: QDoubleSpinBox | None = None
-
# GIF
self._frame_delay: QSpinBox | None = None
self._gif_dither: QDoubleSpinBox | None = None
@@ -300,18 +290,14 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._gif_frame_optimize: QCheckBox | None = None
# Export
- self._export_profile: QComboBox | None = None
self._export_format: QComboBox | None = None
self._export_quality: QSpinBox | None = None
self._strip_metadata: QCheckBox | None = None
self._export_format_hint: QLabel | None = None
self._export_quality_hint: QLabel | None = None
- # Expert encoding
- self._open_encoding_window_btn: QPushButton | None = None
self._compression_level: QSpinBox | None = None
self._chroma_subsampling: QComboBox | None = None
- self._palette_limit: QSpinBox | None = None
self._ico_sizes: QLineEdit | None = None
self._build_ui()
@@ -320,7 +306,7 @@ def bind_state(self, ui_state: EngineUIState) -> None:
self._ui_state = ui_state
ui_state.active_asset_changed.connect(self._on_active_asset_changed)
ui_state.background_removal_mode_changed.connect(self._on_background_removal_mode_changed)
- ui_state.export_profile_changed.connect(lambda _value: self._sync_controls_from_asset(ui_state.active_asset))
+ ui_state.export_profile_changed.connect(self._on_export_profile_applied)
self._on_active_asset_changed(ui_state.active_asset)
def _build_ui(self) -> None:
@@ -348,7 +334,7 @@ def _build_ui(self) -> None:
reset_current_btn = QPushButton("Reset All", header_card)
reset_current_btn.setObjectName("settingsResetButton")
reset_current_btn.setAutoDefault(False)
- reset_current_btn.setToolTip("Reset only the active asset edits back to the original default state.")
+ reset_current_btn.setToolTip("Restore the controls detected for the active asset when it was imported.")
reset_current_btn.clicked.connect(self._emit_reset_current_defaults)
self._reset_current_defaults_btn = reset_current_btn
title_row.addWidget(reset_current_btn, 0)
@@ -373,8 +359,6 @@ def _build_ui(self) -> None:
self._group_controls_by_index[idx] = controls_widget
self._group_lock_labels_by_index[idx] = lock_label
- self._build_hidden_compatibility_controls(container)
-
layout.addStretch(0)
self.setWidget(container)
self.setMinimumWidth(380)
@@ -382,16 +366,6 @@ def _build_ui(self) -> None:
self._set_bound_controls_enabled(False)
self._refresh_header_summary()
- def _build_hidden_compatibility_controls(self, parent: QWidget) -> None:
- """Build settings that remain supported but are not part of the locked mock shell."""
-
- hidden = QWidget(parent)
- hidden.setVisible(False)
- form = QFormLayout(hidden)
- builder = settings_group_builders(self).get("AI Enhance")
- if builder is not None:
- builder(form, hidden)
-
def _new_float_spin(
self,
parent: QWidget,
@@ -506,64 +480,27 @@ def _emit_reset_current_defaults(self) -> None:
if self._ui_state is not None:
self._ui_state.request_global_reset()
- def _emit_open_encoding_window(self) -> None:
- self.open_encoding_window_requested.emit()
-
- def _on_resize_percent_changed(self, value: float) -> None:
- value_f = float(value)
- self._update_setting("pixel", "resize_percent", value_f)
-
- if self._suspend_setting_events:
- self._last_resize_percent_for_dpi_sync = value_f
+ def _on_output_size_changed(self, _index: int) -> None:
+ if self._suspend_setting_events or self._output_size is None or self._ui_state is None:
return
-
- if self._syncing_resize_dpi or self._dpi is None:
- self._last_resize_percent_for_dpi_sync = value_f
+ asset = self._ui_state.active_asset
+ if asset is None:
return
- previous_resize = max(0.01, float(self._last_resize_percent_for_dpi_sync))
- current_dpi = int(self._dpi.value())
- scaled_dpi = int(round(current_dpi * (value_f / previous_resize)))
- self._last_resize_percent_for_dpi_sync = value_f
- scaled_dpi = max(1, min(int(self._dpi.maximum()), scaled_dpi))
- if scaled_dpi == current_dpi:
- self._last_dpi_for_resize_sync = current_dpi
+ choice_key = str(self._output_size.currentData() or CUSTOM_SIZE)
+ if choice_key == CUSTOM_SIZE:
return
-
- self._syncing_resize_dpi = True
- try:
- self._dpi.setValue(scaled_dpi)
- finally:
- self._syncing_resize_dpi = False
- self._last_dpi_for_resize_sync = int(self._dpi.value())
-
- def _on_dpi_changed(self, value: int) -> None:
- value_i = int(value)
- self._update_setting("pixel", "dpi", value_i)
-
- if self._suspend_setting_events:
- self._last_dpi_for_resize_sync = value_i
+ if not apply_output_size_choice(asset.edit_state.settings.pixel, choice_key):
return
- if self._syncing_resize_dpi or self._resize_percent is None:
- self._last_dpi_for_resize_sync = value_i
- return
+ self._sync_controls_from_asset(asset)
+ self._ui_state.request_light_preview()
- previous_dpi = max(1, int(self._last_dpi_for_resize_sync))
- current_resize = float(self._resize_percent.value())
- scaled_resize = current_resize * (value_i / previous_dpi)
- self._last_dpi_for_resize_sync = value_i
- scaled_resize = max(float(self._resize_percent.minimum()), min(float(self._resize_percent.maximum()), scaled_resize))
- if abs(scaled_resize - current_resize) < 0.01:
- self._last_resize_percent_for_dpi_sync = current_resize
- return
+ def _on_resize_percent_changed(self, value: float) -> None:
+ self._update_setting("pixel", "resize_percent", float(value))
- self._syncing_resize_dpi = True
- try:
- self._resize_percent.setValue(scaled_resize)
- finally:
- self._syncing_resize_dpi = False
- self._last_resize_percent_for_dpi_sync = float(self._resize_percent.value())
+ def _on_dpi_changed(self, value: int) -> None:
+ self._update_setting("pixel", "dpi", int(value))
def _on_active_asset_changed(self, asset: object) -> None:
if asset is None:
@@ -581,6 +518,7 @@ def _on_active_asset_changed(self, asset: object) -> None:
self._update_export_controls_hint(asset)
self._refresh_header_summary()
self._refresh_background_status(asset)
+ self._update_alpha_control_availability(asset)
def _apply_filters(self) -> None:
visible_count = 0
@@ -640,6 +578,9 @@ def _update_export_controls_hint(self, asset: object) -> None:
self._export_quality_hint.setText(
"Quality control is enabled after you select an asset."
)
+ for widget in (self._compression_level, self._chroma_subsampling, self._ico_sizes):
+ if widget is not None:
+ widget.setEnabled(False)
return
settings = getattr(getattr(asset, "edit_state", None), "settings", None)
@@ -668,6 +609,12 @@ def _update_export_controls_hint(self, asset: object) -> None:
if self._export_quality is not None:
self._export_quality.setEnabled(lossy)
+ if self._compression_level is not None:
+ self._compression_level.setEnabled(effective_fmt_value == "png")
+ if self._chroma_subsampling is not None:
+ self._chroma_subsampling.setEnabled(effective_fmt_value in {"jpg", "jpeg"})
+ if self._ico_sizes is not None:
+ self._ico_sizes.setEnabled(effective_fmt_value == "ico")
if self._export_quality_hint is not None:
if lossy:
@@ -692,6 +639,12 @@ def _sync_controls_from_asset(self, asset: object) -> None:
self._suspend_setting_events = True
try:
# Pixel
+ if self._output_size is not None:
+ choice_key = output_size_choice_for(settings.pixel)
+ for idx in range(self._output_size.count()):
+ if self._output_size.itemData(idx) == choice_key:
+ self._output_size.setCurrentIndex(idx)
+ break
if self._resize_percent is not None:
self._resize_percent.setValue(float(getattr(settings.pixel, "resize_percent", 100.0)))
if self._dpi is not None:
@@ -771,19 +724,9 @@ def _sync_controls_from_asset(self, asset: object) -> None:
if self._alpha_threshold is not None:
self._alpha_threshold.setValue(int(getattr(settings.alpha, "alpha_threshold", 0)))
- # AI
- if self._upscale_factor is not None:
- self._upscale_factor.setValue(float(getattr(settings.ai, "upscale_factor", 1.0)))
- if self._ai_deblur is not None:
- self._ai_deblur.setValue(float(getattr(settings.ai, "deblur_strength", 0.0)))
- if self._ai_detail_reconstruct is not None:
- self._ai_detail_reconstruct.setValue(float(getattr(settings.ai, "detail_reconstruct", 0.0)))
- if self._ai_bg_remove is not None:
- self._ai_bg_remove.setValue(float(getattr(settings.ai, "bg_remove_strength", 0.0)))
-
# GIF
if self._frame_delay is not None:
- self._frame_delay.setValue(int(getattr(settings.gif, "frame_delay_ms", 100)))
+ self._frame_delay.setValue(int(getattr(settings.gif, "frame_delay_ms", 0)))
if self._gif_dither is not None:
self._gif_dither.setValue(float(getattr(settings.gif, "dither_strength", 0.0)))
if self._gif_loop is not None:
@@ -794,12 +737,6 @@ def _sync_controls_from_asset(self, asset: object) -> None:
self._gif_frame_optimize.setChecked(bool(getattr(settings.gif, "frame_optimize", True)))
# Export + expert
- if self._export_profile is not None:
- current_profile = getattr(settings.export, "export_profile", ExportProfile.WEB)
- for idx in range(self._export_profile.count()):
- if self._export_profile.itemData(idx) == current_profile:
- self._export_profile.setCurrentIndex(idx)
- break
if self._export_format is not None:
current_format = getattr(settings.export, "format", ExportFormat.AUTO)
for idx in range(self._export_format.count()):
@@ -818,23 +755,17 @@ def _sync_controls_from_asset(self, asset: object) -> None:
if self._chroma_subsampling.itemData(idx) == current:
self._chroma_subsampling.setCurrentIndex(idx)
break
- if self._palette_limit is not None:
- palette_value = getattr(settings.export, "palette_limit", None)
- self._palette_limit.setValue(int(palette_value) if palette_value is not None else 0)
if self._ico_sizes is not None:
sizes = getattr(settings.export, "ico_sizes", None)
if not isinstance(sizes, list) or not sizes:
sizes = [16, 32, 48, 64, 128, 256]
self._ico_sizes.setText(", ".join(str(max(1, int(value))) for value in sizes))
- if self._resize_percent is not None:
- self._last_resize_percent_for_dpi_sync = float(self._resize_percent.value())
- if self._dpi is not None:
- self._last_dpi_for_resize_sync = int(self._dpi.value())
finally:
self._suspend_setting_events = False
def _set_bound_controls_enabled(self, enabled: bool) -> None:
for widget in (
+ self._output_size,
self._resize_percent,
self._dpi,
self._pixel_snap,
@@ -863,24 +794,17 @@ def _set_bound_controls_enabled(self, enabled: bool) -> None:
self._alpha_smooth,
self._matte_fix,
self._alpha_threshold,
- self._upscale_factor,
- self._ai_deblur,
- self._ai_detail_reconstruct,
- self._ai_bg_remove,
self._frame_delay,
self._gif_dither,
self._gif_loop,
self._gif_palette_size,
self._gif_frame_optimize,
- self._export_profile,
self._export_format,
self._export_quality,
self._strip_metadata,
self._compression_level,
self._chroma_subsampling,
- self._palette_limit,
self._ico_sizes,
- self._open_encoding_window_btn,
):
if widget is not None:
widget.setEnabled(bool(enabled))
@@ -907,9 +831,25 @@ def _update_setting(self, group_name: str, field_name: str, value: Any) -> None:
return
setattr(group, field_name, value)
- if bool(getattr(asset.edit_state, "auto_apply_light", False)):
+ if group_name == "pixel" and field_name in {"resize_percent", "width", "height"}:
+ self._sync_output_size_from_pixel(group)
+
+ if group_name != "export":
self._ui_state.request_light_preview()
+ def _sync_output_size_from_pixel(self, pixel_settings: object) -> None:
+ if self._output_size is None:
+ return
+ choice_key = output_size_choice_for(pixel_settings)
+ self._output_size.blockSignals(True)
+ try:
+ for idx in range(self._output_size.count()):
+ if self._output_size.itemData(idx) == choice_key:
+ self._output_size.setCurrentIndex(idx)
+ break
+ finally:
+ self._output_size.blockSignals(False)
+
def _on_white_bg_mode_changed(self, _index: int) -> None:
if self._suspend_setting_events or self._white_bg_mode is None:
return
@@ -923,8 +863,31 @@ def _on_white_bg_mode_changed(self, _index: int) -> None:
def _on_background_removal_mode_changed(self, _mode_value: str) -> None:
if self._ui_state is None:
return
- self._sync_controls_from_asset(self._ui_state.active_asset)
- self._refresh_background_status(self._ui_state.active_asset)
+ asset = self._ui_state.active_asset
+ self._sync_controls_from_asset(asset)
+ self._update_export_controls_hint(asset)
+ self._refresh_background_status(asset)
+ self._update_alpha_control_availability(asset)
+
+ def _update_alpha_control_availability(self, asset: object) -> None:
+ has_asset = asset is not None and getattr(asset, "edit_state", None) is not None
+ settings = getattr(getattr(asset, "edit_state", None), "settings", None)
+ mode = BackgroundRemovalMode.OFF
+ if settings is not None:
+ mode = normalize_background_removal_mode(
+ getattr(settings.alpha, "background_removal_mode", None),
+ remove_white_bg=bool(getattr(settings.alpha, "remove_white_bg", False)),
+ )
+ alpha_available = has_asset and (self._asset_has_alpha or mode is not BackgroundRemovalMode.OFF)
+ for widget in (
+ self._alpha_smooth,
+ self._matte_fix,
+ self._alpha_threshold,
+ self._edge_grow_shrink,
+ self._edge_feather,
+ ):
+ if widget is not None:
+ widget.setEnabled(alpha_available)
def _on_chroma_subsampling_changed(self, _index: int) -> None:
if self._chroma_subsampling is None:
@@ -933,13 +896,12 @@ def _on_chroma_subsampling_changed(self, _index: int) -> None:
if isinstance(value, ChromaSubsampling):
self._update_setting("export", "chroma_subsampling", value)
- def _on_export_profile_changed(self, _index: int) -> None:
- if self._suspend_setting_events or self._export_profile is None or self._ui_state is None:
+ def _on_export_profile_applied(self, _profile_value: str) -> None:
+ if self._ui_state is None:
return
- value = self._export_profile.currentData()
- if isinstance(value, ExportProfile):
- self._ui_state.set_export_profile(value)
- self._update_export_controls_hint(self._ui_state.active_asset)
+ asset = self._ui_state.active_asset
+ self._sync_controls_from_asset(asset)
+ self._update_export_controls_hint(asset)
def _on_export_format_changed(self, _index: int) -> None:
if self._export_format is None:
@@ -950,9 +912,6 @@ def _on_export_format_changed(self, _index: int) -> None:
if self._ui_state is not None:
self._update_export_controls_hint(self._ui_state.active_asset)
- def _on_palette_limit_changed(self, value: int) -> None:
- self._update_setting("export", "palette_limit", (None if int(value) <= 0 else int(value)))
-
def _on_ico_sizes_changed(self) -> None:
if self._ico_sizes is None:
return
@@ -1115,4 +1074,4 @@ def _build_background_status_text(
-
+
diff --git a/image_engine_app/ui/main_window/web_sources_coordinator.py b/image_engine_app/ui/main_window/web_sources_coordinator.py
index fb260ce..50ae0bc 100644
--- a/image_engine_app/ui/main_window/web_sources_coordinator.py
+++ b/image_engine_app/ui/main_window/web_sources_coordinator.py
@@ -1,4 +1,4 @@
-"""Web Sources coordinator for main-window UI event handling."""
+"""Coordinator for the Web Sources workspace."""
from __future__ import annotations
@@ -8,26 +8,26 @@
from urllib.parse import urlparse
from urllib.request import ProxyHandler, Request, build_opener, urlopen
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QApplication, QProgressDialog, QWidget
+
from image_engine_app.app.services.web_sources_service import WebSourcesService
from image_engine_app.app.settings_store import load_web_sources_settings, save_web_sources_settings
from image_engine_app.app.web_sources_models import (
ScanResults,
SmartOptions,
- coerce_web_index_links,
- coerce_import_target,
- coerce_smart_options,
- coerce_web_items,
+ WebDiagnosticsRequest,
+ WebDownloadRequest,
+ WebLinkDiscoveryRequest,
+ WebScanRequest,
)
from image_engine_app.engine.ingest.url_ingest import DownloadGuards
from image_engine_app.engine.ingest.webpage_scan import WebpageScanCancelledError
from image_engine_app.engine.models import AssetRecord
-from PySide6.QtCore import Qt
-from PySide6.QtWidgets import QApplication, QProgressDialog, QWidget
-
class _ProgressSession:
- """Cancelable progress dialog wrapper used by web scan/download workflows."""
+ """Cancelable progress dialog shared by scan, discovery, and download work."""
def __init__(
self,
@@ -55,7 +55,6 @@ def __init__(
self.dialog.setAutoClose(False)
self.dialog.setAutoReset(False)
self.dialog.canceled.connect(self._on_cancel_requested)
-
self.dialog.show()
QApplication.processEvents()
@@ -68,7 +67,7 @@ def _on_cancel_requested(self) -> None:
QApplication.processEvents()
def is_cancel_requested(self) -> bool:
- return bool(self._cancel_requested)
+ return self._cancel_requested
def update(
self,
@@ -94,7 +93,6 @@ def close(self, *, complete: bool = False) -> None:
pass
if complete and not self._cancel_requested:
self.dialog.setValue(self.dialog.maximum())
- # Prefer hide/deleteLater over close(): some platforms can emit canceled on close.
try:
self.dialog.hide()
finally:
@@ -102,7 +100,7 @@ def close(self, *, complete: bool = False) -> None:
class WebSourcesCoordinator:
- """Encapsulates Web Sources panel initialization + scan/download actions."""
+ """Own Web Sources calls while keeping the panel free of network and file I/O."""
WINDOWS_BLOCKED_ACCESS_TEXT = (
"Windows blocked network access (WinError 10013). "
@@ -112,134 +110,6 @@ class WebSourcesCoordinator:
def __init__(self, window: Any) -> None:
self._window = window
- @classmethod
- def _normalize_network_error_message(cls, detail: str) -> str:
- raw = str(detail or "").strip()
- lowered = raw.lower()
- if "winerror 10013" in lowered or "forbidden by its access permissions" in lowered:
- return cls.WINDOWS_BLOCKED_ACCESS_TEXT
- if "timed out" in lowered or "timeout" in lowered:
- return "Network timeout: the website did not respond in time. Try again or scan fewer pages."
-
- http_match = re.search(r"http error\s+(\d{3})(?::\s*([^>]+))?", raw, flags=re.IGNORECASE)
- if http_match:
- code = int(http_match.group(1))
- reason = " ".join(str(http_match.group(2) or "").split())
- return cls._friendly_http_error(code, reason=reason)
-
- return raw or "Unknown network error"
-
- @staticmethod
- def _friendly_http_error(code: int, *, reason: str = "") -> str:
- reason_text = f" ({reason})" if reason else ""
- if code == 401:
- return "HTTP 401 (Unauthorized): this page needs authentication/cookies before scanning."
- if code == 403:
- return "HTTP 403 (Forbidden): website blocked automated scan requests. Try Network Check or a direct file URL."
- if code == 404:
- return "HTTP 404 (Not Found): the page or file URL no longer exists."
- if code == 429:
- return "HTTP 429 (Rate limited): try again in a minute, or reduce repeated scans on this host."
- if code in {500, 502, 503, 504}:
- return (
- f"HTTP {code}{reason_text}: the website/server failed before Sprite Factory could scan it. "
- "Try again later, scan a smaller page list, or use a direct file URL."
- )
- if 400 <= code < 500:
- return f"HTTP {code}{reason_text}: the website rejected this request."
- if 500 <= code < 600:
- return f"HTTP {code}{reason_text}: the website/server failed. Try again later."
- return f"HTTP {code}{reason_text}"
-
- @staticmethod
- def _is_timeout_error_message(detail: str) -> bool:
- lowered = str(detail or "").lower()
- return "timed out" in lowered or "timeout" in lowered or "winerror 10060" in lowered
-
- @classmethod
- def _is_recoverable_page_scan_error_message(cls, detail: str) -> bool:
- if cls._is_timeout_error_message(detail):
- return True
- http_match = re.search(r"http error\s+(\d{3})", str(detail or ""), flags=re.IGNORECASE)
- if not http_match:
- return False
- return int(http_match.group(1)) in {429, 500, 502, 503, 504}
-
- @classmethod
- def _is_socket_access_denied(cls, exc: Exception) -> bool:
- reason = getattr(exc, "reason", exc)
- win_error = getattr(reason, "winerror", None)
- if win_error == 10013:
- return True
- message = str(reason or exc).lower()
- return "winerror 10013" in message or "forbidden by its access permissions" in message
-
- @staticmethod
- def _normalize_diagnostics_url(raw_url: str) -> str:
- candidate = str(raw_url or "").strip()
- if not candidate:
- raise ValueError("Missing page URL for diagnostics.")
- if "://" not in candidate:
- candidate = f"https://{candidate}"
- parsed = urlparse(candidate)
- if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
- raise ValueError("Invalid URL. Use http(s)://domain/path.")
- return candidate
-
- def _diagnostics_summary_for_url(self, area_url: str) -> str:
- normalized = self._normalize_diagnostics_url(area_url)
- parsed = urlparse(normalized)
- host = (parsed.hostname or "").strip()
- if not host:
- raise ValueError("Diagnostics failed: URL host is missing.")
-
- port = parsed.port
- if port is None:
- port = 443 if parsed.scheme.lower() == "https" else 80
-
- try:
- socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
- except Exception as exc:
- detail = self._normalize_network_error_message(str(exc))
- return f"Network diagnostics: DNS lookup failed for {host} ({detail})"
-
- try:
- with socket.create_connection((host, int(port)), timeout=4.0):
- pass
- except Exception as exc:
- if self._is_socket_access_denied(exc):
- return f"Network diagnostics: {self.WINDOWS_BLOCKED_ACCESS_TEXT}"
- detail = self._normalize_network_error_message(str(exc))
- return f"Network diagnostics: TCP connect failed to {host}:{port} ({detail})"
-
- request = Request(normalized, headers={"User-Agent": "SpriteFactory/1.0.3 (Network Diagnostics)"})
-
- try:
- with urlopen(request, timeout=8.0) as response:
- status = getattr(response, "status", None)
- code = int(status) if isinstance(status, int) else 200
- return f"Network diagnostics OK: DNS + TCP + HTTP {code} for {host}:{port}"
- except Exception as first_exc:
- if self._is_socket_access_denied(first_exc):
- return f"Network diagnostics: {self.WINDOWS_BLOCKED_ACCESS_TEXT}"
-
- try:
- direct_opener = build_opener(ProxyHandler({}))
- with direct_opener.open(request, timeout=8.0) as response:
- status = getattr(response, "status", None)
- code = int(status) if isinstance(status, int) else 200
- return (
- f"Network diagnostics OK (direct/no-proxy): DNS + TCP + HTTP {code} for {host}:{port}"
- )
- except Exception as second_exc:
- if self._is_socket_access_denied(second_exc):
- return f"Network diagnostics: {self.WINDOWS_BLOCKED_ACCESS_TEXT}"
- detail = self._normalize_network_error_message(str(first_exc))
- return (
- "Network diagnostics partial: DNS + TCP succeeded, "
- f"HTTP request failed ({detail})"
- )
-
def init_panel(self) -> None:
registry: list[dict] = []
selected_website_id: str | None = None
@@ -255,16 +125,20 @@ def init_panel(self) -> None:
paths = getattr(self._window.controller, "app_paths", None)
if paths is not None:
try:
- web_cfg = load_web_sources_settings(paths)
- raw_registry = web_cfg.get("registry")
+ config = load_web_sources_settings(paths)
+ raw_registry = config.get("registry")
if isinstance(raw_registry, list):
registry = self._window.controller.load_web_sources_registry(raw_registry)
-
- selected = web_cfg.get("last_selected") if isinstance(web_cfg.get("last_selected"), dict) else {}
+ selected = config.get("last_selected") if isinstance(config.get("last_selected"), dict) else {}
selected_website_id = str(selected.get("website_id")) if selected.get("website_id") else None
selected_area_id = str(selected.get("area_id")) if selected.get("area_id") else None
-
- smart = coerce_smart_options(web_cfg.get("options"))
+ options = config.get("options") if isinstance(config.get("options"), dict) else {}
+ smart = SmartOptions(
+ show_likely=bool(options.get("show_likely", False)),
+ auto_sort=True,
+ skip_duplicates=bool(options.get("skip_duplicates", True)),
+ allow_zip=bool(options.get("allow_zip", True)),
+ )
except Exception:
pass
@@ -283,17 +157,16 @@ def persist_state(
smart: SmartOptions | None = None,
registry: list[dict] | None = None,
) -> None:
- if self._window.controller is None:
+ controller = self._window.controller
+ if controller is None:
return
-
- paths = getattr(self._window.controller, "app_paths", None)
+ paths = getattr(controller, "app_paths", None)
if paths is None:
return
panel_website_id, panel_area_id = self._window.web_sources_panel.selected_source_ids()
active_smart = smart or self._window.web_sources_panel.smart_options()
active_registry = registry if registry is not None else self._window.web_sources_panel.sources_registry()
-
try:
save_web_sources_settings(
paths,
@@ -304,308 +177,156 @@ def persist_state(
},
options={
"show_likely": active_smart.show_likely,
- "auto_sort": active_smart.auto_sort,
+ "auto_sort": True,
"skip_duplicates": active_smart.skip_duplicates,
"allow_zip": active_smart.allow_zip,
},
)
except Exception:
- # Persistence failures should not block the UI workflow.
- pass
+ # Settings persistence must never block scanning or downloading.
+ return
def on_registry_changed(self, payload: object) -> None:
- if self._window.controller is None:
+ controller = self._window.controller
+ if controller is None:
return
-
if not isinstance(payload, list):
- self._window.web_sources_panel.set_status("Invalid website registry payload.")
+ self._window.web_sources_panel.set_status("Saved-page update was invalid.")
return
- registry = self._window.controller.load_web_sources_registry(payload)
-
+ registry = controller.load_web_sources_registry(payload)
website_id, area_id = self._window.web_sources_panel.selected_source_ids()
self._window.web_sources_panel.set_sources(
websites=registry,
selected_website_id=website_id,
selected_area_id=area_id,
)
- persisted_website_id, persisted_area_id = self._window.web_sources_panel.selected_source_ids()
- self.persist_state(
- website_id=persisted_website_id,
- area_id=persisted_area_id,
- registry=registry,
- )
-
- def on_scan_requested(self, payload: object) -> None:
- if self._window.controller is None:
- self._window.web_sources_panel.set_status("Web Sources scan unavailable: controller not configured")
- return
-
- if not isinstance(payload, dict):
- self._window.web_sources_panel.set_status("Invalid scan payload.")
- return
-
- area_url = str(payload.get("area_url", "")).strip()
- if not area_url:
- self._window.web_sources_panel.set_status("Enter a URL or pick a saved page first.")
- return
-
- smart = coerce_smart_options(payload.get("smart"))
- progress = _ProgressSession(
- window=self._window,
- panel=self._window.web_sources_panel,
- label_text="Scanning webpage for assets...",
- title="Web Sources Scan",
- minimum=0,
- maximum=0,
- cancel_label_text="Cancelling scan...",
- cancel_status_text="Cancelling scan...",
- )
-
- try:
- results = self._window.controller.scan_web_sources_area(
- area_url,
- show_likely=smart.show_likely,
- cancel_requested=progress.is_cancel_requested,
- )
- except WebpageScanCancelledError:
- self._window.web_sources_panel.set_status("Scan cancelled")
- self._window._status("Web Sources scan cancelled")
- return
- except Exception as exc:
- detail = self._normalize_network_error_message(str(exc))
- if self._is_recoverable_page_scan_error_message(str(exc)):
- results = ScanResults(items=(), filtered_count=0, failed_pages=(f"{area_url}: {detail}",))
- self._window.web_sources_panel.set_results(results)
- self._window._status("Web Sources scan finished: 0 item(s), 1 failed page")
- self.persist_state(
- website_id=(str(payload.get("website_id")) if payload.get("website_id") else None),
- area_id=(str(payload.get("area_id")) if payload.get("area_id") else None),
- smart=smart,
- )
- return
- self._window.web_sources_panel.set_status(f"Scan failed: {detail}")
- return
- finally:
- progress.close()
-
- self._window.web_sources_panel.set_results(results)
- self._window._status(f"Web Sources scan complete: {len(results.items)} item(s)")
- self.persist_state(
- website_id=(str(payload.get("website_id")) if payload.get("website_id") else None),
- area_id=(str(payload.get("area_id")) if payload.get("area_id") else None),
- smart=smart,
- )
+ self.persist_state(website_id=website_id, area_id=area_id, registry=registry)
- def on_index_links_requested(self, payload: object) -> None:
- if self._window.controller is None:
- self._window.web_sources_panel.set_status("Web Sources index scan unavailable: controller not configured")
+ def on_preferences_changed(self, payload: object) -> None:
+ if not isinstance(payload, SmartOptions):
return
+ self.persist_state(smart=payload)
- if not isinstance(payload, dict):
- self._window.web_sources_panel.set_status("Invalid index scan payload.")
- return
-
- index_url = str(payload.get("index_url", "")).strip()
- if not index_url:
- self._window.web_sources_panel.set_status("Enter a URL or pick a saved page first.")
- return
-
- smart = coerce_smart_options(payload.get("smart"))
- progress = _ProgressSession(
- window=self._window,
- panel=self._window.web_sources_panel,
- label_text="Finding linked sprite pages...",
- title="Web Sources Index Scan",
- minimum=0,
- maximum=0,
- cancel_label_text="Cancelling index scan...",
- cancel_status_text="Cancelling index scan...",
- )
-
- try:
- links = self._window.controller.discover_web_source_index_links(
- index_url,
- same_domain_only=True,
- cancel_requested=progress.is_cancel_requested,
- )
- except WebpageScanCancelledError:
- self._window.web_sources_panel.set_status("Index scan cancelled")
- self._window._status("Web Sources index scan cancelled")
- return
- except Exception as exc:
- detail = self._normalize_network_error_message(str(exc))
- self._window.web_sources_panel.set_status(f"Index scan failed: {detail}")
+ def on_scan_requested(self, payload: object) -> None:
+ controller = self._window.controller
+ panel = self._window.web_sources_panel
+ if controller is None:
+ panel.set_status("Web Sources scanning is unavailable because the controller is not configured.")
return
- finally:
- progress.close()
-
- self._window.web_sources_panel.set_index_links(links)
- self._window._status(f"Web Sources index scan complete: {len(links)} linked page(s)")
- self.persist_state(
- website_id=(str(payload.get("website_id")) if payload.get("website_id") else None),
- area_id=(str(payload.get("area_id")) if payload.get("area_id") else None),
- smart=smart,
- )
-
- def on_multi_scan_requested(self, payload: object) -> None:
- if self._window.controller is None:
- self._window.web_sources_panel.set_status("Web Sources multi-page scan unavailable: controller not configured")
+ if not isinstance(payload, WebScanRequest):
+ panel.set_status("The page scan request was invalid.")
return
- if not isinstance(payload, dict):
- self._window.web_sources_panel.set_status("Invalid multi-page scan payload.")
+ urls = self._unique_urls(payload.urls)
+ if not urls:
+ panel.set_status("Choose at least one valid page URL to scan.")
return
- links = coerce_web_index_links(payload.get("pages"))
- if not links:
- self._window.web_sources_panel.set_status("Select one or more linked pages first.")
- return
+ cap = int(getattr(panel, "PAGE_SCAN_CAP", 100))
+ if len(urls) > cap:
+ if not panel.confirm_large_page_scan(len(urls), cap=cap):
+ panel.set_status("Page scan cancelled before starting.")
+ return
+ urls = urls[:cap]
- smart = coerce_smart_options(payload.get("smart"))
progress = _ProgressSession(
window=self._window,
- panel=self._window.web_sources_panel,
- label_text=f"Scanning {len(links)} linked page(s)...",
- title="Web Sources Multi-Page Scan",
+ panel=panel,
+ label_text=f"Scanning {len(urls)} page(s)...",
+ title="Web Sources Scan",
minimum=0,
maximum=0,
- cancel_label_text="Cancelling multi-page scan...",
- cancel_status_text="Cancelling multi-page scan...",
+ cancel_label_text="Cancelling page scan...",
+ cancel_status_text="Cancelling page scan...",
)
-
+ results: ScanResults | None = None
try:
- results = self._window.controller.scan_web_source_pages(
- [link.url for link in links],
- show_likely=smart.show_likely,
+ results = controller.scan_web_source_pages(
+ urls,
+ show_likely=payload.smart.show_likely,
cancel_requested=progress.is_cancel_requested,
)
except WebpageScanCancelledError:
- self._window.web_sources_panel.set_status("Multi-page scan cancelled")
- self._window._status("Web Sources multi-page scan cancelled")
+ panel.set_status("Page scan cancelled. Existing Found Files were kept.")
+ self._window._status("Web Sources scan cancelled")
return
except Exception as exc:
- detail = self._normalize_network_error_message(str(exc))
- self._window.web_sources_panel.set_status(f"Multi-page scan failed: {detail}")
+ panel.set_status(f"Page scan failed: {self._normalize_network_error_message(str(exc))}")
return
finally:
- progress.close()
-
- self._window.web_sources_panel.set_results(results)
- self._window._status(
- f"Web Sources multi-page scan complete: {len(results.items)} item(s) from {len(links)} page(s)"
+ progress.close(complete=results is not None)
+
+ friendly_results = self._friendly_scan_results(results)
+ outcome = panel.add_results(friendly_results)
+ failed_count = len(friendly_results.failed_pages)
+ status = (
+ f"Web Sources scan complete: {outcome.added_count} new, "
+ f"{len(outcome.results.items)} total"
)
+ if failed_count:
+ status += f", {failed_count} page(s) failed"
+ self._window._status(status)
self.persist_state(
- website_id=(str(payload.get("website_id")) if payload.get("website_id") else None),
- area_id=(str(payload.get("area_id")) if payload.get("area_id") else None),
- smart=smart,
+ website_id=payload.website_id,
+ area_id=payload.area_id,
+ smart=payload.smart,
)
- def on_index_scan_all_requested(self, payload: object) -> None:
- if self._window.controller is None:
- self._window.web_sources_panel.set_status("Web Sources linked-page scan unavailable: controller not configured")
- return
-
- if not isinstance(payload, dict):
- self._window.web_sources_panel.set_status("Invalid linked-page scan payload.")
+ def on_discover_links_requested(self, payload: object) -> None:
+ controller = self._window.controller
+ panel = self._window.web_sources_panel
+ if controller is None:
+ panel.set_status("Linked-page discovery is unavailable because the controller is not configured.")
return
-
- index_url = str(payload.get("index_url", "")).strip()
- if not index_url:
- self._window.web_sources_panel.set_status("Enter a URL or pick a saved page first.")
+ if not isinstance(payload, WebLinkDiscoveryRequest) or not payload.url.strip():
+ panel.set_status("Choose a valid page before finding linked pages.")
return
- smart = coerce_smart_options(payload.get("smart"))
progress = _ProgressSession(
window=self._window,
- panel=self._window.web_sources_panel,
+ panel=panel,
label_text="Finding linked pages...",
- title="Web Sources Linked Page Scan",
+ title="Find Linked Pages",
minimum=0,
maximum=0,
- cancel_label_text="Cancelling linked-page scan...",
- cancel_status_text="Cancelling linked-page scan...",
+ cancel_label_text="Cancelling page discovery...",
+ cancel_status_text="Cancelling page discovery...",
)
-
+ links = None
try:
- links = self._window.controller.discover_web_source_index_links(
- index_url,
- same_domain_only=True,
- cancel_requested=progress.is_cancel_requested,
- )
- self._window.web_sources_panel.set_index_links(links)
- if not links:
- self._window.web_sources_panel.set_status(
- "Found 0 linked pages. Try Scan Page for this page or choose a broader index page."
- )
- self._window._status("Web Sources linked-page scan found 0 linked pages")
- return
-
- page_count = len(links)
- scan_cap = int(getattr(self._window.web_sources_panel, "LINKED_PAGE_SCAN_CAP", 100))
- if page_count > scan_cap:
- should_continue = self._window.web_sources_panel.confirm_large_linked_page_scan(
- page_count,
- cap=scan_cap,
- )
- if not should_continue:
- self._window.web_sources_panel.set_status("Linked-page scan cancelled before starting.")
- self._window._status("Web Sources linked-page scan cancelled before starting")
- return
- links = links[:scan_cap]
-
- progress.update(
- done_count=0,
- total_count=max(1, len(links)),
- label_text=f"Scanning {len(links)} linked page(s)...",
- status_text=f"Scanning {len(links)} linked page(s)...",
- )
- results = self._window.controller.scan_web_source_pages(
- [link.url for link in links],
- show_likely=smart.show_likely,
+ links = controller.discover_web_source_index_links(
+ payload.url,
cancel_requested=progress.is_cancel_requested,
)
except WebpageScanCancelledError:
- self._window.web_sources_panel.set_status("Linked-page scan cancelled")
- self._window._status("Web Sources linked-page scan cancelled")
+ panel.set_status("Linked-page discovery cancelled.")
+ self._window._status("Web Sources page discovery cancelled")
return
except Exception as exc:
- detail = self._normalize_network_error_message(str(exc))
- self._window.web_sources_panel.set_status(f"Linked-page scan failed: {detail}")
+ panel.set_status(f"Could not find linked pages: {self._normalize_network_error_message(str(exc))}")
return
finally:
- progress.close()
+ progress.close(complete=links is not None)
- self._window.web_sources_panel.set_results(results)
- self._window._status(
- f"Web Sources linked-page scan complete: {len(results.items)} item(s) from {len(links)} page(s)"
- )
- self.persist_state(
- website_id=(str(payload.get("website_id")) if payload.get("website_id") else None),
- area_id=(str(payload.get("area_id")) if payload.get("area_id") else None),
- smart=smart,
- )
+ panel.set_index_links(tuple(links))
+ self._window._status(f"Web Sources found {len(links)} linked page(s)")
+ self.persist_state(website_id=payload.website_id, area_id=payload.area_id)
def on_download_requested(self, payload: object) -> None:
- if self._window.controller is None:
- self._window.web_sources_panel.set_status("Web Sources download unavailable: controller not configured")
+ controller = self._window.controller
+ panel = self._window.web_sources_panel
+ if controller is None:
+ panel.set_status("Web Sources downloading is unavailable because the controller is not configured.")
return
-
- if not isinstance(payload, dict):
- self._window.web_sources_panel.set_status("Invalid download payload.")
+ if not isinstance(payload, WebDownloadRequest) or not payload.items:
+ panel.set_status("Select at least one found file to download.")
return
- items = coerce_web_items(payload.get("items"))
- if not items:
- self._window.web_sources_panel.set_status("Select at least one item to download.")
- return
-
- target = coerce_import_target(payload.get("target"))
- smart = coerce_smart_options(payload.get("smart"))
-
+ items = list(payload.items)
progress = _ProgressSession(
window=self._window,
- panel=self._window.web_sources_panel,
+ panel=panel,
label_text="Preparing downloads...",
title="Web Sources Download",
minimum=0,
@@ -615,17 +336,17 @@ def on_download_requested(self, payload: object) -> None:
)
progress.update(
done_count=0,
- total_count=max(1, len(items)),
+ total_count=len(items),
label_text="Preparing downloads...",
status_text="Preparing downloads...",
)
report = None
try:
- report = self._window.controller.download_web_sources_items(
+ report = controller.download_web_sources_items(
items,
- target,
- smart=smart,
+ payload.target,
+ smart=payload.smart,
guards=DownloadGuards(max_bytes=25 * 1024 * 1024, max_pixels=64_000_000),
progress_callback=lambda done, total, message: progress.update(
done_count=done,
@@ -636,62 +357,183 @@ def on_download_requested(self, payload: object) -> None:
cancel_requested=progress.is_cancel_requested,
)
except Exception as exc:
- detail = self._normalize_network_error_message(str(exc))
- self._window.web_sources_panel.set_status(f"Download failed: {detail}")
+ panel.set_status(f"Download failed: {self._normalize_network_error_message(str(exc))}")
return
finally:
- should_mark_complete = bool(report is not None and not getattr(report, "cancelled", False))
- progress.close(complete=should_mark_complete)
-
- if report.assets:
+ progress.close(complete=bool(report is not None and not getattr(report, "cancelled", False)))
+
+ workspace_assets = [
+ asset
+ for asset in tuple(getattr(report, "assets", ()) or ())
+ if isinstance(asset, AssetRecord)
+ ]
+ if workspace_assets:
try:
- workspace_assets = [
- asset
- for asset in tuple(getattr(report, "assets", ()) or ())
- if isinstance(asset, AssetRecord)
- ]
- if workspace_assets:
- self._window._register_assets(workspace_assets, set_active=True)
+ self._window._register_assets(workspace_assets, set_active=True)
except Exception as exc:
detail = str(exc).strip() or exc.__class__.__name__
- self._window.web_sources_panel.set_status(
- f"Download completed but failed to load workspace assets: {detail}"
- )
- self._window._status("Web Sources import completed, but workspace load failed")
+ panel.set_status(f"Download completed but workspace loading failed: {detail}")
+ self._window._status("Web Sources download completed, but workspace load failed")
self.persist_state(
- website_id=(str(payload.get("website_id")) if payload.get("website_id") else None),
- area_id=(str(payload.get("area_id")) if payload.get("area_id") else None),
- smart=smart,
+ website_id=payload.website_id,
+ area_id=payload.area_id,
+ smart=payload.smart,
)
return
message = self.format_download_status(report)
- self._window.web_sources_panel.set_status(message)
+ panel.set_status(message)
self._window._status(message)
self.persist_state(
- website_id=(str(payload.get("website_id")) if payload.get("website_id") else None),
- area_id=(str(payload.get("area_id")) if payload.get("area_id") else None),
- smart=smart,
+ website_id=payload.website_id,
+ area_id=payload.area_id,
+ smart=payload.smart,
)
- def on_network_diagnostics_requested(self, payload: object) -> None:
- if not isinstance(payload, dict):
- self._window.web_sources_panel.set_status("Invalid diagnostics payload.")
+ def on_diagnostics_requested(self, payload: object) -> None:
+ panel = self._window.web_sources_panel
+ if not isinstance(payload, WebDiagnosticsRequest) or not payload.url.strip():
+ panel.set_status("Choose a valid page URL before running a connection check.")
return
+ try:
+ summary = self._diagnostics_summary_for_url(payload.url)
+ except Exception as exc:
+ summary = f"Connection check failed: {self._normalize_network_error_message(str(exc))}"
+ panel.set_status(summary)
+ self._window._status(summary)
- area_url = str(payload.get("area_url", "")).strip()
- if not area_url:
- self._window.web_sources_panel.set_status("Enter a URL or pick a saved page for diagnostics.")
- return
+ @staticmethod
+ def _unique_urls(values: tuple[str, ...]) -> list[str]:
+ urls: list[str] = []
+ seen: set[str] = set()
+ for value in values:
+ url = str(value or "").strip()
+ if not url or url.casefold() in seen:
+ continue
+ seen.add(url.casefold())
+ urls.append(url)
+ return urls
+
+ @classmethod
+ def _friendly_scan_results(cls, results: ScanResults) -> ScanResults:
+ failures: list[str] = []
+ for entry in tuple(results.failed_pages or ()):
+ text = " ".join(str(entry).split())
+ scheme_at = text.find("://")
+ separator = text.find(": ", scheme_at + 3 if scheme_at >= 0 else 0)
+ if separator < 0:
+ failures.append(cls._normalize_network_error_message(text))
+ continue
+ page = text[:separator]
+ detail = text[separator + 2 :]
+ failures.append(f"{page}: {cls._normalize_network_error_message(detail)}")
+ return ScanResults(
+ items=tuple(results.items),
+ filtered_count=int(results.filtered_count or 0),
+ failed_pages=tuple(failures),
+ )
+
+ @classmethod
+ def _normalize_network_error_message(cls, detail: str) -> str:
+ raw = str(detail or "").strip()
+ lowered = raw.lower()
+ if "winerror 10013" in lowered or "forbidden by its access permissions" in lowered:
+ return cls.WINDOWS_BLOCKED_ACCESS_TEXT
+ if "timed out" in lowered or "timeout" in lowered or "winerror 10060" in lowered:
+ return "Network timeout: the website did not respond in time. Try again or scan fewer pages."
+ http_match = re.search(r"http error\s+(\d{3})(?::\s*([^>]+))?", raw, flags=re.IGNORECASE)
+ if http_match:
+ return cls._friendly_http_error(
+ int(http_match.group(1)),
+ reason=" ".join(str(http_match.group(2) or "").split()),
+ )
+ return raw or "Unknown network error"
+
+ @staticmethod
+ def _friendly_http_error(code: int, *, reason: str = "") -> str:
+ reason_text = f" ({reason})" if reason else ""
+ if code == 401:
+ return "HTTP 401 (Unauthorized): this page needs authentication or cookies before scanning."
+ if code == 403:
+ return "HTTP 403 (Forbidden): the website blocked automated scanning. Try a connection check or a direct file URL."
+ if code == 404:
+ return "HTTP 404 (Not Found): the page or file URL no longer exists."
+ if code == 429:
+ return "HTTP 429 (Rate limited): wait briefly, then scan fewer pages on this website."
+ if code in {500, 502, 503, 504}:
+ return (
+ f"HTTP {code}{reason_text}: the website or server failed before Sprite Factory could scan it. "
+ "Try again later, scan fewer pages, or use a direct file URL."
+ )
+ if 400 <= code < 500:
+ return f"HTTP {code}{reason_text}: the website rejected this request."
+ if 500 <= code < 600:
+ return f"HTTP {code}{reason_text}: the website or server failed. Try again later."
+ return f"HTTP {code}{reason_text}"
+
+ @classmethod
+ def _is_socket_access_denied(cls, exc: Exception) -> bool:
+ reason = getattr(exc, "reason", exc)
+ if getattr(reason, "winerror", None) == 10013:
+ return True
+ message = str(reason or exc).lower()
+ return "winerror 10013" in message or "forbidden by its access permissions" in message
+
+ @staticmethod
+ def _normalize_diagnostics_url(raw_url: str) -> str:
+ candidate = str(raw_url or "").strip()
+ if not candidate:
+ raise ValueError("Missing page URL for the connection check.")
+ if "://" not in candidate:
+ candidate = f"https://{candidate}"
+ parsed = urlparse(candidate)
+ if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
+ raise ValueError("Invalid URL. Use http(s)://domain/path.")
+ return candidate
+
+ def _diagnostics_summary_for_url(self, area_url: str) -> str:
+ normalized = self._normalize_diagnostics_url(area_url)
+ parsed = urlparse(normalized)
+ host = (parsed.hostname or "").strip()
+ if not host:
+ raise ValueError("Connection check failed because the URL host is missing.")
+ port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
try:
- summary = self._diagnostics_summary_for_url(area_url)
+ socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except Exception as exc:
- detail = self._normalize_network_error_message(str(exc))
- summary = f"Network diagnostics failed: {detail}"
+ return f"Connection check: DNS lookup failed for {host} ({self._normalize_network_error_message(str(exc))})"
+ try:
+ with socket.create_connection((host, int(port)), timeout=4.0):
+ pass
+ except Exception as exc:
+ if self._is_socket_access_denied(exc):
+ return f"Connection check: {self.WINDOWS_BLOCKED_ACCESS_TEXT}"
+ return (
+ f"Connection check: could not connect to {host}:{port} "
+ f"({self._normalize_network_error_message(str(exc))})"
+ )
- self._window.web_sources_panel.set_status(summary)
- self._window._status(summary)
+ request = Request(normalized, headers={"User-Agent": "SpriteFactory/1.2 (Connection Check)"})
+ try:
+ with urlopen(request, timeout=8.0) as response:
+ status = getattr(response, "status", None)
+ code = int(status) if isinstance(status, int) else 200
+ return f"Connection check passed: DNS, connection, and HTTP {code} for {host}:{port}"
+ except Exception as first_exc:
+ if self._is_socket_access_denied(first_exc):
+ return f"Connection check: {self.WINDOWS_BLOCKED_ACCESS_TEXT}"
+ try:
+ direct_opener = build_opener(ProxyHandler({}))
+ with direct_opener.open(request, timeout=8.0) as response:
+ status = getattr(response, "status", None)
+ code = int(status) if isinstance(status, int) else 200
+ return f"Connection check passed without proxy: DNS, connection, and HTTP {code} for {host}:{port}"
+ except Exception as second_exc:
+ if self._is_socket_access_denied(second_exc):
+ return f"Connection check: {self.WINDOWS_BLOCKED_ACCESS_TEXT}"
+ detail = self._normalize_network_error_message(str(first_exc))
+ return f"Connection check partial: DNS and connection passed, but the page request failed ({detail})"
@staticmethod
def format_download_status(report: object) -> str:
@@ -707,17 +549,10 @@ def format_download_status(report: object) -> str:
)
reused = max(0, loaded - downloaded)
cancelled = bool(getattr(report, "cancelled", False))
- prefix = "Web Sources import cancelled" if cancelled else "Web Sources import"
+ prefix = "Web Sources download cancelled" if cancelled else "Web Sources download"
message = (
f"{prefix}: downloaded {downloaded}, reused {reused} cached, "
- f"skipped {skipped}, failed {failed}, workspace loaded {loaded}"
+ f"skipped {skipped}, failed {failed}, loaded {loaded} into the workspace"
)
- failure_preview = WebSourcesService.summarize_failures(tuple(getattr(report, "failed", ()) or ()), limit=2)
- if failure_preview:
- return f"{message} | sample failures: {failure_preview}"
- return message
-
-
-
-
-
+ sample = WebSourcesService.summarize_failures(tuple(getattr(report, "failed", ()) or ()), limit=2)
+ return f"{message} | sample failures: {sample}" if sample else message
diff --git a/image_engine_app/ui/main_window/web_sources_panel.py b/image_engine_app/ui/main_window/web_sources_panel.py
index 664a4d6..e47bad2 100644
--- a/image_engine_app/ui/main_window/web_sources_panel.py
+++ b/image_engine_app/ui/main_window/web_sources_panel.py
@@ -1,233 +1,169 @@
-"""Web Sources panel.
+"""Web Sources workspace.
-This is a thin UI shell.
-- UI emits scan/download requests.
-- Controller performs scan/download and calls set_results()/set_status().
-
-Keeping UI simple makes the scan/download workflow safer to maintain.
+The panel only collects user intent and renders state. Network work, persistence,
+downloads, and workspace imports are owned by the coordinator and services.
"""
from __future__ import annotations
-from dataclasses import asdict
+from copy import deepcopy
import re
import unicodedata
-from urllib.parse import unquote, urlparse
+from urllib.parse import unquote, urlparse, urlunparse
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
- QCheckBox,
QComboBox,
QFrame,
+ QHeaderView,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
+ QMenu,
QMessageBox,
QPlainTextEdit,
QPushButton,
- QMenu,
QToolButton,
+ QTreeWidget,
+ QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
-from image_engine_app.app.web_sources_models import Confidence, ImportTarget, ScanResults, SmartOptions, WebIndexLink, WebItem
+from image_engine_app.app.web_sources_models import (
+ Confidence,
+ FoundFilesStore,
+ ImportTarget,
+ ScanMergeResult,
+ ScanOrigin,
+ ScanResults,
+ SmartOptions,
+ WebDiagnosticsRequest,
+ WebDownloadRequest,
+ WebIndexLink,
+ WebItem,
+ WebLinkDiscoveryRequest,
+ WebScanRequest,
+)
class WebSourcesPanel(QFrame):
- """Main-window tab panel for Website/Area scanning + importing."""
+ """Clear four-step UI for scanning pages and importing discovered files."""
- LINKED_PAGE_SCAN_CAP = 100
+ PAGE_SCAN_CAP = 100
+ _ROLE_KIND = int(Qt.ItemDataRole.UserRole)
+ _ROLE_DATA = int(Qt.ItemDataRole.UserRole) + 1
- scan_requested = Signal(object) # payload dict
- index_links_requested = Signal(object) # payload dict
- index_scan_all_requested = Signal(object) # payload dict
- multi_scan_requested = Signal(object) # payload dict
- download_requested = Signal(object) # payload dict
- registry_changed = Signal(object) # payload list[dict]
- network_diagnostics_requested = Signal(object) # payload dict
+ scan_requested = Signal(object)
+ discover_links_requested = Signal(object)
+ download_requested = Signal(object)
+ diagnostics_requested = Signal(object)
+ registry_changed = Signal(object)
+ preferences_changed = Signal(object)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
- self._website = QComboBox(self)
- self._area = QComboBox(self)
- self._saved_page = QComboBox(self)
- self._scan_btn = QPushButton("Scan Page", self)
- self._scan_saved_btn = QPushButton("Scan Saved", self)
- self._source_more_btn = QToolButton(self)
- self._url_more_btn = QToolButton(self)
- self._custom_url = QLineEdit(self)
- self._selected_page_hint = QLabel("Choose a website and page to scan.", self)
-
- self._find_index_links_btn = QPushButton("Find Pages", self)
- self._index_more_btn = QToolButton(self)
- self._index_keyword = QLineEdit(self)
- self._index_links = QListWidget(self)
- self._manual_links = QPlainTextEdit(self)
- self._manual_count = QLabel("0 valid URL(s)", self)
- self._scan_manual_links_btn = QPushButton("Scan List", self)
-
- self._search = QLineEdit(self)
- self._exclude_keywords = QLineEdit(self)
- self._filter_png = QCheckBox("PNG", self)
- self._filter_gif = QCheckBox("GIF", self)
- self._filter_webp = QCheckBox("WEBP", self)
- self._filter_jpg = QCheckBox("JPG", self)
- self._filter_zip = QCheckBox("ZIP", self)
-
- self._show_likely = QCheckBox("Show likely links", self)
- self._skip_dupes = QCheckBox("Skip duplicates", self)
- self._allow_zip = QCheckBox("Allow ZIP imports", self)
- self._filters_btn = QToolButton(self)
-
- self._results = QListWidget(self)
- self._selection_detail = QLabel("Select an item to see its source URL.", self)
- self._status = QLabel("", self)
- self._destination_hint = QLabel("Auto destination: Sprite Factory routes downloads into Main / Shiny / Animated / Items.", self)
- self._download_btn = QPushButton("Download", self)
+ self._entered_urls = QPlainTextEdit(self)
+ self._entered_count = QLabel("0 valid URLs", self)
+ self._scan_entered_btn = QPushButton("Scan Pages", self)
+ self._entered_more_btn = QToolButton(self)
+
+ self._saved_tree = QTreeWidget(self)
+ self._saved_count = QLabel("0 saved pages", self)
+ self._scan_saved_btn = QPushButton("Scan Selected", self)
+ self._saved_more_btn = QToolButton(self)
+
+ self._link_source = QComboBox(self)
+ self._find_links_btn = QPushButton("Find Pages", self)
+ self._scan_links_btn = QPushButton("Scan Selected", self)
+ self._links_more_btn = QToolButton(self)
+ self._links_search = QLineEdit(self)
+ self._links_count = QLabel("0 linked pages", self)
+ self._links = QListWidget(self)
+
+ self._results_search = QLineEdit(self)
+ self._exclude_words = QLineEdit(self)
+ self._filters_btn = QToolButton(self)
+ self._results_more_btn = QToolButton(self)
+ self._results_count = QLabel("0 files", self)
+ self._results = QTreeWidget(self)
+ self._selection_detail = QLabel("Select a file to see its source URL.", self)
- self._download_more_btn = QToolButton(self)
+ self._destination_hint = QLabel(
+ "Downloads are routed automatically into Main, Shiny, Animated, or Items.",
+ self,
+ )
+ self._download_options_btn = QToolButton(self)
+ self._download_btn = QPushButton("Download Selected", self)
+ self._status = QLabel("Paste one or more page URLs to begin.", self)
+
+ self._include_likely_action = self._checkable_action("Include uncertain image links", False)
+ self._skip_downloaded_action = self._checkable_action("Skip files already downloaded", True)
+ self._allow_zip_action = self._checkable_action("Allow ZIP extraction", True)
+ self._format_actions = {
+ ".png": self._checkable_action("PNG", True),
+ ".gif": self._checkable_action("GIF", True),
+ ".webp": self._checkable_action("WEBP", True),
+ ".jpg": self._checkable_action("JPG / JPEG", True),
+ ".zip": self._checkable_action("ZIP", True),
+ }
- self._items: list[WebItem] = []
- self._index_link_items: list[WebIndexLink] = []
- self._syncing_saved_page = False
- self._apply_web_sources_object_names()
+ self._registry: list[dict] = []
+ self._found_files = FoundFilesStore()
+ self._linked_items: list[WebIndexLink] = []
+ self._selected_link_urls: set[str] = set()
+ self._selected_file_urls: set[str] = set()
+ self._apply_object_names()
self._build_ui()
- self._set_index_controls_enabled(False)
-
- # --- Public API for controller ---
-
- def set_sources(self, *, websites: list[dict], selected_website_id: str | None = None, selected_area_id: str | None = None) -> None:
- """Populate saved website/page dropdowns.
-
- websites format (dict):
- {"id": str, "name": str, "areas": [{"id": str, "label": str, "url": str}, ...]}
- """
- self._website.blockSignals(True)
- self._area.blockSignals(True)
- self._saved_page.blockSignals(True)
-
- self._website.clear()
- for w in websites:
- self._website.addItem(str(w.get("name", "Website")), w)
-
- # select website
- if selected_website_id:
- for i in range(self._website.count()):
- w = self._website.itemData(i)
- if isinstance(w, dict) and w.get("id") == selected_website_id:
- self._website.setCurrentIndex(i)
- break
-
- self._rebuild_areas(selected_area_id)
- self._rebuild_saved_pages()
- self._sync_saved_page_from_selection()
-
- self._website.blockSignals(False)
- self._area.blockSignals(False)
- self._saved_page.blockSignals(False)
- self._update_selected_page_hint()
-
- def set_results(self, results: ScanResults) -> None:
- """Render scan results into the list."""
- self._items = list(results.items)
- self._refresh_list()
- visible_count = self._results.count()
- failed_pages = tuple(getattr(results, "failed_pages", ()) or ())
- failure_note = self._scan_failure_note(failed_pages)
- failure_tooltip = self._scan_failure_tooltip(failed_pages)
- if not self._items and int(results.filtered_count or 0) > 0:
- self._set_status_text(
- (
- f"Found 0 item(s); filtered out {results.filtered_count}. "
- f"Open Filters and enable 'Show likely links'.{f' {failure_note}' if failure_note else ''}"
- ),
- failure_tooltip,
- )
- return
- if not self._items and failed_pages:
- self._set_status_text(f"Found 0 item(s). {failure_note}", failure_tooltip)
- return
- if self._items and visible_count == 0:
- self._set_status_text(
- (
- f"Found {len(self._items)} item(s), but current search/filter options hide them."
- f"{f' {failure_note}' if failure_note else ''}"
- ),
- failure_tooltip,
- )
- return
- self._set_status_text(
- (
- f"Found {len(self._items)} item(s); filtered out {results.filtered_count}."
- f"{f' {failure_note}' if failure_note else ''}"
- ),
- failure_tooltip,
+ self._connect_state_actions()
+ self._refresh_entered_state()
+ self._refresh_saved_tree()
+ self._refresh_link_sources()
+ self._refresh_link_list(capture_selection=False)
+ self._refresh_results(capture_selection=False)
+
+ # --- Public coordinator boundary ---
+
+ def set_sources(
+ self,
+ *,
+ websites: list[dict],
+ selected_website_id: str | None = None,
+ selected_area_id: str | None = None,
+ ) -> None:
+ checked_urls = {page["url"] for page in self._checked_saved_pages(fallback_to_current=False)}
+ self._registry = deepcopy(websites if isinstance(websites, list) else [])
+ self._refresh_saved_tree(
+ selected_website_id=selected_website_id,
+ selected_area_id=selected_area_id,
+ checked_urls=checked_urls,
)
-
- def set_index_links(self, links: tuple[WebIndexLink, ...] | list[WebIndexLink]) -> None:
- self._index_link_items = list(links)
- self._refresh_index_link_list()
- count = len(self._index_link_items)
- self._set_index_controls_enabled(count > 0)
- if count:
- self._select_visible_index_links()
- self._status.setText(
- f"Found {count} linked page(s). Filter the list, select what you want, then scan selected pages."
- )
- else:
- self._status.setText("Found 0 linked pages. Try Scan Page for this page or choose a broader index page.")
-
- def set_status(self, msg: str) -> None:
- text = str(msg)
- self._set_status_text(text, text if len(text) > 120 else "")
-
- def _set_status_text(self, text: str, tooltip: str = "") -> None:
- self._status.setText(str(text))
- self._status.setToolTip(str(tooltip or ""))
+ self._refresh_link_sources()
def sources_registry(self) -> list[dict]:
- registry: list[dict] = []
- for index in range(self._website.count()):
- source = self._website.itemData(index)
- if not isinstance(source, dict):
- continue
- source_id = str(source.get("id", "")).strip()
- name = str(source.get("name", "")).strip()
- areas_raw = source.get("areas")
- if not source_id or not name or not isinstance(areas_raw, list):
- continue
- areas: list[dict] = []
- for area in areas_raw:
- if not isinstance(area, dict):
- continue
- area_id = str(area.get("id", "")).strip()
- label = str(area.get("label", "")).strip()
- url = str(area.get("url", "")).strip()
- if not area_id or not label or not url:
- continue
- areas.append({"id": area_id, "label": label, "url": url})
- if not areas:
- continue
- registry.append({"id": source_id, "name": name, "areas": areas})
- return registry
+ return deepcopy(self._registry)
+
+ def selected_source_ids(self) -> tuple[str | None, str | None]:
+ item = self._saved_tree.currentItem()
+ payload = self._saved_item_payload(item)
+ if payload is None:
+ return None, None
+ return payload.get("website_id"), payload.get("area_id")
def smart_options(self) -> SmartOptions:
return SmartOptions(
- show_likely=bool(self._show_likely.isChecked()),
+ show_likely=self._include_likely_action.isChecked(),
auto_sort=True,
- skip_duplicates=bool(self._skip_dupes.isChecked()),
- allow_zip=bool(self._allow_zip.isChecked()),
+ skip_duplicates=self._skip_downloaded_action.isChecked(),
+ allow_zip=self._allow_zip_action.isChecked(),
)
def set_smart_options(self, options: SmartOptions | dict | None) -> None:
- if options is None:
- return
if isinstance(options, SmartOptions):
smart = options
elif isinstance(options, dict):
@@ -240,1305 +176,960 @@ def set_smart_options(self, options: SmartOptions | dict | None) -> None:
else:
return
- self._show_likely.setChecked(smart.show_likely)
- self._skip_dupes.setChecked(smart.skip_duplicates)
- self._allow_zip.setChecked(smart.allow_zip)
+ for action, checked in (
+ (self._include_likely_action, smart.show_likely),
+ (self._skip_downloaded_action, smart.skip_duplicates),
+ (self._allow_zip_action, smart.allow_zip),
+ ):
+ action.blockSignals(True)
+ action.setChecked(bool(checked))
+ action.blockSignals(False)
- def selected_source_ids(self) -> tuple[str | None, str | None]:
- website = self._website.currentData()
- area = self._area.currentData()
- website_id = str(website.get("id")) if isinstance(website, dict) and website.get("id") else None
- area_id = str(area.get("id")) if isinstance(area, dict) and area.get("id") else None
- return website_id, area_id
+ def set_results(self, results: ScanResults) -> None:
+ self._found_files.replace(results)
+ available = {item.url for item in self._found_files.items}
+ self._selected_file_urls.intersection_update(available)
+ self._refresh_results(capture_selection=False)
+ self._show_scan_status(results)
+
+ def add_results(self, results: ScanResults) -> ScanMergeResult:
+ self._capture_result_selection()
+ outcome = self._found_files.add(results)
+ self._refresh_results(capture_selection=False)
+ self._show_scan_status(results, merge=outcome)
+ return outcome
+
+ def found_items(self) -> tuple[WebItem, ...]:
+ return self._found_files.items
- # --- Internals ---
+ def set_index_links(self, links: tuple[WebIndexLink, ...] | list[WebIndexLink]) -> None:
+ self._linked_items = list(links)
+ self._selected_link_urls = {link.url for link in self._linked_items}
+ self._refresh_link_list(capture_selection=False)
+ if self._linked_items:
+ self.set_status(
+ f"Found {len(self._linked_items)} linked page(s). Select the pages you want, then scan them."
+ )
+ else:
+ self.set_status("No linked pages were found. Try scanning this page directly or choose a broader index page.")
- def _apply_web_sources_object_names(self) -> None:
+ def set_status(self, message: str) -> None:
+ text = " ".join(str(message or "").split())
+ self._set_status_text(text, text if len(text) > 140 else "")
+
+ def confirm_large_page_scan(self, page_count: int, *, cap: int | None = None) -> bool:
+ limit = int(cap or self.PAGE_SCAN_CAP)
+ if int(page_count) <= limit:
+ return True
+ answer = QMessageBox.question(
+ self,
+ "Large page scan",
+ (
+ f"You selected {page_count} pages.\n\n"
+ "Scanning too many pages together may make the app or website unstable.\n"
+ f"Sprite Factory will scan the first {limit} pages only.\n\n"
+ "Continue?"
+ ),
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ )
+ return answer == QMessageBox.StandardButton.Yes
+
+ # --- UI construction ---
+
+ def _apply_object_names(self) -> None:
+ self.setObjectName("webSourcesCard")
for button in (
- self._scan_btn,
+ self._scan_entered_btn,
self._scan_saved_btn,
- self._find_index_links_btn,
- self._scan_manual_links_btn,
+ self._find_links_btn,
+ self._scan_links_btn,
self._download_btn,
):
button.setObjectName("webSourcesPrimaryAction")
- self._index_links.setObjectName("webSourcesIndexList")
- self._manual_links.setObjectName("webSourcesManualList")
- self._results.setObjectName("webSourcesResultsList")
+ for label in (self._entered_count, self._saved_count, self._links_count, self._results_count):
+ label.setObjectName("webSourcesCountBadge")
+ self._entered_urls.setObjectName("webSourcesUrlList")
+ self._saved_tree.setObjectName("webSourcesSavedTree")
+ self._links.setObjectName("webSourcesIndexList")
+ self._results.setObjectName("webSourcesResultsTree")
def _build_ui(self) -> None:
- self.setObjectName("webSourcesCard")
self.setFrameShape(QFrame.Shape.NoFrame)
-
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 10, 10, 10)
outer.setSpacing(8)
- outer.addWidget(self._build_source_section())
- outer.addWidget(self._build_saved_section())
- outer.addWidget(self._build_pages_section())
+ source_row = QHBoxLayout()
+ source_row.setSpacing(8)
+ source_row.addWidget(self._build_entered_section(), 3)
+ source_row.addWidget(self._build_saved_section(), 2)
+ outer.addLayout(source_row)
+ outer.addWidget(self._build_links_section())
outer.addWidget(self._build_results_section(), 1)
- outer.addLayout(self._build_download_footer())
self._status.setObjectName("shellHint")
+ self._status.setWordWrap(True)
outer.addWidget(self._status)
- # These controls are internal state for menus/filters, not direct layout widgets.
- # Hide them so Qt never paints a stray control at (0, 0).
- for orphan in (
- self._saved_page,
- self._show_likely,
- self._skip_dupes,
- self._allow_zip,
- self._filter_png,
- self._filter_gif,
- self._filter_webp,
- self._filter_jpg,
- self._filter_zip,
- ):
- orphan.hide()
-
- self._website.currentIndexChanged.connect(lambda _=None: self._on_website_changed())
- self._saved_page.currentIndexChanged.connect(lambda _=None: self._select_saved_page())
- self._website.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- self._website.customContextMenuRequested.connect(self._show_website_context_menu)
- self._area.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- self._area.customContextMenuRequested.connect(self._show_area_context_menu)
- self._area.currentIndexChanged.connect(lambda _=None: self._sync_saved_page_from_selection())
-
- def _build_source_section(self) -> QFrame:
+ def _build_entered_section(self) -> QFrame:
section, body = self._section_card(
"1. Scan Pages",
- "Start here. Scan one page, or scan many page URLs at once.",
+ "Paste one page URL or one full URL per line, including pages from different websites.",
)
-
- single_title = QLabel("Single page", self)
- single_title.setObjectName("shellTitle")
- body.addWidget(single_title)
-
- custom = QHBoxLayout()
- custom.setSpacing(8)
- self._custom_url.setPlaceholderText("Paste one page URL, e.g. https://example.com/sprites")
- self._custom_url.textChanged.connect(lambda _=None: self._update_selected_page_hint())
- self._scan_btn.clicked.connect(self._emit_url_scan)
- custom.addWidget(self._custom_url, 1)
- custom.addWidget(self._scan_btn)
- self._configure_more_button(
- self._url_more_btn,
- [
- ("Save URL as Page", self._add_custom_website),
- ("Clear URL", self._clear_url),
- ("Clear Page List", self._clear_manual_links),
- ("Check Pasted URL", self._emit_custom_url_network_diagnostics),
- ],
+ self._entered_urls.setPlaceholderText(
+ "https://example.com/sprites\nhttps://another-site.example/characters"
)
- custom.addWidget(self._url_more_btn)
- body.addLayout(custom)
-
- manual_header = QHBoxLayout()
- manual_title = QLabel("Multiple pages", self)
- manual_title.setObjectName("shellTitle")
- manual_header.addWidget(manual_title)
- manual_hint = QLabel("Paste one full URL per line. These do not need to be saved first.", self)
- manual_hint.setObjectName("shellHint")
- manual_header.addWidget(manual_hint, 1)
- self._manual_count.setObjectName("shellHint")
- manual_header.addWidget(self._manual_count)
- body.addLayout(manual_header)
-
- self._manual_links.setPlaceholderText(
- "https://example.com/sprites/gen-1\nhttps://another-site.example/sprites"
- )
- self._manual_links.setFixedHeight(76)
- self._manual_links.textChanged.connect(self._update_manual_link_count)
- body.addWidget(self._manual_links)
-
- manual_actions = QHBoxLayout()
- manual_actions.addStretch(1)
- self._scan_manual_links_btn.clicked.connect(self._emit_manual_page_scan)
- manual_actions.addWidget(self._scan_manual_links_btn)
- body.addLayout(manual_actions)
-
+ self._entered_urls.setFixedHeight(88)
+ self._entered_urls.textChanged.connect(self._refresh_entered_state)
+ body.addWidget(self._entered_urls)
+
+ actions = QHBoxLayout()
+ actions.addWidget(self._entered_count)
+ actions.addStretch(1)
+ self._scan_entered_btn.clicked.connect(self._emit_entered_scan)
+ actions.addWidget(self._scan_entered_btn)
+
+ entered_menu = QMenu(self._entered_more_btn)
+ entered_menu.addAction(self._action("Save Entered Pages", self._save_entered_pages))
+ entered_menu.addAction(self._action("Check First URL", self._diagnose_first_entered_url))
+ entered_menu.addSeparator()
+ entered_menu.addAction(self._include_likely_action)
+ entered_menu.addSeparator()
+ entered_menu.addAction(self._action("Clear Entered URLs", self._clear_entered_urls))
+ self._set_menu(self._entered_more_btn, entered_menu)
+ actions.addWidget(self._entered_more_btn)
+ body.addLayout(actions)
return section
def _build_saved_section(self) -> QFrame:
section, body = self._section_card(
- "2. Saved Shortcuts",
- "Reusable pages you saved from the URL box.",
+ "2. Saved Pages",
+ "Check pages across one or more websites, then scan them together.",
)
-
- picker_row = QHBoxLayout()
- picker_row.setSpacing(8)
- picker_row.addLayout(self._labeled_control("Website", self._website), 1)
- picker_row.addLayout(self._labeled_control("Saved page", self._area), 2)
-
- self._scan_saved_btn.clicked.connect(self._emit_saved_page_scan)
- picker_row.addWidget(self._scan_saved_btn, 0, Qt.AlignmentFlag.AlignBottom)
-
- self._configure_more_button(
- self._source_more_btn,
- [
- ("Scan All Saved", self._emit_saved_pages_scan),
- ("Remove Saved Page", self._remove_selected_area),
- ("Remove Website", self._remove_selected_website),
- ("Check Saved Page", self._emit_saved_page_network_diagnostics),
- ],
- )
- picker_row.addWidget(self._source_more_btn, 0, Qt.AlignmentFlag.AlignBottom)
- body.addLayout(picker_row)
-
- self._selected_page_hint.setObjectName("shellHint")
- body.addWidget(self._selected_page_hint)
+ self._saved_tree.setHeaderHidden(True)
+ self._saved_tree.setFixedHeight(88)
+ self._saved_tree.currentItemChanged.connect(lambda _current, _previous: self._refresh_link_sources())
+ body.addWidget(self._saved_tree)
+
+ actions = QHBoxLayout()
+ actions.addWidget(self._saved_count)
+ actions.addStretch(1)
+ self._scan_saved_btn.clicked.connect(self._emit_saved_scan)
+ actions.addWidget(self._scan_saved_btn)
+
+ saved_menu = QMenu(self._saved_more_btn)
+ saved_menu.addAction(self._action("Check Current Page", self._check_current_saved_page))
+ saved_menu.addAction(self._action("Clear Checked Pages", self._clear_checked_saved_pages))
+ saved_menu.addAction(self._action("Check Current Page Connection", self._diagnose_current_saved_page))
+ saved_menu.addSeparator()
+ saved_menu.addAction(self._action("Remove Current Page", self._remove_current_saved_page))
+ saved_menu.addAction(self._action("Remove Current Website", self._remove_current_saved_website))
+ self._set_menu(self._saved_more_btn, saved_menu)
+ actions.addWidget(self._saved_more_btn)
+ body.addLayout(actions)
return section
- def _build_pages_section(self) -> QFrame:
+ def _build_links_section(self) -> QFrame:
section, body = self._section_card(
"3. Find Linked Pages",
- "Optional. Use this when a page is an index and you want to choose pages inside it.",
+ "Optional. Choose an index or category page, find its page links, then scan only the ones you select.",
)
- index_header = QHBoxLayout()
- index_title = QLabel("Linked pages", self)
- index_title.setObjectName("shellTitle")
- index_header.addWidget(index_title)
- index_hint = QLabel("Uses the pasted URL first, otherwise the selected saved page.", self)
- index_hint.setObjectName("shellHint")
- index_header.addWidget(index_hint, 1)
- self._find_index_links_btn.clicked.connect(self._emit_index_links_scan)
- index_header.addWidget(self._find_index_links_btn)
- self._configure_more_button(
- self._index_more_btn,
- [
- ("Scan Selected Links", self._emit_multi_page_scan),
- ("Find and Scan First 100", self._emit_index_scan_all),
- ("Select Visible Links", self._select_visible_index_links),
- ("Clear Link Selection", self._index_links.clearSelection),
- ("Clear Linked Pages", self._clear_found_pages),
- ],
- )
- index_header.addWidget(self._index_more_btn)
- body.addLayout(index_header)
-
- index_filter = QHBoxLayout()
- self._index_keyword.setPlaceholderText("Search linked pages, e.g. gen 1, home, animation...")
- self._index_keyword.textChanged.connect(lambda _=None: self._refresh_index_link_list())
- index_filter.addWidget(self._index_keyword, 1)
- body.addLayout(index_filter)
-
- self._index_links.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
- self._index_links.setFixedHeight(96)
- body.addWidget(self._index_links)
-
+ source_row = QHBoxLayout()
+ source_label = QLabel("Discover from", self)
+ source_label.setObjectName("shellHint")
+ source_row.addWidget(source_label)
+ source_row.addWidget(self._link_source, 1)
+ self._find_links_btn.clicked.connect(self._emit_discover_links)
+ source_row.addWidget(self._find_links_btn)
+ self._scan_links_btn.clicked.connect(self._emit_linked_scan)
+ source_row.addWidget(self._scan_links_btn)
+
+ links_menu = QMenu(self._links_more_btn)
+ links_menu.addAction(self._action("Select Visible Pages", self._select_visible_links))
+ links_menu.addAction(self._action("Clear Page Selection", self._clear_link_selection))
+ links_menu.addSeparator()
+ links_menu.addAction(self._action("Clear Linked Pages", self._clear_linked_pages))
+ self._set_menu(self._links_more_btn, links_menu)
+ source_row.addWidget(self._links_more_btn)
+ body.addLayout(source_row)
+
+ filter_row = QHBoxLayout()
+ self._links_search.setPlaceholderText("Search linked pages by name or URL...")
+ self._links_search.textChanged.connect(lambda _text: self._refresh_link_list())
+ filter_row.addWidget(self._links_search, 1)
+ filter_row.addWidget(self._links_count)
+ body.addLayout(filter_row)
+
+ self._links.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
+ self._links.setFixedHeight(104)
+ self._links.itemSelectionChanged.connect(self._capture_link_selection)
+ body.addWidget(self._links)
return section
def _build_results_section(self) -> QFrame:
section, body = self._section_card(
"4. Found Files",
- "Search scan results, select the files you want, then download them into the workspace.",
+ "Results stay here across scans. Search, filter, select, then download them into the workspace.",
)
- options = QHBoxLayout()
- options.setSpacing(8)
- self._show_likely.setChecked(False)
- self._skip_dupes.setChecked(True)
- self._allow_zip.setChecked(True)
-
- filt = QHBoxLayout()
- filt.setSpacing(6)
- self._search.setPlaceholderText("Search results by filename, URL, or source page...")
- self._search.textChanged.connect(lambda _: self._refresh_list())
- self._exclude_keywords.setPlaceholderText("Exclude words, e.g. shiny, thumb")
- self._exclude_keywords.textChanged.connect(lambda _: self._refresh_list())
- for cb in (self._filter_png, self._filter_gif, self._filter_webp, self._filter_jpg, self._filter_zip):
- cb.setChecked(True)
- cb.stateChanged.connect(lambda _=None: self._refresh_list())
-
- search_label = QLabel("Search results", self)
- search_label.setObjectName("shellHint")
- filt.addWidget(search_label)
- filt.addWidget(self._search, 1)
- exclude_label = QLabel("Exclude words", self)
- exclude_label.setObjectName("shellHint")
- filt.addWidget(exclude_label)
- filt.addWidget(self._exclude_keywords, 0)
+ filter_row = QHBoxLayout()
+ self._results_search.setPlaceholderText("Search filename, URL, or source page...")
+ self._results_search.textChanged.connect(lambda _text: self._refresh_results())
+ filter_row.addWidget(self._results_search, 2)
+ self._exclude_words.setPlaceholderText("Hide words, e.g. shiny, thumb")
+ self._exclude_words.textChanged.connect(lambda _text: self._refresh_results())
+ filter_row.addWidget(self._exclude_words, 1)
filters_menu = QMenu(self._filters_btn)
- filters_menu.addAction(self._show_likely_action())
- filters_menu.addSeparator()
- filters_menu.addAction(self._checkbox_action(self._filter_png))
- filters_menu.addAction(self._checkbox_action(self._filter_gif))
- filters_menu.addAction(self._checkbox_action(self._filter_webp))
- filters_menu.addAction(self._checkbox_action(self._filter_jpg))
- filters_menu.addAction(self._checkbox_action(self._filter_zip))
+ for action in self._format_actions.values():
+ filters_menu.addAction(action)
filters_menu.addSeparator()
- filters_menu.addAction(self._checkbox_action(self._skip_dupes))
- filters_menu.addAction(self._checkbox_action(self._allow_zip))
- self._filters_btn.setText("Filters")
- self._filters_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
- self._filters_btn.setMenu(filters_menu)
- filt.addWidget(self._filters_btn)
-
- body.addLayout(filt)
-
- self._results.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
- self._results.itemSelectionChanged.connect(self._update_preview)
+ filters_menu.addAction(self._action("Reset File Filters", self._reset_file_filters))
+ self._filters_btn.setText("File Types")
+ self._set_menu(self._filters_btn, filters_menu)
+ filter_row.addWidget(self._filters_btn)
+
+ results_menu = QMenu(self._results_more_btn)
+ results_menu.addAction(self._action("Select All Visible Files", self._select_all_visible_results))
+ results_menu.addAction(self._action("Clear File Selection", self._clear_result_selection))
+ results_menu.addSeparator()
+ results_menu.addAction(self._action("Clear Found Files", self._clear_found_files))
+ self._set_menu(self._results_more_btn, results_menu)
+ filter_row.addWidget(self._results_more_btn)
+ filter_row.addWidget(self._results_count)
+ body.addLayout(filter_row)
+
+ self._results.setHeaderLabels(["File", "Type", "Source page"])
+ self._results.setRootIsDecorated(False)
+ self._results.setAlternatingRowColors(True)
+ self._results.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection)
+ header = self._results.header()
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
+ header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
+ header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
+ self._results.itemSelectionChanged.connect(self._on_result_selection_changed)
body.addWidget(self._results, 1)
- self._selection_detail.setWordWrap(True)
self._selection_detail.setObjectName("shellHint")
+ self._selection_detail.setWordWrap(True)
body.addWidget(self._selection_detail)
- return section
-
- def _build_download_footer(self) -> QHBoxLayout:
- bottom = QHBoxLayout()
- bottom.setSpacing(8)
-
+ download_row = QHBoxLayout()
self._destination_hint.setObjectName("shellHint")
- bottom.addWidget(self._destination_hint, 1)
-
- self._configure_more_button(
- self._download_more_btn,
- [
- ("Select All Results", self._select_all_visible),
- ("Clear Result Selection", self._clear_selection),
- ("Clear Found Files", self._clear_found_files),
- ],
- )
- bottom.addWidget(self._download_more_btn)
-
+ download_row.addWidget(self._destination_hint, 1)
+
+ download_menu = QMenu(self._download_options_btn)
+ download_menu.addAction(self._skip_downloaded_action)
+ download_menu.addAction(self._allow_zip_action)
+ self._download_options_btn.setText("Download Options")
+ self._set_menu(self._download_options_btn, download_menu)
+ download_row.addWidget(self._download_options_btn)
self._download_btn.clicked.connect(self._emit_download)
- bottom.addWidget(self._download_btn)
-
- return bottom
+ download_row.addWidget(self._download_btn)
+ body.addLayout(download_row)
+ return section
- def _section_card(self, title: str, hint: str) -> tuple[QFrame, QVBoxLayout]:
- section = QFrame(self)
+ @staticmethod
+ def _section_card(title: str, hint: str) -> tuple[QFrame, QVBoxLayout]:
+ section = QFrame()
section.setObjectName("webSourcesSectionCard")
section.setFrameShape(QFrame.Shape.NoFrame)
layout = QVBoxLayout(section)
- layout.setContentsMargins(7, 6, 7, 6)
- layout.setSpacing(5)
+ layout.setContentsMargins(8, 7, 8, 7)
+ layout.setSpacing(6)
header = QHBoxLayout()
- header.setSpacing(8)
- title_label = QLabel(title, self)
+ title_label = QLabel(title, section)
title_label.setObjectName("shellTitle")
header.addWidget(title_label)
- hint_label = QLabel(hint, self)
+ hint_label = QLabel(hint, section)
hint_label.setObjectName("shellHint")
+ hint_label.setWordWrap(True)
header.addWidget(hint_label, 1)
layout.addLayout(header)
-
return section, layout
- def _labeled_control(self, label: str, widget: QWidget) -> QVBoxLayout:
- layout = QVBoxLayout()
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(2)
- text = QLabel(label, self)
- text.setObjectName("shellHint")
- layout.addWidget(text)
- layout.addWidget(widget)
- return layout
-
- def _configure_more_button(self, button: QToolButton, actions: list[tuple[str, object]]) -> None:
- button.setText("More")
- button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
- menu = QMenu(button)
- for label, callback in actions:
- menu.addAction(label, callback) # type: ignore[arg-type]
- button.setMenu(menu)
+ def _action(self, label: str, callback) -> QAction: # noqa: ANN001
+ action = QAction(label, self)
+ action.triggered.connect(callback)
+ return action
- def _checkbox_action(self, checkbox: QCheckBox):
- action = QAction(checkbox.text(), self)
+ def _checkable_action(self, label: str, checked: bool) -> QAction:
+ action = QAction(label, self)
action.setCheckable(True)
- action.setChecked(checkbox.isChecked())
- action.toggled.connect(checkbox.setChecked)
- checkbox.stateChanged.connect(lambda _=None, action=action, checkbox=checkbox: action.setChecked(checkbox.isChecked()))
+ action.setChecked(bool(checked))
return action
- def _show_likely_action(self):
- action = self._checkbox_action(self._show_likely)
- action.setText("Show likely links")
- return action
+ @staticmethod
+ def _set_menu(button: QToolButton, menu: QMenu) -> None:
+ if not button.text():
+ button.setText("More")
+ button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ button.setMenu(menu)
- def _rebuild_areas(self, selected_area_id: str | None = None) -> None:
- self._area.clear()
- w = self._website.currentData()
- if not isinstance(w, dict):
- return
- for a in (w.get("areas") or []):
- label = self._compact_area_label(
- str(a.get("label", "Area")),
- str(a.get("url", "")),
- )
- self._area.addItem(label, a)
- index = self._area.count() - 1
- self._area.setItemData(index, str(a.get("url", "")), Qt.ItemDataRole.ToolTipRole)
-
- if selected_area_id:
- for i in range(self._area.count()):
- a = self._area.itemData(i)
- if isinstance(a, dict) and a.get("id") == selected_area_id:
- self._area.setCurrentIndex(i)
- break
- self._update_selected_page_hint()
-
- def _on_website_changed(self) -> None:
- self._rebuild_areas()
- self._sync_saved_page_from_selection()
-
- def _rebuild_saved_pages(self) -> None:
- self._saved_page.clear()
- for website_index in range(self._website.count()):
- website = self._website.itemData(website_index)
- if not isinstance(website, dict):
+ def _connect_state_actions(self) -> None:
+ for action in (
+ self._include_likely_action,
+ self._skip_downloaded_action,
+ self._allow_zip_action,
+ ):
+ action.toggled.connect(lambda _checked, self=self: self.preferences_changed.emit(self.smart_options()))
+ for action in self._format_actions.values():
+ action.toggled.connect(lambda _checked, self=self: self._refresh_results())
+
+ # --- Entered pages ---
+
+ def _entered_url_values(self) -> tuple[tuple[str, ...], int, int]:
+ urls: list[str] = []
+ seen: set[str] = set()
+ invalid = 0
+ duplicates = 0
+ for raw_line in self._entered_urls.toPlainText().splitlines():
+ raw = raw_line.strip()
+ if not raw:
continue
- website_id = str(website.get("id", "")).strip()
- website_name = str(website.get("name", "")).strip() or "Website"
- for area in website.get("areas") or []:
- if not isinstance(area, dict):
- continue
- area_id = str(area.get("id", "")).strip()
- area_url = str(area.get("url", "")).strip()
- if not website_id or not area_id or not area_url:
- continue
- area_label = self._compact_area_label(str(area.get("label", "")), area_url)
- combo_label = f"{website_name} - {area_label}"
- self._saved_page.addItem(
- combo_label,
- {
- "website_id": website_id,
- "area_id": area_id,
- "url": area_url,
- },
- )
- self._saved_page.setItemData(
- self._saved_page.count() - 1,
- area_url,
- Qt.ItemDataRole.ToolTipRole,
- )
-
- if self._saved_page.count() == 0:
- self._saved_page.addItem("No saved pages yet", None)
+ normalized = self._normalize_page_url(raw)
+ if normalized is None:
+ invalid += 1
+ continue
+ key = normalized.casefold()
+ if key in seen:
+ duplicates += 1
+ continue
+ seen.add(key)
+ urls.append(normalized)
+ return tuple(urls), invalid, duplicates
- def _select_saved_page(self) -> None:
- if self._syncing_saved_page:
+ def _refresh_entered_state(self) -> None:
+ urls, invalid, duplicates = self._entered_url_values()
+ detail = f"{len(urls)} valid URL{'s' if len(urls) != 1 else ''}"
+ skipped: list[str] = []
+ if invalid:
+ skipped.append(f"{invalid} invalid")
+ if duplicates:
+ skipped.append(f"{duplicates} duplicate")
+ if skipped:
+ detail += f"; {', '.join(skipped)} skipped"
+ self._entered_count.setText(detail)
+ self._scan_entered_btn.setEnabled(bool(urls))
+ self._refresh_link_sources()
+
+ def _emit_entered_scan(self) -> None:
+ urls, _, _ = self._entered_url_values()
+ if not urls:
+ self.set_status("Enter at least one valid http or https page URL.")
return
+ self.scan_requested.emit(
+ WebScanRequest(urls=urls, smart=self.smart_options(), origin=ScanOrigin.ENTERED)
+ )
- selection = self._saved_page.currentData()
- if not isinstance(selection, dict):
- self._update_selected_page_hint()
- return
+ def _clear_entered_urls(self) -> None:
+ self._entered_urls.clear()
+ self.set_status("Entered URLs cleared. Found Files were kept.")
- website_id = str(selection.get("website_id", "")).strip()
- area_id = str(selection.get("area_id", "")).strip()
- if not website_id or not area_id:
- self._update_selected_page_hint()
+ def _diagnose_first_entered_url(self) -> None:
+ urls, _, _ = self._entered_url_values()
+ if not urls:
+ self.set_status("Enter a valid URL before checking its connection.")
return
+ self.diagnostics_requested.emit(WebDiagnosticsRequest(url=urls[0]))
- self._syncing_saved_page = True
- try:
- for website_index in range(self._website.count()):
- website = self._website.itemData(website_index)
- if isinstance(website, dict) and str(website.get("id", "")).strip() == website_id:
- self._website.setCurrentIndex(website_index)
- self._rebuild_areas(area_id)
- break
- finally:
- self._syncing_saved_page = False
- self._update_selected_page_hint()
-
- def _sync_saved_page_from_selection(self) -> None:
- if self._syncing_saved_page:
- self._update_selected_page_hint()
+ def _save_entered_pages(self) -> None:
+ urls, _, _ = self._entered_url_values()
+ if not urls:
+ self.set_status("Enter at least one valid URL before saving pages.")
return
- website_id, area_id = self.selected_source_ids()
- self._syncing_saved_page = True
- self._saved_page.blockSignals(True)
- try:
- for index in range(self._saved_page.count()):
- data = self._saved_page.itemData(index)
- if (
- isinstance(data, dict)
- and str(data.get("website_id", "")).strip() == str(website_id or "")
- and str(data.get("area_id", "")).strip() == str(area_id or "")
- ):
- self._saved_page.setCurrentIndex(index)
- break
- finally:
- self._saved_page.blockSignals(False)
- self._syncing_saved_page = False
- self._update_selected_page_hint()
-
- def _update_selected_page_hint(self) -> None:
- custom_url = self._custom_url.text().strip()
- if custom_url:
- self._selected_page_hint.setText(f"Pasted URL will be used for scan: {custom_url}")
- return
+ registry = deepcopy(self._registry)
+ saved_count = 0
+ duplicate_count = 0
+ selected_website_id: str | None = None
+ selected_area_id: str | None = None
- area = self._area.currentData()
- if not isinstance(area, dict) or not area.get("url"):
- self._selected_page_hint.setText("Choose a website and page to scan.")
- return
+ for url in urls:
+ parsed = urlparse(url)
+ host = (parsed.hostname or parsed.netloc).strip().lower()
+ source = next(
+ (entry for entry in registry if str(entry.get("name", "")).strip().lower() == host),
+ None,
+ )
+ if source is None:
+ source_ids = {str(entry.get("id", "")) for entry in registry if isinstance(entry, dict)}
+ source_id = self._unique_id(self._slugify(host) or "website", source_ids)
+ source = {"id": source_id, "name": host, "areas": []}
+ registry.append(source)
+
+ areas = source.setdefault("areas", [])
+ existing = next(
+ (
+ area
+ for area in areas
+ if isinstance(area, dict) and str(area.get("url", "")).strip().casefold() == url.casefold()
+ ),
+ None,
+ )
+ selected_website_id = str(source.get("id", "")) or None
+ if existing is not None:
+ duplicate_count += 1
+ selected_area_id = str(existing.get("id", "")) or None
+ continue
- label = self._compact_area_label(str(area.get("label", "Page")), str(area.get("url", "")))
- self._selected_page_hint.setText(f"Selected page: {label} - {area['url']}")
+ area_ids = {str(area.get("id", "")) for area in areas if isinstance(area, dict)}
+ label = self._page_label(url)
+ area_id = self._unique_id(self._slugify(label) or "page", area_ids)
+ areas.append({"id": area_id, "label": label, "url": url})
+ selected_area_id = area_id
+ saved_count += 1
- def _emit_scan(self) -> None:
- custom_url = self._custom_url.text().strip()
- if custom_url:
- self._emit_url_scan()
- return
+ self._registry = registry
+ self._refresh_saved_tree(
+ selected_website_id=selected_website_id,
+ selected_area_id=selected_area_id,
+ )
+ self._refresh_link_sources()
+ self.registry_changed.emit(self.sources_registry())
+ self.set_status(
+ f"Saved {saved_count} new page(s); skipped {duplicate_count} already saved page(s)."
+ )
- self._emit_saved_page_scan()
+ # --- Saved pages ---
+
+ def _refresh_saved_tree(
+ self,
+ *,
+ selected_website_id: str | None = None,
+ selected_area_id: str | None = None,
+ checked_urls: set[str] | None = None,
+ ) -> None:
+ checked = checked_urls or set()
+ self._saved_tree.blockSignals(True)
+ self._saved_tree.clear()
+ page_count = 0
+ selected_item: QTreeWidgetItem | None = None
+ first_page: QTreeWidgetItem | None = None
+
+ for website in self._registry:
+ if not isinstance(website, dict):
+ continue
+ website_id = str(website.get("id", "")).strip()
+ website_name = str(website.get("name", "Website")).strip() or "Website"
+ website_item = QTreeWidgetItem([website_name])
+ website_item.setData(0, self._ROLE_KIND, "website")
+ website_item.setData(0, self._ROLE_DATA, {"website_id": website_id, "area_id": None})
+ website_item.setFlags(
+ website_item.flags()
+ | Qt.ItemFlag.ItemIsUserCheckable
+ | Qt.ItemFlag.ItemIsAutoTristate
+ )
+ website_item.setCheckState(0, Qt.CheckState.Unchecked)
+ self._saved_tree.addTopLevelItem(website_item)
+ if website_id == selected_website_id and not selected_area_id:
+ selected_item = website_item
- def _emit_url_scan(self) -> None:
- custom_url = self._custom_url.text().strip()
- if not custom_url:
- self.set_status("Paste a URL first, or use Scan Saved for saved pages.")
+ for area in website.get("areas", []):
+ if not isinstance(area, dict):
+ continue
+ area_id = str(area.get("id", "")).strip()
+ label = str(area.get("label", "Page")).strip() or "Page"
+ url = str(area.get("url", "")).strip()
+ if not area_id or not url:
+ continue
+ child = QTreeWidgetItem([label])
+ payload = {
+ "website_id": website_id,
+ "area_id": area_id,
+ "website_name": website_name,
+ "label": label,
+ "url": url,
+ }
+ child.setData(0, self._ROLE_KIND, "page")
+ child.setData(0, self._ROLE_DATA, payload)
+ child.setToolTip(0, url)
+ child.setFlags(child.flags() | Qt.ItemFlag.ItemIsUserCheckable)
+ child.setCheckState(0, Qt.CheckState.Checked if url in checked else Qt.CheckState.Unchecked)
+ website_item.addChild(child)
+ page_count += 1
+ first_page = first_page or child
+ if website_id == selected_website_id and area_id == selected_area_id:
+ selected_item = child
+
+ website_item.setExpanded(True)
+
+ self._saved_tree.setCurrentItem(selected_item or first_page)
+ self._saved_tree.blockSignals(False)
+ self._saved_count.setText(f"{page_count} saved page{'s' if page_count != 1 else ''}")
+ self._scan_saved_btn.setEnabled(page_count > 0)
+
+ def _saved_item_payload(self, item: QTreeWidgetItem | None) -> dict | None:
+ if item is None:
+ return None
+ payload = item.data(0, self._ROLE_DATA)
+ return payload if isinstance(payload, dict) else None
+
+ def _checked_saved_pages(self, *, fallback_to_current: bool = True) -> list[dict]:
+ pages: list[dict] = []
+ for top_index in range(self._saved_tree.topLevelItemCount()):
+ website = self._saved_tree.topLevelItem(top_index)
+ for child_index in range(website.childCount()):
+ child = website.child(child_index)
+ if child.checkState(0) == Qt.CheckState.Checked:
+ payload = self._saved_item_payload(child)
+ if payload is not None:
+ pages.append(payload)
+ if pages or not fallback_to_current:
+ return pages
+
+ current = self._saved_tree.currentItem()
+ if current is None:
+ return []
+ if current.data(0, self._ROLE_KIND) == "page":
+ payload = self._saved_item_payload(current)
+ return [payload] if payload is not None else []
+ for child_index in range(current.childCount()):
+ payload = self._saved_item_payload(current.child(child_index))
+ if payload is not None:
+ pages.append(payload)
+ return pages
+
+ def _emit_saved_scan(self) -> None:
+ pages = self._checked_saved_pages()
+ if not pages:
+ self.set_status("Check one or more saved pages before scanning.")
return
+ website_ids = {str(page.get("website_id", "")) for page in pages}
+ area_ids = {str(page.get("area_id", "")) for page in pages}
+ self.scan_requested.emit(
+ WebScanRequest(
+ urls=tuple(str(page["url"]) for page in pages),
+ smart=self.smart_options(),
+ origin=ScanOrigin.SAVED,
+ website_id=next(iter(website_ids)) if len(website_ids) == 1 else None,
+ area_id=next(iter(area_ids)) if len(area_ids) == 1 else None,
+ )
+ )
- normalized = self._normalize_custom_url(custom_url)
- if normalized is None:
- self.set_status("Invalid URL. Use http(s)://domain/path.")
+ def _check_current_saved_page(self) -> None:
+ item = self._saved_tree.currentItem()
+ if item is None:
+ self.set_status("Choose a saved page first.")
return
+ item.setCheckState(0, Qt.CheckState.Checked)
- payload = {
- "area_url": normalized[0],
- "website_id": None,
- "area_id": None,
- "smart": asdict(self.smart_options()),
- }
- self._clear_result_text_filters()
- self.set_status(f"Scanning URL: {normalized[0]}")
- self.scan_requested.emit(payload)
+ def _clear_checked_saved_pages(self) -> None:
+ for top_index in range(self._saved_tree.topLevelItemCount()):
+ self._saved_tree.topLevelItem(top_index).setCheckState(0, Qt.CheckState.Unchecked)
+ self.set_status("Saved-page checks cleared.")
- def _emit_saved_page_scan(self) -> None:
- a = self._area.currentData()
- if not isinstance(a, dict) or not a.get("url"):
- self.set_status("Choose a saved page first.")
+ def _diagnose_current_saved_page(self) -> None:
+ payload = self._saved_item_payload(self._saved_tree.currentItem())
+ if payload is None or not payload.get("url"):
+ self.set_status("Choose a saved page before checking its connection.")
return
+ self.diagnostics_requested.emit(WebDiagnosticsRequest(url=str(payload["url"])))
- website_id, area_id = self.selected_source_ids()
- payload = {
- "area_url": str(a["url"]),
- "website_id": website_id,
- "area_id": area_id,
- "smart": asdict(self.smart_options()),
- }
- self._clear_result_text_filters()
- self.set_status(f"Scanning saved page: {str(a['url'])}")
- self.scan_requested.emit(payload)
+ def _remove_current_saved_page(self) -> None:
+ payload = self._saved_item_payload(self._saved_tree.currentItem())
+ if payload is None or not payload.get("area_id"):
+ self.set_status("Choose a saved page to remove.")
+ return
+ website_id = str(payload.get("website_id", ""))
+ area_id = str(payload.get("area_id", ""))
+ for website in self._registry:
+ if str(website.get("id", "")) != website_id:
+ continue
+ website["areas"] = [
+ area for area in website.get("areas", []) if str(area.get("id", "")) != area_id
+ ]
+ self._registry = [website for website in self._registry if website.get("areas")]
+ self._refresh_saved_tree()
+ self._refresh_link_sources()
+ self.registry_changed.emit(self.sources_registry())
+ self.set_status("Saved page removed. Found Files were kept.")
- def _emit_index_links_scan(self) -> None:
- area_payload = self._current_area_payload(status_action="find linked pages")
- if area_payload is None:
+ def _remove_current_saved_website(self) -> None:
+ payload = self._saved_item_payload(self._saved_tree.currentItem())
+ if payload is None or not payload.get("website_id"):
+ self.set_status("Choose a saved website to remove.")
return
+ website_id = str(payload["website_id"])
+ self._registry = [website for website in self._registry if str(website.get("id", "")) != website_id]
+ self._refresh_saved_tree()
+ self._refresh_link_sources()
+ self.registry_changed.emit(self.sources_registry())
+ self.set_status("Saved website removed. Found Files were kept.")
- payload = {
- "index_url": area_payload["area_url"],
- "website_id": area_payload.get("website_id"),
- "area_id": area_payload.get("area_id"),
- "smart": asdict(self.smart_options()),
- }
- self._index_link_items = []
- self._refresh_index_link_list()
- self._set_index_controls_enabled(False)
- self.set_status(f"Finding linked pages: {payload['index_url']}")
- self.index_links_requested.emit(payload)
-
- def _emit_saved_pages_scan(self) -> None:
- links: list[dict] = []
- seen_urls: set[str] = set()
- for website_index in range(self._website.count()):
- website = self._website.itemData(website_index)
- if not isinstance(website, dict):
- continue
- areas = website.get("areas")
- if not isinstance(areas, list):
+ # --- Linked pages ---
+
+ def _refresh_link_sources(self) -> None:
+ if not hasattr(self, "_link_source"):
+ return
+ current_data = self._link_source.currentData()
+ current_url = str(current_data.get("url", "")) if isinstance(current_data, dict) else ""
+ candidates: list[dict] = []
+ seen: set[str] = set()
+
+ entered, _, _ = self._entered_url_values()
+ for url in entered:
+ key = url.casefold()
+ if key in seen:
continue
- for area in areas:
+ seen.add(key)
+ candidates.append({"label": f"Entered: {self._short_url(url)}", "url": url})
+
+ for website in self._registry:
+ website_id = str(website.get("id", ""))
+ website_name = str(website.get("name", "Website"))
+ for area in website.get("areas", []):
if not isinstance(area, dict):
continue
url = str(area.get("url", "")).strip()
- if not url or url in seen_urls:
+ if not url or url.casefold() in seen:
continue
- seen_urls.add(url)
- links.append(
+ seen.add(url.casefold())
+ candidates.append(
{
- "label": str(area.get("label", "")).strip() or self._compact_area_label("", url),
+ "label": f"Saved: {website_name} / {str(area.get('label', 'Page'))}",
"url": url,
- "source_page": None,
+ "website_id": website_id,
+ "area_id": str(area.get("id", "")) or None,
}
)
- if not links:
- self.set_status("Save at least one website page before scanning all saved pages.")
- return
-
- original_count = len(links)
- links = self._cap_link_payloads_with_warning(links)
- if not links:
- return
-
- website_id, area_id = self.selected_source_ids()
- payload = {
- "pages": links,
- "website_id": website_id,
- "area_id": area_id,
- "smart": asdict(self.smart_options()),
- }
- if original_count > len(links):
- self.set_status(f"Scanning first {len(links)} of {original_count} saved page(s)...")
- else:
- self.set_status(f"Scanning {len(links)} saved page(s)...")
- self._clear_result_text_filters()
- self.multi_scan_requested.emit(payload)
-
- def _emit_index_scan_all(self) -> None:
- area_payload = self._current_area_payload(status_action="scan linked pages")
- if area_payload is None:
- return
-
- payload = {
- "index_url": area_payload["area_url"],
- "website_id": area_payload.get("website_id"),
- "area_id": area_payload.get("area_id"),
- "smart": asdict(self.smart_options()),
- }
- self._index_link_items = []
- self._refresh_index_link_list()
- self._set_index_controls_enabled(False)
- self._clear_result_text_filters()
- self.set_status(f"Finding and scanning linked pages: {payload['index_url']}")
- self.index_scan_all_requested.emit(payload)
-
- def _emit_multi_page_scan(self) -> None:
- if not self._index_link_items:
- self.set_status("Click Find Pages first, then scan the linked pages you want.")
- return
-
- selected_links = self._selected_index_link_payloads()
- if not selected_links:
- selected_links = self._visible_index_link_payloads()
- if not selected_links:
- self.set_status("Select one or more linked pages first.")
- return
- original_count = len(selected_links)
- selected_links = self._cap_link_payloads_with_warning(selected_links)
- if not selected_links:
- return
-
- website_id, area_id = self.selected_source_ids()
- payload = {
- "pages": selected_links,
- "website_id": website_id,
- "area_id": area_id,
- "smart": asdict(self.smart_options()),
- }
- if original_count > len(selected_links):
- self.set_status(f"Scanning first {len(selected_links)} of {original_count} linked page(s)...")
+ self._link_source.blockSignals(True)
+ self._link_source.clear()
+ if not candidates:
+ self._link_source.addItem("Enter or save a page first", None)
else:
- self.set_status(f"Scanning {len(selected_links)} linked page(s)...")
- self._clear_result_text_filters()
- self.multi_scan_requested.emit(payload)
-
- def _emit_manual_page_scan(self) -> None:
- links, invalid_count, duplicate_count = self._manual_index_links()
- if not links:
- self.set_status("Paste one or more valid page URLs to scan manually.")
+ selected_index = 0
+ for index, candidate in enumerate(candidates):
+ self._link_source.addItem(candidate["label"], candidate)
+ self._link_source.setItemData(index, candidate["url"], Qt.ItemDataRole.ToolTipRole)
+ if candidate["url"] == current_url:
+ selected_index = index
+ self._link_source.setCurrentIndex(selected_index)
+ self._link_source.blockSignals(False)
+ self._find_links_btn.setEnabled(bool(candidates))
+
+ def _emit_discover_links(self) -> None:
+ payload = self._link_source.currentData()
+ if not isinstance(payload, dict) or not payload.get("url"):
+ self.set_status("Choose a page in Discover from before finding linked pages.")
return
+ self.discover_links_requested.emit(
+ WebLinkDiscoveryRequest(
+ url=str(payload["url"]),
+ website_id=str(payload.get("website_id")) if payload.get("website_id") else None,
+ area_id=str(payload.get("area_id")) if payload.get("area_id") else None,
+ )
+ )
- payload_links = [asdict(link) for link in links]
- original_count = len(payload_links)
- payload_links = self._cap_link_payloads_with_warning(payload_links)
- if not payload_links:
- return
-
- skipped_parts: list[str] = []
- if invalid_count:
- skipped_parts.append(f"{invalid_count} invalid")
- if duplicate_count:
- skipped_parts.append(f"{duplicate_count} duplicate")
-
- website_id, area_id = self.selected_source_ids()
- payload = {
- "pages": payload_links,
- "website_id": website_id,
- "area_id": area_id,
- "smart": asdict(self.smart_options()),
- }
+ def _refresh_link_list(self, *, capture_selection: bool = True) -> None:
+ if capture_selection:
+ self._capture_link_selection()
+ query = self._links_search.text().strip().casefold()
+ self._links.blockSignals(True)
+ self._links.clear()
+ visible_count = 0
+ for link in self._linked_items:
+ haystack = f"{link.label} {link.url} {link.source_page or ''}".casefold()
+ if query and query not in haystack:
+ continue
+ item = QListWidgetItem(f"{link.label}\n{link.url}")
+ item.setData(Qt.ItemDataRole.UserRole, link)
+ item.setToolTip(link.url)
+ self._links.addItem(item)
+ item.setSelected(link.url in self._selected_link_urls)
+ visible_count += 1
+ self._links.blockSignals(False)
+ selected_count = len(self._selected_link_urls)
+ total = len(self._linked_items)
+ self._links_count.setText(f"{visible_count}/{total} shown; {selected_count} selected")
+ self._scan_links_btn.setEnabled(selected_count > 0)
+
+ def _capture_link_selection(self) -> None:
+ visible_urls: set[str] = set()
+ selected_urls: set[str] = set()
+ for row in range(self._links.count()):
+ item = self._links.item(row)
+ link = item.data(Qt.ItemDataRole.UserRole)
+ if not isinstance(link, WebIndexLink):
+ continue
+ visible_urls.add(link.url)
+ if item.isSelected():
+ selected_urls.add(link.url)
+ self._selected_link_urls.difference_update(visible_urls)
+ self._selected_link_urls.update(selected_urls)
+ if hasattr(self, "_links_count"):
+ self._links_count.setText(
+ f"{self._links.count()}/{len(self._linked_items)} shown; {len(self._selected_link_urls)} selected"
+ )
+ self._scan_links_btn.setEnabled(bool(self._selected_link_urls))
- if original_count > len(payload_links):
- message = f"Scanning first {len(payload_links)} of {original_count} manual page URL(s)"
- else:
- message = f"Scanning {len(payload_links)} manual page URL(s)"
- if skipped_parts:
- message = f"{message}; skipped {', '.join(skipped_parts)}"
- self._clear_result_text_filters()
- self.set_status(f"{message}...")
- self.multi_scan_requested.emit(payload)
-
- def _emit_custom_url_network_diagnostics(self) -> None:
- custom_url = self._custom_url.text().strip()
- if not custom_url:
- self.set_status("Paste a URL before running a pasted URL network check.")
+ def _emit_linked_scan(self) -> None:
+ self._capture_link_selection()
+ selected = [link for link in self._linked_items if link.url in self._selected_link_urls]
+ if not selected:
+ self.set_status("Select one or more linked pages before scanning.")
return
+ source = self._link_source.currentData()
+ self.scan_requested.emit(
+ WebScanRequest(
+ urls=tuple(link.url for link in selected),
+ smart=self.smart_options(),
+ origin=ScanOrigin.LINKED,
+ website_id=(str(source.get("website_id")) if isinstance(source, dict) and source.get("website_id") else None),
+ area_id=(str(source.get("area_id")) if isinstance(source, dict) and source.get("area_id") else None),
+ )
+ )
- normalized = self._normalize_custom_url(custom_url)
- if normalized is None:
- self.set_status("Invalid URL. Use http(s)://domain/path.")
- return
- payload = {
- "area_url": normalized[0],
- "website_id": None,
- "area_id": None,
- }
- self.set_status(f"Running network diagnostics for pasted URL: {normalized[0]}")
- self.network_diagnostics_requested.emit(payload)
+ def _select_visible_links(self) -> None:
+ for row in range(self._links.count()):
+ self._links.item(row).setSelected(True)
+ self._capture_link_selection()
- def _emit_saved_page_network_diagnostics(self) -> None:
- a = self._area.currentData()
- if not isinstance(a, dict) or not a.get("url"):
- self.set_status("Choose a saved page before running a saved page network check.")
- return
+ def _clear_link_selection(self) -> None:
+ self._selected_link_urls.clear()
+ self._links.clearSelection()
+ self._refresh_link_list(capture_selection=False)
- website_id, area_id = self.selected_source_ids()
- payload = {
- "area_url": str(a["url"]),
- "website_id": website_id,
- "area_id": area_id,
- }
- self.set_status(f"Running network diagnostics for saved page: {str(a['url'])}")
- self.network_diagnostics_requested.emit(payload)
+ def _clear_linked_pages(self) -> None:
+ self._linked_items = []
+ self._selected_link_urls.clear()
+ self._refresh_link_list(capture_selection=False)
+ self.set_status("Linked pages cleared. Found Files were kept.")
- def _current_area_payload(self, *, status_action: str) -> dict | None:
- custom_url = self._custom_url.text().strip()
- if custom_url:
- normalized = self._normalize_custom_url(custom_url)
- if normalized is None:
- self.set_status("Invalid URL. Use http(s)://domain/path.")
- return None
- return {
- "area_url": normalized[0],
- "website_id": None,
- "area_id": None,
- }
-
- area = self._area.currentData()
- if not isinstance(area, dict) or not area.get("url"):
- self.set_status(f"Enter a URL or pick a saved page to {status_action}.")
- return None
+ # --- Found files ---
- website_id, area_id = self.selected_source_ids()
- return {
- "area_url": str(area["url"]),
- "website_id": website_id,
- "area_id": area_id,
- }
-
- def _manual_index_links(self) -> tuple[list[WebIndexLink], int, int]:
- links: list[WebIndexLink] = []
- seen_urls: set[str] = set()
- invalid_count = 0
- duplicate_count = 0
+ def _refresh_results(self, *, capture_selection: bool = True) -> None:
+ if capture_selection:
+ self._capture_result_selection()
+ query = self._results_search.text().strip().casefold()
+ excluded = self._excluded_terms()
+ allowed = {ext for ext, action in self._format_actions.items() if action.isChecked()}
- for raw_line in self._manual_links.toPlainText().splitlines():
- raw_url = raw_line.strip()
- if not raw_url:
+ self._results.blockSignals(True)
+ self._results.clear()
+ visible_count = 0
+ for file_item in self._found_files.items:
+ ext = (file_item.ext or "").lower()
+ filter_ext = ".jpg" if ext == ".jpeg" else ext
+ haystack = " ".join(
+ (file_item.name, file_item.url, file_item.source_page or "", ext)
+ ).casefold()
+ if filter_ext not in allowed:
continue
-
- normalized = self._normalize_custom_url(raw_url)
- if normalized is None:
- invalid_count += 1
+ if query and query not in haystack:
continue
-
- url = normalized[0]
- if url in seen_urls:
- duplicate_count += 1
+ if excluded and any(term in haystack for term in excluded):
continue
- seen_urls.add(url)
- links.append(
- WebIndexLink(
- label=self._compact_area_label("", url),
- url=url,
- source_page=None,
- )
+ source = self._short_url(file_item.source_page or file_item.url)
+ row = QTreeWidgetItem([file_item.name or "download", ext.lstrip(".").upper(), source])
+ row.setData(0, Qt.ItemDataRole.UserRole, file_item)
+ row.setToolTip(0, file_item.url)
+ row.setToolTip(2, file_item.source_page or file_item.url)
+ self._results.addTopLevelItem(row)
+ row.setSelected(file_item.url in self._selected_file_urls)
+ visible_count += 1
+ self._results.blockSignals(False)
+ total = len(self._found_files.items)
+ selected = len(self._selected_file_urls)
+ self._results_count.setText(f"{visible_count}/{total} shown; {selected} selected")
+ self._download_btn.setEnabled(selected > 0)
+ self._update_selection_detail()
+ if total and visible_count == 0:
+ self.set_status(
+ f"Found Files contains {total} item(s), but the current search or file-type filters hide all stored results."
)
- return links, invalid_count, duplicate_count
+ def _capture_result_selection(self) -> None:
+ visible_urls: set[str] = set()
+ selected_urls: set[str] = set()
+ for row_index in range(self._results.topLevelItemCount()):
+ row = self._results.topLevelItem(row_index)
+ file_item = row.data(0, Qt.ItemDataRole.UserRole)
+ if not isinstance(file_item, WebItem):
+ continue
+ visible_urls.add(file_item.url)
+ if row.isSelected():
+ selected_urls.add(file_item.url)
+ self._selected_file_urls.difference_update(visible_urls)
+ self._selected_file_urls.update(selected_urls)
+
+ def _on_result_selection_changed(self) -> None:
+ self._capture_result_selection()
+ self._results_count.setText(
+ f"{self._results.topLevelItemCount()}/{len(self._found_files.items)} shown; "
+ f"{len(self._selected_file_urls)} selected"
+ )
+ self._download_btn.setEnabled(bool(self._selected_file_urls))
+ self._update_selection_detail()
- def _update_manual_link_count(self) -> None:
- links, invalid_count, duplicate_count = self._manual_index_links()
- detail = f"{len(links)} valid URL(s)"
- skipped: list[str] = []
- if invalid_count:
- skipped.append(f"{invalid_count} invalid")
- if duplicate_count:
- skipped.append(f"{duplicate_count} duplicate")
- if skipped:
- detail = f"{detail}; {', '.join(skipped)} skipped"
- self._manual_count.setText(detail)
+ def _update_selection_detail(self) -> None:
+ selected = self._selected_result_items()
+ if not selected:
+ self._selection_detail.setText("Select a file to see its source URL.")
+ return
+ first = selected[0]
+ prefix = f"{len(selected)} selected. " if len(selected) > 1 else ""
+ self._selection_detail.setText(
+ f"{prefix}{first.name} | {first.ext or 'unknown type'} | {first.confidence.value} | {first.url}"
+ )
- def _clear_manual_links(self) -> None:
- self._manual_links.clear()
- self._update_manual_link_count()
+ def _selected_result_items(self) -> list[WebItem]:
+ self._capture_result_selection()
+ return [item for item in self._found_files.items if item.url in self._selected_file_urls]
- def _clear_url(self) -> None:
- self._custom_url.clear()
- self._update_selected_page_hint()
- self.set_status("URL cleared.")
+ def _select_all_visible_results(self) -> None:
+ self._results.selectAll()
+ self._on_result_selection_changed()
- def _clear_found_pages(self) -> None:
- self._index_link_items = []
- self._refresh_index_link_list()
- self._set_index_controls_enabled(False)
- self.set_status("Linked pages cleared.")
+ def _clear_result_selection(self) -> None:
+ self._selected_file_urls.clear()
+ self._results.clearSelection()
+ self._refresh_results(capture_selection=False)
def _clear_found_files(self) -> None:
- self._items = []
- self._refresh_list()
- self._selection_detail.setText("Select an item to see its source URL.")
- self.set_status("Found files cleared.")
+ self._found_files.clear()
+ self._selected_file_urls.clear()
+ self._refresh_results(capture_selection=False)
+ self.set_status("Found Files cleared.")
+
+ def _reset_file_filters(self) -> None:
+ self._results_search.clear()
+ self._exclude_words.clear()
+ for action in self._format_actions.values():
+ action.setChecked(True)
+ self._refresh_results()
def _emit_download(self) -> None:
- selected = self._selected_items_payloads()
+ selected = self._selected_result_items()
if not selected:
- self.set_status("Select at least one item to download.")
+ self.set_status("Select at least one found file before downloading.")
return
-
website_id, area_id = self.selected_source_ids()
- area = self._area.currentData()
- area_url = str(area.get("url", "")).strip() if isinstance(area, dict) else ""
- area_label = str(area.get("label", "")).strip() if isinstance(area, dict) else ""
-
- payload = {
- "items": [asdict(x) for x in selected],
- "target": ImportTarget.NORMAL.value,
- "website_id": website_id,
- "area_id": area_id,
- "area_url": area_url or None,
- "area_label": area_label or None,
- "smart": asdict(self.smart_options()),
- }
- self.set_status("Downloading with smart routing...")
- self.download_requested.emit(payload)
-
- def _add_custom_website(self) -> None:
- raw_url = self._custom_url.text().strip()
- if not raw_url:
- self.set_status("Enter a website URL to add.")
- return
-
- normalized = self._normalize_custom_url(raw_url)
- if normalized is None:
- self.set_status("Invalid URL. Use http(s)://domain/path.")
- return
-
- normalized_url, host = normalized
- sources = self.sources_registry()
- source = self._find_source_by_host(sources, host)
-
- if source is None:
- existing_ids = {str(item.get("id", "")).strip() for item in sources if isinstance(item, dict)}
- source_id = self._next_unique_id(self._slugify(host) or "custom_site", existing_ids)
- source = {"id": source_id, "name": host, "areas": []}
- sources.append(source)
-
- areas = source.get("areas")
- if not isinstance(areas, list):
- areas = []
- source["areas"] = areas
-
- existing_area_ids = {str(a.get("id", "")).strip() for a in areas if isinstance(a, dict)}
- existing_urls = {str(a.get("url", "")).strip() for a in areas if isinstance(a, dict)}
-
- selected_area_id: str | None = None
- created = False
- if normalized_url in existing_urls:
- selected_area = next(
- (
- area
- for area in areas
- if isinstance(area, dict) and str(area.get("url", "")).strip() == normalized_url
- ),
- None,
+ self.download_requested.emit(
+ WebDownloadRequest(
+ items=tuple(selected),
+ smart=self.smart_options(),
+ target=ImportTarget.NORMAL,
+ website_id=website_id,
+ area_id=area_id,
)
- if isinstance(selected_area, dict):
- selected_area_id = str(selected_area.get("id", "")).strip() or None
+ )
+
+ def _show_scan_status(self, latest: ScanResults, *, merge: ScanMergeResult | None = None) -> None:
+ total = len(self._found_files.items)
+ failed = tuple(latest.failed_pages or ())
+ failure_note = self._scan_failure_note(failed)
+ tooltip = self._scan_failure_tooltip(failed)
+ if merge is None:
+ text = f"Found Files contains {total} item(s); scanner filtered out {latest.filtered_count}."
else:
- parsed = urlparse(normalized_url)
- path_parts = [part for part in str(parsed.path or "").split("/") if part]
- area_label = self._path_label_from_parts(path_parts)
- if parsed.query:
- area_label = f"{area_label} (Query)"
- area_id = self._next_unique_id(self._slugify(area_label) or "page", existing_area_ids)
- areas.append({"id": area_id, "label": area_label, "url": normalized_url})
- selected_area_id = area_id
- created = True
+ text = f"Added {merge.added_count} new item(s); {total} total"
+ if merge.duplicate_count:
+ text += f"; ignored {merge.duplicate_count} duplicate(s)"
+ if latest.filtered_count:
+ text += f"; scanner filtered out {latest.filtered_count}"
+ text += "."
+ if failed:
+ text += f" {failure_note}"
+ if total and self._results.topLevelItemCount() == 0:
+ text += " Current search or file-type filters hide all stored results."
+ self._set_status_text(text, tooltip)
+
+ def _excluded_terms(self) -> list[str]:
+ normalized = self._exclude_words.text().strip().casefold().replace(",", " ").replace(";", " ")
+ return [term for term in normalized.split() if term]
- source_id = str(source.get("id", "")).strip()
- self.set_sources(websites=sources, selected_website_id=source_id, selected_area_id=selected_area_id)
- self._custom_url.clear()
- self.registry_changed.emit(self.sources_registry())
+ # --- Shared helpers ---
- if created:
- self.set_status(f"Saved page: {normalized_url}")
- else:
- self.set_status("Page URL already exists in the saved list.")
+ def _set_status_text(self, text: str, tooltip: str = "") -> None:
+ self._status.setText(str(text))
+ self._status.setToolTip(str(tooltip or ""))
@staticmethod
- def _normalize_custom_url(raw_url: str) -> tuple[str, str] | None:
- candidate = (raw_url or "").strip()
+ def _normalize_page_url(raw_url: str) -> str | None:
+ candidate = str(raw_url or "").strip()
if not candidate:
return None
-
if "://" not in candidate:
candidate = f"https://{candidate}"
-
parsed = urlparse(candidate)
- scheme = parsed.scheme.lower()
- if scheme not in {"http", "https"} or not parsed.netloc:
+ if (
+ parsed.scheme.lower() not in {"http", "https"}
+ or not parsed.netloc
+ or any(character.isspace() for character in parsed.netloc)
+ ):
return None
-
- host = parsed.netloc.lower()
- if host.startswith("www."):
- host = host[4:]
-
- return candidate, host
+ return urlunparse(
+ (
+ parsed.scheme.lower(),
+ parsed.netloc.lower(),
+ parsed.path or "/",
+ parsed.params,
+ parsed.query,
+ "",
+ )
+ )
@staticmethod
- def _friendly_path_segment(segment: str) -> str:
- raw = WebSourcesPanel._ascii_clean(unquote(segment or "")).strip().replace("-", " ").replace("_", " ")
- words = [word for word in raw.split() if word]
- if not words:
- return "Area"
- return " ".join(word.capitalize() for word in words)
-
- @classmethod
- def _path_label_from_parts(cls, path_parts: list[str]) -> str:
- friendly_parts = [cls._friendly_path_segment(part) for part in path_parts if str(part or "").strip()]
- if not friendly_parts:
- return "Root"
- if len(friendly_parts) > 3:
- friendly_parts = ["..."] + friendly_parts[-3:]
- return " / ".join(friendly_parts)
+ def _short_url(url: str, *, limit: int = 72) -> str:
+ parsed = urlparse(str(url or ""))
+ text = f"{parsed.netloc}{unquote(parsed.path or '/')}"
+ if parsed.query:
+ text += "?" + parsed.query
+ if len(text) > limit:
+ return text[: limit - 3].rstrip() + "..."
+ return text or str(url or "")
@classmethod
- def _compact_area_label(cls, label: str, url: str) -> str:
- parsed = urlparse(str(url or "").strip())
- path_parts = [part for part in str(parsed.path or "").split("/") if part]
- clean = cls._path_label_from_parts(path_parts)
+ def _page_label(cls, url: str) -> str:
+ parsed = urlparse(url)
+ parts = [cls._friendly_segment(part) for part in parsed.path.split("/") if part]
+ label = " / ".join(parts[-3:]) if parts else "Root"
+ if len(parts) > 3:
+ label = f"... / {label}"
if parsed.query:
- clean = f"{clean} (Query)"
-
- if not path_parts:
- fallback = " ".join(cls._ascii_clean(unquote(label or "")).split())
- clean = fallback or "Root"
-
- max_len = 72
- if len(clean) > max_len:
- clean = f"{clean[: max_len - 3].rstrip()}..."
- return clean or "Area"
+ label += " (Query)"
+ return label
@staticmethod
- def _ascii_clean(value: str) -> str:
- normalized = unicodedata.normalize("NFKD", str(value or ""))
- return normalized.encode("ascii", "ignore").decode("ascii")
+ def _friendly_segment(segment: str) -> str:
+ normalized = unicodedata.normalize("NFKD", unquote(str(segment or "")))
+ ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
+ words = ascii_text.replace("-", " ").replace("_", " ").split()
+ return " ".join(word.capitalize() for word in words) or "Page"
@staticmethod
def _slugify(value: str) -> str:
- out = "".join(ch.lower() if ch.isalnum() else "_" for ch in (value or ""))
- return "_".join(part for part in out.split("_") if part)
+ slug = re.sub(r"[^a-z0-9]+", "_", str(value or "").lower()).strip("_")
+ return slug
@staticmethod
- def _next_unique_id(base: str, existing: set[str]) -> str:
- candidate = base.strip() or "custom"
- if candidate not in existing:
- return candidate
- index = 2
- while True:
- next_candidate = f"{candidate}_{index}"
- if next_candidate not in existing:
- return next_candidate
- index += 1
-
- @staticmethod
- def _find_source_by_host(sources: list[dict], host: str) -> dict | None:
- for source in sources:
- if not isinstance(source, dict):
- continue
- areas = source.get("areas")
- if not isinstance(areas, list):
- continue
- for area in areas:
- if not isinstance(area, dict):
- continue
- parsed = urlparse(str(area.get("url", "")).strip())
- netloc = parsed.netloc.lower()
- if netloc.startswith("www."):
- netloc = netloc[4:]
- if netloc and netloc == host:
- return source
- return None
- def _show_website_context_menu(self, pos) -> None: # noqa: ANN001
- menu = QMenu(self)
- remove_action = menu.addAction("Remove Website")
- chosen = menu.exec(self._website.mapToGlobal(pos))
- if chosen is remove_action:
- self._remove_selected_website()
- def _show_area_context_menu(self, pos) -> None: # noqa: ANN001
- menu = QMenu(self)
- remove_action = menu.addAction("Remove URL")
- chosen = menu.exec(self._area.mapToGlobal(pos))
- if chosen is remove_action:
- self._remove_selected_area()
- def _remove_selected_website(self) -> None:
- source = self._website.currentData()
- if not isinstance(source, dict):
- self.set_status("No website selected.")
- return
-
- source_id = str(source.get("id", "")).strip()
- source_name = str(source.get("name", source_id or "Website")).strip() or "Website"
- if not source_id:
- self.set_status("No website selected.")
- return
-
- sources = self.sources_registry()
- remove_index = next(
- (
- index
- for index, item in enumerate(sources)
- if isinstance(item, dict) and str(item.get("id", "")).strip() == source_id
- ),
- None,
- )
- if remove_index is None:
- self.set_status("Website not found.")
- return
-
- sources.pop(remove_index)
-
- selected_website_id: str | None = None
- selected_area_id: str | None = None
- if sources:
- next_index = min(remove_index, len(sources) - 1)
- next_source = sources[next_index] if isinstance(sources[next_index], dict) else None
- if isinstance(next_source, dict):
- selected_website_id = str(next_source.get("id", "")).strip() or None
- next_areas = next_source.get("areas")
- if isinstance(next_areas, list) and next_areas:
- first_area = next_areas[0]
- if isinstance(first_area, dict):
- selected_area_id = str(first_area.get("id", "")).strip() or None
-
- self.set_sources(
- websites=sources,
- selected_website_id=selected_website_id,
- selected_area_id=selected_area_id,
- )
- self._clear_results_for_registry_change()
- self.registry_changed.emit(self.sources_registry())
- self.set_status(f"Removed website: {source_name}")
-
- def _remove_selected_area(self) -> None:
- source = self._website.currentData()
- area = self._area.currentData()
- if not isinstance(source, dict) or not isinstance(area, dict):
- self.set_status("No URL selected.")
- return
-
- source_id = str(source.get("id", "")).strip()
- source_name = str(source.get("name", source_id or "Website")).strip() or "Website"
- area_id = str(area.get("id", "")).strip()
- area_label = str(area.get("label", "URL")).strip() or "URL"
- if not source_id or not area_id:
- self.set_status("No URL selected.")
- return
-
- sources = self.sources_registry()
- source_index = next(
- (
- index
- for index, item in enumerate(sources)
- if isinstance(item, dict) and str(item.get("id", "")).strip() == source_id
- ),
- None,
- )
- if source_index is None:
- self.set_status("Selected website not found.")
- return
-
- target_source = sources[source_index]
- if not isinstance(target_source, dict):
- self.set_status("Selected website not found.")
- return
-
- areas = target_source.get("areas")
- if not isinstance(areas, list):
- self.set_status("Selected URL not found.")
- return
-
- remove_area_index = next(
- (
- index
- for index, item in enumerate(areas)
- if isinstance(item, dict) and str(item.get("id", "")).strip() == area_id
- ),
- None,
- )
- if remove_area_index is None:
- self.set_status("Selected URL not found.")
- return
-
- areas.pop(remove_area_index)
-
- selected_website_id: str | None = source_id
- selected_area_id: str | None = None
- if areas:
- next_area_index = min(remove_area_index, len(areas) - 1)
- next_area = areas[next_area_index]
- if isinstance(next_area, dict):
- selected_area_id = str(next_area.get("id", "")).strip() or None
- else:
- sources.pop(source_index)
- if sources:
- next_source_index = min(source_index, len(sources) - 1)
- next_source = sources[next_source_index] if isinstance(sources[next_source_index], dict) else None
- if isinstance(next_source, dict):
- selected_website_id = str(next_source.get("id", "")).strip() or None
- next_areas = next_source.get("areas")
- if isinstance(next_areas, list) and next_areas:
- first_area = next_areas[0]
- if isinstance(first_area, dict):
- selected_area_id = str(first_area.get("id", "")).strip() or None
- else:
- selected_website_id = None
- selected_area_id = None
-
- self.set_sources(
- websites=sources,
- selected_website_id=selected_website_id,
- selected_area_id=selected_area_id,
- )
- self._clear_results_for_registry_change()
- self.registry_changed.emit(self.sources_registry())
- self.set_status(f"Removed URL: {area_label} ({source_name})")
-
- def _clear_results_for_registry_change(self) -> None:
- self._items = []
- self._index_link_items = []
- self._refresh_list()
- self._refresh_index_link_list()
- self._set_index_controls_enabled(False)
- self._selection_detail.setText("Select an item to see its source URL.")
-
- def _refresh_list(self) -> None:
- self._results.clear()
- query = self._search.text().strip().lower()
- excluded_terms = self._excluded_result_terms()
-
- allow = set()
- if self._filter_png.isChecked():
- allow.add(".png")
- if self._filter_gif.isChecked():
- allow.add(".gif")
- if self._filter_webp.isChecked():
- allow.add(".webp")
- if self._filter_jpg.isChecked():
- allow.add(".jpg")
- allow.add(".jpeg")
- if self._filter_zip.isChecked():
- allow.add(".zip")
-
- for item in self._items:
- name = item.name or ""
- ext = (item.ext or "").lower()
- haystack = " ".join(
- (
- name,
- ext,
- item.url or "",
- item.source_page or "",
- )
- ).lower()
- if allow and ext and ext not in allow:
- continue
- if query and query not in haystack:
- continue
- if excluded_terms and any(term in haystack for term in excluded_terms):
- continue
-
- badge = "DIRECT"
- if item.confidence == Confidence.LIKELY:
- badge = "LIKELY"
- elif item.confidence == Confidence.UNKNOWN:
- badge = "UNKNOWN"
-
- label = f"[{badge}] {name} ({ext.lstrip('.') if ext else 'file'})"
- lw = QListWidgetItem(label)
- lw.setToolTip(item.url)
- lw.setData(Qt.ItemDataRole.UserRole, item)
- self._results.addItem(lw)
-
- def _excluded_result_terms(self) -> list[str]:
- raw = self._exclude_keywords.text().strip().lower()
- if not raw:
- return []
- normalized = raw.replace(",", " ").replace(";", " ")
- return [term for term in normalized.split() if term]
+ def _unique_id(base: str, existing: set[str]) -> str:
+ candidate = base or "item"
+ suffix = 2
+ while candidate in existing:
+ candidate = f"{base}_{suffix}"
+ suffix += 1
+ return candidate
@staticmethod
def _scan_failure_note(failed_pages: tuple[str, ...]) -> str:
if not failed_pages:
return ""
- reason = WebSourcesPanel._scan_failure_reason(failed_pages[0])
- suffix = f" ({reason})" if reason else ""
noun = "page" if len(failed_pages) == 1 else "pages"
- return f"{len(failed_pages)} {noun} failed{suffix}. Hover for details."
+ return f"{len(failed_pages)} {noun} failed; hover for details."
@staticmethod
def _scan_failure_tooltip(failed_pages: tuple[str, ...]) -> str:
if not failed_pages:
return ""
lines = ["Failed page details:"]
- for page in failed_pages[:10]:
- lines.append(f"- {' '.join(str(page).split())}")
+ lines.extend(f"- {' '.join(str(page).split())}" for page in failed_pages[:10])
if len(failed_pages) > 10:
lines.append(f"- ...and {len(failed_pages) - 10} more")
return "\n".join(lines)
-
- @staticmethod
- def _scan_failure_reason(failure: str) -> str:
- text = " ".join(str(failure or "").split())
- http_match = re.search(r"HTTP\s+(\d{3})(?:\s*\(([^)]+)\))?", text, flags=re.IGNORECASE)
- if http_match:
- code = http_match.group(1)
- label = " ".join(str(http_match.group(2) or "").split())
- return f"HTTP {code}{f' {label}' if label else ''}"
- if "timeout" in text.lower() or "timed out" in text.lower():
- return "timeout"
- return ""
-
- def _refresh_index_link_list(self) -> None:
- self._index_links.clear()
- query = self._index_keyword.text().strip().lower()
-
- for link in self._index_link_items:
- haystack = f"{link.label} {link.url} {link.source_page or ''}".lower()
- if query and query not in haystack:
- continue
-
- item = QListWidgetItem(f"{link.label}\n{link.url}")
- item.setToolTip(link.url)
- item.setData(Qt.ItemDataRole.UserRole, link)
- self._index_links.addItem(item)
-
- def _selected_index_link_payloads(self) -> list[dict]:
- selected = self._index_links.selectedItems()
- if not selected:
- return []
-
- out: list[dict] = []
- for item in selected:
- link = item.data(Qt.ItemDataRole.UserRole)
- if isinstance(link, WebIndexLink):
- out.append(asdict(link))
- return out
-
- def _visible_index_link_payloads(self) -> list[dict]:
- out: list[dict] = []
- for row in range(self._index_links.count()):
- item = self._index_links.item(row)
- if item is None:
- continue
- link = item.data(Qt.ItemDataRole.UserRole)
- if isinstance(link, WebIndexLink):
- out.append(asdict(link))
- return out
-
- def _select_visible_index_links(self) -> None:
- self._index_links.selectAll()
-
- def _clear_result_text_filters(self) -> None:
- if self._search.text():
- self._search.clear()
- if self._exclude_keywords.text():
- self._exclude_keywords.clear()
-
- def _set_index_controls_enabled(self, enabled: bool) -> None:
- for widget in (
- self._index_keyword,
- ):
- widget.setEnabled(bool(enabled))
-
- def confirm_large_linked_page_scan(self, page_count: int, *, cap: int | None = None) -> bool:
- limit = int(cap or self.LINKED_PAGE_SCAN_CAP)
- if int(page_count) <= limit:
- return True
-
- answer = QMessageBox.question(
- self,
- "Large linked-page scan",
- (
- f"This index has {page_count} linked pages.\n\n"
- "Scanning too many pages at once can make the app slow or unstable.\n"
- f"Sprite Factory will scan the first {limit} pages only.\n\n"
- "Continue?"
- ),
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- QMessageBox.StandardButton.No,
- )
- return answer == QMessageBox.StandardButton.Yes
-
- def _cap_link_payloads_with_warning(self, links: list[dict]) -> list[dict]:
- cap = int(self.LINKED_PAGE_SCAN_CAP)
- if len(links) <= cap:
- return links
- if not self.confirm_large_linked_page_scan(len(links), cap=cap):
- self.set_status("Linked-page scan cancelled before starting.")
- return []
- return links[:cap]
-
- def _selected_items_payloads(self) -> list[WebItem]:
- selected = self._results.selectedItems()
- if not selected:
- return []
- out: list[WebItem] = []
- for lw in selected:
- meta = lw.data(Qt.ItemDataRole.UserRole)
- if isinstance(meta, WebItem):
- out.append(meta)
- return out
-
- def _update_preview(self) -> None:
- selected = self._selected_items_payloads()
- if not selected:
- self._selection_detail.setText("Select an item to see its source URL.")
- return
- item = selected[0]
- self._selection_detail.setText(
- f"Selected: {item.name} | {item.ext} | {item.confidence.value} | {item.url}"
- )
-
- def _select_all_visible(self) -> None:
- self._results.selectAll()
-
- def _clear_selection(self) -> None:
- self._results.clearSelection()
-
-
-
-
-
-
-
-
-
-
-
diff --git a/image_engine_app/ui/windows/__init__.py b/image_engine_app/ui/windows/__init__.py
index 31103ba..82e56a7 100644
--- a/image_engine_app/ui/windows/__init__.py
+++ b/image_engine_app/ui/windows/__init__.py
@@ -1,11 +1,9 @@
"""Secondary dialogs/windows for the Image Engine UI."""
from .batch_manager import BatchManagerDialog
-from .export_encoding import ExportEncodingDialog
from .preset_manager import PresetManagerDialog
__all__ = [
"BatchManagerDialog",
- "ExportEncodingDialog",
"PresetManagerDialog",
]
diff --git a/image_engine_app/ui/windows/batch_manager.py b/image_engine_app/ui/windows/batch_manager.py
index c9f0e3f..f45d781 100644
--- a/image_engine_app/ui/windows/batch_manager.py
+++ b/image_engine_app/ui/windows/batch_manager.py
@@ -28,7 +28,7 @@
QWidget,
)
-from image_engine_app.engine.models import BackgroundRemovalMode
+from image_engine_app.engine.models import BackgroundRemovalMode, BatchEditSource
from image_engine_app.ui.windows.batch_queue_state import (
BatchQueueRowState,
BatchQueueState,
@@ -43,13 +43,11 @@
class BatchDialogOptions:
"""UI-selected batch run options."""
- auto_preset: bool
+ edit_source: BatchEditSource
auto_export: bool
preview_skip_mode: bool
export_name_template: str
avoid_overwrite: bool
- apply_active_edits: bool
- apply_selected_preset: bool
selected_preset_name: str | None
background_removal_override: str | None = None
export_directory: str | None = None
@@ -74,10 +72,8 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._progress_label = QLabel("Batch progress: 0%", self)
self._current_item_label = QLabel("Current item: --", self)
self._current_stage_label = QLabel("Stage: --", self)
- self._auto_preset_check = QCheckBox("Smart presets by asset type", self)
self._auto_export_check = QCheckBox("Save files after processing", self)
- self._apply_active_edits_check = QCheckBox("Copy current asset edits", self)
- self._apply_preset_check = QCheckBox("Apply chosen preset", self)
+ self._edit_source_combo = QComboBox(self)
self._background_combo = QComboBox(self)
self._batch_preset_combo = QComboBox(self)
self._export_name_combo = QComboBox(self)
@@ -94,7 +90,6 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._select_all_action: QAction | None = None
self._select_failed_action: QAction | None = None
self._clear_selection_action: QAction | None = None
- self._auto_preset_action: QAction | None = None
self._auto_export_action: QAction | None = None
self._preview_skip_action: QAction | None = None
self._run_btn = QPushButton("Run Selected", self)
@@ -110,16 +105,17 @@ def current_options(self) -> BatchDialogOptions:
"""Return current run options from dialog controls."""
return BatchDialogOptions(
- auto_preset=self._auto_preset_check.isChecked(),
+ edit_source=BatchEditSource(str(self._edit_source_combo.currentData())),
auto_export=self._auto_export_check.isChecked(),
preview_skip_mode=self._preview_skip_check.isChecked(),
export_name_template=str(self._export_name_combo.currentData() or "{stem}"),
avoid_overwrite=self._avoid_overwrite_check.isChecked(),
- apply_active_edits=self._apply_active_edits_check.isChecked(),
- apply_selected_preset=self._apply_preset_check.isChecked(),
selected_preset_name=(
str(self._batch_preset_combo.currentData()).strip()
- if self._batch_preset_combo.currentData()
+ if (
+ self._edit_source_combo.currentData() == BatchEditSource.CHOSEN_PRESET.value
+ and self._batch_preset_combo.currentData()
+ )
else None
),
background_removal_override=(
@@ -223,11 +219,9 @@ def set_running(self, is_running: bool) -> None:
self._is_running = bool(is_running)
self._cancel_btn.setEnabled(self._is_running)
- self._auto_preset_check.setEnabled(not self._is_running)
self._auto_export_check.setEnabled(not self._is_running)
self._preview_skip_check.setEnabled(not self._is_running)
- self._apply_active_edits_check.setEnabled(not self._is_running)
- self._apply_preset_check.setEnabled(not self._is_running)
+ self._edit_source_combo.setEnabled(not self._is_running)
self._background_combo.setEnabled(not self._is_running)
self.queue_list.setEnabled(not self._is_running)
self._refresh_idle_controls()
@@ -383,7 +377,7 @@ def _build_ui(self) -> None:
self._workflow_label.setWordWrap(True)
self._workflow_label.setStyleSheet("color:#49686d;")
self._workflow_label.setText(
- "Run order: copy current edits, apply chosen preset, apply background override, run smart presets, process, then export."
+ "Choose one edit source. Batch then applies the optional background override, processes each item, and exports."
)
layout.addWidget(self._workflow_label)
@@ -470,13 +464,10 @@ def _build_ui(self) -> None:
status_layout.addWidget(self._details_label)
layout.addWidget(status_box)
- self._auto_preset_check.setChecked(True)
self._auto_export_check.setChecked(True)
self._preview_skip_check.setChecked(True)
- self._auto_preset_check.setToolTip("Auto-pick safe batch presets from the asset type, tags, and format.")
self._auto_export_check.setToolTip("Save processed files to the selected export folder.")
self._preview_skip_check.setToolTip("Skip extra planning UI steps for a faster queue run.")
- self._auto_preset_check.hide()
self._auto_export_check.hide()
self._preview_skip_check.hide()
@@ -485,11 +476,6 @@ def _build_ui(self) -> None:
rules_header.addStretch(1)
options_menu = QMenu(self._run_options_btn)
- self._auto_preset_action = self._option_action(
- self._auto_preset_check,
- "Smart presets by asset type",
- "Auto-pick safe batch presets from the asset type, tags, and format.",
- )
self._auto_export_action = self._option_action(
self._auto_export_check,
"Export after processing",
@@ -500,7 +486,6 @@ def _build_ui(self) -> None:
"Fast run",
"Skip extra planning UI steps for a faster queue run.",
)
- options_menu.addAction(self._auto_preset_action)
options_menu.addAction(self._auto_export_action)
options_menu.addAction(self._preview_skip_action)
self._run_options_btn.setText("Options")
@@ -510,17 +495,17 @@ def _build_ui(self) -> None:
rules_header.addWidget(self._run_options_btn)
apply_row = QHBoxLayout()
- self._apply_active_edits_check.setChecked(False)
- self._apply_preset_check.setChecked(False)
- self._apply_active_edits_check.setToolTip("Copy the active asset's edit settings and queued heavy jobs onto the batch clones.")
- self._apply_preset_check.setToolTip("Apply one chosen preset to all compatible selected items before the batch run starts.")
- apply_row.addWidget(self._apply_active_edits_check)
- apply_row.addWidget(self._apply_preset_check)
- apply_row.addWidget(QLabel("Preset:", self))
+ apply_row.addWidget(QLabel("Edit source:", self))
+ self._edit_source_combo.addItem("Keep each asset's controls", BatchEditSource.KEEP_EACH.value)
+ self._edit_source_combo.addItem("Apply one preset", BatchEditSource.CHOSEN_PRESET.value)
+ self._edit_source_combo.addItem("Copy active asset controls", BatchEditSource.COPY_ACTIVE.value)
+ self._edit_source_combo.addItem("Smart match each asset", BatchEditSource.SMART_MATCH.value)
+ self._edit_source_combo.setToolTip("Choose exactly one source of control settings for this run.")
+ apply_row.addWidget(self._edit_source_combo, 1)
self._batch_preset_combo.clear()
self._batch_preset_combo.addItem("Choose preset...", None)
self._batch_preset_combo.setMinimumWidth(220)
- self._batch_preset_combo.setToolTip("Only compatible items will receive the chosen preset.")
+ self._batch_preset_combo.setToolTip("The chosen preset starts from each asset's own detected controls.")
apply_row.addWidget(self._batch_preset_combo, 1)
apply_row.addStretch(1)
@@ -584,7 +569,7 @@ def _build_ui(self) -> None:
layout.addLayout(options_row)
self._auto_export_check.toggled.connect(self._on_auto_export_toggled)
- self._apply_preset_check.toggled.connect(self._on_apply_preset_toggled)
+ self._edit_source_combo.currentIndexChanged.connect(self._on_edit_source_changed)
self._batch_preset_combo.currentIndexChanged.connect(self._on_batch_preset_changed)
actions = QHBoxLayout()
@@ -605,8 +590,7 @@ def _on_auto_export_toggled(self, checked: bool) -> None:
_ = checked
self._refresh_idle_controls()
- def _on_apply_preset_toggled(self, checked: bool) -> None:
- _ = checked
+ def _on_edit_source_changed(self, _index: int) -> None:
self._refresh_idle_controls()
def _on_batch_preset_changed(self, _index: int) -> None:
@@ -675,8 +659,9 @@ def _refresh_idle_controls(self, *, total: int | None = None, selected: int | No
has_selection = selected > 0
has_failed = bool(self._queue_state.failed_asset_ids())
+ edit_source = str(self._edit_source_combo.currentData() or BatchEditSource.KEEP_EACH.value)
preset_selected = bool(self._batch_preset_combo.currentData())
- preset_ready = (not self._apply_preset_check.isChecked()) or preset_selected
+ preset_ready = edit_source != BatchEditSource.CHOSEN_PRESET.value or preset_selected
self._run_btn.setEnabled(is_idle and has_selection and preset_ready)
self._select_all_btn.setEnabled(is_idle and has_rows)
@@ -690,13 +675,11 @@ def _refresh_idle_controls(self, *, total: int | None = None, selected: int | No
self._clear_selection_action.setEnabled(is_idle and has_rows)
self._queue_more_btn.setEnabled(is_idle and has_rows)
self._run_options_btn.setEnabled(is_idle and has_selection)
- if self._auto_preset_action is not None:
- self._auto_preset_action.setEnabled(is_idle and has_selection)
if self._auto_export_action is not None:
self._auto_export_action.setEnabled(is_idle and has_selection)
if self._preview_skip_action is not None:
self._preview_skip_action.setEnabled(is_idle and has_selection)
- self._apply_active_edits_check.setEnabled(is_idle and has_selection)
+ self._edit_source_combo.setEnabled(is_idle and has_selection)
allow_export_controls = is_idle and self._auto_export_check.isChecked()
self._export_name_combo.setEnabled(allow_export_controls)
@@ -705,9 +688,13 @@ def _refresh_idle_controls(self, *, total: int | None = None, selected: int | No
self._browse_export_dir_btn.setEnabled(allow_export_controls)
has_presets = self._batch_preset_combo.count() > 1
- allow_preset_controls = is_idle and has_selection and has_presets
- self._apply_preset_check.setEnabled(allow_preset_controls)
- self._batch_preset_combo.setEnabled(allow_preset_controls and self._apply_preset_check.isChecked())
+ allow_preset_controls = (
+ is_idle
+ and has_selection
+ and has_presets
+ and edit_source == BatchEditSource.CHOSEN_PRESET.value
+ )
+ self._batch_preset_combo.setEnabled(allow_preset_controls)
self._background_combo.setEnabled(is_idle and has_selection)
def _set_progress(self, percent: int, *, total: int | None = None, processed: int | None = None) -> None:
@@ -730,4 +717,4 @@ def _to_int(value: object, *, default: int) -> int:
return int(value)
except Exception:
return default
-
+
diff --git a/image_engine_app/ui/windows/export_encoding.py b/image_engine_app/ui/windows/export_encoding.py
deleted file mode 100644
index 6eb707d..0000000
--- a/image_engine_app/ui/windows/export_encoding.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""Expert export encoding dialog with apply signal for active-asset export settings."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-from PySide6.QtCore import Signal
-from PySide6.QtWidgets import (
- QCheckBox,
- QComboBox,
- QDialog,
- QFormLayout,
- QHBoxLayout,
- QLabel,
- QPushButton,
- QSpinBox,
- QVBoxLayout,
- QWidget,
-)
-
-from image_engine_app.engine.models import ChromaSubsampling, ExportFormat
-
-
-@dataclass(frozen=True)
-class ExportEncodingOptions:
- """Encoding options chosen in the expert dialog."""
-
- format: str
- quality: int
- compression_level: int
- chroma_subsampling: str
- strip_metadata: bool
-
-
-class ExportEncodingDialog(QDialog):
- """Advanced export encoding window for expert controls."""
-
- apply_requested = Signal(object)
-
- def __init__(self, parent: QWidget | None = None) -> None:
- super().__init__(parent)
- self.setWindowTitle("Export Encoding")
- self.resize(540, 380)
-
- self._format_combo = QComboBox(self)
- self._quality_spin = QSpinBox(self)
- self._compression_spin = QSpinBox(self)
- self._chroma_combo = QComboBox(self)
- self._strip_metadata = QCheckBox("Strip metadata", self)
- self._apply_btn = QPushButton("Apply to Current Asset", self)
-
- self._build_ui()
-
- def current_options(self) -> ExportEncodingOptions:
- """Return current dialog values as a plain options object."""
-
- return ExportEncodingOptions(
- format=str(self._format_combo.currentData() or ExportFormat.AUTO.value),
- quality=int(self._quality_spin.value()),
- compression_level=int(self._compression_spin.value()),
- chroma_subsampling=str(self._chroma_combo.currentData() or ChromaSubsampling.AUTO.value),
- strip_metadata=bool(self._strip_metadata.isChecked()),
- )
-
- def load_from_asset(self, asset: object) -> None:
- """Load export settings from an asset into the dialog controls."""
-
- settings = getattr(getattr(getattr(asset, "edit_state", None), "settings", None), "export", None)
- if settings is None:
- return
-
- format_value = str(getattr(getattr(settings, "format", None), "value", ExportFormat.AUTO.value) or ExportFormat.AUTO.value)
- quality = int(getattr(settings, "quality", 90))
- compression = int(getattr(settings, "compression_level", 6))
- chroma_value = str(
- getattr(getattr(settings, "chroma_subsampling", None), "value", ChromaSubsampling.AUTO.value)
- or ChromaSubsampling.AUTO.value
- )
- strip_metadata = bool(getattr(settings, "strip_metadata", True))
-
- self._set_combo_by_data(self._format_combo, format_value)
- self._quality_spin.setValue(max(1, min(100, quality)))
- self._compression_spin.setValue(max(0, min(9, compression)))
- self._set_combo_by_data(self._chroma_combo, chroma_value)
- self._strip_metadata.setChecked(strip_metadata)
-
- def _build_ui(self) -> None:
- layout = QVBoxLayout(self)
- layout.setContentsMargins(12, 12, 12, 12)
- layout.setSpacing(10)
-
- guide = QLabel(
- "Apply advanced format/encoding values to the active asset. "
- "Use this for final size tuning after visual edits."
- )
- guide.setWordWrap(True)
- layout.addWidget(guide)
-
- form = QFormLayout()
-
- self._format_combo.addItem("Auto", ExportFormat.AUTO.value)
- self._format_combo.addItem("PNG", ExportFormat.PNG.value)
- self._format_combo.addItem("JPEG", ExportFormat.JPG.value)
- self._format_combo.addItem("WebP", ExportFormat.WEBP.value)
- self._format_combo.addItem("GIF", ExportFormat.GIF.value)
- self._format_combo.addItem("ICO", ExportFormat.ICO.value)
- self._format_combo.addItem("TIFF", ExportFormat.TIFF.value)
- self._format_combo.addItem("BMP", ExportFormat.BMP.value)
- form.addRow("Format", self._format_combo)
-
- self._quality_spin.setRange(1, 100)
- self._quality_spin.setValue(90)
- self._quality_spin.setToolTip("Lossy quality (mainly JPEG/WEBP).")
- form.addRow("Quality", self._quality_spin)
-
- self._compression_spin.setRange(0, 9)
- self._compression_spin.setValue(6)
- self._compression_spin.setToolTip("Compression level for supported formats.")
- form.addRow("Compression", self._compression_spin)
-
- self._chroma_combo.addItem("Auto", ChromaSubsampling.AUTO.value)
- self._chroma_combo.addItem("4:4:4", ChromaSubsampling.CS_444.value)
- self._chroma_combo.addItem("4:2:2", ChromaSubsampling.CS_422.value)
- self._chroma_combo.addItem("4:2:0", ChromaSubsampling.CS_420.value)
- form.addRow("Chroma", self._chroma_combo)
-
- self._strip_metadata.setChecked(True)
- form.addRow("", self._strip_metadata)
-
- layout.addLayout(form)
-
- footer = QHBoxLayout()
- footer.addStretch(1)
- self._apply_btn.clicked.connect(self._emit_apply_requested)
- footer.addWidget(self._apply_btn)
- footer.addWidget(QPushButton("Close", self, clicked=self.close))
- layout.addLayout(footer)
-
- def _emit_apply_requested(self) -> None:
- self.apply_requested.emit(self.current_options())
-
- @staticmethod
- def _set_combo_by_data(combo: QComboBox, value: str) -> None:
- for index in range(combo.count()):
- if str(combo.itemData(index)) == str(value):
- combo.setCurrentIndex(index)
- return
-
diff --git a/image_engine_app/ui/windows/preset_manager.py b/image_engine_app/ui/windows/preset_manager.py
index 1dab197..a58f6ce 100644
--- a/image_engine_app/ui/windows/preset_manager.py
+++ b/image_engine_app/ui/windows/preset_manager.py
@@ -1,16 +1,18 @@
-"""Preset manager dialog (create/edit/save user presets)."""
+"""Preset manager built around reusable control changes."""
from __future__ import annotations
import json
from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable
-from PySide6.QtCore import Qt
+from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
- QFrame,
QFormLayout,
+ QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
@@ -20,11 +22,12 @@
QPushButton,
QPlainTextEdit,
QSplitter,
+ QToolButton,
QVBoxLayout,
QWidget,
)
-from image_engine_app.engine.models import EditMode, PresetModel
+from image_engine_app.engine.models import AssetRecord, EditMode, PresetModel
@dataclass
@@ -36,22 +39,26 @@ class _PresetDraft:
settings_delta_text: str = "{}"
uses_heavy_tools: bool = False
requires_apply: bool = False
- mode_min: str = EditMode.ADVANCED.value
class PresetManagerDialog(QDialog):
- """UI for managing user presets.
+ """Create user presets from controls and manage system templates."""
- Built-in presets are shown but treated as read-only; saving edits creates/overrides
- a user preset entry.
- """
+ presets_changed = Signal()
- def __init__(self, controller, parent: QWidget | None = None) -> None:
+ def __init__(
+ self,
+ controller,
+ parent: QWidget | None = None,
+ *,
+ active_asset_provider: Callable[[], AssetRecord | None] | None = None,
+ ) -> None:
super().__init__(parent)
self._controller = controller
+ self._active_asset_provider = active_asset_provider or (lambda: None)
self.setObjectName("presetManagerDialog")
self.setWindowTitle("Preset Manager")
- self.resize(980, 620)
+ self.resize(980, 640)
self._list = QListWidget(self)
self._list.setObjectName("shellListPanel")
@@ -59,9 +66,10 @@ def __init__(self, controller, parent: QWidget | None = None) -> None:
self._desc = QLineEdit(self)
self._formats = QLineEdit(self)
self._tags = QLineEdit(self)
- self._uses_heavy = QCheckBox("Uses heavy tools", self)
+ self._uses_heavy = QCheckBox("Uses heavy processing", self)
self._requires_apply = QCheckBox("Requires Apply", self)
self._delta = QPlainTextEdit(self)
+
self._preset_kind = QLabel("No preset selected", self)
self._preset_kind.setObjectName("shellBadgeWarm")
self._scope_hint = QLabel(self)
@@ -70,40 +78,53 @@ def __init__(self, controller, parent: QWidget | None = None) -> None:
self._save_hint = QLabel(self)
self._save_hint.setObjectName("shellHint")
self._save_hint.setWordWrap(True)
+ self._capture_status = QLabel(self)
+ self._capture_status.setObjectName("shellHint")
+ self._capture_status.setWordWrap(True)
- self._btn_new = QPushButton("New", self)
+ self._btn_new = QPushButton("New from Active", self)
self._btn_duplicate = QPushButton("Duplicate", self)
self._btn_delete = QPushButton("Delete", self)
+ self._btn_capture = QPushButton("Use Active Controls", self)
+ self._btn_capture.setObjectName("shellPrimaryAction")
self._btn_format_json = QPushButton("Format JSON", self)
- self._btn_example = QPushButton("Insert Example", self)
- self._btn_save = QPushButton("Save User Preset", self)
+ self._btn_save = QPushButton("Save Preset", self)
+ self._btn_save.setObjectName("shellPrimaryAction")
self._btn_close = QPushButton("Close", self)
+ self._advanced_toggle = QToolButton(self)
+ self._advanced_toggle.setText("Advanced")
+ self._advanced_toggle.setCheckable(True)
+ self._advanced_toggle.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ self._advanced_panel = QFrame(self)
+ self._advanced_panel.setObjectName("presetManagerCard")
+
self._build_ui()
self._wire()
self.refresh_from_controller()
def refresh_from_controller(self) -> None:
+ selected_name = self._selected_preset_name()
self._list.blockSignals(True)
self._list.clear()
- for entry in self._controller.available_preset_entries():
+ selected_row = -1
+ for row, entry in enumerate(self._controller.available_preset_entries()):
preset = self._controller.get_preset(entry.name)
- label = entry.label
- if self._controller.is_user_preset(preset.name):
- label = f"{label} (User)"
- else:
- label = f"{label} (System)"
- item = QListWidgetItem(label)
+ source = "User" if self._controller.is_user_preset(preset.name) else "System"
+ item = QListWidgetItem(f"{entry.label} ({source})")
item.setData(Qt.ItemDataRole.UserRole, preset.name)
self._list.addItem(item)
+ if preset.name == selected_name:
+ selected_row = row
self._list.blockSignals(False)
if self._list.count() > 0:
- self._list.setCurrentRow(0)
+ self._list.setCurrentRow(selected_row if selected_row >= 0 else 0)
self._load_selected_into_form()
else:
self._load_draft_into_form(_PresetDraft())
+ self._refresh_active_asset_status()
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
@@ -118,7 +139,7 @@ def _build_ui(self) -> None:
title = QLabel("Preset Studio", header_card)
title.setObjectName("shellTitle")
intro = QLabel(
- "System presets are safe starting templates. Duplicate one or write your own user preset with scoped formats, tags, and a JSON settings delta.",
+ "Save the active asset's control changes once, then reuse the same preset in Workspace or Batch.",
header_card,
)
intro.setObjectName("shellSubtitle")
@@ -134,14 +155,13 @@ def _build_ui(self) -> None:
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(12, 12, 12, 12)
left_layout.setSpacing(8)
- left_layout.addWidget(QLabel("Presets"))
+ left_layout.addWidget(QLabel("Preset Library", left))
left_layout.addWidget(self._list, 1)
left_buttons = QHBoxLayout()
left_buttons.addWidget(self._btn_new)
left_buttons.addWidget(self._btn_duplicate)
left_buttons.addWidget(self._btn_delete)
- left_buttons.addStretch(1)
left_layout.addLayout(left_buttons)
right = QFrame(splitter)
@@ -150,34 +170,57 @@ def _build_ui(self) -> None:
right_layout.setContentsMargins(12, 12, 12, 12)
right_layout.setSpacing(8)
- right_layout.addWidget(self._preset_kind)
+ capture_card = QFrame(right)
+ capture_card.setObjectName("presetManagerCard")
+ capture_layout = QHBoxLayout(capture_card)
+ capture_layout.setContentsMargins(10, 10, 10, 10)
+ capture_text = QVBoxLayout()
+ capture_title = QLabel("Create from current controls", capture_card)
+ capture_title.setObjectName("shellTitle")
+ capture_text.addWidget(capture_title)
+ capture_text.addWidget(self._capture_status)
+ capture_layout.addLayout(capture_text, 1)
+ capture_layout.addWidget(self._btn_capture)
+ right_layout.addWidget(capture_card)
+
+ meta_row = QHBoxLayout()
+ meta_row.addWidget(self._preset_kind)
+ meta_row.addStretch(1)
+ right_layout.addLayout(meta_row)
right_layout.addWidget(self._scope_hint)
form = QFormLayout()
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
form.setFormAlignment(Qt.AlignmentFlag.AlignTop)
-
form.addRow("Name", self._name)
form.addRow("Description", self._desc)
- form.addRow("Applies to formats", self._formats)
- form.addRow("Applies to tags", self._tags)
-
- form.addRow("", self._uses_heavy)
- form.addRow("", self._requires_apply)
-
+ form.addRow("Formats", self._formats)
+ form.addRow("Asset types", self._tags)
right_layout.addLayout(form)
right_layout.addWidget(self._save_hint)
- right_layout.addWidget(QLabel("Settings delta (JSON)", self))
- self._delta.setPlaceholderText('{"cleanup": {"denoise": 0.2}, "export": {"format": "png"}}')
+ self._advanced_toggle.setArrowType(Qt.ArrowType.RightArrow)
+ right_layout.addWidget(self._advanced_toggle, 0, Qt.AlignmentFlag.AlignLeft)
+
+ advanced_layout = QVBoxLayout(self._advanced_panel)
+ advanced_layout.setContentsMargins(10, 10, 10, 10)
+ advanced_layout.setSpacing(7)
+ flags = QHBoxLayout()
+ flags.addWidget(self._uses_heavy)
+ flags.addWidget(self._requires_apply)
+ flags.addStretch(1)
+ advanced_layout.addLayout(flags)
+ advanced_layout.addWidget(QLabel("Captured settings (JSON)", self._advanced_panel))
+ self._delta.setPlaceholderText('{"cleanup": {"denoise": 0.2}}')
self._delta.setTabStopDistance(28)
- right_layout.addWidget(self._delta, 1)
-
+ advanced_layout.addWidget(self._delta, 1)
json_tools = QHBoxLayout()
json_tools.addWidget(self._btn_format_json)
- json_tools.addWidget(self._btn_example)
json_tools.addStretch(1)
- right_layout.addLayout(json_tools)
+ advanced_layout.addLayout(json_tools)
+ self._advanced_panel.setVisible(False)
+ right_layout.addWidget(self._advanced_panel, 1)
+ right_layout.addStretch(1)
footer = QHBoxLayout()
footer.addStretch(1)
@@ -187,7 +230,7 @@ def _build_ui(self) -> None:
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
- splitter.setSizes([320, 640])
+ splitter.setSizes([330, 650])
layout.addWidget(splitter, 1)
def _wire(self) -> None:
@@ -195,11 +238,18 @@ def _wire(self) -> None:
self._btn_new.clicked.connect(self._new_preset)
self._btn_duplicate.clicked.connect(self._duplicate_selected)
self._btn_delete.clicked.connect(self._delete_selected)
+ self._btn_capture.clicked.connect(self._capture_active_controls)
self._btn_format_json.clicked.connect(self._format_delta_json)
- self._btn_example.clicked.connect(self._insert_example_delta)
self._btn_save.clicked.connect(self._save_current)
+ self._advanced_toggle.toggled.connect(self._set_advanced_visible)
self._list.currentItemChanged.connect(lambda *_args: self._load_selected_into_form())
+ def _active_asset(self) -> AssetRecord | None:
+ try:
+ return self._active_asset_provider()
+ except Exception:
+ return None
+
def _selected_preset_name(self) -> str | None:
item = self._list.currentItem()
if item is None:
@@ -208,7 +258,11 @@ def _selected_preset_name(self) -> str | None:
return str(raw) if raw else None
def _new_preset(self) -> None:
+ self._list.clearSelection()
+ self._list.setCurrentItem(None)
self._load_draft_into_form(_PresetDraft())
+ if self._active_asset() is not None:
+ self._capture_active_controls(fill_identity=True)
self._name.setFocus()
def _duplicate_selected(self) -> None:
@@ -216,17 +270,17 @@ def _duplicate_selected(self) -> None:
if not selected:
return
base = self._controller.get_preset(selected)
- draft = _PresetDraft(
- name=f"{base.name} Copy",
- description=base.description,
- applies_to_formats=list(base.applies_to_formats or []),
- applies_to_tags=list(base.applies_to_tags or []),
- settings_delta_text=json.dumps(base.settings_delta or {}, indent=2, sort_keys=True),
- uses_heavy_tools=bool(base.uses_heavy_tools),
- requires_apply=bool(base.requires_apply),
- mode_min=base.mode_min.value,
+ self._load_draft_into_form(
+ _PresetDraft(
+ name=f"{base.name} Copy",
+ description=base.description,
+ applies_to_formats=list(base.applies_to_formats or []),
+ applies_to_tags=list(base.applies_to_tags or []),
+ settings_delta_text=json.dumps(base.settings_delta or {}, indent=2, sort_keys=True),
+ uses_heavy_tools=bool(base.uses_heavy_tools),
+ requires_apply=bool(base.requires_apply),
+ )
)
- self._load_draft_into_form(draft)
def _delete_selected(self) -> None:
selected = self._selected_preset_name()
@@ -244,8 +298,31 @@ def _delete_selected(self) -> None:
if confirm != QMessageBox.StandardButton.Yes:
return
self._controller.delete_user_preset(selected)
+ self.presets_changed.emit()
self.refresh_from_controller()
+ def _capture_active_controls(self, _checked: bool = False, *, fill_identity: bool = False) -> None:
+ asset = self._active_asset()
+ if asset is None:
+ self._capture_status.setText("Select an asset in Workspace before capturing controls.")
+ return
+
+ captured = self._controller.capture_preset_controls(asset)
+ if fill_identity and not self._name.text().strip():
+ stem = Path(asset.original_name or "Custom").stem.strip() or "Custom"
+ self._name.setText(f"{stem} Polish")
+ if fill_identity and not self._desc.text().strip():
+ self._desc.setText("Control adjustments captured from the active asset")
+
+ self._formats.setText(", ".join(captured.applies_to_formats))
+ self._tags.setText(", ".join(captured.applies_to_tags))
+ self._delta.setPlainText(json.dumps(captured.settings_delta, indent=2, sort_keys=True))
+ self._uses_heavy.setChecked(captured.uses_heavy_tools)
+ self._requires_apply.setChecked(captured.requires_apply)
+ self._preset_kind.setText("New user preset")
+ self._btn_delete.setEnabled(False)
+ self._refresh_delta_summary()
+
def _save_current(self) -> None:
try:
preset = self._read_form_as_preset()
@@ -253,8 +330,9 @@ def _save_current(self) -> None:
except Exception as exc:
QMessageBox.critical(self, "Save failed", str(exc))
return
+
+ self.presets_changed.emit()
self.refresh_from_controller()
- # Re-select the saved preset.
for row in range(self._list.count()):
item = self._list.item(row)
if item is not None and item.data(Qt.ItemDataRole.UserRole) == preset.name:
@@ -262,72 +340,65 @@ def _save_current(self) -> None:
break
def _format_delta_json(self) -> None:
- raw_delta = self._delta.toPlainText().strip() or "{}"
try:
- parsed = json.loads(raw_delta)
+ parsed = json.loads(self._delta.toPlainText().strip() or "{}")
except Exception as exc:
- QMessageBox.critical(self, "Format failed", f"Settings delta must be valid JSON before formatting:\n{exc}")
+ QMessageBox.critical(self, "Format failed", f"Captured settings must be valid JSON:\n{exc}")
return
self._delta.setPlainText(json.dumps(parsed, indent=2, sort_keys=True))
-
- def _insert_example_delta(self) -> None:
- example = {
- "cleanup": {"denoise": 0.2, "artifact_removal": 0.15},
- "detail": {"sharpen_amount": 0.4},
- "export": {"format": "png"},
- }
- self._delta.setPlainText(json.dumps(example, indent=2, sort_keys=True))
+ self._refresh_delta_summary()
def _read_form_as_preset(self) -> PresetModel:
- name = (self._name.text() or "").strip()
+ name = self._name.text().strip()
if not name:
raise ValueError("Preset name cannot be empty")
- desc = (self._desc.text() or "").strip()
- formats = [p.strip() for p in (self._formats.text() or "").split(",") if p.strip()]
- tags = [p.strip() for p in (self._tags.text() or "").split(",") if p.strip()]
-
- raw_delta = self._delta.toPlainText().strip() or "{}"
+ formats = [part.strip() for part in self._formats.text().split(",") if part.strip()]
+ tags = [part.strip() for part in self._tags.text().split(",") if part.strip()]
try:
- delta_obj = json.loads(raw_delta)
+ delta = json.loads(self._delta.toPlainText().strip() or "{}")
except Exception as exc:
- raise ValueError(f"Settings delta must be valid JSON: {exc}") from exc
- if not isinstance(delta_obj, dict):
- raise ValueError("Settings delta JSON must be an object (dict)")
+ raise ValueError(f"Captured settings must be valid JSON: {exc}") from exc
+ if not isinstance(delta, dict):
+ raise ValueError("Captured settings must be a JSON object")
+ if not delta:
+ raise ValueError("No control changes were captured. Adjust a control first or duplicate a system preset.")
- preset = PresetModel(
+ return PresetModel(
name=name,
- description=desc,
+ description=self._desc.text().strip(),
applies_to_formats=formats or ["*"],
applies_to_tags=tags or ["*"],
- settings_delta=delta_obj,
- uses_heavy_tools=bool(self._uses_heavy.isChecked()),
- requires_apply=bool(self._requires_apply.isChecked()),
+ settings_delta=delta,
+ uses_heavy_tools=self._uses_heavy.isChecked(),
+ requires_apply=self._requires_apply.isChecked(),
mode_min=EditMode.ADVANCED,
)
- return preset
def _load_selected_into_form(self) -> None:
selected = self._selected_preset_name()
if not selected:
- self._load_draft_into_form(_PresetDraft())
return
preset = self._controller.get_preset(selected)
- draft = _PresetDraft(
- name=preset.name,
- description=preset.description,
- applies_to_formats=list(preset.applies_to_formats or []),
- applies_to_tags=list(preset.applies_to_tags or []),
- settings_delta_text=json.dumps(preset.settings_delta or {}, indent=2, sort_keys=True),
- uses_heavy_tools=bool(preset.uses_heavy_tools),
- requires_apply=bool(preset.requires_apply),
- mode_min=preset.mode_min.value,
+ self._load_draft_into_form(
+ _PresetDraft(
+ name=preset.name,
+ description=preset.description,
+ applies_to_formats=list(preset.applies_to_formats or []),
+ applies_to_tags=list(preset.applies_to_tags or []),
+ settings_delta_text=json.dumps(preset.settings_delta or {}, indent=2, sort_keys=True),
+ uses_heavy_tools=bool(preset.uses_heavy_tools),
+ requires_apply=bool(preset.requires_apply),
+ )
+ )
+ is_user = self._controller.is_user_preset(selected)
+ self._btn_delete.setEnabled(is_user)
+ self._preset_kind.setText("User preset" if is_user else "System template")
+ self._save_hint.setText(
+ "Saving updates this user preset."
+ if is_user
+ else "Duplicate this template, or capture the active controls to create your own preset."
)
- self._load_draft_into_form(draft)
-
- # Delete button only for user presets.
- self._btn_delete.setEnabled(bool(self._controller.is_user_preset(selected)))
- self._refresh_editor_meta(selected, preset)
def _load_draft_into_form(self, draft: _PresetDraft) -> None:
self._name.setText(draft.name)
@@ -335,32 +406,37 @@ def _load_draft_into_form(self, draft: _PresetDraft) -> None:
self._formats.setText(", ".join(draft.applies_to_formats or []))
self._tags.setText(", ".join(draft.applies_to_tags or []))
self._delta.setPlainText(draft.settings_delta_text or "{}")
- self._uses_heavy.setChecked(bool(draft.uses_heavy_tools))
- self._requires_apply.setChecked(bool(draft.requires_apply))
-
+ self._uses_heavy.setChecked(draft.uses_heavy_tools)
+ self._requires_apply.setChecked(draft.requires_apply)
self._btn_delete.setEnabled(False)
self._preset_kind.setText("New user preset")
self._scope_hint.setText(
- "Formats and tags are comma-separated scopes. Leave them blank for any asset. Examples: png, webp, gif | sprite_sheet, photo, icon, animation."
- )
- self._save_hint.setText(
- "Save creates a user preset. Duplicate a system preset first if you want a safer starting point."
+ "Formats and asset types are filled from the active asset. Advanced users can edit these scopes."
)
+ self._save_hint.setText("Only controls that differ from the detected asset baseline are saved.")
+ self._refresh_delta_summary()
+
+ def _refresh_active_asset_status(self) -> None:
+ asset = self._active_asset()
+ self._btn_capture.setEnabled(asset is not None)
+ if asset is None:
+ self._capture_status.setText("No active asset. Select one in Workspace to capture its control changes.")
+ return
+ self._capture_status.setText(f"Active: {asset.original_name or asset.id}")
- def _refresh_editor_meta(self, preset_name: str, preset: PresetModel) -> None:
- is_user = bool(self._controller.is_user_preset(preset_name))
- scope_parts: list[str] = []
- formats = ", ".join(preset.applies_to_formats or ["*"])
- tags = ", ".join(preset.applies_to_tags or ["*"])
- scope_parts.append(f"Formats: {formats}")
- scope_parts.append(f"Tags: {tags}")
- self._scope_hint.setText(" | ".join(scope_parts))
- if is_user:
- self._preset_kind.setText("User preset")
- self._save_hint.setText("Saving updates this user preset in your local preset library.")
+ def _refresh_delta_summary(self) -> None:
+ try:
+ delta = json.loads(self._delta.toPlainText().strip() or "{}")
+ except Exception:
+ self._capture_status.setText("Advanced settings contain invalid JSON.")
+ return
+ groups = [str(group).replace("_", " ").title() for group in delta if isinstance(delta, dict)]
+ if groups:
+ self._capture_status.setText(f"Captured groups: {', '.join(groups)}")
else:
- self._preset_kind.setText("System template")
- self._save_hint.setText(
- "Saving with the same name creates a user override. Duplicate it first if you want to keep the system preset untouched."
- )
-
+ self._refresh_active_asset_status()
+
+ def _set_advanced_visible(self, visible: bool) -> None:
+ self._advanced_panel.setVisible(bool(visible))
+ self._advanced_toggle.setArrowType(Qt.ArrowType.DownArrow if visible else Qt.ArrowType.RightArrow)
+
diff --git a/pyproject.toml b/pyproject.toml
index 4b682f9..732721e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,3 +25,6 @@ spritefactory-audit = "image_engine_app.app.audit:main"
[tool.setuptools.packages.find]
include = ["image_engine_app*", "image_engine_v3*"]
exclude = ["image_engine_app.tests*"]
+
+[tool.setuptools.package-data]
+image_engine_app = ["assets/icons/*.ico", "assets/icons/*.png", "docs/*.json"]
diff --git a/spritefactory.spec b/spritefactory.spec
index 66761b3..3572669 100644
--- a/spritefactory.spec
+++ b/spritefactory.spec
@@ -31,10 +31,11 @@ datas = []
datas += collect_data_files("image_engine_app", include_py_files=False)
# Bundle runtime icon files so Qt can load them directly in frozen mode.
+icon_source_dir = os.path.join(project_root, "image_engine_app", "assets", "icons")
for icon_name in ("spritefactory_pro.png", "spritefactory_pro.ico", "spritefactory.png", "spritefactory.ico"):
- icon_data = os.path.join(project_root, icon_name)
+ icon_data = os.path.join(icon_source_dir, icon_name)
if os.path.exists(icon_data):
- datas.append((icon_data, "."))
+ datas.append((icon_data, os.path.join("image_engine_app", "assets", "icons")))
# Runtime hook: fixes Qt plugin lookup when running from dist/
rthook = os.path.join(project_root, "pyinstaller_rthooks", "pyside6_plugin_path.py")
@@ -78,7 +79,11 @@ exe_kwargs = dict(
console=False,
)
-icon_file = os.path.join(project_root, "spritefactory_pro.ico")
+icon_file = os.path.join(icon_source_dir, "spritefactory_pro.ico")
+if not os.path.exists(icon_file):
+ icon_file = os.path.join(icon_source_dir, "spritefactory.ico")
+if not os.path.exists(icon_file):
+ icon_file = os.path.join(project_root, "spritefactory_pro.ico")
if not os.path.exists(icon_file):
icon_file = os.path.join(project_root, "spritefactory.ico")
if os.path.exists(icon_file):
diff --git a/spritefactory_onefile.spec b/spritefactory_onefile.spec
index 5401c9b..0126b5a 100644
--- a/spritefactory_onefile.spec
+++ b/spritefactory_onefile.spec
@@ -26,10 +26,11 @@ datas = []
datas += collect_data_files("image_engine_app", include_py_files=False)
# Bundle runtime icon files so Qt can load them directly in frozen mode.
+icon_source_dir = os.path.join(project_root, "image_engine_app", "assets", "icons")
for icon_name in ("spritefactory_pro.png", "spritefactory_pro.ico", "spritefactory.png", "spritefactory.ico"):
- icon_data = os.path.join(project_root, icon_name)
+ icon_data = os.path.join(icon_source_dir, icon_name)
if os.path.exists(icon_data):
- datas.append((icon_data, "."))
+ datas.append((icon_data, os.path.join("image_engine_app", "assets", "icons")))
rthook = os.path.join(project_root, "pyinstaller_rthooks", "pyside6_plugin_path.py")
runtime_hooks = [rthook] if os.path.exists(rthook) else []
@@ -73,7 +74,11 @@ exe_kwargs = dict(
console=False,
)
-icon_file = os.path.join(project_root, "spritefactory_pro.ico")
+icon_file = os.path.join(icon_source_dir, "spritefactory_pro.ico")
+if not os.path.exists(icon_file):
+ icon_file = os.path.join(icon_source_dir, "spritefactory.ico")
+if not os.path.exists(icon_file):
+ icon_file = os.path.join(project_root, "spritefactory_pro.ico")
if not os.path.exists(icon_file):
icon_file = os.path.join(project_root, "spritefactory.ico")
if os.path.exists(icon_file):