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/__init__.py +3 -0
- pasteup/__main__.py +4 -0
- pasteup/cli.py +171 -0
- pasteup/main.py +34 -0
- pasteup/models/__init__.py +0 -0
- pasteup/models/schemas.py +410 -0
- pasteup/routes/__init__.py +0 -0
- pasteup/routes/jobs.py +678 -0
- pasteup/routes/ui.py +43 -0
- pasteup/services/__init__.py +0 -0
- pasteup/services/cover_collage.py +379 -0
- pasteup/services/exporter.py +113 -0
- pasteup/services/folder_scanner.py +164 -0
- pasteup/services/grid_composer.py +199 -0
- pasteup/services/image_batcher.py +21 -0
- pasteup/services/video_exporter.py +227 -0
- pasteup/shortcut.py +97 -0
- pasteup/static/css/styles.css +983 -0
- pasteup/static/favicon.svg +9 -0
- pasteup/static/fonts/OFL-Caveat.txt +93 -0
- pasteup/static/fonts/OFL-PatrickHand.txt +93 -0
- pasteup/static/fonts/caveat-latin-700.woff2 +0 -0
- pasteup/static/fonts/patrick-hand-latin-400.woff2 +0 -0
- pasteup/static/js/app.js +1680 -0
- pasteup/templates/index.html +309 -0
- pasteup-0.1.0.dist-info/METADATA +481 -0
- pasteup-0.1.0.dist-info/RECORD +32 -0
- pasteup-0.1.0.dist-info/WHEEL +4 -0
- pasteup-0.1.0.dist-info/entry_points.txt +2 -0
- pasteup-0.1.0.dist-info/licenses/LICENSE +202 -0
- pasteup-0.1.0.dist-info/licenses/src/pasteup/static/fonts/OFL-Caveat.txt +93 -0
- pasteup-0.1.0.dist-info/licenses/src/pasteup/static/fonts/OFL-PatrickHand.txt +93 -0
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
|
+
]
|