Try-On Pipeline

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

  1. SessionPOST /api/storefront/session { shopDomain, productHandle } → creates a tryon_sessions row, checks widget-enabled access, returns a server sessionId + product snapshot (incl. primary reference image URL).
  2. Try-onPOST /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 (persistInputImageTRYVIO_STORAGE_INPUT_BUCKET).
    • createTryOnProviderClient(...).createTryOnTask({ personImageUrl, garmentImageUrl, callBackUrl }) → provider task id stored in tryon_provider_jobs.
    • Returns { status: 'pending', pollAfterMs }.
  3. Callback — the provider calls /api/storefront/try-on/callback (signed token) when done; the output is persisted (persistOutputImage → output bucket).
  4. 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) → finalizeSuccessfulStorefrontTryOn persists the output and completes the session. This is the only outcome that consumes quota (and the only one that writes a billable row to tryon_billing_ledger — see the billing pipeline’s Billing ledger).
  • failed / no_output (succeeded but no image) → finalizeFailedStorefrontTryOn marks 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 varBucket
TRYVIO_STORAGE_INPUT_BUCKETuploaded person images (short retention)
TRYVIO_STORAGE_OUTPUT_BUCKETgenerated results
TRYVIO_STORAGE_MERCHANT_BUCKETmerchant-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:

  1. resolveOutfit (lib/widget/bundle-pairing.ts) — pure function; takes the primary product id, the ordered manual slots from product_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).

  2. resolveStorefrontBundle (lib/server/storefront-bundle.ts) — server-side; queries the product_bundles slot rows for the primary, calls resolveOutfit, attaches resolveBundleDiscount data, and returns a complements[] 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 complementPlacement clause 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 with handle, 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 getUserMedia call 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 in tryvio-ai-22.

Photo reuse — two paths (neither is an in-result button):

  1. Reopen — on modal reopen the persisted state is cleared (provider result URLs expire); enterDefaultCaptureState() reads tryvio_photo_v1 from sessionStorage and, if found, restores the photo directly into the preview state — one-tap reuse without a re-upload.
  2. Similar itemsstartRelatedProductTryOn presents “My current photo” as the primary option when a photo is available; “Upload new photo” is secondary. Prompt buttons are flex-centered in tryvio-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.