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/jobs.py
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from pasteup import __version__
|
|
11
|
+
from pasteup.models.schemas import (
|
|
12
|
+
BatchBundleResult,
|
|
13
|
+
BatchCoverCollageGenerationRequest,
|
|
14
|
+
BatchCoverCollageGenerationResponse,
|
|
15
|
+
BatchCoverCollageResult,
|
|
16
|
+
BatchGenerationRequest,
|
|
17
|
+
BatchGenerationResponse,
|
|
18
|
+
CoverCollageGenerationRequest,
|
|
19
|
+
CoverCollageResponse,
|
|
20
|
+
GenerationRequest,
|
|
21
|
+
GenerationResponse,
|
|
22
|
+
ScanRequest,
|
|
23
|
+
ScanResponse,
|
|
24
|
+
SelectedBatchFolderScanResult,
|
|
25
|
+
SelectedBatchFolderValidationRequest,
|
|
26
|
+
SelectedBatchFolderValidationResponse,
|
|
27
|
+
)
|
|
28
|
+
from pasteup.services.cover_collage import compose_cover_collage
|
|
29
|
+
from mullion import DEFAULT_TOLERANCE, BackgroundCleanMode
|
|
30
|
+
from pasteup.services.exporter import export_pages
|
|
31
|
+
from mullion import (
|
|
32
|
+
DEFAULT_DPI,
|
|
33
|
+
PageGeometry,
|
|
34
|
+
resolve_page_geometry,
|
|
35
|
+
)
|
|
36
|
+
from pasteup.services.folder_scanner import (
|
|
37
|
+
is_generated_output_folder,
|
|
38
|
+
list_supported_image_paths,
|
|
39
|
+
resolve_folder_path,
|
|
40
|
+
scan_folder,
|
|
41
|
+
)
|
|
42
|
+
from pasteup.services.grid_composer import compose_image_grids
|
|
43
|
+
from pasteup.services.video_exporter import build_preview_video
|
|
44
|
+
|
|
45
|
+
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
|
46
|
+
|
|
47
|
+
# Every page of a run is composed in memory, as RGB, before any is exported.
|
|
48
|
+
# 100 megapixels (~300 MB a page) still covers a 24x36in poster at 300 DPI; past
|
|
49
|
+
# it, a typo or a unit mix-up asks for tens of gigabytes and kills the process.
|
|
50
|
+
MAX_PAGE_MEGAPIXELS = 100
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class _OutputOptions:
|
|
55
|
+
"""The print/cleanup options a page-producing request carries.
|
|
56
|
+
|
|
57
|
+
Threaded as one object so adding a sixth option later does not mean editing
|
|
58
|
+
four call chains — the drift this codebase is otherwise prone to.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
dpi: int = DEFAULT_DPI
|
|
62
|
+
page_unit: str = "px"
|
|
63
|
+
background_clean_mode: BackgroundCleanMode = BackgroundCleanMode.OFF
|
|
64
|
+
background_tolerance: int = DEFAULT_TOLERANCE
|
|
65
|
+
background_feather: int = 1
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_payload(cls, payload) -> "_OutputOptions":
|
|
69
|
+
return cls(
|
|
70
|
+
dpi=getattr(payload, "dpi", DEFAULT_DPI),
|
|
71
|
+
page_unit=getattr(payload, "page_unit", "px"),
|
|
72
|
+
background_clean_mode=getattr(
|
|
73
|
+
payload, "background_clean_mode", BackgroundCleanMode.OFF
|
|
74
|
+
),
|
|
75
|
+
background_tolerance=getattr(payload, "background_tolerance", DEFAULT_TOLERANCE),
|
|
76
|
+
background_feather=getattr(payload, "background_feather", 1),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def geometry(self, *, page_width: float, page_height: float) -> PageGeometry:
|
|
80
|
+
if not (math.isfinite(page_width) and math.isfinite(page_height)):
|
|
81
|
+
raise ValueError("Page width and height must be finite numbers.")
|
|
82
|
+
|
|
83
|
+
geometry = resolve_page_geometry(
|
|
84
|
+
page_width=page_width,
|
|
85
|
+
page_height=page_height,
|
|
86
|
+
unit=self.page_unit,
|
|
87
|
+
dpi=self.dpi,
|
|
88
|
+
)
|
|
89
|
+
megapixels = geometry.width_px * geometry.height_px / 1_000_000
|
|
90
|
+
if megapixels > MAX_PAGE_MEGAPIXELS:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Page is {geometry.width_px}x{geometry.height_px}px ({megapixels:,.0f} MP); "
|
|
93
|
+
f"the limit is {MAX_PAGE_MEGAPIXELS} MP. Reduce the page size or DPI."
|
|
94
|
+
)
|
|
95
|
+
return geometry
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.get("/health")
|
|
99
|
+
def health_check() -> dict[str, str]:
|
|
100
|
+
# "app" lets `pasteup` tell a running Pasteup from another program on its port.
|
|
101
|
+
return {"status": "ok", "app": "pasteup", "version": __version__}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@router.post("/scan-folder", response_model=ScanResponse)
|
|
105
|
+
def scan_folder_job(payload: ScanRequest) -> ScanResponse:
|
|
106
|
+
try:
|
|
107
|
+
scan_result = scan_folder(payload.folder_path)
|
|
108
|
+
except (TypeError, ValueError) as exc:
|
|
109
|
+
raise HTTPException(status_code=400, detail="Invalid folder path.") from exc
|
|
110
|
+
except OSError as exc:
|
|
111
|
+
return ScanResponse(
|
|
112
|
+
valid=False,
|
|
113
|
+
image_count=0,
|
|
114
|
+
sample_files=[],
|
|
115
|
+
estimated_pages=0,
|
|
116
|
+
available_subfolders=[],
|
|
117
|
+
message=f"Unable to access folder: {exc}",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return ScanResponse(**scan_result)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.post("/scan-selected-batch-folders", response_model=SelectedBatchFolderValidationResponse)
|
|
124
|
+
def scan_selected_batch_folders_job(
|
|
125
|
+
payload: SelectedBatchFolderValidationRequest,
|
|
126
|
+
) -> SelectedBatchFolderValidationResponse:
|
|
127
|
+
parent_folder = resolve_folder_path(payload.parent_folder_path)
|
|
128
|
+
|
|
129
|
+
if not parent_folder.exists() or not parent_folder.is_dir():
|
|
130
|
+
raise HTTPException(status_code=400, detail=f"Invalid parent folder: {parent_folder}")
|
|
131
|
+
|
|
132
|
+
discovered_bundle_folders = _list_detected_bundle_folders(
|
|
133
|
+
parent_folder=parent_folder,
|
|
134
|
+
created_root_name=None,
|
|
135
|
+
)
|
|
136
|
+
bundle_folder_lookup = {str(path): path for path in discovered_bundle_folders}
|
|
137
|
+
selected_bundle_folders = _resolve_selected_bundle_folders(
|
|
138
|
+
parent_folder=parent_folder,
|
|
139
|
+
selected_subfolders=payload.selected_subfolders,
|
|
140
|
+
bundle_folder_lookup=bundle_folder_lookup,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
scanned_folders: list[SelectedBatchFolderScanResult] = []
|
|
144
|
+
skipped_folders: list[SelectedBatchFolderScanResult] = []
|
|
145
|
+
|
|
146
|
+
for selected_folder in selected_bundle_folders:
|
|
147
|
+
scan_result = scan_folder(str(selected_folder))
|
|
148
|
+
folder_result = SelectedBatchFolderScanResult(
|
|
149
|
+
display_name=selected_folder.name,
|
|
150
|
+
full_path=str(selected_folder),
|
|
151
|
+
valid=bool(scan_result["valid"]),
|
|
152
|
+
image_count=int(scan_result["image_count"]),
|
|
153
|
+
sample_files=list(scan_result["sample_files"]),
|
|
154
|
+
estimated_pages=int(scan_result["estimated_pages"]),
|
|
155
|
+
message=str(scan_result["message"]),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if folder_result.valid:
|
|
159
|
+
scanned_folders.append(folder_result)
|
|
160
|
+
else:
|
|
161
|
+
skipped_folders.append(folder_result)
|
|
162
|
+
|
|
163
|
+
selected_folders = len(selected_bundle_folders)
|
|
164
|
+
valid_selected_folders = len(scanned_folders)
|
|
165
|
+
invalid_selected_folders = len(skipped_folders)
|
|
166
|
+
|
|
167
|
+
return SelectedBatchFolderValidationResponse(
|
|
168
|
+
parent_folder_path=str(parent_folder),
|
|
169
|
+
selected_folders=selected_folders,
|
|
170
|
+
valid_selected_folders=valid_selected_folders,
|
|
171
|
+
invalid_selected_folders=invalid_selected_folders,
|
|
172
|
+
scanned_folders=scanned_folders,
|
|
173
|
+
skipped_folders=skipped_folders,
|
|
174
|
+
message=(
|
|
175
|
+
f"Validated {valid_selected_folders} of {selected_folders} selected folder(s). "
|
|
176
|
+
f"Skipped {invalid_selected_folders} folder(s)."
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@router.post("/generate", response_model=GenerationResponse)
|
|
182
|
+
def generate_job(payload: GenerationRequest):
|
|
183
|
+
return _generate_for_single_folder(
|
|
184
|
+
folder_path=payload.folder_path,
|
|
185
|
+
output_path=payload.output_path,
|
|
186
|
+
page_width=payload.page_width,
|
|
187
|
+
page_height=payload.page_height,
|
|
188
|
+
margin=payload.margin,
|
|
189
|
+
gap=payload.gap,
|
|
190
|
+
export_format=payload.format,
|
|
191
|
+
create_video=payload.create_video,
|
|
192
|
+
video_duration_seconds=payload.video_duration_seconds,
|
|
193
|
+
video_transition=payload.video_transition,
|
|
194
|
+
video_filename=payload.video_filename,
|
|
195
|
+
include_partial_page=payload.include_partial_page,
|
|
196
|
+
options=_OutputOptions.from_payload(payload),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@router.post("/generate-cover-collage", response_model=CoverCollageResponse)
|
|
201
|
+
def generate_cover_collage_job(payload: CoverCollageGenerationRequest) -> CoverCollageResponse:
|
|
202
|
+
response = _generate_cover_collage_for_single_folder(
|
|
203
|
+
folder_path=payload.folder_path,
|
|
204
|
+
output_path=payload.output_path,
|
|
205
|
+
page_width=payload.page_width,
|
|
206
|
+
page_height=payload.page_height,
|
|
207
|
+
export_format=payload.format,
|
|
208
|
+
collage_template=payload.collage_template,
|
|
209
|
+
image_fit=payload.image_fit,
|
|
210
|
+
selected_cover_images=payload.selected_cover_images,
|
|
211
|
+
options=_OutputOptions.from_payload(payload),
|
|
212
|
+
)
|
|
213
|
+
if isinstance(response, JSONResponse):
|
|
214
|
+
content = json.loads(response.body.decode("utf-8"))
|
|
215
|
+
raise HTTPException(status_code=response.status_code, detail=content.get("message", "Unable to generate cover collage."))
|
|
216
|
+
return response
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@router.post("/generate-cover-collage-batch", response_model=BatchCoverCollageGenerationResponse)
|
|
222
|
+
def generate_cover_collage_batch_job(
|
|
223
|
+
payload: BatchCoverCollageGenerationRequest,
|
|
224
|
+
) -> BatchCoverCollageGenerationResponse:
|
|
225
|
+
parent_folder = resolve_folder_path(payload.parent_folder_path)
|
|
226
|
+
created_root = Path(payload.created_output_root_path).expanduser()
|
|
227
|
+
|
|
228
|
+
if not parent_folder.exists() or not parent_folder.is_dir():
|
|
229
|
+
raise HTTPException(status_code=400, detail=f"Invalid parent folder: {parent_folder}")
|
|
230
|
+
|
|
231
|
+
discovered_bundle_folders = _list_detected_bundle_folders(
|
|
232
|
+
parent_folder=parent_folder,
|
|
233
|
+
created_root_name=created_root.name,
|
|
234
|
+
)
|
|
235
|
+
bundle_folder_lookup = {str(path): path for path in discovered_bundle_folders}
|
|
236
|
+
bundle_folders = _resolve_selected_bundle_folders(
|
|
237
|
+
parent_folder=parent_folder,
|
|
238
|
+
selected_subfolders=payload.selected_subfolders,
|
|
239
|
+
bundle_folder_lookup=bundle_folder_lookup,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
total_detected_folders = len(discovered_bundle_folders)
|
|
243
|
+
selected_folders = len(bundle_folders)
|
|
244
|
+
|
|
245
|
+
bundle_results = [
|
|
246
|
+
_generate_cover_collage_batch_result(
|
|
247
|
+
bundle_folder=bundle_folder,
|
|
248
|
+
created_root=created_root,
|
|
249
|
+
page_width=payload.page_width,
|
|
250
|
+
page_height=payload.page_height,
|
|
251
|
+
export_format=payload.format,
|
|
252
|
+
collage_template=payload.collage_template,
|
|
253
|
+
image_fit=payload.image_fit,
|
|
254
|
+
options=_OutputOptions.from_payload(payload),
|
|
255
|
+
)
|
|
256
|
+
for bundle_folder in bundle_folders
|
|
257
|
+
]
|
|
258
|
+
generated_folders = sum(1 for result in bundle_results if result.success and result.pages_generated > 0)
|
|
259
|
+
|
|
260
|
+
return BatchCoverCollageGenerationResponse(
|
|
261
|
+
success=True,
|
|
262
|
+
processed_folders=selected_folders,
|
|
263
|
+
generated_folders=generated_folders,
|
|
264
|
+
total_detected_folders=total_detected_folders,
|
|
265
|
+
selected_folders=selected_folders,
|
|
266
|
+
bundle_results=bundle_results,
|
|
267
|
+
created_output_root_path=str(created_root),
|
|
268
|
+
message=(
|
|
269
|
+
f"Detected {total_detected_folders} eligible folder(s) under the parent. "
|
|
270
|
+
f"Processed {selected_folders} selected folder(s). "
|
|
271
|
+
f"Generated cover collages for {generated_folders} folder(s)."
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@router.post("/generate-all-folders", response_model=BatchGenerationResponse)
|
|
277
|
+
def generate_all_folders_job(payload: BatchGenerationRequest) -> BatchGenerationResponse:
|
|
278
|
+
parent_folder = resolve_folder_path(payload.parent_folder_path)
|
|
279
|
+
created_root = Path(payload.created_output_root_path).expanduser()
|
|
280
|
+
|
|
281
|
+
if not parent_folder.exists() or not parent_folder.is_dir():
|
|
282
|
+
raise HTTPException(status_code=400, detail=f"Invalid parent folder: {parent_folder}")
|
|
283
|
+
|
|
284
|
+
discovered_bundle_folders = _list_detected_bundle_folders(
|
|
285
|
+
parent_folder=parent_folder,
|
|
286
|
+
created_root_name=created_root.name,
|
|
287
|
+
)
|
|
288
|
+
bundle_folder_lookup = {str(path): path for path in discovered_bundle_folders}
|
|
289
|
+
|
|
290
|
+
if payload.selected_subfolders is None:
|
|
291
|
+
bundle_folders = discovered_bundle_folders
|
|
292
|
+
else:
|
|
293
|
+
bundle_folders = _resolve_selected_bundle_folders(
|
|
294
|
+
parent_folder=parent_folder,
|
|
295
|
+
selected_subfolders=payload.selected_subfolders,
|
|
296
|
+
bundle_folder_lookup=bundle_folder_lookup,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
total_detected_folders = len(discovered_bundle_folders)
|
|
300
|
+
selected_folders = len(bundle_folders)
|
|
301
|
+
|
|
302
|
+
bundle_results: list[BatchBundleResult] = []
|
|
303
|
+
generated_folders = 0
|
|
304
|
+
|
|
305
|
+
for bundle_folder in bundle_folders:
|
|
306
|
+
bundle_output = _build_batch_bundle_output_path(
|
|
307
|
+
created_root=created_root,
|
|
308
|
+
bundle_folder=bundle_folder,
|
|
309
|
+
)
|
|
310
|
+
generation_response = _generate_for_single_folder(
|
|
311
|
+
folder_path=str(bundle_folder),
|
|
312
|
+
output_path=str(bundle_output),
|
|
313
|
+
page_width=payload.page_width,
|
|
314
|
+
page_height=payload.page_height,
|
|
315
|
+
margin=payload.margin,
|
|
316
|
+
gap=payload.gap,
|
|
317
|
+
export_format=payload.format,
|
|
318
|
+
create_video=payload.create_video,
|
|
319
|
+
video_duration_seconds=payload.video_duration_seconds,
|
|
320
|
+
video_transition=payload.video_transition,
|
|
321
|
+
video_filename=payload.video_filename,
|
|
322
|
+
include_partial_page=payload.include_partial_page,
|
|
323
|
+
options=_OutputOptions.from_payload(payload),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if isinstance(generation_response, JSONResponse):
|
|
327
|
+
content = json.loads(generation_response.body.decode("utf-8"))
|
|
328
|
+
bundle_results.append(
|
|
329
|
+
BatchBundleResult(
|
|
330
|
+
folder_name=bundle_folder.name,
|
|
331
|
+
success=False,
|
|
332
|
+
pages_generated=0,
|
|
333
|
+
total_images=0,
|
|
334
|
+
output_path=str(bundle_output),
|
|
335
|
+
video_file=None,
|
|
336
|
+
video_created=False,
|
|
337
|
+
message=str(content.get("message", "Unable to generate folder.")),
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
if generation_response.pages_generated > 0:
|
|
343
|
+
generated_folders += 1
|
|
344
|
+
|
|
345
|
+
bundle_results.append(
|
|
346
|
+
BatchBundleResult(
|
|
347
|
+
folder_name=bundle_folder.name,
|
|
348
|
+
success=generation_response.success,
|
|
349
|
+
pages_generated=generation_response.pages_generated,
|
|
350
|
+
total_images=generation_response.total_images,
|
|
351
|
+
output_path=generation_response.output_path,
|
|
352
|
+
video_file=generation_response.video_file,
|
|
353
|
+
video_created=generation_response.video_created,
|
|
354
|
+
message=generation_response.message,
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return BatchGenerationResponse(
|
|
359
|
+
success=True,
|
|
360
|
+
processed_folders=selected_folders,
|
|
361
|
+
generated_folders=generated_folders,
|
|
362
|
+
total_detected_folders=total_detected_folders,
|
|
363
|
+
selected_folders=selected_folders,
|
|
364
|
+
bundle_results=bundle_results,
|
|
365
|
+
created_output_root_path=str(created_root),
|
|
366
|
+
message=(
|
|
367
|
+
f"Detected {total_detected_folders} eligible folder(s) under the parent. "
|
|
368
|
+
f"Processed {selected_folders} selected folder(s). "
|
|
369
|
+
f"Generated collage pages for {generated_folders} folder(s)."
|
|
370
|
+
),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _generate_cover_collage_batch_result(
|
|
377
|
+
*,
|
|
378
|
+
bundle_folder: Path,
|
|
379
|
+
created_root: Path,
|
|
380
|
+
page_width: float,
|
|
381
|
+
page_height: float,
|
|
382
|
+
export_format: str,
|
|
383
|
+
collage_template,
|
|
384
|
+
image_fit,
|
|
385
|
+
options: _OutputOptions | None = None,
|
|
386
|
+
) -> BatchCoverCollageResult:
|
|
387
|
+
bundle_output = _build_batch_bundle_output_path(
|
|
388
|
+
created_root=created_root,
|
|
389
|
+
bundle_folder=bundle_folder,
|
|
390
|
+
)
|
|
391
|
+
generation_response = _generate_cover_collage_for_single_folder(
|
|
392
|
+
folder_path=str(bundle_folder),
|
|
393
|
+
output_path=str(bundle_output),
|
|
394
|
+
page_width=page_width,
|
|
395
|
+
page_height=page_height,
|
|
396
|
+
export_format=export_format,
|
|
397
|
+
collage_template=collage_template,
|
|
398
|
+
image_fit=image_fit,
|
|
399
|
+
options=options,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if isinstance(generation_response, JSONResponse):
|
|
403
|
+
content = json.loads(generation_response.body.decode("utf-8"))
|
|
404
|
+
return BatchCoverCollageResult(
|
|
405
|
+
folder_name=bundle_folder.name,
|
|
406
|
+
success=False,
|
|
407
|
+
pages_generated=0,
|
|
408
|
+
total_images=0,
|
|
409
|
+
output_path=str(bundle_output),
|
|
410
|
+
output_files=[],
|
|
411
|
+
message=str(content.get("message", "Unable to generate cover collage.")),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return BatchCoverCollageResult(
|
|
415
|
+
folder_name=bundle_folder.name,
|
|
416
|
+
success=generation_response.success,
|
|
417
|
+
pages_generated=generation_response.pages_generated,
|
|
418
|
+
total_images=generation_response.total_images,
|
|
419
|
+
output_path=generation_response.output_path,
|
|
420
|
+
output_files=generation_response.output_files,
|
|
421
|
+
message=generation_response.message,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _generate_cover_collage_for_single_folder(
|
|
426
|
+
*,
|
|
427
|
+
folder_path: str,
|
|
428
|
+
output_path: str,
|
|
429
|
+
page_width: float,
|
|
430
|
+
page_height: float,
|
|
431
|
+
export_format: str,
|
|
432
|
+
collage_template,
|
|
433
|
+
image_fit,
|
|
434
|
+
selected_cover_images: list[str] | None = None,
|
|
435
|
+
options: _OutputOptions | None = None,
|
|
436
|
+
):
|
|
437
|
+
resolved_options = options or _OutputOptions()
|
|
438
|
+
try:
|
|
439
|
+
geometry = resolved_options.geometry(page_width=page_width, page_height=page_height)
|
|
440
|
+
except ValueError as exc:
|
|
441
|
+
return JSONResponse(status_code=400, content={"message": str(exc)})
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
input_folder = resolve_folder_path(folder_path)
|
|
445
|
+
except (TypeError, ValueError):
|
|
446
|
+
return JSONResponse(
|
|
447
|
+
status_code=400,
|
|
448
|
+
content={"message": "Invalid folder path."},
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
if not input_folder.exists():
|
|
452
|
+
return JSONResponse(status_code=400, content={"message": f"Folder not found: {input_folder}"})
|
|
453
|
+
if not input_folder.is_dir():
|
|
454
|
+
return JSONResponse(status_code=400, content={"message": f"Path is not a folder: {input_folder}"})
|
|
455
|
+
|
|
456
|
+
image_paths = [str(path) for path in list_supported_image_paths(input_folder, recursive=True)]
|
|
457
|
+
if not image_paths:
|
|
458
|
+
return JSONResponse(
|
|
459
|
+
status_code=400,
|
|
460
|
+
content={"message": "No supported image files found (.jpg, .jpeg, .png, .webp)."},
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
if selected_cover_images is not None:
|
|
464
|
+
selected_cover_image_names = set(selected_cover_images)
|
|
465
|
+
image_paths = [
|
|
466
|
+
image_path for image_path in image_paths if Path(image_path).name in selected_cover_image_names
|
|
467
|
+
]
|
|
468
|
+
if not image_paths:
|
|
469
|
+
return JSONResponse(
|
|
470
|
+
status_code=400,
|
|
471
|
+
content={
|
|
472
|
+
"message": (
|
|
473
|
+
"Cover collage include list did not match any supported images in the selected folder. "
|
|
474
|
+
"Use exact filenames, including extensions."
|
|
475
|
+
)
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
pages, summary = compose_cover_collage(
|
|
480
|
+
image_paths,
|
|
481
|
+
page_width=geometry.width_px,
|
|
482
|
+
page_height=geometry.height_px,
|
|
483
|
+
template=collage_template,
|
|
484
|
+
fitting_mode=image_fit,
|
|
485
|
+
background_clean_mode=resolved_options.background_clean_mode,
|
|
486
|
+
background_tolerance=resolved_options.background_tolerance,
|
|
487
|
+
background_feather=resolved_options.background_feather,
|
|
488
|
+
)
|
|
489
|
+
output_files = export_pages(
|
|
490
|
+
pages,
|
|
491
|
+
output_path,
|
|
492
|
+
export_format,
|
|
493
|
+
filename_prefix="cover_collage",
|
|
494
|
+
dpi=geometry.dpi,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
return CoverCollageResponse(
|
|
498
|
+
success=bool(output_files),
|
|
499
|
+
total_images=summary.total_images_placed,
|
|
500
|
+
pages_generated=len(output_files),
|
|
501
|
+
output_files=output_files,
|
|
502
|
+
output_path=str(Path(output_path).expanduser()),
|
|
503
|
+
page_geometry=geometry.description,
|
|
504
|
+
message=summary.user_message,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
def _generate_for_single_folder(
|
|
508
|
+
*,
|
|
509
|
+
folder_path: str,
|
|
510
|
+
output_path: str,
|
|
511
|
+
page_width: float,
|
|
512
|
+
page_height: float,
|
|
513
|
+
margin: float,
|
|
514
|
+
gap: float,
|
|
515
|
+
export_format: str,
|
|
516
|
+
create_video: bool = False,
|
|
517
|
+
video_duration_seconds: float = 15.0,
|
|
518
|
+
video_transition: Literal["none", "crossfade", "flip"] = "none",
|
|
519
|
+
video_filename: str = "preview.mp4",
|
|
520
|
+
include_partial_page: bool = False,
|
|
521
|
+
options: _OutputOptions | None = None,
|
|
522
|
+
):
|
|
523
|
+
resolved_options = options or _OutputOptions()
|
|
524
|
+
try:
|
|
525
|
+
geometry = resolved_options.geometry(page_width=page_width, page_height=page_height)
|
|
526
|
+
except ValueError as exc:
|
|
527
|
+
return _generation_error(output_path=output_path, message=str(exc))
|
|
528
|
+
|
|
529
|
+
scan_result = scan_folder(folder_path)
|
|
530
|
+
if not scan_result["valid"]:
|
|
531
|
+
return _generation_error(output_path=output_path, message=str(scan_result["message"]))
|
|
532
|
+
|
|
533
|
+
input_folder = Path(folder_path).expanduser()
|
|
534
|
+
image_paths = [str(path) for path in list_supported_image_paths(input_folder)]
|
|
535
|
+
|
|
536
|
+
# Every path goes to the composer, which decides what to do with a trailing
|
|
537
|
+
# remainder. Truncating here with build_image_batches would hide the
|
|
538
|
+
# remainder from it, so the summary could not report what was left out --
|
|
539
|
+
# which is the silent data loss this reporting exists to remove. The page
|
|
540
|
+
# output is identical either way when include_partial_page is off.
|
|
541
|
+
try:
|
|
542
|
+
pages, compose_summary = compose_image_grids(
|
|
543
|
+
image_paths,
|
|
544
|
+
page_width=geometry.width_px,
|
|
545
|
+
page_height=geometry.height_px,
|
|
546
|
+
margin=margin,
|
|
547
|
+
gap=gap,
|
|
548
|
+
background_clean_mode=resolved_options.background_clean_mode,
|
|
549
|
+
background_tolerance=resolved_options.background_tolerance,
|
|
550
|
+
background_feather=resolved_options.background_feather,
|
|
551
|
+
include_partial_page=include_partial_page,
|
|
552
|
+
)
|
|
553
|
+
except ValueError as exc:
|
|
554
|
+
# Settings that leave no room for a cell, such as a margin wider than
|
|
555
|
+
# half the page. Reported as the user's to fix, not as a server fault.
|
|
556
|
+
return _generation_error(output_path=output_path, message=str(exc))
|
|
557
|
+
output_files = export_pages(pages, output_path, export_format, dpi=geometry.dpi)
|
|
558
|
+
video_file: str | None = None
|
|
559
|
+
video_created = False
|
|
560
|
+
video_status_message: str | None = None
|
|
561
|
+
if create_video and output_files:
|
|
562
|
+
requested_video_path = str((Path(output_path).expanduser() / video_filename).resolve())
|
|
563
|
+
try:
|
|
564
|
+
video_file = build_preview_video(
|
|
565
|
+
page_paths=output_files,
|
|
566
|
+
output_dir=output_path,
|
|
567
|
+
duration_seconds=video_duration_seconds,
|
|
568
|
+
transition=video_transition,
|
|
569
|
+
filename=video_filename,
|
|
570
|
+
)
|
|
571
|
+
video_status_message = f"Preview video saved to: {video_file}"
|
|
572
|
+
except (RuntimeError, ValueError) as exc:
|
|
573
|
+
video_file = None
|
|
574
|
+
video_status_message = (
|
|
575
|
+
f"Preview video was requested but could not be created ({exc}). "
|
|
576
|
+
f"Expected output path: {requested_video_path}."
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
video_created = video_file is not None
|
|
580
|
+
|
|
581
|
+
response_message = compose_summary.user_message
|
|
582
|
+
if video_status_message:
|
|
583
|
+
response_message = f"{response_message} {video_status_message}"
|
|
584
|
+
|
|
585
|
+
return GenerationResponse(
|
|
586
|
+
success=True,
|
|
587
|
+
total_images=compose_summary.total_images_placed,
|
|
588
|
+
pages_generated=len(output_files),
|
|
589
|
+
output_files=output_files,
|
|
590
|
+
output_path=str(Path(output_path).expanduser()),
|
|
591
|
+
video_file=video_file,
|
|
592
|
+
video_created=video_created,
|
|
593
|
+
images_dropped=compose_summary.total_images_dropped,
|
|
594
|
+
images_skipped=compose_summary.total_images_skipped,
|
|
595
|
+
backgrounds_cleaned=compose_summary.background_cleaned,
|
|
596
|
+
page_geometry=geometry.description,
|
|
597
|
+
message=response_message,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _generation_error(*, output_path: str, message: str) -> JSONResponse:
|
|
602
|
+
return JSONResponse(
|
|
603
|
+
status_code=400,
|
|
604
|
+
content=GenerationResponse(
|
|
605
|
+
success=False,
|
|
606
|
+
total_images=0,
|
|
607
|
+
pages_generated=0,
|
|
608
|
+
output_files=[],
|
|
609
|
+
output_path=output_path,
|
|
610
|
+
video_file=None,
|
|
611
|
+
video_created=False,
|
|
612
|
+
message=message,
|
|
613
|
+
).model_dump(),
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _resolve_selected_bundle_folders(
|
|
618
|
+
*,
|
|
619
|
+
parent_folder: Path,
|
|
620
|
+
selected_subfolders: list[str],
|
|
621
|
+
bundle_folder_lookup: dict[str, Path],
|
|
622
|
+
) -> list[Path]:
|
|
623
|
+
resolved_bundle_folders: list[Path] = []
|
|
624
|
+
|
|
625
|
+
for selected_path in selected_subfolders:
|
|
626
|
+
candidate_folder = resolve_folder_path(selected_path)
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
candidate_folder.relative_to(parent_folder)
|
|
630
|
+
except ValueError as exc:
|
|
631
|
+
raise HTTPException(
|
|
632
|
+
status_code=400,
|
|
633
|
+
detail=f"Selected folder is not inside the scanned parent folder: {candidate_folder}",
|
|
634
|
+
) from exc
|
|
635
|
+
|
|
636
|
+
if not candidate_folder.exists() or not candidate_folder.is_dir():
|
|
637
|
+
raise HTTPException(
|
|
638
|
+
status_code=400,
|
|
639
|
+
detail=f"Selected folder does not exist or is not a directory: {candidate_folder}",
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
if is_generated_output_folder(candidate_folder):
|
|
643
|
+
raise HTTPException(
|
|
644
|
+
status_code=400,
|
|
645
|
+
detail=(
|
|
646
|
+
"Selected folder points to a generated-output directory and cannot be "
|
|
647
|
+
f"used as a source folder: {candidate_folder}"
|
|
648
|
+
),
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
matched_folder = bundle_folder_lookup.get(str(candidate_folder))
|
|
652
|
+
if matched_folder is None:
|
|
653
|
+
raise HTTPException(
|
|
654
|
+
status_code=400,
|
|
655
|
+
detail=f"Selected folder was not part of the detected child folders: {candidate_folder}",
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
resolved_bundle_folders.append(matched_folder)
|
|
659
|
+
|
|
660
|
+
return resolved_bundle_folders
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _build_batch_bundle_output_path(*, created_root: Path, bundle_folder: Path) -> Path:
|
|
664
|
+
"""Mirror batch page-generation outputs under `<created root>/<source folder name>`."""
|
|
665
|
+
return created_root / bundle_folder.name
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _list_detected_bundle_folders(*, parent_folder: Path, created_root_name: str | None) -> list[Path]:
|
|
669
|
+
return sorted(
|
|
670
|
+
[
|
|
671
|
+
item.resolve(strict=False)
|
|
672
|
+
for item in parent_folder.iterdir()
|
|
673
|
+
if item.is_dir()
|
|
674
|
+
and item.name != created_root_name
|
|
675
|
+
and not is_generated_output_folder(item)
|
|
676
|
+
],
|
|
677
|
+
key=lambda path: (path.name.lower(), path.name),
|
|
678
|
+
)
|