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/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
+ )