Gallery v2 — Architecture (Brisket + Sirloin)
Gallery v2 — Architecture (Brisket + Sirloin)
Section titled “Gallery v2 — Architecture (Brisket + Sirloin)”The engineering view of the redesigned gallery. For the flow-level “what and why” (albums, carousels, edit bundles, 18+ gating as product behaviour) see Gallery & Collections. This doc is the full-stack implementation across the two tiers that own it: brisket (the UI) and sirloin (the API + storage).
The thesis
Section titled “The thesis”The gallery is a cross-character feed built from one backend read
(ListAllMedia) and one collections API, both served by sirloin. The feed
itself is derived client-side: it fetches flat media pages and builds date
groups, edit bundles, and in-flight generation overlays in a pure transform
layer with no extra round-trips. The same ListAllMedia call powers the main
grid, Favorites, Archived, search, and every album view — only its filter
arguments change.
Smart album folders are the exception, and deliberately so. “By character”,
“By month”, and “By type” are not derived from the loaded feed pages — that
would only ever see the newest ~48 items, so old months and less-recent
characters silently vanished (the original bug this design fixes). Instead each
character and month folder counts come from their own bounded ListAllMedia
queries (limit: 4, real total). Type folders use one
GetGalleryMediaTypeCounts aggregate plus three count-free ListAllMedia
queries for covers. The numbers stay server-truthful on accounts with years of
media without fetching the whole library. See §5.
flowchart LR
A["route /"] --> B["GalleryV2Shell"]
B --> C["useGalleryMedia<br/>api.media.listAllMedia<br/>(infinite query)"]
B --> D["useCollections<br/>api.collections.*"]
B --> K["useSmartFolders<br/>exact counts +<br/>bounded cover queries"]
C --> E["transforms<br/>constants + helper-functions"]
E --> F["GalleryToolbar · GalleryContent · DetailPortal · GalleryOverlays"]
K --> F
C -->|tRPC → ConnectRPC| G["sirloin ListAllMedia<br/>reuses ListMedia<br/>(+ date range)"]
K -->|covers and character/month counts| G
K -->|type counts| L["sirloin GetGalleryMediaTypeCounts<br/>one conditional aggregate"]
D -->|tRPC → ConnectRPC| H["sirloin collections RPCs"]
G --> I["media.media (+ carousel/character joins)"]
H --> J["media.collections<br/>media.collection_media"]
1. Where it lives
Section titled “1. Where it lives”The home page / always renders GalleryV2Shell. The legacy Gallery and its
rollout flag branch have been removed.
The feature is rooted at GalleryV2Shell (features/gallery-v2/index.tsx), a
~300-line composition shell that wires together extracted hooks and sub-components:
| Module | Role |
|---|---|
GalleryToolbar |
Header: view toggle, search, filters, select + density controls |
GalleryContent |
Tab content switch (media grid, albums, smart views, saved views) |
DetailPortal |
Fullscreen detail overlay with all action callbacks |
GalleryOverlays |
Add-to-album, 18+ gate, and selection bar sheets |
2. Brisket — state model
Section titled “2. Brisket — state model”State lives in three tiers:
- Server state (React Query via tRPC). All media and collections, fetched
through
useGalleryMediaanduseCollections. - View state (React hooks). Filters and selection in
useGalleryFilters: active tab, type/character filters, debounced search (1s), selection mode, and theselectedItemsMap(snapshots full items so selections survive filter changes). - Local persistence. Grid density via
atomWithStorage(store.ts), recent searches inuseRecentSearches(capped, deduped).
Extracted hooks keep the shell thin:
| Hook | Responsibility |
|---|---|
useGalleryFeed |
Pending media split + feed group memos |
useMediaActions |
Download, favorite, archive, create actions + PostHog tracking |
useDetailNavigation |
Detail state, resolution, related items, prev/next nav |
useSelection |
Selected IDs/items memos + bulk actions wiring |
useBulkActions |
Archived vs standard action sets |
useCharacters |
Character query + draft filtering |
3. Brisket — data fetching and pagination
Section titled “3. Brisket — data fetching and pagination”One procedure backs the entire surface: api.media.listAllMedia. The grid,
Favorites, Archived, search, and album views all call it with different arguments:
characterId— empty = whole gallery; set = “Characters” filter.collectionId— scopes to one album’s members.filter.search/filter.mediaType/filter.displayType— text search, Photos/Videos/Carousels filter, DEFAULT / FAVORITES_ONLY / ARCHIVE_ONLY scope.filter.dateFrom/filter.dateTo— half-open created-at range[dateFrom, dateTo)(RFC3339). Powers the “By month” album and its folder counts. Empty strings are a no-op, so every other caller is unaffected.skipTotal: true— opts out of the expensiveCOUNT(*); the folder-count character and month count queries deliberately set it false to read the realtotal; type cover queries set it true because their counts come from the aggregate RPC.
Paging is an infinite query. The shared Sentinel component
(components/infinite-scroll-sentinel) drives prefetch with configurable
rootMargin. Type filter resolution uses the shared resolveMediaType helper.
Loading state gates on isPending (not isLoading): after a filter change
re-keys the query, the fetch hasn’t started for one render tick, and isLoading
would flash the empty state before the loading grid.
Search is debounced 1s on the client (≥2 chars); the search panel shows skeleton loading during debounce and query in-flight.
Favorite/archive/download are optimistic mutations that patch the infinite-query
cache and reconcile on settle. The settle-time invalidate is debounced
~300ms so a bulk action (one mutation per selected item) collapses into a
single refetch wave instead of one per item — important now that the album hub
keeps ~N folder-count queries live.
4. Brisket — the gallery-data transform layer
Section titled “4. Brisket — the gallery-data transform layer”Raw media pages are turned into everything the UI shows by pure functions in
constants.ts (types) and helper-functions.ts (transforms). This is where the
feed’s “intelligence” lives, and none of it touches the network.
- Adapt.
mediaToItemmaps a sirloinMediarow to the clientGalleryItem;groupByDatebuckets items under human date labels preserving server order. - Lineage (edit bundles).
buildLineagecollapses an edit chain into a single latest tile for the grid while keeping the full family for the detail’s Related strip. It works only over already-loaded pages. - Pending merge.
splitPendingMediasorts in-flight generations into three buckets (edits, carousel-cover edits, fresh);buildFeedGroupssplices them into the feed as overlays or shimmer tiles in “Today”. - Month ranges.
monthRangesenumerates calendar-month buckets (local-time boundaries, sent as absolute instants) from the current month back to the account’s oldest item — the input to the “By month” album’s date-bounded count queries. (The old client-sidesmartGroupsgrouper is gone; folders are server-backed now — see §5.) - Carousels in the detail. The detail renders a carousel as its individual
slides (each a
photoitem carryingcarouselOf= the parent).wholeCarouselOfrecovers the whole carousel from either shape (for “download all”), and the shared recipe lookup decides whether Recreate is offered. - Download.
shareOrDownloadImageshandles platform-specific download strategies: embedded browser guard (FB/Instagram), native share sheet on mobile, iOS Safari/downloadpage fallback for bulk images, and staggered auto-download on desktop. PostHogGeneration Downloadedevents fire for every download.
5. Brisket — albums, detail, selection
Section titled “5. Brisket — albums, detail, selection”- Albums hub.
hooks/use-hub-cards.tsbuilds the card list: real collections, Favorites/Archived system views, the 18+ card, and the three smart shortcuts. Real-album counts/covers come from the collections API; smart-card group counts come from the folder hooks below. - Smart folders (server-backed).
hooks/use-smart-folders.tsreplaces the old client grouper:useCharacterFoldersenumerates folders fromgetAllCharacters(every non-draft character, not gated on the per-type image/video aggregates — a carousel-only character must still appear) and gives each a{characterId, limit: 4, skipTotal: false}query for its exact count + 4 covers; zero-count folders are hidden.useTypeFoldersreads all three exact counts from oneGetGalleryMediaTypeCountsquery. It separately runs three{limit: 4, skipTotal: true}queries for the folder covers.useMonthFoldersruns alimit: 1OLDEST probe to find the account’s first month, builds ranges withmonthRanges, then counts each month with a date-bounded query — lazily, a year at a time as the list scrolls, so a six-year account never fires every month’s count up front.- Count queries run only while the hub is on screen. Character and month
counts share their
ListAllMediaquery keys with the opened folder. Type-folder counts use the separateGetGalleryMediaTypeCountsquery key; archive, favorite, and completion changes invalidate that key. TheuseCharacterCountshook labels the “Characters” filter/dropdown from the same queries as the By-character folders. - Opening a folder renders an
AlbumViewscoped by the same filter (characterId/mediaType/ date range) with normalSentinelpaging — no client-side slicing of pre-loaded items. - Counts that can’t be fully enumerated cheaply get an honest “N+” suffix (the 18+ card while more feed pages exist; the month card before every month is counted). “Save as album” is hidden for smart groups — it would persist only the loaded ids.
- Album name validation.
collectionNameError(inuse-collections.ts) blocks duplicate names (case-insensitive, trimmed) and the reserved system names (“Favorites”/“Archived”/“18+”, which the hub filters out) on both create and rename. Client-side only — the backend still accepts duplicates. - Detail / fullscreen. Split into
mobile/(draggable bottom sheet withuse-detail-layoutgeometry hook) anddesktop/(split layout). Types inconstants.ts, actions inactions/, carousel nav, related strip, prompt/info blocks each in their own modules. Keyboard navigation suppressed when a dialog is layered on top. Two carousel-specific rules mirror prod:- Recreate uses the same saved-recipe lookup on desktop and mobile. It is hidden only when lookup confirms there is no supported binding. While the lookup runs the action is disabled; a failed lookup leaves it actionable so opening can retry and explain the failure. Recreate on a carousel slide uses the parent carousel’s recipe; Edit still targets the selected slide.
- Download on a carousel opens a choice sheet (
use-detail-download.ts+download-choice-sheet): the whole carousel (all slides, rebuilt viawholeCarouselOf) or just the shown photo. The sheet layers atz-[400], above the detail’sz-[300]. Multi-select already downloads whole carousels by default; edit bundles download only their latest version. Both download variants are pre-warmed so the pick fires the iOS share sheet under its own tap activation.
- Selection & bulk actions. Selection snapshots the full
GalleryItemviaselectedItemsMapwhen toggled, so items survive filter changes. The floating selection bar shows context-aware actions (download + unarchive in archived view; add + download + archive elsewhere). Count badge on the select button.
Recreate: resolve a saved recipe
Section titled “Recreate: resolve a saved recipe”useRecreateEligibility and useOpenGalleryComposer use the same
resolveRecreateBinding decision. The authenticated getRecreateBinding tRPC
procedure reads the current catalog; it does not map historical workflow names
or infer products from a prompt, media type, or source image.
| Saved provenance | Resolution |
|---|---|
| Family key | Open that family, prefer the saved workflow, then use the family’s compatible axis selection if the member was replaced. |
| Workflow name, no family key | Look up actual current family membership; a genuine standalone workflow uses its own contract. |
| Preset ID | Read the preset’s family or direct workflow binding and preserve its authored input restrictions. This also covers older media linked to a migrated preset. |
| Prompt, output, or source only | Hide Recreate. These fields describe inputs and results, but cannot identify a recipe. |
| Historical workflow name no longer in the catalog, no family or bound preset | Hide Recreate. There is no name-based compatibility map. |
A recorded family remains identifiable when temporarily unavailable; opening shows the unavailable state. Missing preset data is never silently discarded in favor of a guessed form. Network errors are distinct from a confirmed missing binding and remain retryable.
After resolving a recipe, saved prompt, rating, aspect ratio, duration and source media seed its supported inputs. A missing required source never causes the selected output to be substituted as the input. Recreating an edited image repeats its edit recipe using the saved original input; the separate Edit action starts a new edit using the selected image. Recreating a recreation follows the provenance persisted on that result using the same rules.
Gallery Re-create keeps the result’s SFW/18+ rating when the character or model changes. Another eligible character can reuse an uploaded Recreate photo or an image-to-carousel reference owned by the same user. See the rating rules and manual QA sequence for source restrictions and expected results.
6. Sirloin — ListAllMedia
Section titled “6. Sirloin — ListAllMedia”A thin cross-character wrapper over ListMedia, reusing its filtering, carousel
loading, and presigned-URL logic.
- Optional character scope (empty = all characters).
- Optional collection scope (resolves member ids, then
IN (...)filter). - Optional date range:
filter.date_from/filter.date_toaddcreated_at >= from/created_at < to(half-open, RFC3339). Empty strings stay a strict no-op, so pre-existing callers are byte-identical. Applies on both theskip_totalandCOUNT(*)paths. (internal/pkg/storage/media.go, in theListMediaquery builder —ListAllMediadelegates to it.) - Empty-collection invariant: returns empty, never falls back to all media.
- Character context fields (
media_character_*) joined only for cross-character reads. skip_totalpaging:limit+1sentinel, noCOUNT(*).GetGalleryMediaTypeCounts: one conditional aggregate returns image, video, and carousel totals for AVAILABLE, unarchived, top-level gallery media owned by active characters. Its predicates match the three default type folders.
7. Sirloin — collections service and storage
Section titled “7. Sirloin — collections service and storage”| RPC | Notes |
|---|---|
| CreateCollection | name required |
| UpdateCollection | partial update (only provided fields) |
| DeleteCollection | soft-deletes collection, hard-deletes membership rows |
| ListCollections | computes count + cover per row |
| AddMediaToCollection | ownership-checked, idempotent (ON CONFLICT DO NOTHING) |
| RemoveMediaFromCollection | hard-deletes membership only |
Ownership enforced on every read/mutation. resolveCollectionAndMedia verifies
both the collection and all media ids belong to the caller before any write.
Count, cover, and member list all exclude soft-deleted media via consistent
deleted_at IS NULL filters.
8. Schema and proto
Section titled “8. Schema and proto”Migration 127_collections.sql adds two tables under the media schema:
media.collections— soft-deletable, owned byuser_id.media.collection_media— join table with composite PK,added_atfor ordering,ON DELETE CASCADEon both FKs.
Proto surface: rpc ListAllMedia, rpc GetGalleryMediaTypeCounts, six
collection RPCs, ListAllMediaRequest
with optional collection_id/character_id, character-context fields on Media,
and the Collection message with computed count + cover URL. ListMediaFilter
already carried date_from/date_to fields — they were dormant (the storage
builder ignored them) until the “By month” work activated them; no proto change
was needed.
Invariants
Section titled “Invariants”- Cross-character reads carry character context; single-character reads skip the join.
- An empty-but-valid collection returns no media — never a fallback to all media.
- Ownership enforced server-side on every collection read/mutation.
- Member list,
media_count, andcover_pathall exclude soft-deleted media. - Smart album folder lists, counts, and covers are server-truthful, never derived from the loaded feed pages.
- Character and month counts use the same filtered
ListAllMediapath as their opened contents. Type counts use a dedicated aggregate with matching default gallery predicates; regression tests lock those predicates together. - The date filter is a strict no-op on empty strings — activating it changed no existing caller’s query.
- Album ordering and recent searches are client-local.
- PostHog
Generation Downloadedfires for every download (single + bulk).
Known gaps
Section titled “Known gaps”- Edit bundles are page-local (no server-side history walk).
- Cover vs first tile can differ (added-at vs created-at ordering).
- 18+ is still a client-side filter of the loaded pages (count + folder), so it’s
a lower bound (“N+”). Its real fix is a server
is_nsfwfilter (FOXY-483); until then the 18+ folder has no pagination of its own. - The character card’s generation chips count raw media rows (carousel slides, co-star media), so they can legitimately differ from the gallery’s per-character counts, which count gallery tiles for the current user.
- Duplicate-album-name blocking is client-only; the backend still accepts dupes, so albums created before this guard stay duplicated until renamed.
- Month bucketing uses local-time boundaries; an item created late on the last day of a month can bucket differently than a UTC reading would.
- Per-slide carousel edit yields a standalone photo (needs image-pipeline support).
Verification
Section titled “Verification”- Open
/→ the Gallery loads without waiting for a feature flag. - Grid, detail, search, filters, album CRUD, add-to-album, smart folders, favorites/archive on a PR preview.
- On a >1-year, many-character account: “By month” lists every non-empty month (not just the newest page) and “By character” lists every character with media — opening an old month or a less-recent character paginates correctly.
- Character-filter dropdown counts match the By-character folder counts.
- Type folders show exact counts while their three cover requests omit totals.
- Carousel detail: Download prompts whole-vs-single; Recreate follows the parent recipe, including carousels created by a previous Recreate.
- Recreate visibility: no saved binding is hidden; a failed lookup remains actionable; a recorded family with a replaced workflow uses a compatible current variant while retaining the original input.
- Multi-select download across carousels + videos + photos.
- Create an album; a duplicate or reserved name is rejected inline.
- Create an album, add/remove media, delete album; confirm count and cover consistency.
Fullscreen 18+ visibility
Section titled “Fullscreen 18+ visibility”The global 18+ viewing preference applies inside fullscreen detail as well as
the grid. With viewing disabled, NSFW photos, carousel slides and videos render
the design system’s FullAccessMask; their image, video and background preview
elements are not mounted. Desktop arrows, mobile swipes and History navigation
remain available, but cannot reveal masked media. Changing the preference while
detail is open updates the displayed media immediately.
History thumbnails use each item’s rating. Pending History entries lack a reliable rating, so their source preview is omitted while 18+ viewing is off; the generating placeholder remains. This is a viewing preference, independent of generation permissions and the create form’s rating.
Manual verification: with 18+ viewing off, open an SFW item and navigate both directions through mixed photos, videos and carousel slides on desktop and mobile. NSFW items must show the mask without an image flash, background image or video playback. Check History thumbnails and select an NSFW edit. Turn viewing on, then off while detail remains open; media must appear, then disappear, and video playback must stop. SFW items and close/navigation controls must still work.