pasteup 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pasteup/routes/ui.py ADDED
@@ -0,0 +1,43 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from fastapi import APIRouter, Request
5
+ from fastapi.templating import Jinja2Templates
6
+
7
+ from pasteup.services.cover_collage import COVER_COLLAGE_TEMPLATE_OPTIONS
8
+ from mullion import DEFAULT_DPI
9
+
10
+ # Prefills the input-folder field so a regular workflow does not need retyping on
11
+ # every launch. It is read from the environment rather than written here because
12
+ # any real value is somebody's home directory.
13
+ INPUT_FOLDER_ENV_VAR = "PASTEUP_INPUT_FOLDER"
14
+
15
+ router = APIRouter()
16
+ templates = Jinja2Templates(directory=Path(__file__).resolve().parents[1] / "templates")
17
+
18
+
19
+ @router.get("/")
20
+ def index(request: Request):
21
+ return templates.TemplateResponse(
22
+ request=request,
23
+ name="index.html",
24
+ context={
25
+ "request": request,
26
+ "defaults": {
27
+ "input_folder": os.environ.get(INPUT_FOLDER_ENV_VAR, ""),
28
+ # app.js derives the output folder from the input folder on load.
29
+ "output_folder": "",
30
+ "page_width": 3000,
31
+ "page_height": 3000,
32
+ "gap": 30,
33
+ "margin": 60,
34
+ "format": "JPG",
35
+ "page_unit": "px",
36
+ "dpi": DEFAULT_DPI,
37
+ },
38
+ "cover_collage_templates": [
39
+ {"value": value, "name": name, "label": label}
40
+ for value, name, label in COVER_COLLAGE_TEMPLATE_OPTIONS
41
+ ],
42
+ },
43
+ )
File without changes
@@ -0,0 +1,379 @@
1
+ """Service for generating a full-page cover collage with seeded random placement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ import logging
8
+ from pathlib import Path
9
+ import random
10
+ import re
11
+ from typing import Sequence
12
+
13
+ from PIL import Image, UnidentifiedImageError
14
+
15
+ from mullion import (
16
+ DEFAULT_TOLERANCE,
17
+ BackgroundCleanMode,
18
+ clean_background,
19
+ flatten_onto,
20
+ open_path,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ BACKGROUND_COLOR = (255, 255, 255)
26
+
27
+
28
+ class CoverCollageFittingMode(StrEnum):
29
+ COVER = "cover"
30
+ CONTAIN = "contain"
31
+
32
+
33
+ class CoverCollageTemplate(StrEnum):
34
+ RANDOMIZED_GRID = "randomized-grid"
35
+ FEATURE_SPREAD = "feature-spread"
36
+ CENTER_STAGE = "center-stage"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class CoverCollageTile:
41
+ x: int
42
+ y: int
43
+ width: int
44
+ height: int
45
+ role: str = "default"
46
+ fitting_mode: CoverCollageFittingMode | None = None
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class CoverCollageLayoutTemplate:
51
+ name: str
52
+ tiles: list[CoverCollageTile]
53
+
54
+ @property
55
+ def slot_count(self) -> int:
56
+ return len(self.tiles)
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class CoverCollageTileRect:
61
+ x: float
62
+ y: float
63
+ width: float
64
+ height: float
65
+ role: str = "default"
66
+ fitting_mode: CoverCollageFittingMode | None = None
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class CoverCollageTemplateDefinition:
71
+ name: str
72
+ display_name: str
73
+ tiles: tuple[CoverCollageTileRect, ...]
74
+ default_fitting_mode: CoverCollageFittingMode | None = None
75
+
76
+ @property
77
+ def slot_count(self) -> int:
78
+ return len(self.tiles)
79
+
80
+
81
+ @dataclass(slots=True)
82
+ class CoverCollageSummary:
83
+ total_input_files: int
84
+ total_images_placed: int
85
+ total_images_skipped: int
86
+ output_created: bool
87
+ skipped_files: list[str]
88
+ background_cleaned: int = 0
89
+
90
+ @property
91
+ def user_message(self) -> str:
92
+ parts = [f"Generated 1 cover collage with {self.total_images_placed} image(s)."]
93
+ if self.total_images_skipped:
94
+ parts.append(f"Skipped {self.total_images_skipped} unreadable file(s).")
95
+ if self.background_cleaned:
96
+ parts.append(f"Cleaned the background on {self.background_cleaned} image(s).")
97
+ return " ".join(parts)
98
+
99
+
100
+ def _load_image_rgb(image_path: str) -> Image.Image:
101
+ return open_path(image_path)
102
+
103
+
104
+ def _resize_to_cover(image: Image.Image, width: int, height: int) -> Image.Image:
105
+ scale = max(width / image.width, height / image.height)
106
+ resized = image.resize(
107
+ (max(1, int(round(image.width * scale))), max(1, int(round(image.height * scale)))),
108
+ Image.Resampling.LANCZOS,
109
+ )
110
+
111
+ left = max(0, (resized.width - width) // 2)
112
+ top = max(0, (resized.height - height) // 2)
113
+ return resized.crop((left, top, left + width, top + height))
114
+
115
+
116
+ def _resize_to_contain(
117
+ image: Image.Image,
118
+ width: int,
119
+ height: int,
120
+ background_color: tuple[int, int, int],
121
+ ) -> Image.Image:
122
+ scale = min(width / image.width, height / image.height)
123
+ resized = image.resize(
124
+ (max(1, int(round(image.width * scale))), max(1, int(round(image.height * scale)))),
125
+ Image.Resampling.LANCZOS,
126
+ )
127
+
128
+ canvas = Image.new("RGB", (width, height), color=background_color)
129
+ left = max(0, (width - resized.width) // 2)
130
+ top = max(0, (height - resized.height) // 2)
131
+ canvas.paste(resized, (left, top))
132
+ return canvas
133
+
134
+
135
+ def _fit_tile_image(
136
+ image: Image.Image,
137
+ width: int,
138
+ height: int,
139
+ fitting_mode: CoverCollageFittingMode,
140
+ background_color: tuple[int, int, int],
141
+ ) -> Image.Image:
142
+ if fitting_mode == CoverCollageFittingMode.CONTAIN:
143
+ return _resize_to_contain(image, width, height, background_color)
144
+ return _resize_to_cover(image, width, height)
145
+
146
+
147
+ def _build_feature_spread_tile_rects() -> tuple[CoverCollageTileRect, ...]:
148
+ """Return feature spread geometry separately from placement for easy future gap tuning."""
149
+ column_width = 1 / 4
150
+ row_height = 1 / 3
151
+
152
+ return (
153
+ CoverCollageTileRect(
154
+ 0 * column_width,
155
+ 0 * row_height,
156
+ 2 * column_width,
157
+ 2 * row_height,
158
+ role="feature",
159
+ ),
160
+ CoverCollageTileRect(
161
+ 2 * column_width,
162
+ 0 * row_height,
163
+ 2 * column_width,
164
+ 2 * row_height,
165
+ role="feature",
166
+ ),
167
+ CoverCollageTileRect(0 * column_width, 2 * row_height, 1 * column_width, 1 * row_height, role="bottom"),
168
+ CoverCollageTileRect(1 * column_width, 2 * row_height, 1 * column_width, 1 * row_height, role="bottom"),
169
+ CoverCollageTileRect(2 * column_width, 2 * row_height, 1 * column_width, 1 * row_height, role="bottom"),
170
+ CoverCollageTileRect(3 * column_width, 2 * row_height, 1 * column_width, 1 * row_height, role="bottom"),
171
+ )
172
+
173
+
174
+ # Keep enum values, internal names, and display labels together so the API and UI can expose
175
+ # the same template identifiers without drifting. Templates should only set tile-level
176
+ # fitting overrides when they intentionally need behavior that differs from the request-level
177
+ # `image_fit`; feature-spread now inherits the collage default so its large tiles do not
178
+ # silently reintroduce letterboxing.
179
+ TEMPLATE_REGISTRY: dict[CoverCollageTemplate, CoverCollageTemplateDefinition] = {
180
+ CoverCollageTemplate.RANDOMIZED_GRID: CoverCollageTemplateDefinition(
181
+ name="randomized_grid",
182
+ display_name="Randomized grid",
183
+ tiles=(
184
+ CoverCollageTileRect(0.0, 0.0, 1 / 3, 1 / 3, role="grid"),
185
+ CoverCollageTileRect(1 / 3, 0.0, 1 / 3, 1 / 3, role="grid"),
186
+ CoverCollageTileRect(2 / 3, 0.0, 1 / 3, 1 / 3, role="grid"),
187
+ CoverCollageTileRect(0.0, 1 / 3, 1 / 3, 1 / 3, role="grid"),
188
+ CoverCollageTileRect(1 / 3, 1 / 3, 1 / 3, 1 / 3, role="grid"),
189
+ CoverCollageTileRect(2 / 3, 1 / 3, 1 / 3, 1 / 3, role="grid"),
190
+ CoverCollageTileRect(0.0, 2 / 3, 1 / 3, 1 / 3, role="grid"),
191
+ CoverCollageTileRect(1 / 3, 2 / 3, 1 / 3, 1 / 3, role="grid"),
192
+ CoverCollageTileRect(2 / 3, 2 / 3, 1 / 3, 1 / 3, role="grid"),
193
+ ),
194
+ ),
195
+ CoverCollageTemplate.FEATURE_SPREAD: CoverCollageTemplateDefinition(
196
+ name="feature_spread",
197
+ display_name="Feature spread",
198
+ tiles=_build_feature_spread_tile_rects(),
199
+ ),
200
+ CoverCollageTemplate.CENTER_STAGE: CoverCollageTemplateDefinition(
201
+ name="center_stage",
202
+ display_name="Center stage",
203
+ tiles=(
204
+ CoverCollageTileRect(1 / 4, 0.0, 2 / 4, 1.0, role="feature"),
205
+ CoverCollageTileRect(0.0, 0.0, 1 / 4, 1 / 3, role="side"),
206
+ CoverCollageTileRect(0.0, 1 / 3, 1 / 4, 1 / 3, role="side"),
207
+ CoverCollageTileRect(0.0, 2 / 3, 1 / 4, 1 / 3, role="side"),
208
+ CoverCollageTileRect(3 / 4, 0.0, 1 / 4, 1 / 3, role="side"),
209
+ CoverCollageTileRect(3 / 4, 1 / 3, 1 / 4, 1 / 3, role="side"),
210
+ CoverCollageTileRect(3 / 4, 2 / 3, 1 / 4, 1 / 3, role="side"),
211
+ ),
212
+ ),
213
+ }
214
+
215
+ COVER_COLLAGE_TEMPLATE_OPTIONS: tuple[tuple[str, str, str], ...] = tuple(
216
+ (template.value, definition.name, definition.display_name)
217
+ for template, definition in TEMPLATE_REGISTRY.items()
218
+ )
219
+
220
+
221
+ def _resolve_layout_template(
222
+ page_width: int,
223
+ page_height: int,
224
+ template: CoverCollageTemplate,
225
+ ) -> CoverCollageLayoutTemplate:
226
+ definition = TEMPLATE_REGISTRY[template]
227
+ tiles: list[CoverCollageTile] = []
228
+ for tile_rect in definition.tiles:
229
+ left = int(round(page_width * tile_rect.x))
230
+ top = int(round(page_height * tile_rect.y))
231
+ right = int(round(page_width * (tile_rect.x + tile_rect.width)))
232
+ bottom = int(round(page_height * (tile_rect.y + tile_rect.height)))
233
+ tiles.append(
234
+ CoverCollageTile(
235
+ x=left,
236
+ y=top,
237
+ width=max(1, min(page_width, right) - left),
238
+ height=max(1, min(page_height, bottom) - top),
239
+ role=tile_rect.role,
240
+ fitting_mode=tile_rect.fitting_mode or definition.default_fitting_mode,
241
+ )
242
+ )
243
+
244
+ return CoverCollageLayoutTemplate(name=definition.name, tiles=tiles)
245
+
246
+
247
+ def _build_tile_image_assignments(
248
+ image_paths: Sequence[str],
249
+ slot_count: int,
250
+ rng: random.Random,
251
+ ) -> tuple[list[str], list[str]]:
252
+ readable_paths: list[str] = []
253
+ skipped_files: list[str] = []
254
+
255
+ for image_path in image_paths:
256
+ try:
257
+ with Image.open(image_path) as source_image:
258
+ source_image.verify()
259
+ except (FileNotFoundError, PermissionError, OSError, UnidentifiedImageError, ValueError) as exc:
260
+ skipped_files.append(image_path)
261
+ logger.warning("Skipping unreadable image '%s': %s", image_path, exc)
262
+ continue
263
+
264
+ readable_paths.append(image_path)
265
+
266
+ if not readable_paths:
267
+ return [], skipped_files
268
+
269
+ if len(readable_paths) >= slot_count:
270
+ assignments = rng.sample(readable_paths, k=slot_count)
271
+ rng.shuffle(assignments)
272
+ return assignments, skipped_files
273
+
274
+ suffix_by_path = {path: _parse_trailing_numeric_token(path) for path in readable_paths}
275
+ assignments = []
276
+ previous_path: str | None = None
277
+ for _ in range(slot_count):
278
+ assignments.append(_choose_next_assignment(readable_paths, assignments, previous_path, suffix_by_path, rng))
279
+ previous_path = assignments[-1]
280
+
281
+ return assignments, skipped_files
282
+
283
+
284
+ def _parse_trailing_numeric_token(image_path: str) -> int | None:
285
+ match = re.search(r"(\d+)$", Path(image_path).stem)
286
+ if match is None:
287
+ return None
288
+ return int(match.group(1))
289
+
290
+
291
+ def _is_adjacent_sequence(previous_path: str | None, candidate_path: str, suffix_by_path: dict[str, int | None]) -> bool:
292
+ if previous_path is None:
293
+ return False
294
+
295
+ previous_suffix = suffix_by_path.get(previous_path)
296
+ candidate_suffix = suffix_by_path.get(candidate_path)
297
+ if previous_suffix is None or candidate_suffix is None:
298
+ return False
299
+ return abs(candidate_suffix - previous_suffix) == 1
300
+
301
+
302
+ def _choose_next_assignment(
303
+ readable_paths: Sequence[str],
304
+ assignments: Sequence[str],
305
+ previous_path: str | None,
306
+ suffix_by_path: dict[str, int | None],
307
+ rng: random.Random,
308
+ ) -> str:
309
+ if not assignments:
310
+ return rng.choice(list(readable_paths))
311
+
312
+ non_repeating = [path for path in readable_paths if path != previous_path]
313
+ non_adjacent = [path for path in non_repeating if not _is_adjacent_sequence(previous_path, path, suffix_by_path)]
314
+
315
+ candidate_pool = non_adjacent or non_repeating or list(readable_paths)
316
+ return rng.choice(candidate_pool)
317
+
318
+
319
+ def compose_cover_collage(
320
+ image_paths: Sequence[str],
321
+ page_width: float,
322
+ page_height: float,
323
+ rng: random.Random | None = None,
324
+ fitting_mode: CoverCollageFittingMode | str = CoverCollageFittingMode.COVER,
325
+ template: CoverCollageTemplate | str = CoverCollageTemplate.RANDOMIZED_GRID,
326
+ *,
327
+ background_clean_mode: BackgroundCleanMode | str = BackgroundCleanMode.OFF,
328
+ background_tolerance: int = DEFAULT_TOLERANCE,
329
+ background_feather: int = 1,
330
+ ) -> tuple[list[Image.Image], CoverCollageSummary]:
331
+ canvas_width = int(round(page_width))
332
+ canvas_height = int(round(page_height))
333
+ if canvas_width <= 0 or canvas_height <= 0:
334
+ raise ValueError("Page width and height must be greater than zero.")
335
+
336
+ fitting_mode = CoverCollageFittingMode(fitting_mode)
337
+ template = CoverCollageTemplate(template)
338
+ rng = rng or random.Random()
339
+ layout_template = _resolve_layout_template(canvas_width, canvas_height, template)
340
+ selected_paths, skipped_files = _build_tile_image_assignments(image_paths, layout_template.slot_count, rng)
341
+
342
+ normalized_clean_mode = BackgroundCleanMode(str(background_clean_mode))
343
+ collage = Image.new("RGB", (canvas_width, canvas_height), color=BACKGROUND_COLOR)
344
+
345
+ placed_count = 0
346
+ cleaned_count = 0
347
+
348
+ for tile, image_path in zip(layout_template.tiles, selected_paths):
349
+ image = _load_image_rgb(image_path)
350
+ if normalized_clean_mode is not BackgroundCleanMode.OFF:
351
+ image, clean_result = clean_background(
352
+ image,
353
+ mode=normalized_clean_mode,
354
+ tolerance=background_tolerance,
355
+ feather=background_feather,
356
+ matte=BACKGROUND_COLOR,
357
+ )
358
+ if clean_result.cleaned:
359
+ cleaned_count += 1
360
+ if image.mode != "RGB":
361
+ image = flatten_onto(image, BACKGROUND_COLOR)
362
+ tile_fitting_mode = tile.fitting_mode or fitting_mode
363
+ collage.paste(
364
+ _fit_tile_image(image, tile.width, tile.height, tile_fitting_mode, BACKGROUND_COLOR),
365
+ (tile.x, tile.y),
366
+ )
367
+ placed_count += 1
368
+
369
+ summary = CoverCollageSummary(
370
+ total_input_files=len(image_paths),
371
+ total_images_placed=placed_count,
372
+ total_images_skipped=len(skipped_files),
373
+ output_created=placed_count > 0,
374
+ skipped_files=[Path(path).name for path in skipped_files],
375
+ background_cleaned=cleaned_count,
376
+ )
377
+
378
+ pages = [collage] if placed_count > 0 else []
379
+ return pages, summary
@@ -0,0 +1,113 @@
1
+ """Service for exporting generated outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Literal, Sequence
7
+
8
+ from PIL import Image
9
+
10
+ from mullion import DEFAULT_DPI, validate_dpi
11
+
12
+
13
+ ExportFormat = Literal["PNG", "JPG"]
14
+ PathMode = Literal["relative", "absolute"]
15
+
16
+
17
+ def _next_available_index(
18
+ output_dir: Path,
19
+ prefix: str,
20
+ extension: str,
21
+ start_index: int = 1,
22
+ ) -> int:
23
+ """Find the next available sequential page index for conflict-safe exports."""
24
+ index = start_index
25
+ while (output_dir / f"{prefix}_{index:03d}.{extension}").exists():
26
+ index += 1
27
+ return index
28
+
29
+
30
+ def _save_image(
31
+ page: Image.Image,
32
+ destination: Path,
33
+ export_format: ExportFormat,
34
+ *,
35
+ dpi: int = DEFAULT_DPI,
36
+ ) -> None:
37
+ """Save a single page with format-aware options.
38
+
39
+ ``dpi`` is written into the file's metadata (a ``pHYs`` chunk for PNG, a
40
+ JFIF density for JPEG). Without it a 3000x4000 export carries no resolution
41
+ at all, so print software falls back to 72 DPI and reads the page as
42
+ 41.7x55.6in instead of 10x13.3in — the same pixels, described as a poster.
43
+ """
44
+ resolution = (dpi, dpi)
45
+
46
+ if export_format == "PNG":
47
+ page.save(
48
+ destination,
49
+ format="PNG",
50
+ optimize=True,
51
+ compress_level=6,
52
+ dpi=resolution,
53
+ )
54
+ return
55
+
56
+ # JPEG does not support alpha channels.
57
+ page.convert("RGB").save(
58
+ destination,
59
+ format="JPEG",
60
+ quality=95,
61
+ optimize=True,
62
+ subsampling=0,
63
+ dpi=resolution,
64
+ )
65
+
66
+
67
+ def export_pages(
68
+ pages: Sequence[Image.Image],
69
+ output_dir: str | Path,
70
+ export_format: ExportFormat,
71
+ *,
72
+ path_mode: PathMode = "relative",
73
+ filename_prefix: str = "page",
74
+ dpi: int = DEFAULT_DPI,
75
+ ) -> list[str]:
76
+ """Export composed pages with sequential names and conflict-safe behavior.
77
+
78
+ Filenames are generated as `<prefix>_001.<ext>`, `<prefix>_002.<ext>`, etc.
79
+ The default prefix keeps standard exports as `page_001.<ext>`, `page_002.<ext>`, etc.
80
+ If files already exist, numbering continues from the next available index
81
+ rather than overwriting existing files.
82
+
83
+ ``dpi`` is stamped into every exported file so the page reports its intended
84
+ physical size to print software rather than defaulting to 72 DPI.
85
+ """
86
+ target_dir = Path(output_dir).expanduser()
87
+ target_dir.mkdir(parents=True, exist_ok=True)
88
+
89
+ normalized_format = export_format.upper()
90
+ if normalized_format not in {"PNG", "JPG"}:
91
+ raise ValueError("Unsupported export format. Use 'PNG' or 'JPG'.")
92
+ if not filename_prefix:
93
+ raise ValueError("filename_prefix must be a non-empty string.")
94
+ resolved_dpi = validate_dpi(dpi)
95
+
96
+ extension = "png" if normalized_format == "PNG" else "jpg"
97
+ current_index = _next_available_index(target_dir, filename_prefix, extension)
98
+
99
+ saved_paths: list[str] = []
100
+ for page in pages:
101
+ output_path = target_dir / f"{filename_prefix}_{current_index:03d}.{extension}"
102
+ _save_image(page, output_path, normalized_format, dpi=resolved_dpi)
103
+
104
+ if path_mode == "absolute":
105
+ saved_paths.append(str(output_path.resolve()))
106
+ elif path_mode == "relative":
107
+ saved_paths.append(str(output_path))
108
+ else:
109
+ raise ValueError("Unsupported path_mode. Use 'relative' or 'absolute'.")
110
+
111
+ current_index += 1
112
+
113
+ return saved_paths
@@ -0,0 +1,164 @@
1
+ """Service for scanning image folders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
9
+ DEFAULT_SAMPLE_SIZE = 5
10
+ IMAGES_PER_PAGE_ESTIMATE = 12
11
+ # Batch scanning and validation must reject generated-output folders so the UI only
12
+ # offers real source bundles. Keep this list here alongside the discovery helpers to
13
+ # avoid reintroducing output directories such as `---Created---` into batch selection.
14
+ EXCLUDED_GENERATED_FOLDER_NAMES = frozenset({"---Created---"})
15
+
16
+
17
+ def scan_folder(folder_path: str, sample_size: int = DEFAULT_SAMPLE_SIZE) -> dict[str, object]:
18
+ """Scan a folder for supported image files.
19
+
20
+ The scan is non-recursive and only inspects files directly inside
21
+ ``folder_path``.
22
+ """
23
+ normalized_sample_size = max(0, sample_size)
24
+
25
+ # A blank path would otherwise resolve to the server's working directory and
26
+ # scan that instead.
27
+ if isinstance(folder_path, str) and not folder_path.strip():
28
+ return _build_response(
29
+ valid=False,
30
+ image_count=0,
31
+ sample_files=[],
32
+ estimated_pages=0,
33
+ available_subfolders=[],
34
+ message="Enter a folder path to scan.",
35
+ )
36
+
37
+ try:
38
+ folder = resolve_folder_path(folder_path)
39
+ except (TypeError, ValueError):
40
+ return _build_response(
41
+ valid=False,
42
+ image_count=0,
43
+ sample_files=[],
44
+ estimated_pages=0,
45
+ available_subfolders=[],
46
+ message="Invalid folder path.",
47
+ )
48
+
49
+ if not folder.exists():
50
+ return _build_response(
51
+ valid=False,
52
+ image_count=0,
53
+ sample_files=[],
54
+ estimated_pages=0,
55
+ available_subfolders=[],
56
+ message=f"Folder not found: {folder}. Check the path for typos.",
57
+ )
58
+
59
+ if not folder.is_dir():
60
+ return _build_response(
61
+ valid=False,
62
+ image_count=0,
63
+ sample_files=[],
64
+ estimated_pages=0,
65
+ available_subfolders=[],
66
+ message=f"Path is not a folder: {folder}",
67
+ )
68
+
69
+ image_names = [path.name for path in list_supported_image_paths(folder)]
70
+
71
+ image_count = len(image_names)
72
+ sample_files = image_names[:normalized_sample_size]
73
+
74
+ if image_count == 0:
75
+ available_subfolders = list_child_folder_paths(folder)
76
+ if available_subfolders:
77
+ return _build_response(
78
+ valid=False,
79
+ image_count=0,
80
+ sample_files=[],
81
+ estimated_pages=0,
82
+ available_subfolders=available_subfolders,
83
+ message=(
84
+ "No supported image files were found at the top level, "
85
+ "but direct subfolders are available to scan."
86
+ ),
87
+ )
88
+
89
+ return _build_response(
90
+ valid=False,
91
+ image_count=0,
92
+ sample_files=[],
93
+ estimated_pages=0,
94
+ available_subfolders=[],
95
+ message="No supported image files found (.jpg, .jpeg, .png, .webp).",
96
+ )
97
+
98
+ estimated_pages = image_count // IMAGES_PER_PAGE_ESTIMATE
99
+ return _build_response(
100
+ valid=True,
101
+ image_count=image_count,
102
+ sample_files=sample_files,
103
+ estimated_pages=estimated_pages,
104
+ available_subfolders=[],
105
+ message=f"Found {image_count} supported image file(s).",
106
+ )
107
+
108
+
109
+ def _build_response(
110
+ *,
111
+ valid: bool,
112
+ image_count: int,
113
+ sample_files: list[str],
114
+ estimated_pages: int,
115
+ available_subfolders: list[dict[str, str]],
116
+ message: str,
117
+ ) -> dict[str, object]:
118
+ return {
119
+ "valid": valid,
120
+ "image_count": image_count,
121
+ "sample_files": sample_files,
122
+ "estimated_pages": estimated_pages,
123
+ "available_subfolders": available_subfolders,
124
+ "message": message,
125
+ }
126
+
127
+
128
+ def resolve_folder_path(folder_path: str) -> Path:
129
+ return Path(folder_path).expanduser().resolve(strict=False)
130
+
131
+
132
+ def list_supported_image_paths(folder: Path, *, recursive: bool = False) -> list[Path]:
133
+ iterator: Iterable[Path]
134
+ if recursive:
135
+ iterator = (item for item in folder.rglob("*") if item.is_file())
136
+ else:
137
+ iterator = folder.iterdir()
138
+
139
+ return sorted(
140
+ [
141
+ item
142
+ for item in iterator
143
+ if item.is_file() and item.suffix.lower() in SUPPORTED_EXTENSIONS
144
+ ],
145
+ key=lambda path: (path.name.lower(), path.name, str(path)),
146
+ )
147
+
148
+
149
+ def is_generated_output_folder(folder: Path) -> bool:
150
+ return folder.name in EXCLUDED_GENERATED_FOLDER_NAMES
151
+
152
+
153
+ def list_child_folder_paths(folder: Path) -> list[dict[str, str]]:
154
+ return [
155
+ {"display_name": path.name, "full_path": str(path)}
156
+ for path in sorted(
157
+ (
158
+ item.resolve(strict=False)
159
+ for item in folder.iterdir()
160
+ if item.is_dir() and not is_generated_output_folder(item)
161
+ ),
162
+ key=lambda path: (path.name.lower(), path.name, str(path)),
163
+ )
164
+ ]