Selene is the fastest, most beautiful way to deliver photos to your clients. Built for photographers who care about every pixel — and every detail of the client experience.
Photos by Eclipse Media · Stacie & Callum at Glasshaus. The same lightbox, favorites, and slideshow your clients get.
Who Selene is for
Built for photographers who care about every frame.
…who run more than one wedding a weekend.
Duplicate a fully-branded gallery template in one click. The look-and-feel, watermark rules, product catalog, and password setup carry over — only the photos are new.
…who care more about how a print looks than which one sells.
Per-event brand overrides, per-photo watermark presets, 5 slideshow themes from intimate to editorial. Every gallery feels like the artist made it, not the platform.
…who want their clients to feel something when they open the gallery.
Branded share emails, magic-link one-click access, cinematic lightbox with Ken Burns, blur-up placeholders, accent-themed favorites. Every touchpoint reads like your studio, not ours.
From a folder of photos to a gallery your clients keep coming back to — in three steps.
Step 01
Upload
Drop a folder of full-resolution photos. EXIF orientation, dominant colour, and watermarked previews are extracted in parallel — no waiting on a queue.
Step 02
Brand
Your accent colour, your logo, your domain, your slideshow theme. Six themes from Classic crossfade to a meditative Hush. Hero, watermark presets, dark mode — all yours.
Step 03
Share
One branded link, password-gated if you want, with a real OG preview built from the gallery cover so iMessage and WhatsApp render it beautifully. Clients heart, comment, download, and order.
What's inside
Everything Pixieset and Pic-Time offer — done better.
01 — Foundation
Your own studio
A custom subdomain, your brand colors, your logo, your dark or light theme. Clients see you — not us. Custom domain mapping is one click away.
02 — Upload
Drag, drop, done
Drag-drop a whole folder, watch each photo decode in parallel with progress bars. EXIF orientation honored, dominant color extracted, watermarked previews generated on demand.
03 — Viewing
A gallery to fall for
Masonry that breathes. Lightbox with keyboard and touch. Slideshow themes — Classic crossfade, Cinema Ken Burns, Editorial cuts. Blur-up placeholders, no layout shift.
04 — Video
Films, not just frames
Wedding films and BTS reels live in the same gallery as your photos — one grid, no separate tab. Clients play them inline with a branded player: scrub, fullscreen, the same chrome as the rest of your work. Downloads stay on your terms.
05 — AI face tagging
Find every face
Faces are detected and grouped as you upload — the detection runs in your own browser, so your photos never leave your device for it. Clients tap a face to see every shot that person is in. Name a group once and it stays labelled across the gallery.
06 — Favorites & feedback
Selections, simplified
Clients heart what they love and leave per-photo notes. You see the picks, the comments, and the 7-day analytics. Export selections to CSV for printers and album designers.
07 — Sharing
Branded delivery
Password protection, magic-link invites that bypass the password, an email outbox so you can see every send, custom domains so the URL stays yours.
08 — Sales
Prints & packages
A studio per gallery: prints, digitals, hand-bound albums. Clients order from a branded store; you track pending and paid orders from one inbox. Stripe Checkout when you flip the switch.
[test] SHIPPED `19298688` — closed an untested-shipped-feature gap ([[untested-shipped-feature-e2e-vein]]): the **studio Logo URL** had ZERO e2e (`grep tests/e2e/ logo_url` → only a passing mention in `visual.spec.ts:2669`; the field is a plain `type=url` input in `SettingsForm.tsx:120`, persisted to `studios.brand_json.logo_url`). New `tests/e2e/studio-logo.spec.ts` drives the REAL control end-to-end and asserts against the PUBLIC `/[studioSlug]` page from a fresh anon context (search-engine-like, sidesteps the settings `useFormState` flash crash — same idiom as `studio-socials.spec.ts`): a fresh signup shows the studio name as a text **wordmark** (`img[alt="<name>"]` count 0); owner pastes a logo URL + Save (waiting on the committed POST per [[e2e-server-action-write-race]], not the SuccessFlash); the anon page re-fetch then renders the `<img src={logo_url} alt={studio.name} className="h-10">` in place of the span (`[studioSlug]/page.tsx:388`) AND the ProfessionalService JSON-LD emits `logo: <url>` (`:340`) — proving persistence + render + the Knowledge-Graph signal in one shot. **Determinism trick:** points `logo_url` at the app's OWN `/logo.svg` (via `new URL('/logo.svg', baseURL)`) so the image actually loads with real intrinsic size → `toBeVisible` holds, instead of a zero-width broken external image. **Verify:** first cold run flaked at the `toBeVisible` (storefront route warming) → 3 consecutive clean passes after warmup (59.6s/29.6s/25.3s), the 15s locator timeout absorbs cold-compile. @local-only (describe title → CI-excluded). Committed path-scoped (1 file) per [[polish-worker-path-scoped-commit]]; `status.json` + untracked infra worker-dirs left unstaged. Concurrent polish worker landed `dd7ab953`/`5db0cc0f` mid-wake. Telegram sent. DO NOT PUSH (push-batcher owns upstream).
[polish] SHIPPED `dd7ab953` — baselined the client-viewer Compare overlay in its **4-UP 2×2 grid fold** (`tests/e2e/visual/compare-fourup.spec.ts` + `gallery-compare-overlay-fourup.png`). **The gap:** both existing compare baselines pin the DEFAULT 2-up fold only — `gallery-compare-overlay` (visual.spec.ts:1538, 1280×800) and `gallery-compare-overlay-390x844` (mobile.spec.ts) — each favorites EXACTLY 2 photos so the overlay opens `paneCount===2`: side-by-side panes, L/R badges, Swap shown, the "4-up" toggle HIDDEN (gated `canFourUp = count>=4`, `GalleryView.tsx:3409`). Crossing to ≥4 picks and pressing the toggle swaps in a fold that renders in NO baseline: the PANES container flips `flex flex-col sm:flex-row` → `grid grid-cols-2 grid-rows-2` (a 2×2 quad, `:3556`), each badge goes numeric `1/2/3/4` not `L`/`R` (`:3486`), the Swap button (gated `paneCount===2`, `:3528`) DISAPPEARS, and the toggle label flips to "2-up" (`:3525`). A className/layout drift on the quad grid, numeric badges, or the vanished-Swap top-bar would sail past the whole suite. **Determinism:** seeded DEMO gallery (`/eclipse-media/demo`, fixed studio name+accent) so copy is stable run-to-run; favorited EXACTLY 4 tiles (loop: clearWelcome→hover→exact `Favorite` label→wait each POST, asserted 4× `Remove favorite`) reusing the `/api/client-session`-GET-gated convergent welcome-dismissal + per-iteration `clearWelcome()` scaffold proven on the 23:32 picks-compare de-flake, plus the favorites-GET-before-heart guard ([[playwright-getbyrole-name-is-substring]]). Opened the sheet (asserted `4 photos selected`), launched Compare, then keyed the toggle on its `title` attr (`button[title="Switch to four panes (G)"]`) — the button's accessible NAME is its visible "4-up" span, text-content-wins-over-title, so getByRole(name:'Switch…') MISSED it (cost 1 debug iter); asserts the reverse "Switch to two panes" title is visible BEFORE the snapshot so a dropped-`canFourUp`-gate regression can't bake the 2-up shape under this name. Full-overlay snapshot (clip 1280×800, `maxDiffPixelRatio:0.15`, `test.slow()`) reusing the generous photo-decode tolerance the 2-up sibling uses (four full-res `/api/img` panes). **Verify:** eyeballed the PNG (2×2 quad, numeric 1–4 badges, accent ring + "1/4" on focused pane #1, top-bar shows only "2-up" toggle + close X — Swap gone, 4-slotted thumbnail strip); 2 clean runs vs the baseline (17.8s gen / 34.3s + 41.2s verify), no flaky. @local-only (describe title → CI-excluded). Committed path-scoped (2 files) per [[polish-worker-path-scoped-commit]]; `status.json` + untracked infra worker-dirs left unstaged. DO NOT PUSH. **Next visual candidate:** with picks-summary (resting-1/sent-1/compare-≥2) AND the compare overlay (2-up desktop+mobile, now 4-up) all pinned, the client-viewer overlay state space is saturated — re-grep `tests/e2e/visual/` spec titles before treating any client overlay state as fresh.
[test] SHIPPED `f1c7006a` — DE-FLAKE follow-up on the picks-sheet 2-picks compare-fold spec the concurrent polish worker had JUST landed (`fbda50f3`, 23:25). **Concurrency, not orphan-recovery:** my wake-start `git status` snapshot showed `tests/e2e/visual/picks-sheet-compare.spec.ts` + its baseline as `??` at HEAD `c588f4ea`, so I picked it up as an interrupted WIP per [[orphaned-wip-untracked-seams]]. Between that snapshot and my commit the polish worker (`dispatch-worker-polish.sh`, now exited) committed the SAME spec+baseline (`fbda50f3` + docs `9414c864`) onto main — so my work landed as a refinement ON TOP, not a lost-commit replay ([[orphan-recovery-resets-uncommitted-work]] was the wrong read here: fbda50f3/9414c864 are real linear ancestors, never detached). **The genuine value-add:** the polish worker's `fbda50f3` spec had only a ONE-SHOT welcome-dismissal guard before the favorite loop, so the dismissed `ClientWelcome` backdrop (`role="dialog" aria-label="Welcome"`, `inset-0 z-50`) re-mounting from StrictMode's dev double-effect BETWEEN the two heart clicks swallowed the 2nd tile's hover — I reproduced this twice (`locator.hover` timeout on `figure.nth(1)`, "Welcome … intercepts pointer events"). Fix: a `clearWelcome()` helper (sweep skip-button + assert backdrop count 0) now runs at the TOP OF EACH favorite iteration, not just once before the loop. **Verify:** 2 consecutive clean runs at default retries (30.3s/29.3s), no flaky — the prior version went 1-flaky on every capture attempt. Baseline PNG bytes unchanged (same `gallery-picks-sheet-compare.png`); spec-only behavioural hardening. tsc not needed (test file). Committed path-scoped (2 files) per [[polish-worker-path-scoped-commit]]. Telegram sent. DO NOT PUSH. The 23:25 entry's saturation note stands: the picks-summary state space (resting-1 / sent-1 / compare-≥2) is fully pinned.
[polish] SHIPPED `fbda50f3` — baselined the client-viewer picks-summary sheet in its **≥2-PICKS compare fold** (`tests/e2e/visual/picks-sheet-compare.spec.ts` + `gallery-picks-sheet-compare.png`), closing the carried NEXT candidate from `aa0f5a86`/the 22:52 NO-SHIP. **Wake-start triage:** sole-worker confirmed LIVE — NO `.feature-active` flag, NO feature `dispatch-worker.sh` process (only my own `dispatch-worker-polish.sh` PIDs 81872/81863), `git diff` src/+tests/ clean → capture-safe per [[studio-visual-coverage-gaps]]. Dev `/`→200, db ok; the `.ci-watch` 03:15 DEPLOY-FROZEN is routine batch-tip lag ([[deploys-can-silently-not-ship]]), not a red gate. **The gap:** both 1-pick siblings — `gallery-picks-sheet` (resting, visual.spec.ts) and `gallery-picks-sheet-sent` (sent/done, `aa0f5a86`) — keep favorites at EXACTLY one so the count reads "1 photo selected" and the ≥2-picks affordance stays hidden. Crossing to two picks swaps in a fold that renders in NO baseline: the title pluralises to "2 photos selected" (`GalleryView.tsx:2490`) AND an accent-FILLED `<ArrowLeftRight/>`-iconned "Compare side-by-side" button appears at the head of the footer (gated `favs.size >= 2`, `:2594`), reflowing the `flex-wrap justify-end` action row into its multi-button wrap (Compare / View-only / **Download my picks (2)** / Keep browsing). A className/copy drift on the compare button's accent fill+border or the reflowed row would sail past the whole suite (the 1-pick siblings render the row WITHOUT the gated compare control). The note `<textarea>` + "Send my selection" button are still present (resting, pre-submit) — the deliberately NEW pixels are the plural title + compare button + reflowed footer. **Determinism:** seeded DEMO gallery (`/eclipse-media/demo`) so the studio-name copy is fixed run-to-run; favorited EXACTLY 2 tiles (loop: hover→exact `Favorite` label→wait on each POST, asserted 2× `Remove favorite` after) — element-scoped pixel-exact capture of the opaque text-only card (`:scope > div` first, no photos in frame → no backdrop-blur bleed) reusing the robust `/api/client-session`-GET-gated welcome-dismissal scaffold ([[registration-wall-and-session-race]]) + the favorites-GET-before-heart guard ([[playwright-getbyrole-name-is-substring]]). Sanity-asserts the `Compare side-by-side` button is VISIBLE before the snapshot so a dropped-gate regression can't bake the 1-pick shape under this name. **Verify:** cold `page.goto` timed out (route uncompiled) → warmed via curl ×2 (200 in ~1.6s) then generated; eyeballed the PNG (plural title, accent compare button + ArrowLeftRight icon, reflowed 4-button footer, empty note placeholder, no caret, no bleed); then **2 clean verify runs at `--retries=0`** (30s/27s) to prove stable. @local-only (describe title → CI-excluded via `e2e:prod`'s `--grep-invert @local-only`). Committed path-scoped (2 files) per [[polish-worker-path-scoped-commit]]; `status.json` + untracked infra worker-dirs left unstaged. Telegram sent. DO NOT PUSH (push-batcher owns upstream). **Next visual candidate:** the picks-summary state space is now saturated (resting-1 / sent-1 / compare-≥2 all pinned); the per-overlay element-state pure-visual queue is genuinely exhausted — re-grep `tests/e2e/visual/` spec titles before treating any client-viewer overlay state as a fresh gap.
[polish] NO-SHIP capture-deferral — feature worker is LIVE and mid-e2e-run → capture-unsafe. **Wake-start triage was decisive, not reflexive:** `.feature-active` flag is FRESH (22:45) AND `ps` confirms `dispatch-worker.sh` is genuinely alive (PID 79673/79663, started 22:45) — NOT the stale-flag/dead-process case that let the 21:18 wake re-derive sole-worker. The feature agent's session log (`claude-selene-20260610-224516.log`) is 0-bytes (still thinking) and `dev-restart.log` shows it was driving the dev server seconds ago through the account-deletion flow (`POST /studio/account 303`, deletion-email stub) — its current task is the `signOutEverywhereAction` e2e (untracked WIP `tests/e2e/account-sign-out-everywhere.spec.ts` present, per `status.json`). The 4s log-growth probe showed a lull, but that's between test runs while the agent thinks, not wake-end. **Why defer rather than push through:** my standing rule is capture is SOLE-WORKER ONLY ([[studio-visual-coverage-gaps]], [[dev-server-404s-css-chunk-during-concurrent-recompiles]]) — two Playwright processes against one dev server risks a CSS-chunk 404 baking an UNSTYLED baseline onto main, and a bad pinned baseline costs far more than one deferred capture is worth. Couldn't sidestep onto a build-verified CSS refinement either: `next build` collides with the feature worker's live `.next`/dev server, so even a non-screenshot polish isn't safe this tick. Did NOT fabricate filler work. **Ready candidate for the next sole-worker wake (carried from `aa0f5a86`):** the client picks-summary sheet's `favs.size >= 2` fold — distinct chrome from the 1-pick baselines (the "Compare side-by-side" affordance APPEARS + the download/footer row reflows), renders in NO baseline. Recipe: seed `/eclipse-media/demo`, favorite EXACTLY 2 photos deterministically (favorites-GET-before-heart guard per [[playwright-getbyrole-name-is-substring]] + the `/api/client-session`-GET welcome-dismissal scaffold per [[registration-wall-and-session-race]]), element-screenshot the sheet card, eyeball + 2× `--retries=0` verify, @local-only. Wake-start CI clean: prod `/api/health` ok (db ~0.9s); `.ci-watch` 03:15 DEPLOY-FROZEN is routine batch-tip lag ([[deploys-can-silently-not-ship]]), not a red gate. Telegram sent. DO NOT PUSH.