25 service files in packages/merch/srv/src/ (package @zooly/merch-srv) covering the full backend logic. Payment integration is handled by @zooly/srv-stripe-payment.
Sequential generation with character consistency scoring, configurable quality tiers, and user-driven regeneration:
qualityTiers: QualityTier[] array (e.g. ["low", "medium", "high"]) from the resolved design config; fall back to DEFAULT_QUALITY_TIERS if not setqualityTiers, generating one image per tier at that quality level via generateAIArt() (Vercel AI Gateway)downloadAndProcessAIImage(), upload to S3, then score with validateCharacterConsistency() (Gemini vision)candidates sorted by likeness scoreaiArtCandidatesforceRegenerate: true, bypassing the server-side generation cache; all prior generations remain selectableThe client shows all accumulated candidates (newest first) and lets the fan select their preferred art via POST /api/merch/ai/select, which updates the session's aiArtKey to the chosen candidate.
Quality tier configuration: Admin-configurable per design config. The array length determines how many generation attempts run; the values control quality per attempt. Examples:
["low", "medium", "high"] — 3 attempts at ascending quality["low", "low"] — 2 fast/cheap attemptsDesigns follow a 3-level override hierarchy. At runtime the system merges configs from each level, with child values overriding parent values for non-empty fields.
Level 1: Design (base generation settings — applies to ALL products)
└─ Level 2: Product Variation (overrides template image, prompt, AI model for a specific catalog product)
└─ Level 3: Demographic Variation (overrides for a specific gender/age within a product variation)
Primary resolver — the single entry point for all generation config resolution:
productType column on this row is ignored — it is always treated as the universal default for all products (legacy data may have a specific type like "tshirt" instead of NULL; this is harmless).catalogProductId (with gender and ageGroup both NULL). If found, merges its configJson on top of Level 1. The catalog product's productType is still passed through for config fallback-chain logic.{ gender, ageGroup }. If found, merges on top.Returns { effectiveDesignId, configJson } — the merged config and the ID of the most-specific design that matched.
mergeConfigJson(parent, child) produces a merged config where non-empty child values override parent values. "Empty" means undefined, null, or "". This means a Level 2 config only needs to specify the fields it wants to override — everything else inherits from Level 1.
| Function | Description |
|---|---|
resolveDesignConfigRow | Resolves config for a (designId, productType) pair with fallback chain |
resolveDesignForDemographics | Finds best-matching demographic sub-design (priority: both → gender → ageGroup → unset → parent) |
resolveDesignConfigWithFallback | Resolves config from effective design, falls back to parent design |
In standard campaigns, a session is tightly bound to a campaign at creation. To support multishop Superstore journeys, sessions are campaign-agnostic—the database merch_session.campaign_id column is nullable.
The creation and adoption of sessions depends on the entry point:
POST /session/create with campaignSlug set./merch/superstore/:slug as a top-level SPA route. Tapping a sub-store card calls startSuperstoreJourney(slug) which creates a campaign-null session via POST /session/create (omitting campaignSlug but storing /merch/superstore/{slug} as startPage) and stores journeySessionId + journeySuperstoreSlug in Zustand. The shopper is then navigated in-SPA to the sub-store with ?utm=superstore-{slug} (no ?s= — the session id is in memory).POST /api/merch/session/update)When a shopper enters a sub-store, if the entry detects utm=superstore-* and a journeySessionId in the Zustand store, it calls adoptSession which hits PATCH /api/merch/session/update with campaignSlug.
If the requested campaignSlug differs from the session's existing campaignId:
merch_session.campaign_id to point to the new campaign.designId, pendingItemaiArtKey, aiArtRawKey, aiArtPrompt, aiArtCandidatesfinalImgKey and all illustration/mockup image keysuserId, shipping info, consent flags, and location are reused seamlessly across different sub-stores.merch_session_analytics row exists for the new campaign.Even though a single journey session spans multiple sub-stores/campaigns, analytics must remain granular per campaign.
merch_session_analytics relaxes the session_id uniqueness and is instead keyed on a composite unique key (session_id, campaign_id).The /api/merch/ai/generate route:
designId in request > auto-select by catalog product ID via listDesignsByCatalogProductId > legacy product-type fallback > session fallback. Auto-selection always finds a top-level design (not a sub-design) since session.designId may hold a product/demographic variation from a previous generation for a different product.getActiveSelfie(sessionId) to get demographics (gender, ageGroup)merch_session_generation for a cached result matching (sessionId, effectiveDesignId, productType, selfieKey). On cache hit, updates the session's aiArtKey and aiArtCandidates to match the cached product (so /ai/select sees the correct candidates) and returns immediately.resolveDesignConfig3Level(designId, productType, { gender, ageGroup }, fallbackChain, catalogProductId)qualityTiers, templateImageUrl, and prompt from the merged configqualityTiers to generateConsistentAIArt to control how many attempts and at what qualityModules for post-processing AI output:
| Module | Function |
|---|---|
alpha-mask.ts | Preserves an existing alpha channel through operations such as upscale |
apply-template-mask.ts | Cuts art to an admin-supplied mask's alpha (ZLY-1506) |
composite-overlay.ts | Square crop + overlay compositing |
flatten-alpha.ts | Flatten transparent PNGs to black background |
binarize-alpha.ts | Matte partial alpha onto the product color (ZLY-1512) |
process-ai-image.ts | Generation pipeline: download, crop, mask, overlay |
All model requests set background: "opaque" and outputFormat: "png". Model selection (gpt-image-1.5 or gpt-image-2) is independent from bgRemovalMode.
Background removal is opt-in: bgRemovalMode defaults to none, which preserves the opaque model output, and a remover only runs when a design explicitly selects one in the store builder. bria sends the generated PNG to fal.ai's fal-ai/bria/background/remove endpoint and validates its alpha output. The legacy options apply flood-fill and/or edge feathering in code. The benchmark never applies any remover.
bria — and only bria — also appends REMOVABLE_BACKGROUND_PROMPT_SUFFIX, which asks the model for a flat, solid white backdrop for the segmentation model to cut. The legacy removers must not get that suffix: they flood-fill outward from the image corners of whatever background the template already supplies, so replacing it with forced white removes the thing they were tuned to cut.
The legacy flood fill's colour tolerance (BG_REMOVE_DEFAULTS.threshold) matters more than it looks. Model backgrounds are dithered rather than mathematically flat, and the fill is all-or-nothing — too tight a tolerance traps it in the corner pixel and removes nothing at all, with no error and no log line.
maskImageUrl / maskImageAssetId remains an optional, separate template-shaping feature. It cuts the generated art to an admin-supplied alpha shape during generation. Every later render, mockup, and print file inherits both the generated transparency (when a remover ran) and the optional template mask.
The mask follows the normal 3-level config merge: a product variation (Level 2) or demographic variation (Level 3) that sets maskImageUrl overrides the design default, and one that leaves it empty inherits the top-level mask.
| File | Purpose |
|---|---|
fal/fal-retry.ts | Shared client config + retry for legacy fal generation paths |
img-gen/bria-background-removal.ts | Calls BRIA RMBG 2.0 through fal.ai after GPT Image generation, when bgRemovalMode is bria |
Gemini vision likeness scoring with optional SAM segmentation. Returns overall_likeness_score (0-1).
The current runtime render path is catalog-backed and uses catalog_renderer_config for the selected catalog product:
session.aiArtKey as a media filename.renderProductComposite() using the product renderer config: background, art bounds, overlays, and text layers.digital-img (rendererConfig.disabled === true), skips product compositing and uses the generated art directly.imageKey, cleanImageKey) when a cartItemId is supplied.{ previewFilename, cleanFilename }.Legacy mockup and garment/plaque render endpoints still exist, but new shopper flows should use the catalog renderer.
10-step idempotent pipeline:
Your Order Confirmation - {orderNumber} (DEMO/TEST prefix for non-live)support@zooly.aimerch-orders@zooly.ai (customer orders only)Sent when AI generation takes >90s and fan provides email.
isOpen = status === "LIVE" AND shutdownMode === "NONE"
isSoftClosing = shutdownMode === "SOFT_CLOSE" AND NOT graceExpired
isSoftCloseGraceExpired = shutdownMode === "SOFT_CLOSE" AND shutdownEndsAt < now()
isEmergencyClosed = shutdownMode === "EMERGENCY_CLOSE"
isEnded = status === "ENDED"
isCheckoutBlocked = isEnded OR isEmergencyClosed OR isSoftCloseGraceExpired
| Action | Required State | Result |
|---|---|---|
openStore | DRAFT | LIVE, isActive=true |
reopenStore | ENDED or EMERGENCY_CLOSE | LIVE, isActive=true |
startSoftClose | LIVE, NONE | SOFT_CLOSE + 10min grace |
cancelSoftClose | LIVE, SOFT_CLOSE | NONE |
emergencyClose | LIVE | EMERGENCY_CLOSE |
endActivation | LIVE | ENDED, isActive=false |
SOFT_CLOSE_GRACE_MINUTES = 10
A selfie is checked before it can be used on merchandise. The check asks whether a person is visible, whether the photo is too blurry or poorly lit, whether it shows prohibited nudity, and whether the person is a widely famous celebrity. It also estimates gender, age group, and how many people are in the photo.
The photo is blocked when no person is visible, when it is too blurry or poorly lit, when it shows prohibited nudity (including a shirtless child), or when the person is a widely famous celebrity. Ordinary shirtlessness, tank tops, and swimwear on teens and adults are allowed.
Gender, age group, and how many people are in the photo are saved with the selfie for later art. They never block the photo. Gender and age are left blank when no person is visible. A failure to save them does not fail the check.
Sharp + opentype.js overlay that stamps a semi-transparent diagonal "SAMPLE ONLY" label across the image. Fonts load from S3 and are cached in-process. Any failure returns the original buffer so uploads never break.
Where watermarks are applied (both LIVE and PREVIEW modes):
| Surface | Source | Watermarked? |
|---|---|---|
Candidate carousel thumbnails (/create) | candidate.previewKey JPG | Yes |
Result + cart preview (/create) | Cart item imageKey media filename | Yes |
| History thumbnails (orders + incomplete sessions) | aiArtPreviewKey (preview JPG from origin session's candidates) | Yes |
Confirm page (/confirm) | Cart item cleanImageKey media filename, with legacy fallback handling | No |
The rule: everything pre-purchase is watermarked; the confirm page (post-payment) shows the clean render. Runtime render outputs are media filenames resolved through the media proxy, not raw S3 URLs.
Step order map:
studio/review/upload-verify/camera = 1
merch = 2, size = 3, loading = 4, result = 5
shipping/payment = 6, confirm = 7
Updates maxStepReached when current step exceeds previous max. Appends to stepTimeline JSON array. Tracks milestones: generationStartedAt, generationCompletedAt, checkoutStartedAt, completedAt, abandonedAt.
| Function | Purpose |
|---|---|
getShippingCost(country, flatRate, intlRate) | US/CA get flat rate, others get intl rate |
formatCurrency(amount, currency) | Currency-aware formatting using minorUnitToMajor() and getDecimalPlaces() from @zooly/util. Correctly handles zero-decimal currencies (e.g. JPY). |
generateOrderNumber() | ORD-{timestamp_base36}-{random_base36} |
| Constant | Value |
|---|---|
DEFAULT_SIZES | S, M, L, XL, XXL, XXXL |
DEFAULT_SHIPPING_RATE | $6.95 |
DEFAULT_SHIPPING_INTL_RATE | $15.99 |
DOMESTIC_SHIPPING_COUNTRIES | US, CA |
DEFAULT_PAYMENT_METHODS | card, apple_pay, google_pay |
On This Page
OverviewAI Art GenerationgenerateConsistentAIArt (generate-ai-art.ts)Design Resolution — 3-Level HierarchyresolveDesignConfig3Level (merch-design.ts)Config merging (mergeConfigJson)Legacy resolvers (still exported)Campaign-Agnostic Session & Adoption1. Mode-Dependent Lifecycle2. Session Adoption (,[object Object],)3. Per-Campaign Analytics FunnelsAI generate route integrationImage Processing Pipeline (img-processing/)Transparent backgrounds and template masksFAL.AI IntegrationCharacter Consistency (validate-character-consistency.ts)Catalog Product RenderingPOST /api/merch/renderOrder CreationcreateOrGetMerchOrderFromSession (create-order.ts)EmailsendMerchOrderConfirmationEmail (send-email.ts)sendMerchDelayedReadyEmail (send-email.ts)Campaign Lifecycle FSMState Derivation (activation-lifecycle.ts)Valid TransitionsImage VerificationWatermarkingapplyWatermark (img-processing/watermark.ts)AnalyticsSession Analytics (analytics.ts)Utility Functions (utils.ts)Constants (constants.ts)