Render photos individually, optimize gallery images, and prune old releases
This commit is contained in:
+12
-5
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user