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.
@@ -0,0 +1,199 @@
1
+ """Service for composing 4x3 image grids."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ from PIL import Image, UnidentifiedImageError
11
+
12
+ from mullion import (
13
+ DEFAULT_TOLERANCE,
14
+ BackgroundCleanMode,
15
+ clean_background,
16
+ flatten_onto,
17
+ open_path,
18
+ )
19
+
20
+ GRID_COLUMNS = 4
21
+ GRID_ROWS = 3
22
+ CELLS_PER_PAGE = GRID_COLUMNS * GRID_ROWS
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class ComposeSummary:
28
+ """Summary of a grid composition run."""
29
+
30
+ total_input_files: int
31
+ total_images_placed: int
32
+ total_images_skipped: int
33
+ pages_generated: int
34
+ skipped_files: list[str]
35
+ total_images_dropped: int = 0
36
+ background_cleaned: int = 0
37
+
38
+ @property
39
+ def user_message(self) -> str:
40
+ """Human-friendly summary for API/UI responses."""
41
+ parts = [
42
+ f"Generated {self.pages_generated} page(s) with "
43
+ f"{self.total_images_placed} image(s)."
44
+ ]
45
+ if self.total_images_skipped:
46
+ parts.append(f"Skipped {self.total_images_skipped} unreadable file(s).")
47
+ if self.background_cleaned:
48
+ parts.append(f"Cleaned the background on {self.background_cleaned} image(s).")
49
+ if self.total_images_dropped:
50
+ # This used to happen silently, which read as images going missing.
51
+ parts.append(
52
+ f"Left out {self.total_images_dropped} trailing image(s) that did not "
53
+ f"fill a complete {GRID_COLUMNS}x{GRID_ROWS} page — enable "
54
+ f"'include partial page' to render them."
55
+ )
56
+ return " ".join(parts)
57
+
58
+
59
+ def _create_canvas(page_width: float, page_height: float) -> Image.Image:
60
+ """Create a white page canvas from user settings."""
61
+ canvas_width = int(round(page_width))
62
+ canvas_height = int(round(page_height))
63
+ if canvas_width <= 0 or canvas_height <= 0:
64
+ raise ValueError("Page width and height must be greater than zero.")
65
+ return Image.new("RGB", (canvas_width, canvas_height), color="white")
66
+
67
+
68
+ def _compute_cell_geometry(
69
+ page_width: float,
70
+ page_height: float,
71
+ margin: float,
72
+ gap: float,
73
+ ) -> tuple[float, float]:
74
+ """Compute cell geometry for a fixed 4x3 grid."""
75
+ cell_width = (page_width - 2 * margin - (GRID_COLUMNS - 1) * gap) / GRID_COLUMNS
76
+ cell_height = (page_height - 2 * margin - (GRID_ROWS - 1) * gap) / GRID_ROWS
77
+ if cell_width <= 0 or cell_height <= 0:
78
+ raise ValueError(
79
+ "Computed cell size is invalid. "
80
+ "Adjust page size, margin, or gap values."
81
+ )
82
+ return cell_width, cell_height
83
+
84
+
85
+ def _load_image_rgb(image_path: str) -> Image.Image:
86
+ """Load an image as RGB, raising on unreadable/corrupt content.
87
+
88
+ Delegates to :mod:`mullion` so transparent PNGs land on
89
+ white rather than black, and EXIF-rotated photos land upright. See that
90
+ module for why a bare ``convert("RGB")`` is wrong here.
91
+ """
92
+ return open_path(image_path)
93
+
94
+
95
+ def compose_image_grids(
96
+ image_paths: Sequence[str],
97
+ page_width: float,
98
+ page_height: float,
99
+ margin: float,
100
+ gap: float,
101
+ *,
102
+ background_clean_mode: BackgroundCleanMode | str = BackgroundCleanMode.OFF,
103
+ background_tolerance: int = DEFAULT_TOLERANCE,
104
+ background_feather: int = 1,
105
+ include_partial_page: bool = False,
106
+ ) -> tuple[list[Image.Image], ComposeSummary]:
107
+ """Compose input images into one or more white 4x3 grid pages.
108
+
109
+ Images are proportionally resized to fit in each cell without distortion, then
110
+ centered in their assigned cell. Unreadable image files are skipped and logged.
111
+
112
+ Trailing images that do not complete a full 12-cell page are left out unless
113
+ ``include_partial_page`` is set, in which case the final page is rendered
114
+ with its unused cells left white. Either way the count is reported on the
115
+ summary rather than being dropped silently.
116
+
117
+ ``background_clean_mode`` runs each tile through
118
+ :func:`mullion.clean_background` before it is
119
+ placed, so an off-white source background blends into the page instead of
120
+ reading as a faint rectangle around the tile.
121
+ """
122
+ cell_width, cell_height = _compute_cell_geometry(page_width, page_height, margin, gap)
123
+
124
+ normalized_clean_mode = BackgroundCleanMode(str(background_clean_mode))
125
+
126
+ pages: list[Image.Image] = []
127
+ skipped_files: list[str] = []
128
+ placed_count = 0
129
+ cleaned_count = 0
130
+
131
+ current_page = _create_canvas(page_width, page_height)
132
+ slot_index = 0
133
+
134
+ for image_path in image_paths:
135
+ try:
136
+ image = _load_image_rgb(image_path)
137
+ except (FileNotFoundError, PermissionError, OSError, UnidentifiedImageError, ValueError) as exc:
138
+ skipped_files.append(image_path)
139
+ logger.warning("Skipping unreadable image '%s': %s", image_path, exc)
140
+ continue
141
+
142
+ if normalized_clean_mode is not BackgroundCleanMode.OFF:
143
+ image, clean_result = clean_background(
144
+ image,
145
+ mode=normalized_clean_mode,
146
+ tolerance=background_tolerance,
147
+ feather=background_feather,
148
+ )
149
+ if clean_result.cleaned:
150
+ cleaned_count += 1
151
+ # A transparent-mode result still has to land on the opaque page.
152
+ if image.mode != "RGB":
153
+ image = flatten_onto(image)
154
+
155
+ row = slot_index // GRID_COLUMNS
156
+ col = slot_index % GRID_COLUMNS
157
+
158
+ cell_x = margin + col * (cell_width + gap)
159
+ cell_y = margin + row * (cell_height + gap)
160
+
161
+ scale = min(cell_width / image.width, cell_height / image.height)
162
+ resized_width = max(1, int(round(image.width * scale)))
163
+ resized_height = max(1, int(round(image.height * scale)))
164
+
165
+ resized_image = image.resize((resized_width, resized_height), Image.Resampling.LANCZOS)
166
+
167
+ paste_x = int(round(cell_x + (cell_width - resized_width) / 2))
168
+ paste_y = int(round(cell_y + (cell_height - resized_height) / 2))
169
+ current_page.paste(resized_image, (paste_x, paste_y))
170
+
171
+ placed_count += 1
172
+ slot_index += 1
173
+
174
+ if slot_index == CELLS_PER_PAGE:
175
+ pages.append(current_page)
176
+ current_page = _create_canvas(page_width, page_height)
177
+ slot_index = 0
178
+
179
+ dropped_count = 0
180
+ if slot_index > 0:
181
+ if include_partial_page:
182
+ # Unused cells keep the canvas white, which is what the README has
183
+ # always described.
184
+ pages.append(current_page)
185
+ else:
186
+ placed_count -= slot_index
187
+ dropped_count = slot_index
188
+
189
+ summary = ComposeSummary(
190
+ total_input_files=len(image_paths),
191
+ total_images_placed=max(0, placed_count),
192
+ total_images_skipped=len(skipped_files),
193
+ pages_generated=len(pages),
194
+ skipped_files=[Path(path).name for path in skipped_files],
195
+ total_images_dropped=dropped_count,
196
+ background_cleaned=cleaned_count,
197
+ )
198
+
199
+ return pages, summary
@@ -0,0 +1,21 @@
1
+ """Service for grouping images into page-sized batches."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ IMAGES_PER_PAGE = 12
7
+
8
+
9
+ def build_image_batches(image_paths: list[str]) -> list[list[str]]:
10
+ """Split image paths into ordered full-page batches using a fixed 4x3 layout."""
11
+ full_page_count = len(image_paths) // IMAGES_PER_PAGE
12
+ return [
13
+ image_paths[index : index + IMAGES_PER_PAGE]
14
+ for index in range(0, full_page_count * IMAGES_PER_PAGE, IMAGES_PER_PAGE)
15
+ ]
16
+
17
+
18
+ def estimate_page_count(image_count: int) -> int:
19
+ """Return the number of full 4x3 pages that can be generated."""
20
+ normalized_count = max(0, image_count)
21
+ return normalized_count // IMAGES_PER_PAGE
@@ -0,0 +1,227 @@
1
+ """Service for building preview videos from exported page images."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from PIL import Image
13
+
14
+ VideoTransition = Literal["none", "crossfade", "flip"]
15
+
16
+ # A conventional frame rate, so players and upload pipelines handle the file.
17
+ # Slideshow timing is expressed as frame counts, not by slowing the rate down.
18
+ FRAME_RATE = 30
19
+ # A preview, not a print master: listing sites show video at about 1080p, and
20
+ # full print-size frames take several times longer to encode and can exceed
21
+ # what H.264 decoders accept. Smaller pages are never scaled up.
22
+ MAX_VIDEO_SIDE = 1920
23
+ # Transitions are short and fixed, and never take more than a third of a page's
24
+ # share of the video, so the pages fill it rather than the blends between them.
25
+ MAX_TRANSITION_SECONDS = 0.5
26
+ _XFADE_TRANSITIONS = {"crossfade": "fade", "flip": "squeezev"}
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class _Timeline:
31
+ total_frames: int
32
+ # Frames each page's clip runs, including its overlap with the next page.
33
+ clip_frames: int
34
+ # The last clip also absorbs the rounding remainder, so the total is exact.
35
+ last_clip_frames: int
36
+ transition_frames: int
37
+
38
+
39
+ def build_preview_video(
40
+ page_paths: list[str],
41
+ output_dir: str | Path,
42
+ duration_seconds: float,
43
+ transition: str,
44
+ filename: str,
45
+ ) -> str:
46
+ """Build an MP4 preview from exported page images.
47
+
48
+ Args:
49
+ page_paths: Paths to exported page image files.
50
+ output_dir: Directory where the output MP4 should be written.
51
+ duration_seconds: Desired total video duration.
52
+ transition: Transition mode ("none", "crossfade", "flip").
53
+ filename: Output MP4 filename.
54
+
55
+ Returns:
56
+ The absolute output path for the generated MP4.
57
+
58
+ Raises:
59
+ ValueError: If pages are empty, duration is invalid, paths are invalid,
60
+ or transition mode is unsupported.
61
+ RuntimeError: If ffmpeg is unavailable or video encoding fails.
62
+ """
63
+ validated_page_paths = _validate_page_paths(page_paths)
64
+ # ffmpeg overwrites its output, so the video must not land outside output_dir.
65
+ if filename in {"", ".", ".."} or Path(filename).name != filename:
66
+ raise ValueError("filename must be a plain file name inside output_dir.")
67
+ if duration_seconds <= 0:
68
+ raise ValueError("duration_seconds must be greater than 0.")
69
+
70
+ if transition not in {"none", "crossfade", "flip"}:
71
+ raise ValueError("transition must be one of: 'none', 'crossfade', 'flip'.")
72
+
73
+ ffmpeg_binary = shutil.which("ffmpeg")
74
+ if ffmpeg_binary is None:
75
+ raise RuntimeError("ffmpeg is required to export preview videos but was not found on PATH")
76
+
77
+ target_dir = Path(output_dir).expanduser()
78
+ target_dir.mkdir(parents=True, exist_ok=True)
79
+ output_path = target_dir / filename
80
+
81
+ timeline = _plan_timeline(len(validated_page_paths), duration_seconds, transition)
82
+ width, height = _video_frame_size(validated_page_paths[0])
83
+
84
+ command = [ffmpeg_binary, "-y", "-hide_banner", "-loglevel", "error"]
85
+ for index, page_path in enumerate(validated_page_paths):
86
+ # One decoded frame a second is plenty for a still; the fps filter
87
+ # repeats it, and trim cuts the clip to its exact frame count.
88
+ clip_seconds = _clip_frames(timeline, index, len(validated_page_paths)) / FRAME_RATE
89
+ command += ["-loop", "1", "-framerate", "1", "-t", f"{clip_seconds + 1:.6f}", "-i", str(page_path)]
90
+
91
+ filter_graph, output_label = _build_filter_graph(
92
+ timeline,
93
+ page_count=len(validated_page_paths),
94
+ xfade_transition=_XFADE_TRANSITIONS.get(transition),
95
+ width=width,
96
+ height=height,
97
+ )
98
+ command += [
99
+ "-filter_complex",
100
+ filter_graph,
101
+ "-map",
102
+ f"[{output_label}]",
103
+ "-frames:v",
104
+ str(timeline.total_frames),
105
+ "-c:v",
106
+ "libx264",
107
+ "-pix_fmt",
108
+ "yuv420p",
109
+ "-r",
110
+ str(FRAME_RATE),
111
+ "-movflags",
112
+ "+faststart",
113
+ str(output_path),
114
+ ]
115
+
116
+ try:
117
+ subprocess.run(command, check=True, capture_output=True, text=True)
118
+ except subprocess.CalledProcessError as exc:
119
+ raise RuntimeError(f"ffmpeg failed to encode the preview video: {_first_error_line(exc.stderr)}") from exc
120
+ except OSError as exc:
121
+ raise RuntimeError(f"ffmpeg could not be started: {exc}") from exc
122
+
123
+ return str(output_path.resolve())
124
+
125
+
126
+ def _validate_page_paths(page_paths: list[str]) -> list[Path]:
127
+ if not page_paths:
128
+ raise ValueError("page_paths cannot be empty.")
129
+
130
+ validated_paths = sorted(Path(path).expanduser() for path in page_paths)
131
+ missing_paths = [str(path) for path in validated_paths if not path.exists()]
132
+ if missing_paths:
133
+ raise ValueError(f"One or more page_paths do not exist: {', '.join(missing_paths)}")
134
+
135
+ return validated_paths
136
+
137
+
138
+ def _plan_timeline(page_count: int, duration_seconds: float, transition: str) -> _Timeline:
139
+ # Every page needs at least one frame, even if that runs past the duration.
140
+ total_frames = max(page_count, round(duration_seconds * FRAME_RATE))
141
+
142
+ transition_frames = 0
143
+ if transition != "none" and page_count > 1:
144
+ transition_frames = min(
145
+ round(MAX_TRANSITION_SECONDS * FRAME_RATE),
146
+ total_frames // (page_count * 3),
147
+ )
148
+
149
+ # Consecutive clips overlap by one transition, so together they cover more
150
+ # frames than the video holds.
151
+ covered_frames = total_frames + (page_count - 1) * transition_frames
152
+ clip_frames = covered_frames // page_count
153
+ return _Timeline(
154
+ total_frames=total_frames,
155
+ clip_frames=clip_frames,
156
+ last_clip_frames=clip_frames + covered_frames % page_count,
157
+ transition_frames=transition_frames,
158
+ )
159
+
160
+
161
+ def _clip_frames(timeline: _Timeline, index: int, page_count: int) -> int:
162
+ return timeline.last_clip_frames if index == page_count - 1 else timeline.clip_frames
163
+
164
+
165
+ def _video_frame_size(first_page: Path) -> tuple[int, int]:
166
+ try:
167
+ with Image.open(first_page) as page:
168
+ width, height = page.size
169
+ except OSError as exc:
170
+ raise RuntimeError(f"Could not read page {first_page.name}: {exc}") from exc
171
+
172
+ scale = min(1.0, MAX_VIDEO_SIDE / max(width, height))
173
+ # yuv420p stores colour at half resolution, so libx264 refuses odd sizes.
174
+ return max(2, int(width * scale) // 2 * 2), max(2, int(height * scale) // 2 * 2)
175
+
176
+
177
+ def _build_filter_graph(
178
+ timeline: _Timeline,
179
+ *,
180
+ page_count: int,
181
+ xfade_transition: str | None,
182
+ width: int,
183
+ height: int,
184
+ ) -> tuple[str, str]:
185
+ # Fit each page into the frame without distortion; pages from one run share
186
+ # a size, so the padding only shows if a caller mixes sizes.
187
+ fit = (
188
+ f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
189
+ f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=white"
190
+ )
191
+ parts = [
192
+ (
193
+ f"[{index}:v]{fit},setsar=1,format=yuv420p,fps={FRAME_RATE},"
194
+ f"trim=end_frame={_clip_frames(timeline, index, page_count)},setpts=PTS-STARTPTS[p{index}]"
195
+ )
196
+ for index in range(page_count)
197
+ ]
198
+
199
+ if page_count == 1:
200
+ return ";".join(parts), "p0"
201
+
202
+ if xfade_transition is None or timeline.transition_frames == 0:
203
+ clips = "".join(f"[p{index}]" for index in range(page_count))
204
+ parts.append(f"{clips}concat=n={page_count}:v=1:a=0[video]")
205
+ return ";".join(parts), "video"
206
+
207
+ transition_seconds = timeline.transition_frames / FRAME_RATE
208
+ current_label = "p0"
209
+ for index in range(1, page_count):
210
+ offset_seconds = index * (timeline.clip_frames - timeline.transition_frames) / FRAME_RATE
211
+ output_label = f"x{index}"
212
+ parts.append(
213
+ f"[{current_label}][p{index}]xfade=transition={xfade_transition}:"
214
+ f"duration={transition_seconds:.6f}:offset={offset_seconds:.6f}[{output_label}]"
215
+ )
216
+ current_label = output_label
217
+
218
+ return ";".join(parts), current_label
219
+
220
+
221
+ def _first_error_line(stderr: str | None) -> str:
222
+ """ffmpeg reports the specific failure first and generic wrappers after it."""
223
+ for line in (stderr or "").splitlines():
224
+ if line.strip():
225
+ # Drop the memory address ffmpeg prints beside each component's name.
226
+ return re.sub(r" @ 0x[0-9a-f]+", "", line.strip())
227
+ return "no error output"
pasteup/shortcut.py ADDED
@@ -0,0 +1,97 @@
1
+ """`pasteup --shortcut`: a launcher that starts Pasteup with a double-click.
2
+
3
+ The launcher runs this installation's Python by its full path, so it works
4
+ without a terminal or a PATH set up for `pasteup`. Run the command again after
5
+ moving or reinstalling Pasteup somewhere else.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shlex
12
+ import sys
13
+ from importlib.resources import files
14
+ from pathlib import Path
15
+
16
+ NOTE = "Starts Pasteup. Made by `pasteup --shortcut`; run that again if Pasteup moves."
17
+
18
+
19
+ def create_shortcut(platform: str | None = None) -> str:
20
+ """Write a launcher for this platform and return a sentence saying where it is."""
21
+ platform = platform or sys.platform
22
+
23
+ if platform == "darwin":
24
+ # Double-clicking a .command file runs it in Terminal.
25
+ path = _existing_dir(Path.home() / "Desktop") / "Pasteup.command"
26
+ _write(path, f"#!/bin/sh\n# {NOTE}\nexec {shlex.quote(sys.executable)} -m pasteup\n", executable=True)
27
+ return f"Made {path}. Double-click it to start Pasteup."
28
+
29
+ if platform == "win32":
30
+ path = _windows_desktop() / "Pasteup.bat"
31
+ python = sys.executable.replace("%", "%%")
32
+ # chcp 65001 makes cmd read the rest as UTF-8, for a path with accents in
33
+ # it; the pause keeps an error on screen instead of closing the window.
34
+ script = (
35
+ f'@echo off\r\nchcp 65001 >nul\r\nrem {NOTE}\r\n"{python}" -m pasteup\r\nif errorlevel 1 pause\r\n'
36
+ )
37
+ _write(path, script)
38
+ return f"Made {path}. Double-click it to start Pasteup."
39
+
40
+ data_home = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
41
+ path = data_home / "applications" / "pasteup.desktop"
42
+ _write(path, _desktop_entry(), executable=True)
43
+ return f"Added Pasteup to your applications menu ({path})."
44
+
45
+
46
+ def _desktop_entry() -> str:
47
+ lines = [
48
+ "[Desktop Entry]",
49
+ "Type=Application",
50
+ "Name=Pasteup",
51
+ "Comment=Turn a folder of clip art into listing images",
52
+ f"Exec={_desktop_exec_argument(sys.executable)} -m pasteup",
53
+ "Terminal=true",
54
+ "Categories=Graphics;",
55
+ ]
56
+ icon = Path(str(files("pasteup") / "static" / "favicon.svg"))
57
+ if icon.is_file():
58
+ lines.append(f"Icon={icon}")
59
+ return "\n".join(lines) + "\n"
60
+
61
+
62
+ def _desktop_exec_argument(value: str) -> str:
63
+ """Quote one Exec argument as the desktop entry specification requires."""
64
+ for reserved in ("\\", '"', "`", "$"):
65
+ value = value.replace(reserved, "\\" + reserved)
66
+ # "%" starts a field code, and the value as a whole escapes backslashes again.
67
+ return f'"{value}"'.replace("%", "%%").replace("\\", "\\\\")
68
+
69
+
70
+ def _windows_desktop() -> Path:
71
+ # The Desktop folder can be moved, most often into OneDrive; the registry
72
+ # says where it is now.
73
+ try:
74
+ import winreg
75
+
76
+ with winreg.OpenKey(
77
+ winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders"
78
+ ) as key:
79
+ desktop = Path(os.path.expandvars(winreg.QueryValueEx(key, "Desktop")[0]))
80
+ if desktop.is_dir():
81
+ return desktop
82
+ except (ImportError, OSError):
83
+ pass
84
+ return _existing_dir(Path.home() / "Desktop")
85
+
86
+
87
+ def _existing_dir(preferred: Path) -> Path:
88
+ return preferred if preferred.is_dir() else Path.home()
89
+
90
+
91
+ def _write(path: Path, content: str, *, executable: bool = False) -> None:
92
+ path.parent.mkdir(parents=True, exist_ok=True)
93
+ # newline="" keeps the line endings written above, CRLF included.
94
+ with path.open("w", encoding="utf-8", newline="") as handle:
95
+ handle.write(content)
96
+ if executable:
97
+ path.chmod(0o755)