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
|
@@ -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)
|