Paul Nothaft 1366d6d14c fix(previews): preserve alpha and animation in the preview tier (#1171)
* fix(gallery): stop the lightbox loading originals to display a photo (#1166)

The lightbox read `preview_url`, which the server only emits once an admin has
flipped lightbox_preview_enabled — off by default. So a stock install fell
straight through to `url`, the untouched original: a reporter measured 16.5 MB
for a photo whose preview is 345 KB. The lightbox renders its neighbours too,
so opening one photo pulled three originals.

`slideshow_url` is the same /preview/:id URL, watermark query included, and
has been emitted unconditionally for images since #1015 — the slideshow never
had a fallback worth taking. Preferring it fixes every existing install with no
migration and no admin action, and `url` still backstops videos, where both
derivative URLs are null.

Verified on the local rig with the toggle off, so the photos API returns
preview_url: null exactly as filed. Opening one photo:

  before   GET /photo/82, /photo/81, /photo/21      (3 originals)
  after    GET /preview/82?w=1280, /preview/81, /preview/21

397 KB -> 23 KB per image on that gallery's test photos.

The toggle no longer decides whether the lightbox uses previews, so its copy
said something untrue; it now describes what it still does, which is
pre-generate rather than wait for the first guest to open a photo. Updated in
en/de/fr/sl, the locales that carry those keys.

* fix(gallery): cover the layouts the lightbox fix missed (#1166)

External review found the fix was incomplete, and the review of it found one
more.

Premium galleries were untouched. PhotoGridWithLayouts returns early for
gallery-premium, which builds its own yet-another-react-lightbox slides with
`src: photo.url` — so those galleries kept pulling full originals and the
reported bandwidth problem remained. They now use lightboxImageUrl for the
display source; `download` deliberately stays on photo.url, because what a
guest saves must be the original.

The Story layout was worse, and neither the issue nor the review caught it:
StoryPhotoCard rendered the full original as its GRID TILE, at object-cover in
a small card. That is the one place where "hundreds of megabytes for a gallery"
was literally true. It now uses the per-device thumbnail tier like PhotoCard,
and its PhotoSwipe source uses the preview tier.

Animated GIFs keep the original. generatePreviewImage always encodes JPEG, so
routing an animated source through the preview tier would have replaced the
animation with its first frame — a regression the toggle-off default never
had. Animated WebP has the same problem and cannot be distinguished by MIME
alone; that needs the backend to report it (Sharp's `metadata.pages > 1`) and
is left rather than costing every static-WebP gallery the bandwidth fix.

The settings copy claimed too much. "Pre-generate lightbox previews" does not
generate anything on save — it unlocks the regenerate button and keeps
preview_url emitted. Reworded to say that, in en/de/fr/sl.

Not changed: the review's P1 said this bypassed the secure-image route on
enhanced/maximum galleries. It does not. AuthenticatedImage collects
requiresToken and secureUrlTemplate into an explicitly-voided unusedProps and
never substitutes {{token}}, so on those protection levels photo.url was a
literal `.../secure/82/{{token}}` that returns 400 — the lightbox was falling
back to the 300px thumbnail, not to a protected image. Verified against a live
maximum-protection gallery. Codex withdrew the finding on that evidence.

* fix(gallery): keep premium downloads working and story framing intact (#1166)

Second review round, three findings — two of them regressions this PR
introduced.

Premium Download became a no-op. handleDownloadFromLightbox recovered the
photo with `filteredPhotos.find(p => p.url === slide.src)`, and slide.src is a
derivative now, so the lookup found nothing and the button silently did
nothing. The slide carries the photo id and the handler resolves by that;
what Download hands over is still the original.

Story cards were reframed. thumbnail_fit is seeded to 'cover' on every
install, so thumbnails are square centre-crops — and story cards are not
square (400x500 in the carousel, fixed-height in the desktop grid), so the
card's own object-cover cropped them a second time and every photo shifted.
They now use the preview tier, which is fit:'inside' and therefore the whole
frame: the card looks exactly as it did before, without pulling an original.

APNG joins the animated-format guard. It declares image/apng and the preview
route would serve a static frame. Animated WebP still cannot be detected from
MIME and remains the documented gap.

* fix(gallery): keep PNG on the original, alpha and all (#1166)

Third review round.

generatePreviewImage encodes JPEG, which drops ALPHA as well as animation — a
transparent PNG came back flattened against a solid background. And an APNG is
normally reported as image/png, so the image/apng check alone missed the
common upload path. PNG now stays on the original: it is where transparency is
the norm, and rare enough in an event gallery that the bandwidth given up is
small.

Animated or alpha WebP still cannot be detected from MIME and remains the
documented gap; it needs the backend to report Sharp's `pages`/`hasAlpha`.

Two further findings are acknowledged and deferred rather than fixed here:

- Story cards now request /preview on mount, so a cold gallery generates its
  previews in one burst. That is a new CPU cost, not a regression — those cards
  previously fetched full ORIGINALS on mount, which is strictly worse. Doing it
  properly means viewport-gating AuthenticatedImage, which is a change to a
  component every gallery surface uses and belongs in its own PR.
- The premium layout memoizes slide URLs, so rotating the device before opening
  the lightbox can leave a photo on the tier chosen for the old geometry. The
  result is a slightly undersized image, and the fix is a resize subscription
  this PR does not otherwise need.

* fix(gallery): load Story images on approach, and give the hero its own tier (#1166)

Every card in a Story gallery mounts at page load — `whileInView` gates the
animation, not the render — and AuthenticatedImage fetches from an effect on
mount, so all of them requested at once. That was tolerable while they pointed
at photo.url, because nothing was generated; pointing them at the preview tier
meant a gallery with cold previews would Sharp-decode every original in one
burst. The image now waits until the card is within 200px of the viewport,
using framer-motion's useInView — the same observer the entrance animation
already relies on — with `once` so a card never unloads on scroll-away.

Verified on a 62-photo Story gallery: 3 images fetched at load, growing to 15
as you scroll, where all 62 would have fired before.

While confirming that, the hero turned out to be doing the same thing the
cards were. StoryHero rendered photo.url as a full-bleed object-cover
background — a full original on the critical path for first paint of every
Story gallery — when hero_url exists for exactly this and is a 1920x1080 cover
crop emitted unconditionally for every photo (gallery.js:1139).

That gallery now issues no /photo/ request at all: hero_url for the hero,
the preview tier for the cards, and only as they come into range.

* fix(previews): preserve alpha and animation in the preview tier

Follow-up to #1166, which had to bypass the preview tier for GIF, APNG and PNG
to avoid a visible regression. This removes the cause.

generatePreviewImage encoded JPEG unconditionally. JPEG has no alpha channel
and no second frame, so a transparent PNG came back flattened onto a solid
background and an animated GIF came back as its first frame — for every
consumer of this tier, not just the lightbox: the slideshow (#1015), admin
previews, and the face avatars that read it as a whole-frame rendition. It was
only invisible by default because the lightbox served originals.

Sources with alpha, or more than one page, are now encoded as WebP, which
carries both and is still far smaller than the original. Ordinary photos stay
JPEG — the common path pays nothing.

Two things had to move with it:

- The output extension now matches what was written. A PNG source previously
  produced `preview_foo.png` holding JPEG bytes; harmless while the route
  hard-coded image/jpeg, wrong once the encoding varies. Existing keys keep
  working — they are still JPEG and still served as such.
- The preview route derives Content-Type from the key. With `nosniff` set,
  mislabelling would show a broken image rather than being silently corrected.
  The watermark branch re-encodes to JPEG, so it labels itself explicitly;
  preserving animation through the watermark compositor is a separate problem.

The frontend guess-by-MIME goes away entirely — including the case it could
never get right, since a still and an animated WebP declare the same type.

Verified on the local rig: a transparent PNG round-trips as
`Content-Type: image/webp`, `hasAlpha: true`, 8.3 KB; an ordinary photo still
serves `image/jpeg` from a `.jpg` key.

* fix(previews): retire the legacy preview keys, and stop mislabelling watermarked ones

External review of the stable twin found two defects, both on this branch too.

Legacy keys collide with the new naming. The old generator kept the SOURCE
basename verbatim while always writing JPEG, so a `.webp` upload produced
`previews/preview_shot.webp` holding a JPEG. My PR body claimed "pre-existing
keys have no .webp suffix and are JPEG" — that was simply wrong. The route now
derives Content-Type from the key and the response carries nosniff, so every
photo uploaded as WebP would have rendered as a broken image in the lightbox.
Legacy `.png` keys are wrong the other way: flattened JPEGs of what may have
been transparent sources, which isPreviewValid would have let stand forever.

Migration 188 clears photos.preview_path outright — all of it, not just the
suspicious extensions, because a `.jpg` key can equally be a flattened
rendition and nothing in the key says so. Previews regenerate lazily on next
view under the new encoder, so the cost is one regeneration per photo actually
viewed. Storage is untouched, as elsewhere.

The watermark branch mislabelled its output. applyWatermark PRESERVES the
source format (watermarkService.js:200-211: png stays png, webp stays webp),
and its input is the preview — so the output already matches the key the
header was derived from. Forcing image/jpeg mislabelled every watermarked WebP
preview, and nosniff means the browser would not correct it. The override is
gone; the animation loss through the compositor is documented where it
happens.

* fix(gallery): make the Story hero fix actually work on external galleries (#1166)

External review of the stable twin, both applying here too.

hero_url was inert for external media. ensureHeroImage only ever called
resolvePhotoStorageKey, which returns null for external/reference photos by
design — and that null was handed straight to withLocalCopy, which throws, so
the hero route caught it and redirected to the full ORIGINAL. #1078 fixed
exactly this shape for ensurePreviewImage and nobody carried it across. It
stayed invisible until this PR pointed the Story hero at hero_url: on a
managed gallery that is a real saving, on a reference-mode gallery it quietly
changed nothing. ensureHeroImage now has the same external branch
ensurePreviewImage does — direct fs read, per-photo output basename — and
returns null instead of throwing for a reference-mode row with no
source_origin.

The format bypass trusted mime_type, which is not trustworthy here. Migration
039 backfilled every pre-existing photo to image/jpeg regardless of what it
was, and adminExternalMedia inserts rows with no mime_type at all — so a
mislabelled PNG sailed past the guard and came back flattened. It now checks
the filename extension as well.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:53:05 +02:00

PicPeak Logo

📸 PicPeak

Open-source, self-hosted photo sharing for events.

License: MIT Docker Buy Me A Coffee

Homepage · Live Demo · Documentation · Support


PicPeak is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.

PicPeak Gallery Preview

Important

PicPeak has moved to its own GitHub organization. Docker images are now at ghcr.io/picpeak/picpeak/{backend,frontend,aio,ml} (and on Docker Hub as picpeak/{backend,frontend,aio,ml}) and active development is on main. The old ghcr.io/the-luap/... path still responds but its tags are frozen at 2026-05-27 — if updates never arrive, check your image path first. See docs/migration-to-org.md for the one-line docker-compose.yml edit.

Contents

🎮 Live Demo

Try PicPeak without installing anything — demo.picpeak.app · admin panel

Email Password
demo@picpeak.app Demo2026!

The demo resets periodically. Uploaded content may be removed without notice.

🚀 Quick Start

Get PicPeak running in under 5 minutes:

# Clone the repository
git clone https://github.com/PicPeak/picpeak.git
cd picpeak

# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser. Edit .env only to customise
# (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env

# Start with Docker Compose
docker compose up -d

# Access at http://localhost:3000

On first start, open http://localhost:3000/admin and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in First-run setup.

Updating / release channels: set PICPEAK_CHANNEL (stable default, or beta) in .env, then docker compose pull && docker compose up -d. See RELEASING.md for the promotion cadence.

Or: one container, no compose file

For a home server, a NAS, or a single small studio, the all-in-one image runs the whole app as one process with SQLite — no compose file, no separate database, no reverse proxy to wire up:

docker run -d --name picpeak -p 3000:3000 \
  -v picpeak:/data \
  ghcr.io/picpeak/picpeak/aio:main

No environment variables to set — the JWT secret is generated on first start and kept on the volume.

Then open http://localhost:3000/admin and read the setup token with docker exec picpeak cat /data/db/SETUP_TOKEN, or open db/SETUP_TOKEN on the volume with any file manager if the host has no shell.

:main is the active-development tag, and today it is the only one the all-in-one image has — Dockerfile.aio landed after the current stable release, so :stable and :latest first appear for this image once the aio build reaches the stable branch. Switch to :stable then, or pin a published version tag if you would rather not track main.

The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a .picpeak backup and restore it into the full stack. See Single-container install for the volume layout, the external-Postgres variant, TLS, and the limits.

Docker images

GHCR Docker Hub
Backend ghcr.io/picpeak/picpeak/backend picpeak/backend
Frontend ghcr.io/picpeak/picpeak/frontend picpeak/frontend
All-in-one ghcr.io/picpeak/picpeak/aio picpeak/aio
ML sidecar (optional) ghcr.io/picpeak/picpeak/ml picpeak/ml

Both registries get the same digests and the same tags — stable/latest, a pinned x.y.z, and beta/main for the active development channel — for linux/amd64 and linux/arm64. Keep every image in one install on the same tag.

🌟 Why PicPeak?

Unlike expensive SaaS solutions, PicPeak gives you:

  • 💰 No Monthly Fees — one-time setup, unlimited galleries
  • 🔒 Complete Data Control — your photos stay on your server
  • 🎨 White-Label Ready — full branding customization
  • 📱 Mobile-First Design — beautiful on all devices
  • 🌍 Multi-Language — built-in i18n (EN, DE)

Features

For photographers — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a Live Slideshow projector view that auto-picks-up new uploads during live events.

For clients — clean mobile-optimized galleries, one-click bulk downloads, smart search, People in this gallery face grouping (opt-in per gallery, needs the optional ML sidecar), optional guest uploads, and download protection (watermarking + right-click prevention).

Technical — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible storage backends, webhooks, and security-first defaults (JWT, rate limiting, CORS).

🧾 For studios — CRM & Accounting (Beta, off by default)
  • 📝 Quotes → Contracts → Invoices — one deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
  • ⏱️ Hours Logging & Calendar — per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
  • 🧾 Inbound Supplier Invoices & Expenses — capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
  • 📊 Tax Report & Accountant Export — period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export
  • 🌍 VAT & Multi-currency — single VAT-code registry snapshotted onto each document

Warning

CRM & Accounting — examples only, verify locally. Feature-flagged off by default. Seeded contract blocks are written by the maintainer, not a lawyer; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are jurisdiction-specific guidance only. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read the CRM disclaimers first.

📖 Documentation

Full documentation lives at docs.picpeak.app — deployment, admin settings, API, branding, and more.

Topic Link
🚀 Deployment (Docker, env, reverse proxy, SSL) docs.picpeak.app/deployment
📦 Single-container install (one docker run, SQLite) docs.picpeak.app/deployment/single-container
⚙️ Admin settings reference docs.picpeak.app/guides/admin-settings
🎯 Creating events docs.picpeak.app/guides/creating-events
📽️ Live Slideshow docs.picpeak.app/features/live-slideshow
🙂 People in galleries (face grouping) docs.picpeak.app/features/face-recognition
💾 Backup & Restore docs.picpeak.app/guides/backup-restore
🔌 API reference docs.picpeak.app/api
🪝 Webhooks docs.picpeak.app/features/webhooks
💾 Storage backends (local / S3) docs.picpeak.app/features/storage-backends
💻 System requirements & tuning docs.picpeak.app/deployment/system-requirements
🧾 CRM & Accounting docs.picpeak.app/features/crm · disclaimers
🗺️ Roadmap GitHub Issues

Project meta: Contributing · License · Security · Code of Conduct

📊 Comparison with Alternatives

Feature PicPeak PicDrop Scrapbook.de Pixieset
Self-Hosted
Custom Branding Full Limited Limited (paid)
Monthly Cost $0* $29-199 €19-99 ~$60
Storage Limit Unlimited** 50-500GB 100-1000GB 3GBUnlimited***
Client Uploads Limited
API Access Paid
Open Source
Customer Accounts
Quotes / Contracts / Invoices 🧪 Beta
Incoming Invoices & Accounting 🧪 Beta

*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.

🏗️ Tech Stack

  • Backend: Node.js, Express, SQLite/PostgreSQL
  • Frontend: React, Tailwind CSS, Framer Motion
  • Storage: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see Storage Backends
  • Email: SMTP with customizable templates
  • Analytics: Privacy-focused with Umami integration
  • External media: point PicPeak at EXTERNAL_MEDIA_ROOT to reference existing originals read-only, index quickly, and generate thumbnails on demand

📸 Screenshots

Click to see the admin dashboard, analytics, and event management

🎛️ Admin Dashboard

PicPeak Admin Dashboard

📊 Analytics & Insights

PicPeak Analytics Dashboard

📁 Event Management

PicPeak Events Management

🤝 Contributing

We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the Contributing Guide to get started.

Found a security issue? Please open a security issue. See SECURITY.md for the policy.

Support the Project

PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also star the repo, share it, file good bug reports, or open a PR.

🙏 Acknowledgments

PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.

👥 Contributors

A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:

@the-luap — creator and lead maintainer

  • Gallery foundation (events, uploads, sharing, download protection, templates)
  • Backup & restore, analytics, branding/theming
  • The architecture every later feature builds on

@Luca-Timo

  • Native Apple Silicon multi-arch images
  • CRM & accounting suite (quotes/contracts/invoices)
  • Hours logging & Treuhänder/Banana tax export
  • Gallery header/banner decoupling

@Rekoo-PS — bug reports & product feedback

  • Login-loop fix, mobile-lightbox overhaul, bulk-delete workflow
  • Also a BuyMeACoffee supporter

If you've contributed and aren't listed here, please open a PR — this list is meant to grow.

📄 License

PicPeak is released under the MIT License. Use it freely for personal or commercial projects.


Made with ❤️ by photographers, for photographers
Homepage · Live Demo · Documentation · Support

S
Description
Secure photo sharing platform for weddings and events with automatic expiration and email notifications
Readme MIT 157 MiB
2025-07-24 15:24:20 +02:00
Languages
JavaScript 59.8%
TypeScript 38.5%
Shell 0.7%
Python 0.5%
CSS 0.4%
Other 0.1%