Skip to content

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 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"]

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

State lives in three tiers:

  • Server state (React Query via tRPC). All media and collections, fetched through useGalleryMedia and useCollections.
  • View state (React hooks). Filters and selection in useGalleryFilters: active tab, type/character filters, debounced search (1s), selection mode, and the selectedItemsMap (snapshots full items so selections survive filter changes).
  • Local persistence. Grid density via atomWithStorage (store.ts), recent searches in useRecentSearches (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 expensive COUNT(*); the folder-count character and month count queries deliberately set it false to read the real total; 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.

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. mediaToItem maps a sirloin Media row to the client GalleryItem; groupByDate buckets items under human date labels preserving server order.
  • Lineage (edit bundles). buildLineage collapses 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. splitPendingMedia sorts in-flight generations into three buckets (edits, carousel-cover edits, fresh); buildFeedGroups splices them into the feed as overlays or shimmer tiles in “Today”.
  • Month ranges. monthRanges enumerates 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-side smartGroups grouper is gone; folders are server-backed now — see §5.)
  • Carousels in the detail. The detail renders a carousel as its individual slides (each a photo item carrying carouselOf = the parent). wholeCarouselOf recovers the whole carousel from either shape (for “download all”), and the shared recipe lookup decides whether Recreate is offered.
  • Download. shareOrDownloadImages handles platform-specific download strategies: embedded browser guard (FB/Instagram), native share sheet on mobile, iOS Safari /download page fallback for bulk images, and staggered auto-download on desktop. PostHog Generation Downloaded events fire for every download.
  • Albums hub. hooks/use-hub-cards.ts builds 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.ts replaces the old client grouper:
    • useCharacterFolders enumerates folders from getAllCharacters (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.
    • useTypeFolders reads all three exact counts from one GetGalleryMediaTypeCounts query. It separately runs three {limit: 4, skipTotal: true} queries for the folder covers.
    • useMonthFolders runs a limit: 1 OLDEST probe to find the account’s first month, builds ranges with monthRanges, 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 ListAllMedia query keys with the opened folder. Type-folder counts use the separate GetGalleryMediaTypeCounts query key; archive, favorite, and completion changes invalidate that key. The useCharacterCounts hook labels the “Characters” filter/dropdown from the same queries as the By-character folders.
    • Opening a folder renders an AlbumView scoped by the same filter (characterId / mediaType / date range) with normal Sentinel paging — 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 (in use-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 with use-detail-layout geometry hook) and desktop/ (split layout). Types in constants.ts, actions in actions/, 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 via wholeCarouselOf) or just the shown photo. The sheet layers at z-[400], above the detail’s z-[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 GalleryItem via selectedItemsMap when 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.

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.

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_to add created_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 the skip_total and COUNT(*) paths. (internal/pkg/storage/media.go, in the ListMedia query builder — ListAllMedia delegates 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_total paging: limit+1 sentinel, no COUNT(*).
  • 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.

Migration 127_collections.sql adds two tables under the media schema:

  • media.collections — soft-deletable, owned by user_id.
  • media.collection_media — join table with composite PK, added_at for ordering, ON DELETE CASCADE on 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.

  • 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, and cover_path all 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 ListAllMedia path 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 Downloaded fires for every download (single + bulk).
  • 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_nsfw filter (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).
  • 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.

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.