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/__init__.py
ADDED
pasteup/__main__.py
ADDED
pasteup/cli.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The `pasteup` command: start the app and open it in the browser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import urllib.request
|
|
14
|
+
import webbrowser
|
|
15
|
+
|
|
16
|
+
from pasteup import __version__
|
|
17
|
+
|
|
18
|
+
LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
|
|
19
|
+
WILDCARD_HOSTS = {"0.0.0.0": "127.0.0.1", "::": "::1"}
|
|
20
|
+
DEFAULT_PORT = 8000
|
|
21
|
+
# Ports tried after the default when another program holds it.
|
|
22
|
+
FALLBACK_PORTS = 10
|
|
23
|
+
BROWSER_WAIT_SECONDS = 20
|
|
24
|
+
|
|
25
|
+
# Asking the local server must not go through a proxy set in the environment.
|
|
26
|
+
_local_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> None:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="pasteup",
|
|
32
|
+
description="Turn a folder of clip art into listing images. Starts the app and opens it in your browser.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("--host", default="127.0.0.1", help="address to listen on (default: 127.0.0.1)")
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--port", type=_port_number, help=f"port to listen on (default: {DEFAULT_PORT}, or the next free one)"
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument("--no-browser", action="store_true", help="don't open the app in a browser")
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--shortcut", action="store_true", help="put a Pasteup launcher on your desktop, then exit"
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--reload", action="store_true", help="restart on code changes, for development")
|
|
43
|
+
parser.add_argument("--version", action="version", version=f"pasteup {__version__}")
|
|
44
|
+
args = parser.parse_args(argv)
|
|
45
|
+
|
|
46
|
+
if args.shortcut:
|
|
47
|
+
from pasteup.shortcut import create_shortcut
|
|
48
|
+
|
|
49
|
+
print(create_shortcut())
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if args.host not in LOCAL_HOSTS:
|
|
53
|
+
print(
|
|
54
|
+
f"Warning: listening on {args.host} lets other machines reach an app that reads and "
|
|
55
|
+
"writes your files, with no login. See SECURITY.md before doing this.",
|
|
56
|
+
file=sys.stderr,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
visit_host = WILDCARD_HOSTS.get(args.host, args.host)
|
|
60
|
+
open_browser = not args.no_browser and _can_open_browser(visit_host)
|
|
61
|
+
port, state = _choose_port(visit_host, args.port)
|
|
62
|
+
url = _base_url(visit_host, port)
|
|
63
|
+
|
|
64
|
+
if state == "pasteup":
|
|
65
|
+
print(f"Pasteup is already running at {url}")
|
|
66
|
+
if open_browser:
|
|
67
|
+
webbrowser.open(url)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if port != (args.port or DEFAULT_PORT):
|
|
71
|
+
print(f"Port {DEFAULT_PORT} is taken by another program, so Pasteup is using {port}.")
|
|
72
|
+
print(f"Pasteup is running at {url}")
|
|
73
|
+
if open_browser:
|
|
74
|
+
print("Opening it in your browser...")
|
|
75
|
+
_open_when_ready(url)
|
|
76
|
+
else:
|
|
77
|
+
print("Open that address in your browser.")
|
|
78
|
+
print("Keep this window open while you use Pasteup; press Ctrl+C to stop it.")
|
|
79
|
+
if shutil.which("ffmpeg") is None:
|
|
80
|
+
print(
|
|
81
|
+
"Note: preview videos need ffmpeg (https://ffmpeg.org), which isn't installed. "
|
|
82
|
+
"Everything else works."
|
|
83
|
+
)
|
|
84
|
+
sys.stdout.flush()
|
|
85
|
+
|
|
86
|
+
# Imported here so `pasteup --help` and `--version` stay instant.
|
|
87
|
+
import uvicorn
|
|
88
|
+
|
|
89
|
+
uvicorn.run(
|
|
90
|
+
"pasteup.main:app",
|
|
91
|
+
host=args.host,
|
|
92
|
+
port=port,
|
|
93
|
+
reload=args.reload,
|
|
94
|
+
# Errors only, unless developing: request logs mean nothing to a user.
|
|
95
|
+
log_level="info" if args.reload else "warning",
|
|
96
|
+
)
|
|
97
|
+
print("Pasteup has stopped.")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _port_number(value: str) -> int:
|
|
101
|
+
port = int(value)
|
|
102
|
+
if not 1 <= port <= 65535:
|
|
103
|
+
raise argparse.ArgumentTypeError("must be between 1 and 65535")
|
|
104
|
+
return port
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _choose_port(host: str, requested_port: int | None) -> tuple[int, str]:
|
|
108
|
+
"""Return the port to use, and "pasteup" when Pasteup already runs there."""
|
|
109
|
+
if requested_port is not None:
|
|
110
|
+
state = _probe(host, requested_port)
|
|
111
|
+
if state == "busy":
|
|
112
|
+
sys.exit(f"Port {requested_port} is in use by another program. Choose a different one with --port.")
|
|
113
|
+
return requested_port, state
|
|
114
|
+
|
|
115
|
+
for port in range(DEFAULT_PORT, DEFAULT_PORT + FALLBACK_PORTS + 1):
|
|
116
|
+
state = _probe(host, port)
|
|
117
|
+
if state != "busy":
|
|
118
|
+
return port, state
|
|
119
|
+
sys.exit(
|
|
120
|
+
f"Ports {DEFAULT_PORT} to {DEFAULT_PORT + FALLBACK_PORTS} are all in use by other programs. "
|
|
121
|
+
"Choose a free one with --port."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _probe(host: str, port: int) -> str:
|
|
126
|
+
"""Say whether a port is "free", has Pasteup on it ("pasteup") or another program ("busy")."""
|
|
127
|
+
try:
|
|
128
|
+
with socket.create_connection((host, port), timeout=0.5):
|
|
129
|
+
pass
|
|
130
|
+
except OSError:
|
|
131
|
+
return "free"
|
|
132
|
+
return "pasteup" if _answers_as_pasteup(_base_url(host, port)) else "busy"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _answers_as_pasteup(url: str) -> bool:
|
|
136
|
+
try:
|
|
137
|
+
with _local_opener.open(f"{url}api/jobs/health", timeout=2) as response:
|
|
138
|
+
return json.load(response).get("app") == "pasteup"
|
|
139
|
+
except (OSError, ValueError, AttributeError):
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _open_when_ready(url: str) -> None:
|
|
144
|
+
"""Open the browser once the server answers, without holding up its start."""
|
|
145
|
+
threading.Thread(target=_wait_then_open, args=(url,), daemon=True).start()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _wait_then_open(url: str, timeout: float = BROWSER_WAIT_SECONDS) -> None:
|
|
149
|
+
deadline = time.monotonic() + timeout
|
|
150
|
+
while time.monotonic() < deadline:
|
|
151
|
+
if _answers_as_pasteup(url):
|
|
152
|
+
webbrowser.open(url)
|
|
153
|
+
return
|
|
154
|
+
time.sleep(0.25)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _can_open_browser(host: str) -> bool:
|
|
158
|
+
# A browser on this machine can reach only a server listening on it.
|
|
159
|
+
if host not in LOCAL_HOSTS:
|
|
160
|
+
return False
|
|
161
|
+
if os.environ.get("BROWSER"):
|
|
162
|
+
return True
|
|
163
|
+
# Without a display, Python falls back to text browsers such as lynx, which
|
|
164
|
+
# would take over this terminal.
|
|
165
|
+
if sys.platform.startswith(("linux", "freebsd", "openbsd", "netbsd")):
|
|
166
|
+
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _base_url(host: str, port: int) -> str:
|
|
171
|
+
return f"http://[{host}]:{port}/" if ":" in host else f"http://{host}:{port}/"
|
pasteup/main.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
|
|
8
|
+
from pasteup import __version__
|
|
9
|
+
from pasteup.routes.jobs import router as jobs_router
|
|
10
|
+
from pasteup.routes.ui import router as ui_router
|
|
11
|
+
|
|
12
|
+
APP_DIR = Path(__file__).resolve().parent
|
|
13
|
+
|
|
14
|
+
# The app reads and writes local paths and has no authentication, so it answers
|
|
15
|
+
# only requests addressed to this machine. Checking Host stops a web page whose
|
|
16
|
+
# domain has been re-pointed at 127.0.0.1 (DNS rebinding) from driving the app
|
|
17
|
+
# through the user's browser. Extra names, comma-separated, are for serving the
|
|
18
|
+
# app deliberately under another host.
|
|
19
|
+
ALLOWED_HOSTS_ENV_VAR = "PASTEUP_ALLOWED_HOSTS"
|
|
20
|
+
DEFAULT_ALLOWED_HOSTS = ("localhost", "127.0.0.1", "[::1]")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def allowed_hosts() -> list[str]:
|
|
24
|
+
extra_hosts = os.environ.get(ALLOWED_HOSTS_ENV_VAR, "").split(",")
|
|
25
|
+
return [*DEFAULT_ALLOWED_HOSTS, *(host.strip() for host in extra_hosts if host.strip())]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
app = FastAPI(title="Pasteup", version=__version__)
|
|
29
|
+
|
|
30
|
+
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts())
|
|
31
|
+
app.mount("/static", StaticFiles(directory=APP_DIR / "static"), name="static")
|
|
32
|
+
|
|
33
|
+
app.include_router(ui_router)
|
|
34
|
+
app.include_router(jobs_router, prefix="/api")
|
|
File without changes
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from mullion import DEFAULT_TOLERANCE, BackgroundCleanMode
|
|
5
|
+
from pasteup.services.cover_collage import CoverCollageFittingMode, CoverCollageTemplate
|
|
6
|
+
from mullion import DEFAULT_DPI
|
|
7
|
+
|
|
8
|
+
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
# The preview is encoded at a real frame rate, so its length sets the encoding
|
|
11
|
+
# work. Two minutes is well past any listing site's limit (Etsy's is 15 s).
|
|
12
|
+
MAX_VIDEO_DURATION_SECONDS = 120
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_optional_string_list(value: object, *, field_name: str) -> list[str] | None:
|
|
16
|
+
if value is None:
|
|
17
|
+
return None
|
|
18
|
+
if not isinstance(value, list):
|
|
19
|
+
raise ValueError(f"{field_name} must be a list of strings.")
|
|
20
|
+
|
|
21
|
+
normalized_values: list[str] = []
|
|
22
|
+
seen_values: set[str] = set()
|
|
23
|
+
for item in value:
|
|
24
|
+
if not isinstance(item, str):
|
|
25
|
+
raise ValueError(f"{field_name} must contain only strings.")
|
|
26
|
+
normalized_item = item.strip()
|
|
27
|
+
if not normalized_item or normalized_item in seen_values:
|
|
28
|
+
continue
|
|
29
|
+
seen_values.add(normalized_item)
|
|
30
|
+
normalized_values.append(normalized_item)
|
|
31
|
+
|
|
32
|
+
return normalized_values
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _validate_video_filename(value: str) -> str:
|
|
36
|
+
"""Keep the preview video inside the output folder.
|
|
37
|
+
|
|
38
|
+
The name is joined onto the output folder and ffmpeg overwrites whatever is
|
|
39
|
+
already there, so a path in it would let a request replace any file the user
|
|
40
|
+
can write — ``/Users/me/notes.txt`` or ``../../anything.mp4`` alike.
|
|
41
|
+
"""
|
|
42
|
+
name = value.strip()
|
|
43
|
+
if name in {"", ".", ".."} or "\\" in name or Path(name).name != name:
|
|
44
|
+
raise ValueError("video_filename must be a plain file name, without folders.")
|
|
45
|
+
if Path(name).suffix.lower() != ".mp4":
|
|
46
|
+
raise ValueError("video_filename must end in .mp4.")
|
|
47
|
+
return name
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OutputQualityOptions(BaseModel):
|
|
51
|
+
"""Print-resolution and background-cleaning options.
|
|
52
|
+
|
|
53
|
+
Shared by every request that produces a page, so the four builders cannot
|
|
54
|
+
drift into supporting different subsets of the same feature.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
dpi: int = Field(
|
|
58
|
+
default=DEFAULT_DPI,
|
|
59
|
+
gt=0,
|
|
60
|
+
le=1200,
|
|
61
|
+
description=(
|
|
62
|
+
"Resolution stamped into exported files. Without it a 3000x4000 page "
|
|
63
|
+
"reads as 41.7x55.6in at the 72 DPI default rather than 10x13.3in."
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
page_unit: Literal["px", "in", "mm"] = Field(
|
|
67
|
+
default="px",
|
|
68
|
+
description="Unit for page_width/page_height. Converted to pixels at dpi.",
|
|
69
|
+
)
|
|
70
|
+
background_clean_mode: BackgroundCleanMode = Field(
|
|
71
|
+
default=BackgroundCleanMode.OFF,
|
|
72
|
+
description=(
|
|
73
|
+
"'flatten' turns an edge-connected near-white background pure white; "
|
|
74
|
+
"'transparent' makes it transparent; 'off' leaves images untouched."
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
background_tolerance: int = Field(
|
|
78
|
+
default=DEFAULT_TOLERANCE,
|
|
79
|
+
ge=0,
|
|
80
|
+
le=255,
|
|
81
|
+
description="Minimum channel value counted as background. Higher is stricter.",
|
|
82
|
+
)
|
|
83
|
+
background_feather: int = Field(
|
|
84
|
+
default=1,
|
|
85
|
+
ge=0,
|
|
86
|
+
le=25,
|
|
87
|
+
description="Mask softening in pixels; removes halos on anti-aliased edges.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@field_validator("background_clean_mode", mode="before")
|
|
91
|
+
@classmethod
|
|
92
|
+
def normalize_background_clean_mode(cls, value: object) -> object:
|
|
93
|
+
if isinstance(value, str):
|
|
94
|
+
return value.strip().lower()
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class JobStatus(BaseModel):
|
|
99
|
+
status: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ScanRequest(BaseModel):
|
|
103
|
+
folder_path: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ScanSubfolder(BaseModel):
|
|
107
|
+
display_name: str
|
|
108
|
+
full_path: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ScanResponse(BaseModel):
|
|
112
|
+
valid: bool
|
|
113
|
+
image_count: int
|
|
114
|
+
sample_files: list[str]
|
|
115
|
+
estimated_pages: int
|
|
116
|
+
available_subfolders: list[ScanSubfolder] = Field(default_factory=list)
|
|
117
|
+
message: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class SelectedBatchFolderValidationRequest(BaseModel):
|
|
121
|
+
parent_folder_path: str
|
|
122
|
+
selected_subfolders: list[str] = Field(
|
|
123
|
+
validation_alias=AliasChoices("selected_subfolders", "selected_subfolder_paths"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@field_validator("selected_subfolders", mode="before")
|
|
127
|
+
@classmethod
|
|
128
|
+
def normalize_selected_subfolders(cls, value: object) -> list[str]:
|
|
129
|
+
if not isinstance(value, list):
|
|
130
|
+
raise ValueError("selected_subfolders must be a list of paths.")
|
|
131
|
+
|
|
132
|
+
normalized_paths: list[str] = []
|
|
133
|
+
seen_paths: set[str] = set()
|
|
134
|
+
for item in value:
|
|
135
|
+
if not isinstance(item, str):
|
|
136
|
+
raise ValueError("selected_subfolders must contain only strings.")
|
|
137
|
+
normalized_item = item.strip()
|
|
138
|
+
if not normalized_item or normalized_item in seen_paths:
|
|
139
|
+
continue
|
|
140
|
+
seen_paths.add(normalized_item)
|
|
141
|
+
normalized_paths.append(normalized_item)
|
|
142
|
+
|
|
143
|
+
return normalized_paths
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SelectedBatchFolderScanResult(BaseModel):
|
|
147
|
+
display_name: str
|
|
148
|
+
full_path: str
|
|
149
|
+
valid: bool
|
|
150
|
+
image_count: int
|
|
151
|
+
sample_files: list[str]
|
|
152
|
+
estimated_pages: int
|
|
153
|
+
message: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class SelectedBatchFolderValidationResponse(BaseModel):
|
|
157
|
+
parent_folder_path: str
|
|
158
|
+
selected_folders: int
|
|
159
|
+
valid_selected_folders: int
|
|
160
|
+
invalid_selected_folders: int
|
|
161
|
+
scanned_folders: list[SelectedBatchFolderScanResult]
|
|
162
|
+
skipped_folders: list[SelectedBatchFolderScanResult]
|
|
163
|
+
message: str
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class GenerationRequest(OutputQualityOptions):
|
|
167
|
+
folder_path: str
|
|
168
|
+
output_path: str
|
|
169
|
+
page_width: float = Field(gt=0)
|
|
170
|
+
page_height: float = Field(gt=0)
|
|
171
|
+
gap: float = Field(ge=0)
|
|
172
|
+
margin: float = Field(ge=0)
|
|
173
|
+
format: Literal["PNG", "JPG"]
|
|
174
|
+
include_partial_page: bool = Field(
|
|
175
|
+
default=False,
|
|
176
|
+
description=(
|
|
177
|
+
"Render a final page whose cells are not all filled. Off preserves the "
|
|
178
|
+
"existing behaviour of leaving trailing images out; either way the count "
|
|
179
|
+
"is reported rather than dropped silently."
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
create_video: bool = Field(
|
|
183
|
+
default=False,
|
|
184
|
+
validation_alias=AliasChoices("create_video", "create_preview_video"),
|
|
185
|
+
)
|
|
186
|
+
video_duration_seconds: float = Field(
|
|
187
|
+
default=15.0,
|
|
188
|
+
gt=0,
|
|
189
|
+
le=MAX_VIDEO_DURATION_SECONDS,
|
|
190
|
+
validation_alias=AliasChoices("video_duration_seconds", "duration_seconds"),
|
|
191
|
+
)
|
|
192
|
+
video_transition: Literal["none", "crossfade", "flip"] = Field(
|
|
193
|
+
default="none",
|
|
194
|
+
validation_alias=AliasChoices("video_transition", "transition"),
|
|
195
|
+
)
|
|
196
|
+
video_filename: str = "preview.mp4"
|
|
197
|
+
|
|
198
|
+
@field_validator("format", mode="before")
|
|
199
|
+
@classmethod
|
|
200
|
+
def normalize_format(cls, value: str) -> str:
|
|
201
|
+
if isinstance(value, str):
|
|
202
|
+
return value.upper()
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
@field_validator("video_filename")
|
|
206
|
+
@classmethod
|
|
207
|
+
def validate_video_filename(cls, value: str) -> str:
|
|
208
|
+
return _validate_video_filename(value)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class GenerationResponse(BaseModel):
|
|
212
|
+
success: bool
|
|
213
|
+
total_images: int
|
|
214
|
+
pages_generated: int
|
|
215
|
+
output_files: list[str]
|
|
216
|
+
output_path: str
|
|
217
|
+
video_file: str | None = None
|
|
218
|
+
video_created: bool = False
|
|
219
|
+
images_dropped: int = 0
|
|
220
|
+
images_skipped: int = 0
|
|
221
|
+
backgrounds_cleaned: int = 0
|
|
222
|
+
page_geometry: str | None = None
|
|
223
|
+
message: str
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class CoverCollageGenerationRequest(OutputQualityOptions):
|
|
227
|
+
folder_path: str
|
|
228
|
+
output_path: str
|
|
229
|
+
page_width: float = Field(gt=0)
|
|
230
|
+
page_height: float = Field(gt=0)
|
|
231
|
+
format: Literal["PNG", "JPG"]
|
|
232
|
+
collage_template: CoverCollageTemplate = CoverCollageTemplate.RANDOMIZED_GRID
|
|
233
|
+
image_fit: CoverCollageFittingMode = CoverCollageFittingMode.COVER
|
|
234
|
+
selected_cover_images: list[str] | None = None
|
|
235
|
+
|
|
236
|
+
@field_validator("format", mode="before")
|
|
237
|
+
@classmethod
|
|
238
|
+
def normalize_format(cls, value: str) -> str:
|
|
239
|
+
if isinstance(value, str):
|
|
240
|
+
return value.upper()
|
|
241
|
+
return value
|
|
242
|
+
|
|
243
|
+
@field_validator("selected_cover_images", mode="before")
|
|
244
|
+
@classmethod
|
|
245
|
+
def normalize_selected_cover_images(cls, value: object) -> list[str] | None:
|
|
246
|
+
return _normalize_optional_string_list(value, field_name="selected_cover_images")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class CoverCollageResponse(BaseModel):
|
|
250
|
+
success: bool
|
|
251
|
+
total_images: int
|
|
252
|
+
pages_generated: int
|
|
253
|
+
output_files: list[str]
|
|
254
|
+
output_path: str
|
|
255
|
+
page_geometry: str | None = None
|
|
256
|
+
message: str
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class BatchCoverCollageGenerationRequest(OutputQualityOptions):
|
|
260
|
+
parent_folder_path: str
|
|
261
|
+
created_output_root_path: str = Field(
|
|
262
|
+
validation_alias=AliasChoices("created_output_root_path", "output_root_path"),
|
|
263
|
+
)
|
|
264
|
+
selected_subfolders: list[str] = Field(
|
|
265
|
+
validation_alias=AliasChoices("selected_subfolders", "selected_subfolder_paths"),
|
|
266
|
+
)
|
|
267
|
+
page_width: float = Field(gt=0)
|
|
268
|
+
page_height: float = Field(gt=0)
|
|
269
|
+
format: Literal["PNG", "JPG"]
|
|
270
|
+
collage_template: CoverCollageTemplate = CoverCollageTemplate.RANDOMIZED_GRID
|
|
271
|
+
image_fit: CoverCollageFittingMode = CoverCollageFittingMode.COVER
|
|
272
|
+
|
|
273
|
+
@field_validator("selected_subfolders", mode="before")
|
|
274
|
+
@classmethod
|
|
275
|
+
def normalize_selected_subfolders(cls, value: object) -> list[str]:
|
|
276
|
+
if not isinstance(value, list):
|
|
277
|
+
raise ValueError("selected_subfolders must be a list of paths.")
|
|
278
|
+
|
|
279
|
+
normalized_paths: list[str] = []
|
|
280
|
+
seen_paths: set[str] = set()
|
|
281
|
+
for item in value:
|
|
282
|
+
if not isinstance(item, str):
|
|
283
|
+
raise ValueError("selected_subfolders must contain only strings.")
|
|
284
|
+
normalized_item = item.strip()
|
|
285
|
+
if not normalized_item or normalized_item in seen_paths:
|
|
286
|
+
continue
|
|
287
|
+
seen_paths.add(normalized_item)
|
|
288
|
+
normalized_paths.append(normalized_item)
|
|
289
|
+
|
|
290
|
+
return normalized_paths
|
|
291
|
+
|
|
292
|
+
@field_validator("format", mode="before")
|
|
293
|
+
@classmethod
|
|
294
|
+
def normalize_format(cls, value: str) -> str:
|
|
295
|
+
if isinstance(value, str):
|
|
296
|
+
return value.upper()
|
|
297
|
+
return value
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class BatchCoverCollageResult(BaseModel):
|
|
301
|
+
folder_name: str
|
|
302
|
+
success: bool
|
|
303
|
+
pages_generated: int
|
|
304
|
+
total_images: int
|
|
305
|
+
output_path: str
|
|
306
|
+
output_files: list[str] = Field(default_factory=list)
|
|
307
|
+
message: str
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class BatchCoverCollageGenerationResponse(BaseModel):
|
|
311
|
+
success: bool
|
|
312
|
+
processed_folders: int
|
|
313
|
+
generated_folders: int
|
|
314
|
+
total_detected_folders: int
|
|
315
|
+
selected_folders: int
|
|
316
|
+
bundle_results: list[BatchCoverCollageResult]
|
|
317
|
+
created_output_root_path: str
|
|
318
|
+
message: str
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class BatchGenerationRequest(OutputQualityOptions):
|
|
322
|
+
parent_folder_path: str
|
|
323
|
+
created_output_root_path: str
|
|
324
|
+
selected_subfolders: list[str] | None = Field(
|
|
325
|
+
default=None,
|
|
326
|
+
validation_alias=AliasChoices("selected_subfolders", "selected_subfolder_paths"),
|
|
327
|
+
)
|
|
328
|
+
page_width: float = Field(gt=0)
|
|
329
|
+
page_height: float = Field(gt=0)
|
|
330
|
+
gap: float = Field(ge=0)
|
|
331
|
+
margin: float = Field(ge=0)
|
|
332
|
+
format: Literal["PNG", "JPG"]
|
|
333
|
+
include_partial_page: bool = Field(
|
|
334
|
+
default=False,
|
|
335
|
+
description=(
|
|
336
|
+
"Render a final page whose cells are not all filled. Off preserves the "
|
|
337
|
+
"existing behaviour of leaving trailing images out; either way the count "
|
|
338
|
+
"is reported rather than dropped silently."
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
create_video: bool = Field(
|
|
342
|
+
default=False,
|
|
343
|
+
validation_alias=AliasChoices("create_video", "create_preview_video"),
|
|
344
|
+
)
|
|
345
|
+
video_duration_seconds: float = Field(
|
|
346
|
+
default=15.0,
|
|
347
|
+
gt=0,
|
|
348
|
+
le=MAX_VIDEO_DURATION_SECONDS,
|
|
349
|
+
validation_alias=AliasChoices("video_duration_seconds", "duration_seconds"),
|
|
350
|
+
)
|
|
351
|
+
video_transition: Literal["none", "crossfade", "flip"] = Field(
|
|
352
|
+
default="none",
|
|
353
|
+
validation_alias=AliasChoices("video_transition", "transition"),
|
|
354
|
+
)
|
|
355
|
+
video_filename: str = "preview.mp4"
|
|
356
|
+
|
|
357
|
+
@field_validator("selected_subfolders", mode="before")
|
|
358
|
+
@classmethod
|
|
359
|
+
def normalize_selected_subfolders(cls, value: object) -> list[str] | None:
|
|
360
|
+
if value is None:
|
|
361
|
+
return None
|
|
362
|
+
if not isinstance(value, list):
|
|
363
|
+
raise ValueError("selected_subfolders must be a list of paths.")
|
|
364
|
+
|
|
365
|
+
normalized_paths: list[str] = []
|
|
366
|
+
seen_paths: set[str] = set()
|
|
367
|
+
for item in value:
|
|
368
|
+
if not isinstance(item, str):
|
|
369
|
+
raise ValueError("selected_subfolders must contain only strings.")
|
|
370
|
+
normalized_item = item.strip()
|
|
371
|
+
if not normalized_item or normalized_item in seen_paths:
|
|
372
|
+
continue
|
|
373
|
+
seen_paths.add(normalized_item)
|
|
374
|
+
normalized_paths.append(normalized_item)
|
|
375
|
+
|
|
376
|
+
return normalized_paths
|
|
377
|
+
|
|
378
|
+
@field_validator("format", mode="before")
|
|
379
|
+
@classmethod
|
|
380
|
+
def normalize_format(cls, value: str) -> str:
|
|
381
|
+
if isinstance(value, str):
|
|
382
|
+
return value.upper()
|
|
383
|
+
return value
|
|
384
|
+
|
|
385
|
+
@field_validator("video_filename")
|
|
386
|
+
@classmethod
|
|
387
|
+
def validate_video_filename(cls, value: str) -> str:
|
|
388
|
+
return _validate_video_filename(value)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class BatchBundleResult(BaseModel):
|
|
392
|
+
folder_name: str
|
|
393
|
+
success: bool
|
|
394
|
+
pages_generated: int
|
|
395
|
+
total_images: int
|
|
396
|
+
output_path: str
|
|
397
|
+
video_file: str | None = None
|
|
398
|
+
video_created: bool = False
|
|
399
|
+
message: str
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class BatchGenerationResponse(BaseModel):
|
|
403
|
+
success: bool
|
|
404
|
+
processed_folders: int
|
|
405
|
+
generated_folders: int
|
|
406
|
+
total_detected_folders: int
|
|
407
|
+
selected_folders: int
|
|
408
|
+
bundle_results: list[BatchBundleResult]
|
|
409
|
+
created_output_root_path: str
|
|
410
|
+
message: str
|
|
File without changes
|