Render photos individually, optimize gallery images, and prune old releases

This commit is contained in:
2026-09-19 16:20:07 +03:30
parent e112ed7640
commit 3ec6cdd3ec
210 changed files with 117 additions and 12 deletions
+12 -5
View File
@@ -2,7 +2,7 @@
Photos → Share → **Publish to blog** → optional caption and highlight name → upload.
The authenticated upload service converts photos to JPEG (maximum 2560 px), applies
The authenticated upload service converts photos to progressive JPEG (maximum 1600 px and 300 KiB), applies
orientation, converts embedded color profiles to sRGB, and strips public metadata.
Originals stay in a private volume. A worker commits optimized images and an `_art`
entry to **github.com/mdibaiee/mahdi.blog**, on `master`.
@@ -150,7 +150,8 @@ Create **Publish to blog**:
only `published` means the deployed site's receipt contains this album.
The ZIP route accepts 1–20 images in archive-entry order and keeps them as one
album. The first image in that order is the highlight target. Metadata sidecars
upload. Each photo renders as its own gallery `<li>`, including photos in older
batches. The caption appears on each photo; the first image is the highlight target. Metadata sidecars
under `__MACOSX/` and `.DS_Store` are ignored. Ordinary API clients can also send
repeated `photos` parts; send either `archive` or `photos`, never both.
Select still photos; videos/Live Photo video components aren't supported. Dates
@@ -158,6 +159,10 @@ are upload time in UTC; captions are plain text. A caption is optional.
An optional highlight name (up to 200 characters) adds one link to **Highlights**,
pointing to the first photo in the batch. Leave it blank for an ordinary upload.
Gallery images are progressive JPEGs with a maximum edge of 1600 pixels and a
300 KiB size limit. The uploader tries moderate JPEG quality levels, then reduces
dimensions further when necessary. Private originals are retained unchanged.
The token is visible in the shortcut editor; remove it before sharing the shortcut.
Rotate it by replacing the token file and recreating the upload container with
`podman compose up -d --force-recreate upload`.
@@ -303,9 +308,11 @@ photo anchors so the service does not mistake a successful push for publication.
Back up the private `photo-data` volume (originals and job state) and GitHub's
repository. `photo-work` is disposable. Never expose private data through nginx or
run `compose down -v`. Originals and deployment releases are retained, so monitor
disk usage and periodically remove old releases (never `current` or an in-progress
release). Interrupted uploads may leave files in `photo-data/temporary`; clean that
run `compose down -v`. Originals are retained, so monitor disk usage. After a
successful deployment switches `current`, all older releases are automatically
pruned; newer uploads still in progress are preserved. There are no retained
server-side rollback copies; rebuild an older Git revision if needed.
Interrupted uploads may leave files in `photo-data/temporary`; clean that
folder only while the API is stopped. Public Git images are optimized copies.
## Tests
+15 -2
View File
@@ -22,6 +22,8 @@ from posts import parse_upload
register_heif_opener()
Image.MAX_IMAGE_PIXELS = 60_000_000
GALLERY_MAX_EDGE = 1600
GALLERY_MAX_BYTES = 300 * 1024
warnings.simplefilter("error", Image.DecompressionBombWarning)
@@ -47,7 +49,7 @@ def web_image(raw, destination):
if original.format not in {"JPEG", "PNG", "WEBP", "HEIF"}:
raise ValueError("Use JPEG, PNG, WebP, or HEIC photos")
image = ImageOps.exif_transpose(original)
image.thumbnail((2560, 2560), Image.Resampling.LANCZOS)
image.thumbnail((GALLERY_MAX_EDGE, GALLERY_MAX_EDGE), Image.Resampling.LANCZOS)
if image.info.get("icc_profile"):
image = ImageCms.profileToProfile(
image, ImageCms.ImageCmsProfile(io.BytesIO(image.info["icc_profile"])),
@@ -63,7 +65,18 @@ def web_image(raw, destination):
# Copy pixels only; no EXIF, GPS, XMP, comments, or original profile.
clean = Image.new("RGB", image.size)
clean.paste(image)
clean.save(destination, "JPEG", quality=88, optimize=True)
# Keep a useful display size and moderate JPEG quality, even for grainy photos.
# If quality alone cannot meet the budget, reduce dimensions instead of
# introducing severe compression artifacts. Originals remain untouched.
while True:
for quality in (82, 78, 74):
output = io.BytesIO()
clean.save(output, "JPEG", quality=quality, optimize=True, progressive=True)
if output.tell() <= GALLERY_MAX_BYTES:
Path(destination).write_bytes(output.getvalue())
return
clean.thumbnail((max(1, int(clean.width * 0.85)), max(1, int(clean.height * 0.85))),
Image.Resampling.LANCZOS)
def create_app(data_dir=None, token=None):
+7 -2
View File
@@ -62,13 +62,18 @@ def test_validation_is_all_or_nothing(service):
def test_multi_megabyte_multipart_file(service):
_, client, _ = service
_, client, data = service
output = io.BytesIO()
Image.frombytes("RGB", (1800, 1800), random.Random(2).randbytes(1800 * 1800 * 3)).save(
output, "JPEG", quality=95)
assert len(output.getvalue()) > 3 * 1024 * 1024
response = upload(client, [output.getvalue()])
assert response.status_code == 202, response.json
path = data / "submissions" / response.json["id"] / "images/00.jpg"
assert path.stat().st_size <= 300 * 1024
with Image.open(path) as image:
assert max(image.size) <= 1600
assert image.info.get("progressive")
def test_zip_batch_preserves_all_images_order_and_deduplicates(service):
@@ -137,7 +142,7 @@ def test_orientation_metadata_and_heic(service):
with Image.open(data / "submissions" / response.json["id"] / "images/00.jpg") as image:
assert image.format == "JPEG"
assert not image.getexif()
assert max(image.size) <= 2560
assert max(image.size) <= 1600
if raw[:2] == b"\xff\xd8":
assert image.height > image.width