Try-On Pipeline
The storefront try-on is asynchronous: create a session, kick off a provider task, the provider calls back, the client polls for the result.
Storefront flow
- Session —
POST /api/storefront/session{ shopDomain, productHandle }→ creates atryon_sessionsrow, checks widget-enabled access, returns a serversessionId+ product snapshot (incl. primary reference image URL). - Try-on —
POST /api/storefront/try-on{ shopDomain, productHandle, sessionId, personImageDataUrl }:- Billing gate (
checkBillingGate), IP abuse limit (enforceAuditWindowLimit), per-shopper rate limit (with email-gate bonus). - Resolve product + primary garment image.
- Persist the person image (
persistInputImage→TRYVIO_STORAGE_INPUT_BUCKET). createTryOnProviderClient(...).createTryOnTask({ personImageUrl, garmentImageUrl, callBackUrl })→ provider task id stored intryon_provider_jobs.- Returns
{ status: 'pending', pollAfterMs }.
- Billing gate (
- Callback — the provider calls
/api/storefront/try-on/callback(signed token) when done; the output is persisted (persistOutputImage→ output bucket). - Status — client polls
POST /api/storefront/try-on-status{ sessionId, … }→ returns{ status, outputImageUrl? }once succeeded.
Terminal outcomes & quota refund
Both completion paths (the provider callback and the client poll) are driven by the pure
classifyProviderOutcome(snapshot) → delivered | failed | no_output | pending
(lib/billing/tryon-refund.ts) — the single source of truth for “what counts as delivered”:
delivered(succeeded+ an output image) →finalizeSuccessfulStorefrontTryOnpersists the output and completes the session. This is the only outcome that consumes quota (and the only one that writes abillablerow totryon_billing_ledger— see the billing pipeline’s Billing ledger).failed/no_output(succeededbut no image) →finalizeFailedStorefrontTryOnmarks the session failed and refunds the merchant quota + shopper rate-limit (quota is consumed up-front at the gate; a try-on counts only on delivery — see the billing pipeline’s Usage accounting). The refund is gated by an atomic claim so callback + poll never double-refund, and is attributed to a session id + reason (provider_failed | no_output | timeout).pending→ keep polling; do nothing.
A delivered result arriving after a session was already finalized as failed (e.g. by the
timeout sweep below) does not resurrect it — the refund stands.
Finalize race (fixed 2026-07-10). Both completion paths used to pass a non-atomic read-check,
so the provider callback and the widget poll could each insert a tryon_generation_succeeded
analytics event for the same session (~45% of generations double-fired; billing was never affected,
since quota is only consumed once, at the gate). finalizeSuccessfulStorefrontTryOn now uses an
atomic single-winner claim, claimTryOnProviderJobCompleted (UPDATE tryon_provider_jobs ... WHERE status IN ('pending','running') RETURNING), mirroring claimTryOnSessionFailed above — only the
claim winner writes the output + the succeeded event. analytics_events rows from before this date
may still contain duplicates; use tryon_billing_ledger for billable counts.
Timeout safety net — the daily cron /api/cron/tryon-timeout finalizes storefront sessions
stuck in generating past a 15-min stale threshold as failed(timeout) + refund, covering the
rare case where the provider hangs and never calls back (and the shopper has left). Demo sessions
are skipped. See the billing pipeline for the refund mechanics.
Why async
generateTryOn (the synchronous variant) polls the provider until complete (~60–90s),
which exceeds serverless limits. Production uses createTryOnTask + provider callback +
client polling instead.
Storage buckets
| Env var | Bucket |
|---|---|
TRYVIO_STORAGE_INPUT_BUCKET | uploaded person images (short retention) |
TRYVIO_STORAGE_OUTPUT_BUCKET | generated results |
TRYVIO_STORAGE_MERCHANT_BUCKET | merchant-provided images |
The storage-cleanup cron expires:
- input images after 7 days (fixed privacy window)
- output images after
app_config.output_retention_days(default 14 days, clamped to 1-90)
Internal operators can view/change output retention from /admin via
GET/PATCH /api/admin/storage-retention.
Result hosting note
KIE.AI returns result URLs on tempfile.aiquickdraw.com; fal.ai on fal.media/cdn.fal.ai.
Those provider URLs are treated as temporary. The app persists outputs to Supabase Storage and,
when a ready output has storage_bucket + storage_path, storefront status responses return the
durable public Supabase URL instead.
The widget saves that durable URL into localStorage, shows all gallery items within the configured
output-retention window, and removes broken legacy provider URLs on image load failure.
Complete the Look (outfit bundle)
After a try-on the storefront widget can suggest 1–2 complementary products (“complete the look”), re-generate the full outfit with all garments on the same person in a single combined generation, and let the shopper add the whole outfit to cart at a bundle discount.
Complement resolution
Resolution is two-layer:
-
resolveOutfit(lib/widget/bundle-pairing.ts) — pure function; takes the primary product id, the ordered manual slots fromproduct_bundles, and a related-products pool; returns an ordered array of 1–2 complement items. Priority per slot: merchant-pinned row → Shopify native “Complementary products” (Search & Discovery) → top related product. De-duplicates against the primary and already-chosen slots. When no slots are configured (legacy), falls back to 1 auto-resolved complement (unchanged v1 behaviour). -
resolveStorefrontBundle(lib/server/storefront-bundle.ts) — server-side; queries theproduct_bundlesslot rows for the primary, callsresolveOutfit, attachesresolveBundleDiscountdata, and returns acomplements[]array.
See Data Model for the product_bundles table and stores bundle columns.
Combined N-garment generation
The generation core is N-capable. TryOnGenerateInput.additionalGarments is an array; both
provider clients (fal nano-banana-2, KIE gpt-image-2-image-to-image) assemble
image_urls = [person, primaryGarment, complement1?, complement2?] — at most 4 images, well
within provider limits (fal ≤ 14, KIE ≤ 16 images).
buildCombinedTryOnPrompt (lib/server/kie-ai-client.ts):
- Keeps the primary product’s category prompt unchanged.
- Appends one
complementPlacementclause per complement. Placement is inferred from the complement’s own slug + title (EN + BG keywords) and covers: eyewear, necklace, bracelet, watch, ring, jewelry (generic) and apparel (top = torso, bottom = legs/waist, dress = full body, outerwear = over top layer), footwear (feet), bag (arm/shoulder), hat (head). - Appends an anti-overlap / layering guardrail: each item in its own body region, respect layering order, do not blend or merge two products.
When additionalGarments is empty, buildCombinedTryOnPrompt is identical to the single-product
buildTryOnPrompt — no regression.
Quota
A full-outfit combined generation consumes 1 try-on credit regardless of how many complements are in the outfit. The existing quota gate, refund-on-non-delivery, and timeout sweep apply unchanged.
Proxy backward-compatibility
/api/proxy emits the bundle block additively:
bundle.complements[]— new array (1–2 items, each withhandle,title,imageUrl,source).bundle.complement— legacy single field (first complement) — still emitted so old cached widgets keep working.
The try-on route accepts complementProductHandles[] (new, array) and the legacy single
complementProductHandle. Both paths are additive; old payloads continue to work.
Deploy order is always API first → then widget (backward-compat law).
Gating
The feature is off by default (stores.bundle_enabled = false). Nothing changes for a store
until bundle_enabled is turned on. See Data Model for the stores bundle
columns.
Storefront widget (theme extension)
See also: Widget States — the full state machine; Widget Components — CSS atoms and known duplications.
The storefront UI is a Shopify theme app extension (shopify-app/extensions/tryvio-theme),
deployed separately from Vercel via shopify app deploy --force (latest: tryvio-ai-18). It’s
plain classic-script JS: tryvio-theme.js (bootstrap/button) lazy-loads tryvio-modal.js
(the modal UI), plus tryvio-collection.js for collection/home card buttons.
Camera lifecycle (must turn off on close)
The modal’s camera follows strict rules so the device camera (and its indicator light) is never left running:
- On modal close, desktop & Android stop the camera immediately.
- iOS Safari only keeps the stream alive for 30s after close — iOS re-prompts for
permission on every
getUserMedia, so a quick reopen would otherwise nag the shopper. On a same-facing-mode reopen within the window the cached stream is reused; otherwise it’s stopped when the 30s timer fires. - A
getUserMediacall that resolves after the modal already closed always stops its stream (no leak).
This is gated by _isIOS in tryvio-modal.js (closeModal / startCamera). The rules are
mirrored — and locked against regressions — by the pure, tested unit
apps/web/src/lib/widget/camera-lifecycle.ts + camera-lifecycle.test.ts (npx vitest). The
widget is a classic script (not bundled), so if you edit the modal camera code, keep it in
parity with that tested spec.
Result & reuse flow
Shipped in theme version tryvio-ai-22.
RESULT state actions — the shopper has one reset path and a clear exit:
- “New photo” (
retakeBtn) — clears the current photo, returns to the upload/capture step. - Close X (
.tryvio-modal__close) — now a solid high-contrast button, visible over any result image. - There is no in-result “Try again” /
reuseBtn. That button was removed intryvio-ai-22.
Photo reuse — two paths (neither is an in-result button):
- Reopen — on modal reopen the persisted state is cleared (provider result URLs expire);
enterDefaultCaptureState()readstryvio_photo_v1fromsessionStorageand, if found, restores the photo directly into thepreviewstate — one-tap reuse without a re-upload. - Similar items —
startRelatedProductTryOnpresents “My current photo” as the primary option when a photo is available; “Upload new photo” is secondary. Prompt buttons are flex-centered intryvio-theme.css.
Spec: 05_tasks/specs/similar-products-flow.md. Covered by the
lib/widget/modal-result-actions.ts (reuseEntryState) unit test and the Playwright e2e
shopify-app/playwright-debug/verify-similar-products.js.