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() (FAL.AI)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 the sub-store landing page loads, if it 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, bgMaskKey, 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, prompt, modelEndpoint from the merged configmodelEndpoint from the design config as the primary endpoint. Falls back to the priority model from merch_ai_model table only if the config has no modelEndpoint.qualityTiers to generateConsistentAIArt to control how many attempts and at what quality7 modules for post-processing AI output:
| Module | Function |
|---|---|
flood-fill-mask.ts | BFS flood fill for background detection |
gaussian-blur-mask.ts | Separable Gaussian blur (OpenCV-compatible) |
apply-edge-feather.ts | Smoothstep edge fade |
remove-background-corners.ts | Corner flood-fill background removal with feathering |
composite-overlay.ts | Square crop + overlay compositing |
flatten-alpha.ts | Flatten transparent PNGs to black background |
process-ai-image.ts | Full pipeline: download, BG removal, crop, overlay |
| File | Purpose |
|---|---|
fal-retry.ts | Client config + retry with exponential backoff |
ai-image-service.ts | Calls fal.ai to generate AI art from selfie + template |
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.session.bgMaskKey already exists, the mask is reused; otherwise it is computed, uploaded, registered as media, and persisted on the session.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.
Background masks are scoped to a generation. When the generation route changes aiArtKey, it clears bgMaskKey so the next render computes a fresh mask for that generation.
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
Gemini via Vercel AI SDK generateObject(). Checks:
Moderation signals (block on failure):
personDetected: Is there a clearly visible human face?isBlurry: Is the image too blurry/poorly lit for merchandise?isNudity: Is the image inappropriate? (includes swimwear, shirtless, bare midriff)isCelebrity: Can you identify this person as a specific public figure?Demographics (stats + prompt tuning, never block):
gender: female | male | not-distinctive — null when no person is visibleageGroup: child | teen | 20s | 30s | 40s | elder — null when no person is visiblepeopleCount: integer count of distinct people in the image (0 if none)When the /verify-image or /upload/verify route is called with a sessionId in the body, the three demographic fields are persisted to the session's active merch_selfie record (via updateMerchSelfie). The merch_session.activeSelfieId is used to resolve the selfie row. Persist failures are logged but never fail the verification request.
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/)FAL.AI Integration (fal/)Character Consistency (validate-character-consistency.ts)Catalog Product RenderingPOST /api/merch/renderBackground mask lifecycleOrder CreationcreateOrGetMerchOrderFromSession (create-order.ts)EmailsendMerchOrderConfirmationEmail (send-email.ts)sendMerchDelayedReadyEmail (send-email.ts)Campaign Lifecycle FSMState Derivation (activation-lifecycle.ts)Valid TransitionsImage VerificationverifyImageWithGemini (verify-image.ts)WatermarkingapplyWatermark (img-processing/watermark.ts)AnalyticsSession Analytics (analytics.ts)Utility Functions (utils.ts)Constants (constants.ts)