Vibecode Pixieset
track this build5 phases, 15 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Pixieset, publish a simple password-protected client gallery from user-owned storage. The hard boundary is gallery hosting, proofing, downloads, store, mobile apps, and photographer workflow, plus image pipeline quality, models, and workflow polish.
You are building a lean indie version of Pixieset. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Pixieset · indie build A catalogue of your local photographs that publishes a password-protected gallery to storage you own: generated previews at sensible sizes, client favouriting for proofing, download permissions per gallery, expiry dates, and originals that never leave your disk unless you decide they do. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Catalogue | Python 3.12 with SQLite, reading a local library | the originals stay where they are; the catalogue is an index over them | | Previews | libvips through pyvips | several times faster and far lighter on memory than the alternatives when generating thousands of derivatives | | Gallery | A static site generated per gallery, uploaded to object storage | no server to keep running for a gallery a client opens twice | | Access | Signed URLs plus a gallery password, checked before any image URL exists | a static gallery still needs a real gate, and the gate has to be in front of the images rather than the page | | Storage | S3-compatible object storage behind a content delivery network | photographs are large and clients open galleries from phones on poor connections | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the catalogue, derivative generation and publishing. - Get it: python.org or your package manager, then a virtual environment. - Verify: python3 --version prints 3.12 or higher - [ ] **libvips** · free - Why: Generating thousands of derivatives with a memory-hungry library will exhaust a laptop. libvips is substantially faster and lighter for exactly this workload. - Get it: brew install vips, or your package manager, then pip install pyvips. - Verify: python -c "import pyvips; print(pyvips.version(0))" runs - [ ] **An S3-compatible bucket with a delivery network** · storage is cheap; egress varies enormously by provider - Why: Galleries are opened from phones on poor connections, and photographs are large. Egress rate is the cost that matters. - Get it: Create a private bucket and credentials scoped to it, and put a delivery network in front. Compare egress pricing before choosing. - Verify: A test object uploads, is refused when requested directly, and is served through the network with a signature - [ ] **A real photo library with at least ten thousand images** · free - Why: Indexing time, derivative generation time and gallery weight are all meaningless at a hundred images and decisive at ten thousand. - Get it: Use your own library. If it is smaller, duplicate it to test scale before trusting the performance numbers. - Verify: The library is available and its size is recorded - [ ] **A metadata inspection tool** · free - Why: Phase 2's claim that location metadata is stripped must be verified with a tool rather than assumed, because publishing a client's location is a real harm. - Get it: brew install exiftool, or your package manager. - Verify: It reports metadata for a source image and finds none in a derivative - [ ] **A second location for original backups** · varies with library size; this is usually a photographer's largest storage cost - Why: Originals are the irreplaceable asset and the catalogue does not contain them. - Get it: An external drive plus an offsite copy, or object storage. Decide before Phase 6, and prefer two locations to one. - Verify: A restore of one original has been performed ## Quick start ```sh pip install pyvips ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Raw processing quality, which is the real craft here. - Print store and fulfilment. - Client mobile applications. - Library sync across your own machines. - gallery hosting, proofing, downloads, store, mobile apps, and photographer workflow - proprietary raw-processing quality - cloud sync and sharing - large AI models - camera and print ecosystem If one of those is essential to you, that is the reason to keep paying for Pixieset, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Pixieset The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a client photo gallery published from your own storage, with proofing and controlled downloads to replace Pixieset. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Catalogue: Python 3.12 with SQLite, reading a local library · the originals stay where they are; the catalogue is an index over them - Previews: libvips through pyvips · several times faster and far lighter on memory than the alternatives when generating thousands of derivatives - Gallery: A static site generated per gallery, uploaded to object storage · no server to keep running for a gallery a client opens twice - Access: Signed URLs plus a gallery password, checked before any image URL exists · a static gallery still needs a real gate, and the gate has to be in front of the images rather than the page - Storage: S3-compatible object storage behind a content delivery network · photographs are large and clients open galleries from phones on poor connections ### Data model (create this before Phase 1) - photos(id, path, taken_at, width, height, orientation, camera, content_hash) · the catalogue, pointing at originals in place - galleries(id, name, client_name, slug, password_hash, expires_at, download_policy, published_at) - gallery_photos(gallery_id, photo_id, position, favourited_at, comment) - derivatives(photo_id, size, key, bytes, generated_at) · thumbnail, preview and optional full size - downloads(gallery_id, photo_id, at, ip_hash) · so you can answer what a client actually took - Originals are never uploaded unless the download policy explicitly allows them, and that is a per-gallery decision. ### Phase 1 · Cataloguing what you already have Build: Walk a photo directory, read metadata, and index into SQLite without moving or modifying anything. Handle orientation from the metadata, and use the capture date rather than the file date. Detect duplicates by content hash. Trap: A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. Done when: Ten thousand photographs index in a time you can state. Portrait images report correct orientation. Capture dates come from metadata, falling back to file date only when metadata is absent, and the fallback is recorded. The same photograph in two folders is detected as a duplicate. Do not build yet: No previews, no galleries, no publishing. ### Phase 2 · Derivatives that look right and are not enormous Build: Generate thumbnail and preview sizes with libvips, applying orientation, resizing in linear light, sharpening after the resize, and stripping metadata that should not travel. Measure the total size of a gallery's derivatives. Trap: Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. Trap: Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. Done when: A generated preview shows no visible darkening compared to the original, checked on a textured image. Location metadata is absent from every derivative, verified with a metadata tool. A gallery of two hundred previews has a total size you can state, and it is small enough to open on mobile data. Do not build yet: No galleries or publishing yet. ### Phase 3 · Galleries and a gate that is real Build: Create a gallery from selected photographs, with a password and an expiry. Generate a static gallery page. Serve images through signed URLs issued only after the password check, so the images are not reachable by guessing. Trap: A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. Done when: A gallery opens with the correct password and refuses the wrong one. An image URL taken from a gallery stops working after the signature expires. A direct request to an object without a signature is refused. An expired gallery refuses access entirely rather than showing an empty page. Do not build yet: No favouriting or downloads yet. ### Phase 4 · Proofing Build: Favouriting and per-image comments, stored server side against the gallery rather than in the client's browser. An export of the selection as a list you can act on. Make selections survive the client switching device. Trap: Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. Done when: Favourites set on one device appear when the gallery is opened on another with the same password. An exported selection lists filenames matching the client's favourites exactly. A comment is preserved with its image. Two clients sharing a password do not overwrite each other's selections silently, or the interface says selections are shared. Do not build yet: No downloads or store yet. ### Phase 5 · Downloads on your terms Build: A per-gallery download policy: none, previews only, or originals. Zip downloads generated on demand rather than stored. Record downloads so you can answer what a client already has. Trap: Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. Done when: A gallery set to previews only serves no original, verified by attempting one directly. A zip download of two hundred images streams without the process memory growing to the archive size. Downloads are recorded and can be listed per gallery. Changing the policy takes effect immediately for an already open gallery. Do not build yet: Backups and operations come last. ### Phase 6 · Backups, storage costs and handover Build: Back up originals to a second location, since the catalogue is only an index. Track storage and egress costs per gallery. Add gallery archival that removes derivatives from storage while keeping the catalogue entry, and a final handover export. Trap: The catalogue is an index and contains no photographs, so backing it up protects nothing. The originals need their own backup, and a photographer's originals are usually the largest and most irreplaceable data they own. Done when: Originals are backed up to a second location and a restore of one has been performed and dated. Storage cost per gallery is reported. Archiving a gallery removes its derivatives from object storage and leaves the catalogue entry intact. A handover export produces originals and the selection list for one client. Do not build yet: Nothing after this. ### Out of scope (and why) - Raw processing quality, which is the actual craft in this field and is not what this build touches. - The store and print fulfilment, which is a payment integration plus a relationship with a lab. - Mobile applications for clients, who mostly want a link that works well on a phone. - Cloud sync of your library across machines, which is a separate and larger problem than publishing galleries. ### README must contain - That the catalogue indexes originals in place and is not a backup - That derivatives are resized in linear light and have location metadata stripped - That gallery access issues signed URLs after the password, so images are not reachable by guessing - The download policies and that they are enforced by which signatures are issued - Where originals are backed up and the date of the last restore ===== AGENTS.md ===== # Agent instructions · Pixieset indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with SQLite, reading a local library, libvips through pyvips, A static site generated per gallery, uploaded to object storage, Signed URLs plus a gallery password, checked before any image URL exists, S3-compatible object storage behind a content delivery network. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. - Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. - Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. - A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. - Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. - Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. ===== BUILD_PLAN.md ===== # Build plan · Pixieset A catalogue of your local photographs that publishes a password-protected gallery to storage you own: generated previews at sensible sizes, client favouriting for proofing, download permissions per gallery, expiry dates, and originals that never leave your disk unless you decide they do. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Cataloguing what you already have Your photographs are indexed in place, with correct orientation and dates. ### Steps 1. Walk and index without modifying Read only. The catalogue points at files in place; it never moves or rewrites them. Files: `src/catalogue.py` ```sh pip install pyvips ``` 2. Read orientation and capture date From metadata, with an explicit fallback that is recorded rather than silent. Files: `src/metadata.py` 3. Hash for duplicates Content hash, so the same image in two folders is one photograph with two paths. Files: `src/catalogue.py` ### Done when - [ ] Ten thousand photographs index in a stated time - [ ] Portrait images report correct orientation - [ ] Capture dates come from metadata where present - [ ] Duplicates across folders are detected ### Watch out - A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. ## Phase 2 · Derivatives that look right and are not enormous Previews are sharp on a retina screen and small enough to load on mobile data. ### Steps 1. Resize in linear light Convert, resize, convert back, then sharpen. Sharpening before the resize does the wrong thing. Files: `src/derivatives.py` 2. Strip metadata deliberately Remove location and personal fields, keep copyright if you set it. Verify with a metadata tool rather than assuming. ```sh exiftool -G -a preview.jpg | grep -i gps ``` 3. Measure gallery weight Total bytes for a typical gallery. This is what a client on a phone will download. Files: `src/derivatives.py` ### Done when - [ ] Previews show no darkening on a textured image - [ ] No location metadata survives into a derivative - [ ] A two hundred image gallery's total size is stated - [ ] Sharpening is applied after resizing ### Watch out - Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. - Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. ## Phase 3 · Galleries and a gate that is real A client can open their gallery, and nobody else can open it or its images. ### Steps 1. Generate the gallery page A static page with responsive images and lazy loading. It should open quickly on a phone. Files: `src/gallery.py`, `templates/gallery.html` 2. Gate with a password and signed URLs The password check issues short-lived signed URLs. No image URL exists before authentication. Files: `src/access.py` 3. Test the bypass paths Request an object directly, reuse an expired signature, and open an expired gallery. All three must fail. ### Done when - [ ] The correct password opens the gallery and a wrong one does not - [ ] An image URL stops working after expiry - [ ] A direct object request without a signature is refused - [ ] An expired gallery refuses access ### Watch out - A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. ## Phase 4 · Proofing A client can mark favourites and leave a note, and you get a clean list. ### Steps 1. Store selections server side Against the gallery, keyed by a session established at password entry. Files: `src/proofing.py` 2. Export the selection Filenames and comments in a form you can hand to your editing workflow. Files: `src/export.py` 3. Decide the shared password behaviour Either separate sessions or a shared selection, but say which in the interface rather than leaving it ambiguous. Files: `templates/gallery.html` ### Done when - [ ] Favourites persist across devices with the same password - [ ] The exported selection matches the client's favourites - [ ] Comments stay attached to their image - [ ] Shared-password behaviour is stated in the interface ### Watch out - Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. ## Phase 5 · Downloads on your terms Clients get exactly what you agreed to give them, and you know what they took. ### Steps 1. Enforce the policy at the URL level The policy decides which signatures are issued at all. An original URL should not exist for a previews-only gallery. Files: `src/downloads.py` 2. Stream archives Build and send simultaneously so memory stays flat. Test with a large gallery and watch memory. Files: `src/zip.py` 3. Record what was taken Per image and per gallery, with a hashed address rather than a stored one. Files: `src/downloads.py` ### Done when - [ ] A previews-only gallery serves no original - [ ] A large zip streams without memory growing to its size - [ ] Downloads are recorded and listable per gallery - [ ] A policy change takes effect on an open gallery ### Watch out - Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. ## Not in this build - Raw processing quality, which is the real craft here. - Print store and fulfilment. - Client mobile applications. - Library sync across your own machines. ## After v1, if you want it - Watermarking previews for galleries where downloads are disabled - A client-facing selection limit, so a package of forty images is enforced rather than negotiated - Automatic archival of galleries past their expiry, with a warning first ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where your originals live. Read only; nothing is written here. LIBRARY_PATH=~/Pictures/Library # Required. The index over your library. Rebuildable from the library, so not the important backup. CATALOGUE_DB=./data/catalogue.db # Required. Private bucket holding derivatives. Originals go here only when a gallery policy allows downloads. BUCKET_NAME=client-galleries # Required · secret. Scoped to the gallery bucket only. BUCKET_SECRET_KEY=replace-with-secret # Required. What clients fetch from. The bucket hostname never appears in a gallery. CDN_BASE_URL=https://galleries.example.com # Required. How long an image URL lasts after the password is entered. SIGNED_URL_TTL_SECONDS=3600 # Required. Preview size. Large enough to look sharp, small enough to open on mobile data. PREVIEW_LONG_EDGE=2048
You are building a lean indie version of Pixieset. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Pixieset · indie build A catalogue of your local photographs that publishes a password-protected gallery to storage you own: generated previews at sensible sizes, client favouriting for proofing, download permissions per gallery, expiry dates, and originals that never leave your disk unless you decide they do. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Catalogue | Python 3.12 with SQLite, reading a local library | the originals stay where they are; the catalogue is an index over them | | Previews | libvips through pyvips | several times faster and far lighter on memory than the alternatives when generating thousands of derivatives | | Gallery | A static site generated per gallery, uploaded to object storage | no server to keep running for a gallery a client opens twice | | Access | Signed URLs plus a gallery password, checked before any image URL exists | a static gallery still needs a real gate, and the gate has to be in front of the images rather than the page | | Storage | S3-compatible object storage behind a content delivery network | photographs are large and clients open galleries from phones on poor connections | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the catalogue, derivative generation and publishing. - Get it: python.org or your package manager, then a virtual environment. - Verify: python3 --version prints 3.12 or higher - [ ] **libvips** · free - Why: Generating thousands of derivatives with a memory-hungry library will exhaust a laptop. libvips is substantially faster and lighter for exactly this workload. - Get it: brew install vips, or your package manager, then pip install pyvips. - Verify: python -c "import pyvips; print(pyvips.version(0))" runs - [ ] **An S3-compatible bucket with a delivery network** · storage is cheap; egress varies enormously by provider - Why: Galleries are opened from phones on poor connections, and photographs are large. Egress rate is the cost that matters. - Get it: Create a private bucket and credentials scoped to it, and put a delivery network in front. Compare egress pricing before choosing. - Verify: A test object uploads, is refused when requested directly, and is served through the network with a signature - [ ] **A real photo library with at least ten thousand images** · free - Why: Indexing time, derivative generation time and gallery weight are all meaningless at a hundred images and decisive at ten thousand. - Get it: Use your own library. If it is smaller, duplicate it to test scale before trusting the performance numbers. - Verify: The library is available and its size is recorded - [ ] **A metadata inspection tool** · free - Why: Phase 2's claim that location metadata is stripped must be verified with a tool rather than assumed, because publishing a client's location is a real harm. - Get it: brew install exiftool, or your package manager. - Verify: It reports metadata for a source image and finds none in a derivative - [ ] **A second location for original backups** · varies with library size; this is usually a photographer's largest storage cost - Why: Originals are the irreplaceable asset and the catalogue does not contain them. - Get it: An external drive plus an offsite copy, or object storage. Decide before Phase 6, and prefer two locations to one. - Verify: A restore of one original has been performed ## Quick start ```sh pip install pyvips ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Raw processing quality, which is the real craft here. - Print store and fulfilment. - Client mobile applications. - Library sync across your own machines. - gallery hosting, proofing, downloads, store, mobile apps, and photographer workflow - proprietary raw-processing quality - cloud sync and sharing - large AI models - camera and print ecosystem If one of those is essential to you, that is the reason to keep paying for Pixieset, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Pixieset The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a client photo gallery published from your own storage, with proofing and controlled downloads to replace Pixieset. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Catalogue: Python 3.12 with SQLite, reading a local library · the originals stay where they are; the catalogue is an index over them - Previews: libvips through pyvips · several times faster and far lighter on memory than the alternatives when generating thousands of derivatives - Gallery: A static site generated per gallery, uploaded to object storage · no server to keep running for a gallery a client opens twice - Access: Signed URLs plus a gallery password, checked before any image URL exists · a static gallery still needs a real gate, and the gate has to be in front of the images rather than the page - Storage: S3-compatible object storage behind a content delivery network · photographs are large and clients open galleries from phones on poor connections ### Data model (create this before Phase 1) - photos(id, path, taken_at, width, height, orientation, camera, content_hash) · the catalogue, pointing at originals in place - galleries(id, name, client_name, slug, password_hash, expires_at, download_policy, published_at) - gallery_photos(gallery_id, photo_id, position, favourited_at, comment) - derivatives(photo_id, size, key, bytes, generated_at) · thumbnail, preview and optional full size - downloads(gallery_id, photo_id, at, ip_hash) · so you can answer what a client actually took - Originals are never uploaded unless the download policy explicitly allows them, and that is a per-gallery decision. ### Phase 1 · Cataloguing what you already have Build: Walk a photo directory, read metadata, and index into SQLite without moving or modifying anything. Handle orientation from the metadata, and use the capture date rather than the file date. Detect duplicates by content hash. Trap: A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. Done when: Ten thousand photographs index in a time you can state. Portrait images report correct orientation. Capture dates come from metadata, falling back to file date only when metadata is absent, and the fallback is recorded. The same photograph in two folders is detected as a duplicate. Do not build yet: No previews, no galleries, no publishing. ### Phase 2 · Derivatives that look right and are not enormous Build: Generate thumbnail and preview sizes with libvips, applying orientation, resizing in linear light, sharpening after the resize, and stripping metadata that should not travel. Measure the total size of a gallery's derivatives. Trap: Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. Trap: Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. Done when: A generated preview shows no visible darkening compared to the original, checked on a textured image. Location metadata is absent from every derivative, verified with a metadata tool. A gallery of two hundred previews has a total size you can state, and it is small enough to open on mobile data. Do not build yet: No galleries or publishing yet. ### Phase 3 · Galleries and a gate that is real Build: Create a gallery from selected photographs, with a password and an expiry. Generate a static gallery page. Serve images through signed URLs issued only after the password check, so the images are not reachable by guessing. Trap: A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. Done when: A gallery opens with the correct password and refuses the wrong one. An image URL taken from a gallery stops working after the signature expires. A direct request to an object without a signature is refused. An expired gallery refuses access entirely rather than showing an empty page. Do not build yet: No favouriting or downloads yet. ### Phase 4 · Proofing Build: Favouriting and per-image comments, stored server side against the gallery rather than in the client's browser. An export of the selection as a list you can act on. Make selections survive the client switching device. Trap: Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. Done when: Favourites set on one device appear when the gallery is opened on another with the same password. An exported selection lists filenames matching the client's favourites exactly. A comment is preserved with its image. Two clients sharing a password do not overwrite each other's selections silently, or the interface says selections are shared. Do not build yet: No downloads or store yet. ### Phase 5 · Downloads on your terms Build: A per-gallery download policy: none, previews only, or originals. Zip downloads generated on demand rather than stored. Record downloads so you can answer what a client already has. Trap: Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. Done when: A gallery set to previews only serves no original, verified by attempting one directly. A zip download of two hundred images streams without the process memory growing to the archive size. Downloads are recorded and can be listed per gallery. Changing the policy takes effect immediately for an already open gallery. Do not build yet: Backups and operations come last. ### Phase 6 · Backups, storage costs and handover Build: Back up originals to a second location, since the catalogue is only an index. Track storage and egress costs per gallery. Add gallery archival that removes derivatives from storage while keeping the catalogue entry, and a final handover export. Trap: The catalogue is an index and contains no photographs, so backing it up protects nothing. The originals need their own backup, and a photographer's originals are usually the largest and most irreplaceable data they own. Done when: Originals are backed up to a second location and a restore of one has been performed and dated. Storage cost per gallery is reported. Archiving a gallery removes its derivatives from object storage and leaves the catalogue entry intact. A handover export produces originals and the selection list for one client. Do not build yet: Nothing after this. ### Out of scope (and why) - Raw processing quality, which is the actual craft in this field and is not what this build touches. - The store and print fulfilment, which is a payment integration plus a relationship with a lab. - Mobile applications for clients, who mostly want a link that works well on a phone. - Cloud sync of your library across machines, which is a separate and larger problem than publishing galleries. ### README must contain - That the catalogue indexes originals in place and is not a backup - That derivatives are resized in linear light and have location metadata stripped - That gallery access issues signed URLs after the password, so images are not reachable by guessing - The download policies and that they are enforced by which signatures are issued - Where originals are backed up and the date of the last restore ===== AGENTS.md ===== # Agent instructions · Pixieset indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with SQLite, reading a local library, libvips through pyvips, A static site generated per gallery, uploaded to object storage, Signed URLs plus a gallery password, checked before any image URL exists, S3-compatible object storage behind a content delivery network. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. - Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. - Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. - A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. - Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. - Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. ===== BUILD_PLAN.md ===== # Build plan · Pixieset A catalogue of your local photographs that publishes a password-protected gallery to storage you own: generated previews at sensible sizes, client favouriting for proofing, download permissions per gallery, expiry dates, and originals that never leave your disk unless you decide they do. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Cataloguing what you already have Your photographs are indexed in place, with correct orientation and dates. ### Steps 1. Walk and index without modifying Read only. The catalogue points at files in place; it never moves or rewrites them. Files: `src/catalogue.py` ```sh pip install pyvips ``` 2. Read orientation and capture date From metadata, with an explicit fallback that is recorded rather than silent. Files: `src/metadata.py` 3. Hash for duplicates Content hash, so the same image in two folders is one photograph with two paths. Files: `src/catalogue.py` ### Done when - [ ] Ten thousand photographs index in a stated time - [ ] Portrait images report correct orientation - [ ] Capture dates come from metadata where present - [ ] Duplicates across folders are detected ### Watch out - A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. ## Phase 2 · Derivatives that look right and are not enormous Previews are sharp on a retina screen and small enough to load on mobile data. ### Steps 1. Resize in linear light Convert, resize, convert back, then sharpen. Sharpening before the resize does the wrong thing. Files: `src/derivatives.py` 2. Strip metadata deliberately Remove location and personal fields, keep copyright if you set it. Verify with a metadata tool rather than assuming. ```sh exiftool -G -a preview.jpg | grep -i gps ``` 3. Measure gallery weight Total bytes for a typical gallery. This is what a client on a phone will download. Files: `src/derivatives.py` ### Done when - [ ] Previews show no darkening on a textured image - [ ] No location metadata survives into a derivative - [ ] A two hundred image gallery's total size is stated - [ ] Sharpening is applied after resizing ### Watch out - Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. - Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. ## Phase 3 · Galleries and a gate that is real A client can open their gallery, and nobody else can open it or its images. ### Steps 1. Generate the gallery page A static page with responsive images and lazy loading. It should open quickly on a phone. Files: `src/gallery.py`, `templates/gallery.html` 2. Gate with a password and signed URLs The password check issues short-lived signed URLs. No image URL exists before authentication. Files: `src/access.py` 3. Test the bypass paths Request an object directly, reuse an expired signature, and open an expired gallery. All three must fail. ### Done when - [ ] The correct password opens the gallery and a wrong one does not - [ ] An image URL stops working after expiry - [ ] A direct object request without a signature is refused - [ ] An expired gallery refuses access ### Watch out - A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. ## Phase 4 · Proofing A client can mark favourites and leave a note, and you get a clean list. ### Steps 1. Store selections server side Against the gallery, keyed by a session established at password entry. Files: `src/proofing.py` 2. Export the selection Filenames and comments in a form you can hand to your editing workflow. Files: `src/export.py` 3. Decide the shared password behaviour Either separate sessions or a shared selection, but say which in the interface rather than leaving it ambiguous. Files: `templates/gallery.html` ### Done when - [ ] Favourites persist across devices with the same password - [ ] The exported selection matches the client's favourites - [ ] Comments stay attached to their image - [ ] Shared-password behaviour is stated in the interface ### Watch out - Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. ## Phase 5 · Downloads on your terms Clients get exactly what you agreed to give them, and you know what they took. ### Steps 1. Enforce the policy at the URL level The policy decides which signatures are issued at all. An original URL should not exist for a previews-only gallery. Files: `src/downloads.py` 2. Stream archives Build and send simultaneously so memory stays flat. Test with a large gallery and watch memory. Files: `src/zip.py` 3. Record what was taken Per image and per gallery, with a hashed address rather than a stored one. Files: `src/downloads.py` ### Done when - [ ] A previews-only gallery serves no original - [ ] A large zip streams without memory growing to its size - [ ] Downloads are recorded and listable per gallery - [ ] A policy change takes effect on an open gallery ### Watch out - Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. ## Not in this build - Raw processing quality, which is the real craft here. - Print store and fulfilment. - Client mobile applications. - Library sync across your own machines. ## After v1, if you want it - Watermarking previews for galleries where downloads are disabled - A client-facing selection limit, so a package of forty images is enforced rather than negotiated - Automatic archival of galleries past their expiry, with a warning first ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where your originals live. Read only; nothing is written here. LIBRARY_PATH=~/Pictures/Library # Required. The index over your library. Rebuildable from the library, so not the important backup. CATALOGUE_DB=./data/catalogue.db # Required. Private bucket holding derivatives. Originals go here only when a gallery policy allows downloads. BUCKET_NAME=client-galleries # Required · secret. Scoped to the gallery bucket only. BUCKET_SECRET_KEY=replace-with-secret # Required. What clients fetch from. The bucket hostname never appears in a gallery. CDN_BASE_URL=https://galleries.example.com # Required. How long an image URL lasts after the password is entered. SIGNED_URL_TTL_SECONDS=3600 # Required. Preview size. Large enough to look sharp, small enough to open on mobile data. PREVIEW_LONG_EDGE=2048
You are building a production product version of Pixieset. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Pixieset · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Pixieset, publish a simple password-protected client gallery from user-owned storage. The hard boundary is gallery hosting, proofing, downloads, store, mobile apps, and photographer workflow, plus image pipeline quality, models, and workflow polish. ## Product outcome A gallery system where originals never leave your disk without a decision, images are unreachable without authentication, location metadata never reaches a client, selections survive a device change, and you know what each gallery costs to keep. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop computer - local photo library - sufficient disk space - optional GPU for AI features ## Explicit non-goals for v1 - Raw processing quality, which is the real craft here. - Print store and fulfilment. - Client mobile applications. - Library sync across your own machines. - gallery hosting, proofing, downloads, store, mobile apps, and photographer workflow - proprietary raw-processing quality - cloud sync and sharing - large AI models - camera and print ecosystem ## Success criteria - No location metadata survives into any derivative, verified with a tool - A direct object request without a signature is confirmed refused - A previews-only gallery is confirmed to serve no original - Selections are confirmed to persist across devices - An original has been restored from backup and dated ===== BRIEF.md ===== # Build brief · Pixieset The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a client photo gallery published from your own storage, with proofing and controlled downloads to replace Pixieset. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Catalogue: Python 3.12 with SQLite, reading a local library · the originals stay where they are; the catalogue is an index over them - Previews: libvips through pyvips · several times faster and far lighter on memory than the alternatives when generating thousands of derivatives - Gallery: A static site generated per gallery, uploaded to object storage · no server to keep running for a gallery a client opens twice - Access: Signed URLs plus a gallery password, checked before any image URL exists · a static gallery still needs a real gate, and the gate has to be in front of the images rather than the page - Storage: S3-compatible object storage behind a content delivery network · photographs are large and clients open galleries from phones on poor connections ### Data model (create this before Phase 1) - photos(id, path, taken_at, width, height, orientation, camera, content_hash) · the catalogue, pointing at originals in place - galleries(id, name, client_name, slug, password_hash, expires_at, download_policy, published_at) - gallery_photos(gallery_id, photo_id, position, favourited_at, comment) - derivatives(photo_id, size, key, bytes, generated_at) · thumbnail, preview and optional full size - downloads(gallery_id, photo_id, at, ip_hash) · so you can answer what a client actually took - Originals are never uploaded unless the download policy explicitly allows them, and that is a per-gallery decision. ### Phase 1 · Cataloguing what you already have Build: Walk a photo directory, read metadata, and index into SQLite without moving or modifying anything. Handle orientation from the metadata, and use the capture date rather than the file date. Detect duplicates by content hash. Trap: A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. Done when: Ten thousand photographs index in a time you can state. Portrait images report correct orientation. Capture dates come from metadata, falling back to file date only when metadata is absent, and the fallback is recorded. The same photograph in two folders is detected as a duplicate. Do not build yet: No previews, no galleries, no publishing. ### Phase 2 · Derivatives that look right and are not enormous Build: Generate thumbnail and preview sizes with libvips, applying orientation, resizing in linear light, sharpening after the resize, and stripping metadata that should not travel. Measure the total size of a gallery's derivatives. Trap: Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. Trap: Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. Done when: A generated preview shows no visible darkening compared to the original, checked on a textured image. Location metadata is absent from every derivative, verified with a metadata tool. A gallery of two hundred previews has a total size you can state, and it is small enough to open on mobile data. Do not build yet: No galleries or publishing yet. ### Phase 3 · Galleries and a gate that is real Build: Create a gallery from selected photographs, with a password and an expiry. Generate a static gallery page. Serve images through signed URLs issued only after the password check, so the images are not reachable by guessing. Trap: A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. Done when: A gallery opens with the correct password and refuses the wrong one. An image URL taken from a gallery stops working after the signature expires. A direct request to an object without a signature is refused. An expired gallery refuses access entirely rather than showing an empty page. Do not build yet: No favouriting or downloads yet. ### Phase 4 · Proofing Build: Favouriting and per-image comments, stored server side against the gallery rather than in the client's browser. An export of the selection as a list you can act on. Make selections survive the client switching device. Trap: Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. Done when: Favourites set on one device appear when the gallery is opened on another with the same password. An exported selection lists filenames matching the client's favourites exactly. A comment is preserved with its image. Two clients sharing a password do not overwrite each other's selections silently, or the interface says selections are shared. Do not build yet: No downloads or store yet. ### Phase 5 · Downloads on your terms Build: A per-gallery download policy: none, previews only, or originals. Zip downloads generated on demand rather than stored. Record downloads so you can answer what a client already has. Trap: Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. Done when: A gallery set to previews only serves no original, verified by attempting one directly. A zip download of two hundred images streams without the process memory growing to the archive size. Downloads are recorded and can be listed per gallery. Changing the policy takes effect immediately for an already open gallery. Do not build yet: Backups and operations come last. ### Phase 6 · Backups, storage costs and handover Build: Back up originals to a second location, since the catalogue is only an index. Track storage and egress costs per gallery. Add gallery archival that removes derivatives from storage while keeping the catalogue entry, and a final handover export. Trap: The catalogue is an index and contains no photographs, so backing it up protects nothing. The originals need their own backup, and a photographer's originals are usually the largest and most irreplaceable data they own. Done when: Originals are backed up to a second location and a restore of one has been performed and dated. Storage cost per gallery is reported. Archiving a gallery removes its derivatives from object storage and leaves the catalogue entry intact. A handover export produces originals and the selection list for one client. Do not build yet: Nothing after this. ### Out of scope (and why) - Raw processing quality, which is the actual craft in this field and is not what this build touches. - The store and print fulfilment, which is a payment integration plus a relationship with a lab. - Mobile applications for clients, who mostly want a link that works well on a phone. - Cloud sync of your library across machines, which is a separate and larger problem than publishing galleries. ### README must contain - That the catalogue indexes originals in place and is not a backup - That derivatives are resized in linear light and have location metadata stripped - That gallery access issues signed URLs after the password, so images are not reachable by guessing - The download policies and that they are enforced by which signatures are issued - Where originals are backed up and the date of the last restore ===== ARCHITECTURE.md ===== # Architecture · Pixieset ## Stack | Part | Choice | Why | | --- | --- | --- | | Catalogue | Python 3.12 with SQLite, reading a local library | the originals stay where they are; the catalogue is an index over them | | Previews | libvips through pyvips | several times faster and far lighter on memory than the alternatives when generating thousands of derivatives | | Gallery | A static site generated per gallery, uploaded to object storage | no server to keep running for a gallery a client opens twice | | Access | Signed URLs plus a gallery password, checked before any image URL exists | a static gallery still needs a real gate, and the gate has to be in front of the images rather than the page | | Storage | S3-compatible object storage behind a content delivery network | photographs are large and clients open galleries from phones on poor connections | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Catalogue | Indexing originals in place. | Rebuildable from the library, which is the property that makes it safe to delete and regenerate. | | Derivatives | Resizing, sharpening and metadata stripping. | Any image library; the linear light resize and the metadata strip are correctness and privacy properties respectively. | | Access | Passwords, signatures and expiry. | The rule that no image URL exists before authentication must survive any change, or the gate becomes decoration. | | Proofing | Selections and comments, stored server side. | Any store, provided selections are not in the client's browser. | | Delivery | Serving galleries cheaply. | Providers are interchangeable. Keeping the bucket private is not. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `LIBRARY_PATH` · required · Where your originals live. Read only; nothing is written here. - `CATALOGUE_DB` · required · The index over your library. Rebuildable from the library, so not the important backup. - `BUCKET_NAME` · required · Private bucket holding derivatives. Originals go here only when a gallery policy allows downloads. - `BUCKET_SECRET_KEY` · required, secret · Scoped to the gallery bucket only. - `CDN_BASE_URL` · required · What clients fetch from. The bucket hostname never appears in a gallery. - `SIGNED_URL_TTL_SECONDS` · required · How long an image URL lasts after the password is entered. - `PREVIEW_LONG_EDGE` · required · Preview size. Large enough to look sharp, small enough to open on mobile data. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Pixieset product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Python 3.12 with SQLite, reading a local library, libvips through pyvips, A static site generated per gallery, uploaded to object storage, Signed URLs plus a gallery password, checked before any image URL exists, S3-compatible object storage behind a content delivery network. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. - Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. - Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. - A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. - Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. - Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. - The catalogue is an index and contains no photographs, so backing it up protects nothing. The originals need their own backup, and a photographer's originals are usually the largest and most irreplaceable data they own. ===== MILESTONES.md ===== # Delivery milestones · Pixieset Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Cataloguing what you already have Your photographs are indexed in place, with correct orientation and dates. ### Steps 1. Walk and index without modifying Read only. The catalogue points at files in place; it never moves or rewrites them. Files: `src/catalogue.py` ```sh pip install pyvips ``` 2. Read orientation and capture date From metadata, with an explicit fallback that is recorded rather than silent. Files: `src/metadata.py` 3. Hash for duplicates Content hash, so the same image in two folders is one photograph with two paths. Files: `src/catalogue.py` ### Done when - [ ] Ten thousand photographs index in a stated time - [ ] Portrait images report correct orientation - [ ] Capture dates come from metadata where present - [ ] Duplicates across folders are detected ### Watch out - A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. ## M2 · Derivatives that look right and are not enormous Previews are sharp on a retina screen and small enough to load on mobile data. ### Steps 1. Resize in linear light Convert, resize, convert back, then sharpen. Sharpening before the resize does the wrong thing. Files: `src/derivatives.py` 2. Strip metadata deliberately Remove location and personal fields, keep copyright if you set it. Verify with a metadata tool rather than assuming. ```sh exiftool -G -a preview.jpg | grep -i gps ``` 3. Measure gallery weight Total bytes for a typical gallery. This is what a client on a phone will download. Files: `src/derivatives.py` ### Done when - [ ] Previews show no darkening on a textured image - [ ] No location metadata survives into a derivative - [ ] A two hundred image gallery's total size is stated - [ ] Sharpening is applied after resizing ### Watch out - Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. - Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. ## M3 · Galleries and a gate that is real A client can open their gallery, and nobody else can open it or its images. ### Steps 1. Generate the gallery page A static page with responsive images and lazy loading. It should open quickly on a phone. Files: `src/gallery.py`, `templates/gallery.html` 2. Gate with a password and signed URLs The password check issues short-lived signed URLs. No image URL exists before authentication. Files: `src/access.py` 3. Test the bypass paths Request an object directly, reuse an expired signature, and open an expired gallery. All three must fail. ### Done when - [ ] The correct password opens the gallery and a wrong one does not - [ ] An image URL stops working after expiry - [ ] A direct object request without a signature is refused - [ ] An expired gallery refuses access ### Watch out - A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. ## M4 · Proofing A client can mark favourites and leave a note, and you get a clean list. ### Steps 1. Store selections server side Against the gallery, keyed by a session established at password entry. Files: `src/proofing.py` 2. Export the selection Filenames and comments in a form you can hand to your editing workflow. Files: `src/export.py` 3. Decide the shared password behaviour Either separate sessions or a shared selection, but say which in the interface rather than leaving it ambiguous. Files: `templates/gallery.html` ### Done when - [ ] Favourites persist across devices with the same password - [ ] The exported selection matches the client's favourites - [ ] Comments stay attached to their image - [ ] Shared-password behaviour is stated in the interface ### Watch out - Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. ## M5 · Downloads on your terms Clients get exactly what you agreed to give them, and you know what they took. ### Steps 1. Enforce the policy at the URL level The policy decides which signatures are issued at all. An original URL should not exist for a previews-only gallery. Files: `src/downloads.py` 2. Stream archives Build and send simultaneously so memory stays flat. Test with a large gallery and watch memory. Files: `src/zip.py` 3. Record what was taken Per image and per gallery, with a hashed address rather than a stored one. Files: `src/downloads.py` ### Done when - [ ] A previews-only gallery serves no original - [ ] A large zip streams without memory growing to its size - [ ] Downloads are recorded and listable per gallery - [ ] A policy change takes effect on an open gallery ### Watch out - Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. ## M6 · Backups, storage costs and handover (production only) Your originals survive, the storage bill is predictable, and a client can be handed everything at the end. ### Steps 1. Back up originals separately The catalogue is not a backup. Originals go to a second location, and a restore is performed once and dated. Files: `scripts/backup.sh`, `README.md` 2. Report storage cost per gallery Bytes stored and egress served, priced. Old galleries accumulate cost quietly. Files: `src/costs.py` 3. Implement archival and handover Archival frees storage without losing the record. Handover produces everything a client is owed in one action. Files: `src/archive.py` ### Done when - [ ] An original has been restored from backup and the date recorded - [ ] Storage cost per gallery is reported - [ ] Archiving frees storage and keeps the catalogue entry - [ ] A handover export produces originals and the selection list ### Watch out - The catalogue is an index and contains no photographs, so backing it up protects nothing. The originals need their own backup, and a photographer's originals are usually the largest and most irreplaceable data they own. ===== OPERATIONS.md ===== # Operations · Pixieset ## Backup Originals to a second location, and preferably a third offsite. The catalogue and derivatives are both regenerable and are not worth backing up, which is a useful thing to know when sizing storage. ## Restore Restore originals, rebuild the catalogue, regenerate derivatives for any active gallery. Time this once, because for a large library it is measured in hours. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Storage bytes and egress per gallery against a budget, and expired galleries still holding derivatives. Old galleries are the quiet cost, since they accumulate and nobody looks at them. ## Incident checklist A gallery published with the wrong photographs or the wrong permissions is the realistic incident. Expiry and policy changes take effect immediately, which is the property that makes it recoverable. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No location metadata survives into any derivative, verified with a tool - [ ] A direct object request without a signature is confirmed refused - [ ] A previews-only gallery is confirmed to serve no original - [ ] Selections are confirmed to persist across devices - [ ] An original has been restored from backup and dated ## Launch constraint Do not market omitted Pixieset capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where your originals live. Read only; nothing is written here. LIBRARY_PATH=~/Pictures/Library # Required. The index over your library. Rebuildable from the library, so not the important backup. CATALOGUE_DB=./data/catalogue.db # Required. Private bucket holding derivatives. Originals go here only when a gallery policy allows downloads. BUCKET_NAME=client-galleries # Required · secret. Scoped to the gallery bucket only. BUCKET_SECRET_KEY=replace-with-secret # Required. What clients fetch from. The bucket hostname never appears in a gallery. CDN_BASE_URL=https://galleries.example.com # Required. How long an image URL lasts after the password is entered. SIGNED_URL_TTL_SECONDS=3600 # Required. Preview size. Large enough to look sharp, small enough to open on mobile data. PREVIEW_LONG_EDGE=2048
# Pixieset · indie build A catalogue of your local photographs that publishes a password-protected gallery to storage you own: generated previews at sensible sizes, client favouriting for proofing, download permissions per gallery, expiry dates, and originals that never leave your disk unless you decide they do. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Catalogue | Python 3.12 with SQLite, reading a local library | the originals stay where they are; the catalogue is an index over them | | Previews | libvips through pyvips | several times faster and far lighter on memory than the alternatives when generating thousands of derivatives | | Gallery | A static site generated per gallery, uploaded to object storage | no server to keep running for a gallery a client opens twice | | Access | Signed URLs plus a gallery password, checked before any image URL exists | a static gallery still needs a real gate, and the gate has to be in front of the images rather than the page | | Storage | S3-compatible object storage behind a content delivery network | photographs are large and clients open galleries from phones on poor connections | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the catalogue, derivative generation and publishing. - Get it: python.org or your package manager, then a virtual environment. - Verify: python3 --version prints 3.12 or higher - [ ] **libvips** · free - Why: Generating thousands of derivatives with a memory-hungry library will exhaust a laptop. libvips is substantially faster and lighter for exactly this workload. - Get it: brew install vips, or your package manager, then pip install pyvips. - Verify: python -c "import pyvips; print(pyvips.version(0))" runs - [ ] **An S3-compatible bucket with a delivery network** · storage is cheap; egress varies enormously by provider - Why: Galleries are opened from phones on poor connections, and photographs are large. Egress rate is the cost that matters. - Get it: Create a private bucket and credentials scoped to it, and put a delivery network in front. Compare egress pricing before choosing. - Verify: A test object uploads, is refused when requested directly, and is served through the network with a signature - [ ] **A real photo library with at least ten thousand images** · free - Why: Indexing time, derivative generation time and gallery weight are all meaningless at a hundred images and decisive at ten thousand. - Get it: Use your own library. If it is smaller, duplicate it to test scale before trusting the performance numbers. - Verify: The library is available and its size is recorded - [ ] **A metadata inspection tool** · free - Why: Phase 2's claim that location metadata is stripped must be verified with a tool rather than assumed, because publishing a client's location is a real harm. - Get it: brew install exiftool, or your package manager. - Verify: It reports metadata for a source image and finds none in a derivative - [ ] **A second location for original backups** · varies with library size; this is usually a photographer's largest storage cost - Why: Originals are the irreplaceable asset and the catalogue does not contain them. - Get it: An external drive plus an offsite copy, or object storage. Decide before Phase 6, and prefer two locations to one. - Verify: A restore of one original has been performed ## Quick start ```sh pip install pyvips ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Raw processing quality, which is the real craft here. - Print store and fulfilment. - Client mobile applications. - Library sync across your own machines. - gallery hosting, proofing, downloads, store, mobile apps, and photographer workflow - proprietary raw-processing quality - cloud sync and sharing - large AI models - camera and print ecosystem If one of those is essential to you, that is the reason to keep paying for Pixieset, and the README should say so rather than pretend.
# Build brief · Pixieset The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a client photo gallery published from your own storage, with proofing and controlled downloads to replace Pixieset. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Catalogue: Python 3.12 with SQLite, reading a local library · the originals stay where they are; the catalogue is an index over them - Previews: libvips through pyvips · several times faster and far lighter on memory than the alternatives when generating thousands of derivatives - Gallery: A static site generated per gallery, uploaded to object storage · no server to keep running for a gallery a client opens twice - Access: Signed URLs plus a gallery password, checked before any image URL exists · a static gallery still needs a real gate, and the gate has to be in front of the images rather than the page - Storage: S3-compatible object storage behind a content delivery network · photographs are large and clients open galleries from phones on poor connections ### Data model (create this before Phase 1) - photos(id, path, taken_at, width, height, orientation, camera, content_hash) · the catalogue, pointing at originals in place - galleries(id, name, client_name, slug, password_hash, expires_at, download_policy, published_at) - gallery_photos(gallery_id, photo_id, position, favourited_at, comment) - derivatives(photo_id, size, key, bytes, generated_at) · thumbnail, preview and optional full size - downloads(gallery_id, photo_id, at, ip_hash) · so you can answer what a client actually took - Originals are never uploaded unless the download policy explicitly allows them, and that is a per-gallery decision. ### Phase 1 · Cataloguing what you already have Build: Walk a photo directory, read metadata, and index into SQLite without moving or modifying anything. Handle orientation from the metadata, and use the capture date rather than the file date. Detect duplicates by content hash. Trap: A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. Done when: Ten thousand photographs index in a time you can state. Portrait images report correct orientation. Capture dates come from metadata, falling back to file date only when metadata is absent, and the fallback is recorded. The same photograph in two folders is detected as a duplicate. Do not build yet: No previews, no galleries, no publishing. ### Phase 2 · Derivatives that look right and are not enormous Build: Generate thumbnail and preview sizes with libvips, applying orientation, resizing in linear light, sharpening after the resize, and stripping metadata that should not travel. Measure the total size of a gallery's derivatives. Trap: Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. Trap: Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. Done when: A generated preview shows no visible darkening compared to the original, checked on a textured image. Location metadata is absent from every derivative, verified with a metadata tool. A gallery of two hundred previews has a total size you can state, and it is small enough to open on mobile data. Do not build yet: No galleries or publishing yet. ### Phase 3 · Galleries and a gate that is real Build: Create a gallery from selected photographs, with a password and an expiry. Generate a static gallery page. Serve images through signed URLs issued only after the password check, so the images are not reachable by guessing. Trap: A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. Done when: A gallery opens with the correct password and refuses the wrong one. An image URL taken from a gallery stops working after the signature expires. A direct request to an object without a signature is refused. An expired gallery refuses access entirely rather than showing an empty page. Do not build yet: No favouriting or downloads yet. ### Phase 4 · Proofing Build: Favouriting and per-image comments, stored server side against the gallery rather than in the client's browser. An export of the selection as a list you can act on. Make selections survive the client switching device. Trap: Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. Done when: Favourites set on one device appear when the gallery is opened on another with the same password. An exported selection lists filenames matching the client's favourites exactly. A comment is preserved with its image. Two clients sharing a password do not overwrite each other's selections silently, or the interface says selections are shared. Do not build yet: No downloads or store yet. ### Phase 5 · Downloads on your terms Build: A per-gallery download policy: none, previews only, or originals. Zip downloads generated on demand rather than stored. Record downloads so you can answer what a client already has. Trap: Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. Done when: A gallery set to previews only serves no original, verified by attempting one directly. A zip download of two hundred images streams without the process memory growing to the archive size. Downloads are recorded and can be listed per gallery. Changing the policy takes effect immediately for an already open gallery. Do not build yet: Backups and operations come last. ### Phase 6 · Backups, storage costs and handover Build: Back up originals to a second location, since the catalogue is only an index. Track storage and egress costs per gallery. Add gallery archival that removes derivatives from storage while keeping the catalogue entry, and a final handover export. Trap: The catalogue is an index and contains no photographs, so backing it up protects nothing. The originals need their own backup, and a photographer's originals are usually the largest and most irreplaceable data they own. Done when: Originals are backed up to a second location and a restore of one has been performed and dated. Storage cost per gallery is reported. Archiving a gallery removes its derivatives from object storage and leaves the catalogue entry intact. A handover export produces originals and the selection list for one client. Do not build yet: Nothing after this. ### Out of scope (and why) - Raw processing quality, which is the actual craft in this field and is not what this build touches. - The store and print fulfilment, which is a payment integration plus a relationship with a lab. - Mobile applications for clients, who mostly want a link that works well on a phone. - Cloud sync of your library across machines, which is a separate and larger problem than publishing galleries. ### README must contain - That the catalogue indexes originals in place and is not a backup - That derivatives are resized in linear light and have location metadata stripped - That gallery access issues signed URLs after the password, so images are not reachable by guessing - The download policies and that they are enforced by which signatures are issued - Where originals are backed up and the date of the last restore
# Agent instructions · Pixieset indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with SQLite, reading a local library, libvips through pyvips, A static site generated per gallery, uploaded to object storage, Signed URLs plus a gallery password, checked before any image URL exists, S3-compatible object storage behind a content delivery network. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. - Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. - Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. - A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. - Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. - Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly.
# Build plan · Pixieset A catalogue of your local photographs that publishes a password-protected gallery to storage you own: generated previews at sensible sizes, client favouriting for proofing, download permissions per gallery, expiry dates, and originals that never leave your disk unless you decide they do. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Cataloguing what you already have Your photographs are indexed in place, with correct orientation and dates. ### Steps 1. Walk and index without modifying Read only. The catalogue points at files in place; it never moves or rewrites them. Files: `src/catalogue.py` ```sh pip install pyvips ``` 2. Read orientation and capture date From metadata, with an explicit fallback that is recorded rather than silent. Files: `src/metadata.py` 3. Hash for duplicates Content hash, so the same image in two folders is one photograph with two paths. Files: `src/catalogue.py` ### Done when - [ ] Ten thousand photographs index in a stated time - [ ] Portrait images report correct orientation - [ ] Capture dates come from metadata where present - [ ] Duplicates across folders are detected ### Watch out - A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. ## Phase 2 · Derivatives that look right and are not enormous Previews are sharp on a retina screen and small enough to load on mobile data. ### Steps 1. Resize in linear light Convert, resize, convert back, then sharpen. Sharpening before the resize does the wrong thing. Files: `src/derivatives.py` 2. Strip metadata deliberately Remove location and personal fields, keep copyright if you set it. Verify with a metadata tool rather than assuming. ```sh exiftool -G -a preview.jpg | grep -i gps ``` 3. Measure gallery weight Total bytes for a typical gallery. This is what a client on a phone will download. Files: `src/derivatives.py` ### Done when - [ ] Previews show no darkening on a textured image - [ ] No location metadata survives into a derivative - [ ] A two hundred image gallery's total size is stated - [ ] Sharpening is applied after resizing ### Watch out - Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. - Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. ## Phase 3 · Galleries and a gate that is real A client can open their gallery, and nobody else can open it or its images. ### Steps 1. Generate the gallery page A static page with responsive images and lazy loading. It should open quickly on a phone. Files: `src/gallery.py`, `templates/gallery.html` 2. Gate with a password and signed URLs The password check issues short-lived signed URLs. No image URL exists before authentication. Files: `src/access.py` 3. Test the bypass paths Request an object directly, reuse an expired signature, and open an expired gallery. All three must fail. ### Done when - [ ] The correct password opens the gallery and a wrong one does not - [ ] An image URL stops working after expiry - [ ] A direct object request without a signature is refused - [ ] An expired gallery refuses access ### Watch out - A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. ## Phase 4 · Proofing A client can mark favourites and leave a note, and you get a clean list. ### Steps 1. Store selections server side Against the gallery, keyed by a session established at password entry. Files: `src/proofing.py` 2. Export the selection Filenames and comments in a form you can hand to your editing workflow. Files: `src/export.py` 3. Decide the shared password behaviour Either separate sessions or a shared selection, but say which in the interface rather than leaving it ambiguous. Files: `templates/gallery.html` ### Done when - [ ] Favourites persist across devices with the same password - [ ] The exported selection matches the client's favourites - [ ] Comments stay attached to their image - [ ] Shared-password behaviour is stated in the interface ### Watch out - Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. ## Phase 5 · Downloads on your terms Clients get exactly what you agreed to give them, and you know what they took. ### Steps 1. Enforce the policy at the URL level The policy decides which signatures are issued at all. An original URL should not exist for a previews-only gallery. Files: `src/downloads.py` 2. Stream archives Build and send simultaneously so memory stays flat. Test with a large gallery and watch memory. Files: `src/zip.py` 3. Record what was taken Per image and per gallery, with a hashed address rather than a stored one. Files: `src/downloads.py` ### Done when - [ ] A previews-only gallery serves no original - [ ] A large zip streams without memory growing to its size - [ ] Downloads are recorded and listable per gallery - [ ] A policy change takes effect on an open gallery ### Watch out - Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. ## Not in this build - Raw processing quality, which is the real craft here. - Print store and fulfilment. - Client mobile applications. - Library sync across your own machines. ## After v1, if you want it - Watermarking previews for galleries where downloads are disabled - A client-facing selection limit, so a package of forty images is enforced rather than negotiated - Automatic archival of galleries past their expiry, with a warning first
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where your originals live. Read only; nothing is written here. LIBRARY_PATH=~/Pictures/Library # Required. The index over your library. Rebuildable from the library, so not the important backup. CATALOGUE_DB=./data/catalogue.db # Required. Private bucket holding derivatives. Originals go here only when a gallery policy allows downloads. BUCKET_NAME=client-galleries # Required · secret. Scoped to the gallery bucket only. BUCKET_SECRET_KEY=replace-with-secret # Required. What clients fetch from. The bucket hostname never appears in a gallery. CDN_BASE_URL=https://galleries.example.com # Required. How long an image URL lasts after the password is entered. SIGNED_URL_TTL_SECONDS=3600 # Required. Preview size. Large enough to look sharp, small enough to open on mobile data. PREVIEW_LONG_EDGE=2048
# Pixieset · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Pixieset, publish a simple password-protected client gallery from user-owned storage. The hard boundary is gallery hosting, proofing, downloads, store, mobile apps, and photographer workflow, plus image pipeline quality, models, and workflow polish. ## Product outcome A gallery system where originals never leave your disk without a decision, images are unreachable without authentication, location metadata never reaches a client, selections survive a device change, and you know what each gallery costs to keep. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop computer - local photo library - sufficient disk space - optional GPU for AI features ## Explicit non-goals for v1 - Raw processing quality, which is the real craft here. - Print store and fulfilment. - Client mobile applications. - Library sync across your own machines. - gallery hosting, proofing, downloads, store, mobile apps, and photographer workflow - proprietary raw-processing quality - cloud sync and sharing - large AI models - camera and print ecosystem ## Success criteria - No location metadata survives into any derivative, verified with a tool - A direct object request without a signature is confirmed refused - A previews-only gallery is confirmed to serve no original - Selections are confirmed to persist across devices - An original has been restored from backup and dated
# Build brief · Pixieset The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a client photo gallery published from your own storage, with proofing and controlled downloads to replace Pixieset. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Catalogue: Python 3.12 with SQLite, reading a local library · the originals stay where they are; the catalogue is an index over them - Previews: libvips through pyvips · several times faster and far lighter on memory than the alternatives when generating thousands of derivatives - Gallery: A static site generated per gallery, uploaded to object storage · no server to keep running for a gallery a client opens twice - Access: Signed URLs plus a gallery password, checked before any image URL exists · a static gallery still needs a real gate, and the gate has to be in front of the images rather than the page - Storage: S3-compatible object storage behind a content delivery network · photographs are large and clients open galleries from phones on poor connections ### Data model (create this before Phase 1) - photos(id, path, taken_at, width, height, orientation, camera, content_hash) · the catalogue, pointing at originals in place - galleries(id, name, client_name, slug, password_hash, expires_at, download_policy, published_at) - gallery_photos(gallery_id, photo_id, position, favourited_at, comment) - derivatives(photo_id, size, key, bytes, generated_at) · thumbnail, preview and optional full size - downloads(gallery_id, photo_id, at, ip_hash) · so you can answer what a client actually took - Originals are never uploaded unless the download policy explicitly allows them, and that is a per-gallery decision. ### Phase 1 · Cataloguing what you already have Build: Walk a photo directory, read metadata, and index into SQLite without moving or modifying anything. Handle orientation from the metadata, and use the capture date rather than the file date. Detect duplicates by content hash. Trap: A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. Done when: Ten thousand photographs index in a time you can state. Portrait images report correct orientation. Capture dates come from metadata, falling back to file date only when metadata is absent, and the fallback is recorded. The same photograph in two folders is detected as a duplicate. Do not build yet: No previews, no galleries, no publishing. ### Phase 2 · Derivatives that look right and are not enormous Build: Generate thumbnail and preview sizes with libvips, applying orientation, resizing in linear light, sharpening after the resize, and stripping metadata that should not travel. Measure the total size of a gallery's derivatives. Trap: Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. Trap: Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. Done when: A generated preview shows no visible darkening compared to the original, checked on a textured image. Location metadata is absent from every derivative, verified with a metadata tool. A gallery of two hundred previews has a total size you can state, and it is small enough to open on mobile data. Do not build yet: No galleries or publishing yet. ### Phase 3 · Galleries and a gate that is real Build: Create a gallery from selected photographs, with a password and an expiry. Generate a static gallery page. Serve images through signed URLs issued only after the password check, so the images are not reachable by guessing. Trap: A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. Done when: A gallery opens with the correct password and refuses the wrong one. An image URL taken from a gallery stops working after the signature expires. A direct request to an object without a signature is refused. An expired gallery refuses access entirely rather than showing an empty page. Do not build yet: No favouriting or downloads yet. ### Phase 4 · Proofing Build: Favouriting and per-image comments, stored server side against the gallery rather than in the client's browser. An export of the selection as a list you can act on. Make selections survive the client switching device. Trap: Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. Done when: Favourites set on one device appear when the gallery is opened on another with the same password. An exported selection lists filenames matching the client's favourites exactly. A comment is preserved with its image. Two clients sharing a password do not overwrite each other's selections silently, or the interface says selections are shared. Do not build yet: No downloads or store yet. ### Phase 5 · Downloads on your terms Build: A per-gallery download policy: none, previews only, or originals. Zip downloads generated on demand rather than stored. Record downloads so you can answer what a client already has. Trap: Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. Done when: A gallery set to previews only serves no original, verified by attempting one directly. A zip download of two hundred images streams without the process memory growing to the archive size. Downloads are recorded and can be listed per gallery. Changing the policy takes effect immediately for an already open gallery. Do not build yet: Backups and operations come last. ### Phase 6 · Backups, storage costs and handover Build: Back up originals to a second location, since the catalogue is only an index. Track storage and egress costs per gallery. Add gallery archival that removes derivatives from storage while keeping the catalogue entry, and a final handover export. Trap: The catalogue is an index and contains no photographs, so backing it up protects nothing. The originals need their own backup, and a photographer's originals are usually the largest and most irreplaceable data they own. Done when: Originals are backed up to a second location and a restore of one has been performed and dated. Storage cost per gallery is reported. Archiving a gallery removes its derivatives from object storage and leaves the catalogue entry intact. A handover export produces originals and the selection list for one client. Do not build yet: Nothing after this. ### Out of scope (and why) - Raw processing quality, which is the actual craft in this field and is not what this build touches. - The store and print fulfilment, which is a payment integration plus a relationship with a lab. - Mobile applications for clients, who mostly want a link that works well on a phone. - Cloud sync of your library across machines, which is a separate and larger problem than publishing galleries. ### README must contain - That the catalogue indexes originals in place and is not a backup - That derivatives are resized in linear light and have location metadata stripped - That gallery access issues signed URLs after the password, so images are not reachable by guessing - The download policies and that they are enforced by which signatures are issued - Where originals are backed up and the date of the last restore
# Architecture · Pixieset ## Stack | Part | Choice | Why | | --- | --- | --- | | Catalogue | Python 3.12 with SQLite, reading a local library | the originals stay where they are; the catalogue is an index over them | | Previews | libvips through pyvips | several times faster and far lighter on memory than the alternatives when generating thousands of derivatives | | Gallery | A static site generated per gallery, uploaded to object storage | no server to keep running for a gallery a client opens twice | | Access | Signed URLs plus a gallery password, checked before any image URL exists | a static gallery still needs a real gate, and the gate has to be in front of the images rather than the page | | Storage | S3-compatible object storage behind a content delivery network | photographs are large and clients open galleries from phones on poor connections | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Catalogue | Indexing originals in place. | Rebuildable from the library, which is the property that makes it safe to delete and regenerate. | | Derivatives | Resizing, sharpening and metadata stripping. | Any image library; the linear light resize and the metadata strip are correctness and privacy properties respectively. | | Access | Passwords, signatures and expiry. | The rule that no image URL exists before authentication must survive any change, or the gate becomes decoration. | | Proofing | Selections and comments, stored server side. | Any store, provided selections are not in the client's browser. | | Delivery | Serving galleries cheaply. | Providers are interchangeable. Keeping the bucket private is not. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `LIBRARY_PATH` · required · Where your originals live. Read only; nothing is written here. - `CATALOGUE_DB` · required · The index over your library. Rebuildable from the library, so not the important backup. - `BUCKET_NAME` · required · Private bucket holding derivatives. Originals go here only when a gallery policy allows downloads. - `BUCKET_SECRET_KEY` · required, secret · Scoped to the gallery bucket only. - `CDN_BASE_URL` · required · What clients fetch from. The bucket hostname never appears in a gallery. - `SIGNED_URL_TTL_SECONDS` · required · How long an image URL lasts after the password is entered. - `PREVIEW_LONG_EDGE` · required · Preview size. Large enough to look sharp, small enough to open on mobile data. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Pixieset product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Python 3.12 with SQLite, reading a local library, libvips through pyvips, A static site generated per gallery, uploaded to object storage, Signed URLs plus a gallery password, checked before any image URL exists, S3-compatible object storage behind a content delivery network. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. - Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. - Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. - A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. - Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. - Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. - The catalogue is an index and contains no photographs, so backing it up protects nothing. The originals need their own backup, and a photographer's originals are usually the largest and most irreplaceable data they own.
# Delivery milestones · Pixieset Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Cataloguing what you already have Your photographs are indexed in place, with correct orientation and dates. ### Steps 1. Walk and index without modifying Read only. The catalogue points at files in place; it never moves or rewrites them. Files: `src/catalogue.py` ```sh pip install pyvips ``` 2. Read orientation and capture date From metadata, with an explicit fallback that is recorded rather than silent. Files: `src/metadata.py` 3. Hash for duplicates Content hash, so the same image in two folders is one photograph with two paths. Files: `src/catalogue.py` ### Done when - [ ] Ten thousand photographs index in a stated time - [ ] Portrait images report correct orientation - [ ] Capture dates come from metadata where present - [ ] Duplicates across folders are detected ### Watch out - A photograph's stored orientation is metadata, not pixels, and ignoring it means every portrait shot appears sideways in your previews while looking correct in every viewer you check it in. Apply the orientation when generating derivatives and record that you did. ## M2 · Derivatives that look right and are not enormous Previews are sharp on a retina screen and small enough to load on mobile data. ### Steps 1. Resize in linear light Convert, resize, convert back, then sharpen. Sharpening before the resize does the wrong thing. Files: `src/derivatives.py` 2. Strip metadata deliberately Remove location and personal fields, keep copyright if you set it. Verify with a metadata tool rather than assuming. ```sh exiftool -G -a preview.jpg | grep -i gps ``` 3. Measure gallery weight Total bytes for a typical gallery. This is what a client on a phone will download. Files: `src/derivatives.py` ### Done when - [ ] Previews show no darkening on a textured image - [ ] No location metadata survives into a derivative - [ ] A two hundred image gallery's total size is stated - [ ] Sharpening is applied after resizing ### Watch out - Resizing in gamma-encoded space darkens images and loses fine detail, which is visible on anything with texture. Resize in linear light and convert back. - Metadata travels with a photograph unless you strip it, and that includes location. Publishing a client gallery with location metadata intact tells anyone who downloads an image where it was taken, which for a wedding or a family shoot is information they did not agree to share. ## M3 · Galleries and a gate that is real A client can open their gallery, and nobody else can open it or its images. ### Steps 1. Generate the gallery page A static page with responsive images and lazy loading. It should open quickly on a phone. Files: `src/gallery.py`, `templates/gallery.html` 2. Gate with a password and signed URLs The password check issues short-lived signed URLs. No image URL exists before authentication. Files: `src/access.py` 3. Test the bypass paths Request an object directly, reuse an expired signature, and open an expired gallery. All three must fail. ### Done when - [ ] The correct password opens the gallery and a wrong one does not - [ ] An image URL stops working after expiry - [ ] A direct object request without a signature is refused - [ ] An expired gallery refuses access ### Watch out - A password on the page with images at predictable public URLs is not access control, because anyone with an image URL skips the page entirely and image URLs get shared. Issue signed URLs after authentication and keep the objects private, or the password is decoration. ## M4 · Proofing A client can mark favourites and leave a note, and you get a clean list. ### Steps 1. Store selections server side Against the gallery, keyed by a session established at password entry. Files: `src/proofing.py` 2. Export the selection Filenames and comments in a form you can hand to your editing workflow. Files: `src/export.py` 3. Decide the shared password behaviour Either separate sessions or a shared selection, but say which in the interface rather than leaving it ambiguous. Files: `templates/gallery.html` ### Done when - [ ] Favourites persist across devices with the same password - [ ] The exported selection matches the client's favourites - [ ] Comments stay attached to their image - [ ] Shared-password behaviour is stated in the interface ### Watch out - Storing selections in browser storage means a client who starts on their phone and finishes on a laptop loses half their choices and does not realise. Store selections server side against a gallery session, and let the client resume with the same password. ## M5 · Downloads on your terms Clients get exactly what you agreed to give them, and you know what they took. ### Steps 1. Enforce the policy at the URL level The policy decides which signatures are issued at all. An original URL should not exist for a previews-only gallery. Files: `src/downloads.py` 2. Stream archives Build and send simultaneously so memory stays flat. Test with a large gallery and watch memory. Files: `src/zip.py` 3. Record what was taken Per image and per gallery, with a hashed address rather than a stored one. Files: `src/downloads.py` ### Done when - [ ] A previews-only gallery serves no original - [ ] A large zip streams without memory growing to its size - [ ] Downloads are recorded and listable per gallery - [ ] A policy change takes effect on an open gallery ### Watch out - Generating a zip of two hundred originals in memory will exhaust it and take the process down. Stream the archive as it is built, and cap the request so an unattended retry loop cannot generate the same archive repeatedly. ## M6 · Backups, storage costs and handover (production only) Your originals survive, the storage bill is predictable, and a client can be handed everything at the end. ### Steps 1. Back up originals separately The catalogue is not a backup. Originals go to a second location, and a restore is performed once and dated. Files: `scripts/backup.sh`, `README.md` 2. Report storage cost per gallery Bytes stored and egress served, priced. Old galleries accumulate cost quietly. Files: `src/costs.py` 3. Implement archival and handover Archival frees storage without losing the record. Handover produces everything a client is owed in one action. Files: `src/archive.py` ### Done when - [ ] An original has been restored from backup and the date recorded - [ ] Storage cost per gallery is reported - [ ] Archiving frees storage and keeps the catalogue entry - [ ] A handover export produces originals and the selection list ### Watch out - The catalogue is an index and contains no photographs, so backing it up protects nothing. The originals need their own backup, and a photographer's originals are usually the largest and most irreplaceable data they own.
# Operations · Pixieset ## Backup Originals to a second location, and preferably a third offsite. The catalogue and derivatives are both regenerable and are not worth backing up, which is a useful thing to know when sizing storage. ## Restore Restore originals, rebuild the catalogue, regenerate derivatives for any active gallery. Time this once, because for a large library it is measured in hours. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Storage bytes and egress per gallery against a budget, and expired galleries still holding derivatives. Old galleries are the quiet cost, since they accumulate and nobody looks at them. ## Incident checklist A gallery published with the wrong photographs or the wrong permissions is the realistic incident. Expiry and policy changes take effect immediately, which is the property that makes it recoverable. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No location metadata survives into any derivative, verified with a tool - [ ] A direct object request without a signature is confirmed refused - [ ] A previews-only gallery is confirmed to serve no original - [ ] Selections are confirmed to persist across devices - [ ] An original has been restored from backup and dated ## Launch constraint Do not market omitted Pixieset capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where your originals live. Read only; nothing is written here. LIBRARY_PATH=~/Pictures/Library # Required. The index over your library. Rebuildable from the library, so not the important backup. CATALOGUE_DB=./data/catalogue.db # Required. Private bucket holding derivatives. Originals go here only when a gallery policy allows downloads. BUCKET_NAME=client-galleries # Required · secret. Scoped to the gallery bucket only. BUCKET_SECRET_KEY=replace-with-secret # Required. What clients fetch from. The bucket hostname never appears in a gallery. CDN_BASE_URL=https://galleries.example.com # Required. How long an image URL lasts after the password is entered. SIGNED_URL_TTL_SECONDS=3600 # Required. Preview size. Large enough to look sharp, small enough to open on mobile data. PREVIEW_LONG_EDGE=2048
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Pixieset because photographers pay because image quality, catalog safety, and fast handling of huge libraries matter more than cloning sliders. The recurring cost buys raw codecs, color management, metadata, previews, face models, GPU support, storage, backups, sync, and export fidelity, not just the visible interface.
xgallery hosting, proofing, downloads, store, mobile apps, and photographer workflow
xproprietary raw-processing quality
xcloud sync and sharing
xlarge AI models
xcamera and print ecosystem
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Pixieset pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 3 GB storage; unlimited galleries; up to 3 mobile gallery apps; 15% store commission. |
| basic | $10/workspace | $8/workspace | 10 GB storage; 30 minutes HD video; 100 email sends per month; unlimited mobile gallery apps; 0% Pixieset store commission. |
| plus | $20/workspace | $16/workspace | 100 GB storage; 500 email sends per month; unlimited galleries and mobile apps; 0% Pixieset store commission. |
| pro | $30/workspace | $24/workspace | 1 TB storage; RAW-file support; unlimited galleries and mobile apps; 0% Pixieset store commission. |
| ultimate | $50/workspace | $40/workspace | Unlimited standard photo storage; 1,000 GB RAW-file cap; 10,000 email sends per month; 0% Pixieset store commission. |
free tier3 GB storage, unlimited galleries, up to 3 mobile gallery apps and a 15% store commission
billingmonthly + annual (-20%)
hidden costsThe Free store takes 15% of sales; extra video storage is $5/month per additional hour; Photo Editor is a separate $12/month add-on.
verified 2026-08-14 · source ↗
Vibecode Pixieset
Kinda. The core of Pixieset is buildable in a weekend with the prompt on this page, but there are real gaps: gallery hosting, proofing, downloads, store, mobile apps, and photographer workflow, proprietary raw-processing quality. Read the honest list above before committing.
How much does Pixieset cost?
Pixieset costs about $8/month (Basic, checked 2026-07-31), which is $96 per year.
What do I lose by replacing Pixieset?
Honestly: gallery hosting, proofing, downloads, store, mobile apps, and photographer workflow; proprietary raw-processing quality; cloud sync and sharing; large AI models; camera and print ecosystem. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Pixieset?
Yes: Lychee (A clean self-hosted gallery with password-protected albums, downloads, metadata, and Docker deployment; no built-in storefront.) Piwigo (Private users and albums, passworded access, downloads, themes, and storage you own; less glossy, vastly less hostage-like.) The prompt is for when you want it exactly your way.