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. Session — POST /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-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 + garment image. The garment reference is the selected variant’s image (#395, lib/products/tryon-variant-image.ts): body.variantId (sent only by the in-modal picker, #235) else the variant the widget captured on the session → catalog_products.metadata.variants (exact id or gid://…/<id> suffix) → else the selectedVariantImageUrl the widget stored on tryon_sessions.metadata, accepted only when it is one of this product’s own catalogue images (read lazily, on the miss path only) → else the primary image. The outcome is logged (tryon.variant_image_resolved / tryon.variant_image_unresolved) and stamped on the provider job (request_payload.variantImageSource: metadata_variants | session_snapshot | unresolved | none_requested) so a substituted primary is countable, never silent. Why the second chance exists: the REST products/* webhook path wrote no metadata.variants until #395, so every webhook-created product resolved to nothing and got the default colour (15/15 wrong-colour prod generations in 30 days). Sibling variants are not “other views” (#395 part 2): the up-to-2 extra reference photos are filtered through filterSiblingVariantReferences — with metadata_variants every other variant’s image is dropped (generic photos stay), with session_snapshot the exact variant image goes alone — because a letter-B photo passed as a reference to a letter-C generation made the model render “A” (tryon.sibling_variant_references_withheld).
    • Persist the person image (persistInputImage → TRYVIO_STORAGE_INPUT_BUCKET).
    • Normalize the provider inputs (#204, lib/server/provider-image-input.ts): kie/fal decide whether an image is usable from the URL’s file extension, not the bytes — Shopify serves a real image/jpeg for …/IMG_6306.heic?v=1, yet the provider still answers “File type not supported”. toProviderSafeImageUrl re-encodes .heic/.heif/.avif/.gif (and raw data:image/heic shopper photos) to an inline JPEG via sharp; jpg/png/webp return the same string after one regex (no fetch, no re-encode). Person, garment, references and complement garments are normalized together in one Promise.all, right before the provider call, so the image-picking chain above is unaffected. A failed transcode returns the original URL.
    • createTryOnProviderClient(...).createTryOnTask({ personImageUrl, garmentImageUrl, callBackUrl }) → provider task id stored in tryon_provider_jobs.
    • Returns { status: 'pending', pollAfterMs }.
    • On any throw the catch finalizes through finalizeFailedStorefrontTryOn (#204 / LOG-4 — it used to write updateTryOnSession({status:'failed'}) directly, leaving the provider job pending forever with no tryon_generation_failed event and no refund). The 502 body is { error, code } where code is product_image_unsupported | generation_failed — an additive field; old cached widgets keep reading error unchanged.
  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.

Provider unreachable at callback time (#369). The provider calls us because the job is finished; if its own API then keeps failing when we fetch the result (fal answered 504 Gateway Timeout to nine callbacks over 47 minutes on 2026-08-12), the callback no longer answers 500 and lets the provider retry blind. The client retries the status and the result fetch inside the invocation (3 attempts, backoff); if that still fails, the callback closes the job with the atomic claimTryOnProviderJobAbandoned (only a still-live job can be closed — a poll that delivered first wins and nothing is refunded), finalizes the session failed(provider_failed) + refund, logs ONE error-level callback.provider_unreachable line with the session / job / task ids, and answers 200. The widget’s next poll shows the shopper “try again” instead of a spinner. Two exceptions keep the 500 retry path: a 429 (rate limit — load, cured by the provider’s backoff) and a failure of our own write after the provider answered (the retry’s race-loser branch repairs it).

Timeout safety net — the cron /api/cron/tryon-timeout (every 30 min, 15,45 * * * * since #369; it ran once a night before) finalizes storefront sessions stuck in generating past a 15-min stale threshold as failed(timeout) + refund — so a hung job is refunded within ~45 min, covering the rare case where the provider hangs and never calls back (and the shopper has left). Demo sessions are skipped. Two further passes catch what a session-level sweep cannot see: abandoned provider jobs (rows left pending when the provider submit threw — 269 of them on prod, 2026-07-03 → 07-30, closed via the atomic claimTryOnProviderJobAbandoned) and the undelivered ledger sweep that refunds billable units whose session provably delivered nothing. 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 varBucketPublic?
TRYVIO_STORAGE_INPUT_BUCKETuploaded person images (short retention)private
TRYVIO_STORAGE_OUTPUT_BUCKETgenerated resultspublic
TRYVIO_STORAGE_MERCHANT_BUCKETmerchant-provided imagespublic

⚠️ tryon-inputs is PRIVATE and tryon-outputs is PUBLIC — they are not symmetric, and the difference is easy to miss because both are addressed by the same <bucket>/<store>/<slug>/<file> path shape. Reading a shopper photo needs a signed URL (POST /storage/v1/object/sign/ tryon-inputs/<path> with the service-role key); the public path answers 404 NoSuchBucket. This bit the #361 replay harness: every person URL 404’d, the provider silently invented a stock model instead of failing, and thirty generations of “results” looked entirely plausible while proving nothing. Any tool that replays real generations must verify each input URL returns an image/* before spending a credit.

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.

Makeup shade resolution

Makeup categories (makeup-*) receive a “Shade details:” prompt clause built by resolveMakeupShade (lib/server/makeup-shade.ts, #403), so the AI knows which shade the shopper selected instead of relying only on the reference photo.

Phase 3 — merchant shade words (#406)

v2 (2026-08-21, owner pivot on #406): no Shopify scope, no metafield writes. The picks and shade words live entirely in Tryvio — Shopify is read-only here.

  • Table — product_variant_settings (migration 20260821_product_variant_settings.sql): store_id, product_id, scope_key (option:<name>=<value> = a colour-level rule, variant:<gid> = a single-variant override), option_name/option_value | variant_external_id, reference_image_position (a sort_order index into the product’s own images — positional, so it survives Shopify re-syncs), shade jsonb {colourWords, finish, coverage, hex}. Unique on (product_id, scope_key); RLS on.
  • Resolver — lib/products/variant-reference.ts, pure: detectColourOption (matches Цвят/Color/Нюанс/Shade/Тон by regex; falls back to the first option with ≥2 values) and resolveVariantReference (a variant override wins over its colour rule; image and shade are resolved independently, so a colour photo pick and a per-variant shade note can coexist). A position that no longer exists in the product’s images resolves to no URL — never a stale one.
  • Try-on route — one indexed read, listProductVariantSettings(store.id, product.id), when a variant is requested. rawVariantImageUrl = merchantPick.imageUrl ?? <#395 chain> (Shopify variant image → product image). metadata.resolvedShade = toResolvedShade(merchantPick.shade, variant.swatchHex) feeds the same #403 “Shade details:” clause. No merchant pick anywhere → both fall through unchanged → byte-identical prompt to #403/#395. Logged as tryon.variant_image_merchant_pick.
  • Sync — ProductSync now requests variants(first: 100) (was 30) plus selectedOptions.optionValue.swatch.color, stored read-only as metadata.variants[].swatchHex — no metafields involved. Measured requested query cost on the dev store at 50 products/page: 324 → 639 of the 1000-point limit.
  • Storefront — buildStorefrontProductSnapshot(product, selected, sidesRule, variantSettings) applies the same picks to the colour-swatch thumbnails in the variant picker. The storefront-proxy and session routes read the settings and treat a missing/failed read as non-fatal (fall back to the #395 chain).
  • Merchant API — GET /api/shopify/products adds colour_option and variant_settings per product (one store-wide query, not N+1). PATCH /api/shopify/products/[id] accepts variantSettings[] as a replace-set per product: image positions are validated against the product’s own image list, variant ids/option values against its variants, and shade text is sanitised (sanitizeVariantShade) before replaceProductVariantSettings persists them.
  • UI — product-row-drawer.tsx’s VariantPhotosSection: one row per colour, reuses the same ImagePickerPopup as front/back, a filter box above 10 colours, the four makeup text inputs, and a per-variant expander for single-variant overrides. Everything is draft state feeding the page’s one Save bar — no separate save action.
  • Removed from v1 — the write_products scope, the $app:tryvio metafield definitions and their ensureShadeMetafieldDefinitions ensure-step, the shade-fields paste route, and the POST /api/auth/session scope re-exchange. The dev Shopify app was redeployed without the scope as tryvio-dev-207.

Phase 3 — merchant-uploaded shade references (#547)

#406 gave the merchant words (product_variant_settings.shade) and a positional pick among the product’s own Shopify images — but a catalogue that is packshot-only, with zero colour codes (NL Beauty’s L1/L5 reality: eyeshadow/blush/bronzer/brows), has nothing for #406 to point at. #547 adds what #406 cannot hold: merchant-uploaded reference images (swatch / on-lips photos) and a first-class colour code, per variant.

  • Table — product_variant_references, one row per scope, same shape and precedence as #406’s product_variant_settings: option:<name>=<value> (a colour-level rule) or variant:<gid> (a single-variant override). See Data Model for columns.
  • Resolver — resolveVariantUploads (lib/products/shade-references.ts), pure: a variant override wins over its colour rule, exactly like resolveVariantReference (#406) — so the two tables can never disagree about which scope owns a variant.
  • Storage — uploads live in the private merchant bucket (TRYVIO_STORAGE_MERCHANT_BUCKET, path shade-refs/<storeId>/<productId>/...); the provider only ever receives a short-lived signed URL (2h).
  • Generation order — at generation time the variant’s own uploads are sent first among reference images, ahead of catalog-derived references — including for makeup, where catalog-derived references stay suppressed (#392/#394). The makeup shade clause names images 3..2+n as “the merchant’s own reference photographs of this exact shade”.
  • Colour code — folds into metadata.resolvedShade via mergeColourCode: a code that parses as hex becomes the hex, unless the merchant already typed an explicit hex into #406’s shade fields (explicit words win); anything else (Pantone, an internal SKU-colour code) rides as colour words.
  • Image budget — MAX_VARIANT_REFERENCE_IMAGES = 10 per variant on the Shades screen, derived from the tightest provider budget (kie’s 14-image cap minus 1 person + 1 garment/shade image + up to 2 “Complete the look” complements). At generation time capReferenceImages fits the actual request into the effective model’s real cap (14 or 16 — see Combined N-garment generation above), spending the fixed slots (person, garment, complements) first, then the variant’s uploads, then catalog references — reporting every drop.
  • Merchant UI — /products?tab=shades (ungated). #622 made it overview-first: the tab opens on a readiness table of every makeup product (thumbnail, variant count, “N of M” shades with colour, uploaded photo count, finish coverage), sorted most-missing-first, built by the pure buildShadeOverviewRows (lib/products/shade-overview) from the one products payload — the per-product photo counts ride GET /api/shopify/products as the additive shade_photo_count (one store-wide product_variant_references read in the route’s Promise.all, no N+1). “Has colour” is #551’s shadeCarriesWordsOrHex definition (colour words OR hex), so the table and the dashboard’s Makeup accuracy card can never disagree. Clicking a row (or the quick-jump picker) opens the per-variant state (image / colour / finish / intensity, with an honest “Colour unknown” empty state via variantShadeState), multi-select bulk finish, and bulk import — POST /api/shopify/products/shade-import accepts CSV/ZIP (design-system file buttons since #622), reports a reason per row, keeps successes on partial failure, and merges into each scope (never a replace-set, unlike #406’s PATCH .../variantSettings). Zero makeup products → an explanatory empty state linking to the Catalog tab.

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, the Search & Discovery complements, the store’s co-purchase partners (#393) and an affinity-ranked candidate pool; returns an ordered array of 1–2 complement items. Priority per slot: merchant-pinned row → Shopify native “Complementary products” (Search & Discovery) → bought-together partners (co-purchase, #393) → affinity-ranked candidate. A pin or a Search & Discovery match still wins outright; the co-purchase tier is only consulted when neither decided. De-duplicates against the primary and already-chosen slots. How many the automatic tiers may fill comes from stores.bundle_upsell_count (1–2, default 1).

  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 complements[] plus combinable. Since #202 a pinned slot is only used when the complement is still showable (isPinnableComplement: synced, status = active, has ≥1 image, widget_visible ≠ false, enabled ≠ false). A stale pin is dropped and resolution degrades to the next tier; it never renders a broken upsell card. Since #664 that rule is ONE predicate, complementUnservableReason in lib/products/pin-health.ts, read by the resolver, the save paths, the merchant’s outfit list, the daily pin sweep and the admin store detail — so “not shown” on a screen and “not shown” on the storefront cannot disagree. The automatic tiers additionally honour the store’s widget mode (selected_products / excluded_products).

Where the candidates come from (#243)

The automatic tier used to reuse getStorefrontRelatedProducts, which matches products by identical product_type or vendor — i.e. it looked for things SIMILAR to the primary and then deleted whatever could not be worn with it. Measured on prod (2026-08-03): Icedout’s 322 active products all share one vendor and 62 are sunglasses, so the 4-candidate window on a sunglasses page was four more pairs of sunglasses, every one dropped by the wear-zone rule (#102) → bundle: null, while the same catalogue carried seven glasses chains. The fix is the source, not the filter:

  • Bounded windows — listBundleCandidateProducts asks the database for ~40 rows of a different product_type (plus 8 same-type rows kept only for the last resort). The full-catalogue load is gone from the shopper path entirely; product_type is the SQL proxy for “a different category”, because the category itself is computed in JS and is not a column.
  • Affinity ranking — sortByAffinity / affinityRank order candidates by an explicit “goes with” map (eyewear → necklace/chain, bracelet, watch…; top → bottom, footwear…), then merely compatible, then conflicting. Deterministic: no popularity signal (owner decision, price and collection data are not usable — no price column, collections ~9% synced, see #193).
  • Search & Discovery — read from the PUBLIC /recommendations/products.json?intent=complementary first (no access token, no Admin rate-limit budget, survives an expired offline token), with the shopify--discovery--product_recommendation.complementary_products metafield as the fallback for storefronts that answer with a password page. All configured complements are used, not just the first. Cached ~5 min per store+product with in-flight sharing — it sits on the shopper hot path.
  • Pins override wear zones — a merchant pin is an explicit decision and is honoured even when the pair cannot be worn together (owner, 2026-08-03). The pickers warn at pin time (pairingWarning).
  • combinable — false when any two members of the outfit compete for the same wear zone (an overriding pin, or a single-category store whose only candidate is a look-alike). The widget then shows the card and the “add both, save X%” offer but never offers or runs the combined generation, and the eyebrow reads “Often bought together” instead of “Complete the look”.

See Data Model for the product_bundles table and stores bundle columns.

Shopper-picked stacks — CTL v2 phase 1 (#566)

The shopper can also assemble the look themselves via a store-scoped search picker (GET /api/storefront/product-search — indexed store_id + ilike title window, image-less products excluded at the join, try-on-ability and merchant visibility filtered like the sibling endpoints, enforceAuditWindowLimit 60/5min, CORS, p95 budget ≤ 400ms). The picked handles ride the existing complementProductHandles[] field — same rails as a merchant bundle, same ONE combined generation, ONE billed credit.

The original entry point for this picker — a floating “Add a product to the look” pill — was replaced in #601 by the unified bubble described below. The search module itself (look-search.js) is unchanged and is now mounted embedded inside the bubble’s popup.

  • Cap (lib/widget/look-cap.ts, pure + mirrored in the widget’s counter): 4 total products when EVERY product in the stack is makeup-zone, else 3 — the proven #523-matrix ceilings, hard constants. The merchant’s look.maxProducts named key (product_tryon_settings.metadata, the #511/#176 no-migration slot, on the primary product) may only LOWER it; the route re-resolves and slices server-side, never trusting the widget.
  • Shared composition path: a makeup-zone complement is enriched exactly like a #511 kit component (makeupZone + the #403 shade clause when the shade is unambiguous — a single-variant product, honouring the #406 merchant pick) and feeds the same additionalGarments → buildCombinedTryOnPrompt multi-zone prompt (“image N = zone” + anti-bleed). Garment complements keep the exact pre-#566 shape, so v1 prompts are byte-identical.
  • Result itemisation (lookComponents, also #511’s kit follow-up): the try-on route records the composed look (title/handle/imageUrl, primary first) in the provider-job payload; the try-on and try-on-status responses return it additively (only for multi-product looks — a single-product response is field-identical to before). The widget itemises a kit look read-only (“This look includes”); a shopper stack itemises through v1’s merged receipt with prices + the bundle discount, which the AC13 audit proved covers 3–4 cart lines (TRYVIOBUNDLE is items: { all: true } — no line-count limit exists).

Kit looks (an active kit config) never offer stacking — shopper handles would bypass the kit’s variant-mapped composition (complements win over the kit config in the route). Proof: 05_tasks/proof/566/.

Unified Add/change bubble + tiered discounts — CTL v2 phase 2 (#601)

The bundle card’s separate “add both” checkbox and the #566 floating picker pill are merged into one bubble, and the bundle discount becomes a merchant-configurable tier ladder instead of a single flat %.

Widget UI. The eyebrow row of .tryvio-modal__bundle-card carries a header pill .tryvio-modal__bundle-addpill — copy key widget.look_change_products (“Add or change products”). Tapping it opens a popup: an anchored panel over the modal on desktop, a bottom sheet on mobile (.tryvio-modal__look-pop). It contains, top to bottom:

  • Tier ladder chips (.tryvio-modal__look-tiers) — a reached tier renders filled, the next tier renders dashed with a nudge line (“add 1 more to save 25%”).
  • Look rows (.tryvio-modal__bundle-lookrows) — the main product first, then each picked complement: photo, name, price.
  • The CTL suggestion (if any) with its own Add action.
  • The reused #566 look-search module, mounted deps.embedded — input + results only (no standalone chrome), talking to the same stack API (getStack / addItem / removeAt) as v1.
  • A combined total row.
  • A Done CTA, which stages the picked look onto the same bundle rails (config.bundle.complements) — no behaviour change downstream: generation still happens through the existing “Try them on together” ghost CTA, one generation = one billed credit, unchanged.

Card rendering modes (data-mode on .tryvio-modal__bundle-card):

Picked countModeRenders
≥ 2"look"Uniform rows (photo + name + price + ✕) + total + the highest achieved tier badge
1 or 0(default)The production suggestion card, now also showing the suggestion’s price
No suggestion at all"invite"Eyebrow + the add/change pill only, no product row

Files: extensions/tryvio-theme/src/tryvio-modal/index.js, look-search.js.

Tiered bundle discounts (#601)

The flat stores.bundle_discount_percent becomes the lowest rung of an optional ladder, e.g. 2 products → −20%, 3 products → −25%, 4 products → −30%. No ladder configured = unchanged single-discount behaviour.

  • Storage — additive named key bundleDiscountTiers in storefront_theme_settings.metadata (the #511 named-key pattern: read-modify-write merge, no migration). Shape: [{ count, percent, shopifyDiscountId }].
  • Pure module — lib/billing/bundle-tiers.ts: parse/validate, resolveTierForCount, tiersForStorefront. Valid counts are 2–4, the same #523-proven look caps as the shopper-stack cap above.
  • Shopify discount codes — one auto-managed code per configured tier, extending the existing TRYVIOBUNDLE rails: the count-2 tier reuses TRYVIOBUNDLE itself; higher tiers get TRYVIOBUNDLE3 / TRYVIOBUNDLE4. Same “Tryvio:“-prefixed title ownership guard as before. Codes are synced on save and deleted on tier removal or when the bundle is turned off, in the store-settings PATCH handler (same handler that already manages bundle_shopify_discount_id); the ladder configuration itself survives bundle-off.
  • Not-yet-synced tiers stay invisible (#592) — a tier whose count is above the count-2 minimum is served to the storefront only when its shopifyDiscountId is set. The code is derived from the count (TRYVIOBUNDLE + n), so a ladder can name TRYVIOBUNDLE3 before syncDiscountCodeToShopify has actually created it in the merchant’s Shopify — serving it then would advertise a discount the shop refuses at checkout. The count-2 tier is exempt: it IS the legacy TRYVIOBUNDLE, whose gid lives in stores.bundle_shopify_discount_id, and every cached widget already applies it. This makes the deploy order self-enforcing: a ladder can be written into storefront_theme_settings.metadata at any time (e.g. by a migration), and the tier stays invisible until the store-settings PATCH handler syncs the code and records the gid — at which point it appears with no further configuration change.
  • Proxy config — /api/proxy emits bundle.discount.tiers = [{ count, percent, code }] additively, alongside the untouched legacy bundle.discount.percent / .code. Mapping: no ladder configured → one implicit { count: 2, percent } tier (the legacy percent has always meant “the complete ≥2-line bundle”); ladder configured → the legacy percent/code fields carry the lowest tier, so an old cached widget keeps working unchanged. A tier whose count exceeds the product’s resolved look cap is not served on that page — nor, per above, is a tier whose Shopify code does not exist yet.
  • Cart application — the widget applies the code of the reached tier at Add-to-Cart (complete look only — the existing all-or-nothing rule is unchanged; below the lowest tier, no code is applied). Mirrored server-side in lib/widget/bundle-cart-availability.ts (appliedTier).
  • Admin — a tier editor (count → percent rows) in /settings → Complete the look (Grow · Offers Bundle card, bundle-settings.tsx); drafts through the page’s one save bar. A single flat percent still renders as the one-tier case. Server-side validation is strict (400 before any write). stores.bundle_discount_percent continues to track the lowest tier’s percent when a ladder is saved, so anything still reading that column sees the correct floor.

See Data Model for the storage shape.

Co-purchase tier (#393)

A third automatic candidate source, ranked between Search & Discovery and the affinity pool: products the store’s own shoppers actually bought together in a real Shopify order, not just similar catalog metadata.

  • Compute (cron) — GET /api/cron/copurchase-pairs, daily 03:30 UTC. For every stores.status = 'active' store: getShopifyAccessToken (missing → token_missing, no Shopify call) → listShopifyOrderBaskets (lib/server/shopify.ts) pages the last 60 days of orders (GraphQL orders(first:100, query:"created_at:>=… AND status:any", sortKey: CREATED_AT, reverse:true) with lineItems(first:100){ product{id} }, capped at 50 pages / 5000 orders; a read_orders-scope error surfaces as the typed ShopifyScopeMissingError) → the pure computeCopurchasePairs (lib/products/copurchase.ts) counts each pair once per order (never a product with itself, floor minOrders: 2, sorted by orders-together desc, then confidence desc, then key) → replaceCopurchasePairs upserts on (store_id, product_a_external_id, product_b_external_id) without touching dismissed_at, then deletes only rows with an older computed_at — a merchant’s dismissal survives the recompute. Bounded concurrency: 4 stores at a time. Per-store outcome: ShopifyScopeMissingError → scope_missing (old rows kept), anything else → error.
  • Read (storefront) — listCopurchasePartners is one indexed read joined into the existing Promise.all in resolveStorefrontBundle (lib/server/storefront-bundle.ts), then resolved to storefront products via listStorefrontProductsByExternalIds. A DB error on this read fails soft (logged warning; the bundle resolves exactly as it did before #393).
  • Threshold — a pair is offered only once it recurs in ≥3 orders together (COPURCHASE_MIN_ORDERS, storefront-bundle.ts — stricter than the cron’s own floor of 2, to keep two-order coincidences off the storefront).
  • Same visibility + wear-zone rules as any automatic candidate — active, has an image, widget-visible, inside the widget mode allow-list. A partner that can’t be worn with the primary (e.g. glasses + glasses) is still offered but with combinable=false (no combined generation), exactly like the #243/AC11 rule above.

Configuring the pins (merchant side, #202)

SurfaceWhat it does
/settings → Complete the lookStore on/off + bundle discount (single % or, since #601, a tier ladder), and the outfit list. Both pickers are the canonical EntityPicker in async mode; an orphan pin renders a designed “Product unavailable” slot with a remove action.
/products → Upsell columnPer-row in-place editor: pin/replace/remove 1–2 complements without leaving the catalog. Saves immediately (per-row busy + error state).
/products → bulk bar”Upsell: [picker] → Apply to N” over the selection (including Select all N matching), behind a counted confirm dialog; reports n applied, m failed.
/settings → Complete the look → “Suggested from your orders” (#393)Read-only list of co-purchase pairs (product A + product B, orders-together count, % of A’s buyers) above the pinned outfit list, with per-row Apply/Dismiss, a “Show dismissed” toggle with Restore, and bulk Apply. Applying pins BOTH directions (A→B and B→A) into each product’s next free slot via the same planComplement rules as the pickers above.

APIs: GET/POST/DELETE /api/shopify/product-bundles (POST re-verifies that BOTH ids belong to the authorized store → 403), POST /api/shopify/products/bulk-bundle (same ownership check, 1000-id cap, 100-row upsert chunks) and GET /api/shopify/products/search (indexed picker search — the settings GET no longer ships the whole catalogue). Pure slot arithmetic: lib/products/bundle-slots.ts.

Pins the storefront cannot show (#664). On Icedout (2026-09-16) 9 of 27 pins pointed at a draft product and 6 of 23 primaries had no servable complement, while every screen listed them as working. Owner decision: a mechanism, never a manual fix — prevent, detect, heal, tell.

  • Prevent. POST /api/shopify/product-bundles and POST /api/shopify/products/bulk-bundle refuse an unservable complement with a 422 carrying code: "complement_unservable", reason, productId and productTitle (reason is one of missing, not_active, no_image, hidden_from_widget, tryon_disabled) and write nothing. A hidden PRIMARY is saved (a launch in preparation) but the answer carries warning: {code: "primary_unservable", reason}. The complement pickers pass servable=1 to /api/shopify/products/search, which offers only showable products (status in SQL, images/visibility trimmed by the same predicate over an over-fetched page).
  • Detect. GET /api/shopify/product-bundles adds complementIssue / primaryIssue per pin, status per product and health: {totalPins, deadPins, primariesWithNothingServable} (additive — an older screen ignores them). summarizePinHealth is the one summary.
  • Heal. Unchanged and already true: the resolver drops a dead pin and falls to the next tier, so a dark pin never means an empty card.
  • Tell. The outfit list marks each dead pin “Not shown · reason”, a hidden primary, and a primary with nothing servable; a banner counts them with Remove hidden pins, which calls DELETE /api/shopify/product-bundles?dead=1 behind a confirm dialog — the server decides which pins are dead and answers {ok, removed, failed}; products are never touched. The daily GET /api/cron/bundle-pin-health (05:45 UTC) judges every active pin of every installed, non-test store and posts an in-app bundle_pins_dark notice (link /offers) deduped on the fingerprint of the dark SET — once per new dark pin, not every morning. It never removes anything. The admin store detail shows Outfit pins: N · M not shown (outfitPins on getAdminStoreMetrics).

GET/POST /api/shopify/bundle-suggestions (#393) serves the “Suggested from your orders” panel: GET returns {data:{status, syncedAt, suggestions[], dismissed[]}} (top 20, pairs+pins+status read in one Promise.all, then the product join; hides pairs pinned in both directions and pairs whose product is gone/archived/image-less/disabled). POST {action:'apply', pairs:[…]} (max 50) pins both directions and responds {ok, applied, skipped, details[]} (result per pair: applied/duplicate/full/unknown_product); POST {action:'dismiss', productA, productB, dismissed} sets/clears dismissed_at (404 if the pair doesn’t exist for the store). Product GIDs are resolved against the authorized store’s own catalogue, never trusted as-is.

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 the image budget (see below).

The cap is per MODEL, not per provider (corrected in #547 — a provider can serve more than one model, and each model has its own limit): kie’s nano-banana-2 family accepts ≤14 images per request, gpt-image-2 ≤16. providerImageCap / capReferenceImages (lib/products/shade-references.ts) enforce this in the try-on route after model routing — the effective model’s budget decides, not a fixed provider constant. Every reference dropped to fit the budget is logged (tryon.reference_images_capped) and stamped into tryon_provider_jobs.request_payload as variantReferenceDropped, alongside variantReferenceCount (how many of the merchant’s own uploads rode in that request) — a silent truncation is never invisible.

buildCombinedTryOnPrompt (lib/server/tryon-prompt-builder.ts — provider-neutral since #73):

  • 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.combinable — added in #243. false means the outfit cannot be rendered as one image, so a widget that understands the field offers both products without the combined generation. Absent or true = today’s behaviour, which is exactly what an old cached widget does anyway (bundleCombinable() defaults to true).
  • 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.

Kit products (#511)

A kit product is a single Shopify product whose variants encode a combination of the merchant’s own component products — e.g. NL Beauty’s “Комплект COMPLETE LOOK” (120 variants = lipstick shade × eyeshadow shade). Kit try-on reuses the combined-generation core above but resolves the “garments” from the kit’s own variant, not from pinned complements.

Mapping storage. The mapping lives in the named kit key inside product_tryon_settings.metadata (the #176 named-key slot — no migration needed): { enabled, rules: [{ optionName, componentProductId }], overrides: [{ variantExternalId, components: [{ componentProductId, shadeValue }] }] }. Written only via updateProductKitConfig, a narrow read-modify-write upsert that never clobbers other metadata keys or columns. Accepted by PATCH /api/shopify/products/[productId] under the kit field: option names/variant ids must belong to the product, component ids must be store-owned (filterOwnedProductIds), and at most 4 components (the proven generation ceiling) — otherwise 422 at config time.

Resolution is a pure module, src/lib/products/kit-config.ts: parseProductKitConfig, sanitizeKitConfig, resolveKitComponents (an override wins whole over rules; a rule’s shadeValue is the kit variant’s own value for that option), matchComponentVariant (matches the component’s variant by option value or title, case-insensitive), and previewKitResolution (the admin preview: resolved/unresolved per variant).

Try-on route (/api/storefront/try-on): when the product’s kit config is enabled, no CTL complements were sent, and the selected variant resolves fully, the route loads components via listStorefrontProductsByIds + per-component listProductVariantSettings (parallel), resolves each component’s image through its own #406 chain (merchant pick → matched shade’s Shopify image → primary), and composes: component 1 takes the garment slot (image 2), with its category as resolvedCategoryOverride and its name/description/shade in metadata; components 2..N ride additionalGarments with new optional fields makeupZone + shade. Same-product referenceImageUrls are suppressed for a kit. One checkBillingGate call covers the whole kit — 1 credit total. The request payload records kitComponentCount + kitComponentSlugs (countable in SQL).

Fallback: any gap — no variant, an unresolved option, a missing/unsynced/imageless component, or a read failure — falls back to today’s single-image behaviour; it is never a shopper-facing error. Structured logs: kit.unresolved, kit.component_unavailable, kit.resolution_failed on the fallback paths, kit.composed on success.

Prompt (buildCombinedTryOnPrompt): when every extra garment carries makeupZone, the frame switches to a makeup-kit frame (“This is a makeup kit applied as ONE look…”) with a per-component zone clause per image index (“image N = zone”) plus a per-component #403 shade clause. Non-kit combined prompts stay byte-identical.

Admin: /products drawer gets a “Kit (set of your products)” section (src/app/products/kit-editor.tsx) on the same draft lane as the rest of the drawer, saved by the one save bar (row.kit in save-model.ts). Rules use the ProductEntityPicker; a per-variant exception editor covers overrides; a live preview badge shows “{resolved} of {total} variants resolve”. i18n keys products.kit_* ship in all 24 locales.

Widget: untouched by design — the API additions are additive-only, so old cached widgets keep single-image behaviour. Itemising the resolved components on the result screen is an open follow-up (needs widget JS changes). Kit behaviour only activates for products where the merchant has explicitly enabled the mapping — backward-compatible by construction.

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 (eager bootstrap/button) → tryvio-boot.js (the post-config half, loaded in parallel with /api/proxy) → tryvio-widget.js → tryvio-modal.js (the modal UI), plus tryvio-collection.js for collection/home card buttons. See Widget Load Sequence.

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 items — startRelatedProductTryOn 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.