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 + 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 orgid://…/<id>suffix) → else theselectedVariantImageUrlthe widget stored ontryon_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 RESTproducts/*webhook path wrote nometadata.variantsuntil #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 throughfilterSiblingVariantReferences— withmetadata_variantsevery other variant’s image is dropped (generic photos stay), withsession_snapshotthe 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 realimage/jpegfor…/IMG_6306.heic?v=1, yet the provider still answers “File type not supported”.toProviderSafeImageUrlre-encodes.heic/.heif/.avif/.gif(and rawdata:image/heicshopper 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 onePromise.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 intryon_provider_jobs.- Returns
{ status: 'pending', pollAfterMs }. - On any throw the catch finalizes through
finalizeFailedStorefrontTryOn(#204 / LOG-4 — it used to writeupdateTryOnSession({status:'failed'})directly, leaving the provider jobpendingforever with notryon_generation_failedevent and no refund). The 502 body is{ error, code }wherecodeisproduct_image_unsupported | generation_failed— an additive field; old cached widgets keep readingerrorunchanged.
- 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.
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 var | Bucket | Public? |
|---|---|---|
TRYVIO_STORAGE_INPUT_BUCKET | uploaded person images (short retention) | private |
TRYVIO_STORAGE_OUTPUT_BUCKET | generated results | public |
TRYVIO_STORAGE_MERCHANT_BUCKET | merchant-provided images | public |
⚠️ 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(migration20260821_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(asort_orderindex 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) andresolveVariantReference(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 astryon.variant_image_merchant_pick. - Sync —
ProductSyncnow requestsvariants(first: 100)(was 30) plusselectedOptions.optionValue.swatch.color, stored read-only asmetadata.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/productsaddscolour_optionandvariant_settingsper product (one store-wide query, not N+1).PATCH /api/shopify/products/[id]acceptsvariantSettings[]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) beforereplaceProductVariantSettingspersists them. - UI —
product-row-drawer.tsx’sVariantPhotosSection: one row per colour, reuses the sameImagePickerPopupas 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_productsscope, the$app:tryviometafield definitions and theirensureShadeMetafieldDefinitionsensure-step, the shade-fields paste route, and thePOST /api/auth/sessionscope re-exchange. The dev Shopify app was redeployed without the scope astryvio-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’sproduct_variant_settings:option:<name>=<value>(a colour-level rule) orvariant:<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 likeresolveVariantReference(#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, pathshade-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+nas “the merchant’s own reference photographs of this exact shade”. - Colour code — folds into
metadata.resolvedShadeviamergeColourCode: 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 = 10per 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 timecapReferenceImagesfits 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 purebuildShadeOverviewRows(lib/products/shade-overview) from the one products payload — the per-product photo counts rideGET /api/shopify/productsas the additiveshade_photo_count(one store-wideproduct_variant_referencesread in the route’sPromise.all, no N+1). “Has colour” is #551’sshadeCarriesWordsOrHexdefinition (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 viavariantShadeState), multi-select bulk finish, and bulk import —POST /api/shopify/products/shade-importaccepts 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’sPATCH .../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:
-
resolveOutfit(lib/widget/bundle-pairing.ts) — pure function; takes the primary product id, the ordered manual slots fromproduct_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 fromstores.bundle_upsell_count(1–2, default 1). -
resolveStorefrontBundle(lib/server/storefront-bundle.ts) — server-side; queries theproduct_bundlesslot rows for the primary, callsresolveOutfit, attachesresolveBundleDiscountdata, and returnscomplements[]pluscombinable. 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,complementUnservableReasoninlib/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 —
listBundleCandidateProductsasks the database for ~40 rows of a differentproduct_type(plus 8 same-type rows kept only for the last resort). The full-catalogue load is gone from the shopper path entirely;product_typeis the SQL proxy for “a different category”, because the category itself is computed in JS and is not a column. - Affinity ranking —
sortByAffinity/affinityRankorder 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 — nopricecolumn, collections ~9% synced, see #193). - Search & Discovery — read from the PUBLIC
/recommendations/products.json?intent=complementaryfirst (no access token, no Admin rate-limit budget, survives an expired offline token), with theshopify--discovery--product_recommendation.complementary_productsmetafield 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’slook.maxProductsnamed 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 sameadditionalGarments→buildCombinedTryOnPromptmulti-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 (TRYVIOBUNDLEisitems: { 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 count | Mode | Renders |
|---|---|---|
| ≥ 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
bundleDiscountTiersinstorefront_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
TRYVIOBUNDLErails: the count-2 tier reusesTRYVIOBUNDLEitself; higher tiers getTRYVIOBUNDLE3/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 thestore-settingsPATCH handler (same handler that already managesbundle_shopify_discount_id); the ladder configuration itself survives bundle-off. - Not-yet-synced tiers stay invisible (#592) — a tier whose
countis above the count-2 minimum is served to the storefront only when itsshopifyDiscountIdis set. The code is derived from the count (TRYVIOBUNDLE+ n), so a ladder can nameTRYVIOBUNDLE3beforesyncDiscountCodeToShopifyhas 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 legacyTRYVIOBUNDLE, whose gid lives instores.bundle_shopify_discount_id, and every cached widget already applies it. This makes the deploy order self-enforcing: a ladder can be written intostorefront_theme_settings.metadataat any time (e.g. by a migration), and the tier stays invisible until thestore-settingsPATCH handler syncs the code and records the gid — at which point it appears with no further configuration change. - Proxy config —
/api/proxyemitsbundle.discount.tiers=[{ count, percent, code }]additively, alongside the untouched legacybundle.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 legacypercent/codefields carry the lowest tier, so an old cached widget keeps working unchanged. A tier whosecountexceeds 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_percentcontinues 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 everystores.status = 'active'store:getShopifyAccessToken(missing →token_missing, no Shopify call) →listShopifyOrderBaskets(lib/server/shopify.ts) pages the last 60 days of orders (GraphQLorders(first:100, query:"created_at:>=… AND status:any", sortKey: CREATED_AT, reverse:true)withlineItems(first:100){ product{id} }, capped at 50 pages / 5000 orders; aread_orders-scope error surfaces as the typedShopifyScopeMissingError) → the purecomputeCopurchasePairs(lib/products/copurchase.ts) counts each pair once per order (never a product with itself, floorminOrders: 2, sorted by orders-together desc, then confidence desc, then key) →replaceCopurchasePairsupserts on(store_id, product_a_external_id, product_b_external_id)without touchingdismissed_at, then deletes only rows with an oldercomputed_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) —
listCopurchasePartnersis one indexed read joined into the existingPromise.allinresolveStorefrontBundle(lib/server/storefront-bundle.ts), then resolved to storefront products vialistStorefrontProductsByExternalIds. 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)
| Surface | What it does |
|---|---|
/settings → Complete the look | Store 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 column | Per-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-bundlesandPOST /api/shopify/products/bulk-bundlerefuse an unservable complement with a422carryingcode: "complement_unservable",reason,productIdandproductTitle(reasonis one ofmissing,not_active,no_image,hidden_from_widget,tryon_disabled) and write nothing. A hidden PRIMARY is saved (a launch in preparation) but the answer carrieswarning: {code: "primary_unservable", reason}. The complement pickers passservable=1to/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-bundlesaddscomplementIssue/primaryIssueper pin,statusper product andhealth: {totalPins, deadPins, primariesWithNothingServable}(additive — an older screen ignores them).summarizePinHealthis 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=1behind a confirm dialog — the server decides which pins are dead and answers{ok, removed, failed}; products are never touched. The dailyGET /api/cron/bundle-pin-health(05:45 UTC) judges every active pin of every installed, non-test store and posts an in-appbundle_pins_darknotice (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 (outfitPinsongetAdminStoreMetrics).
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
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.combinable— added in #243.falsemeans the outfit cannot be rendered as one image, so a widget that understands the field offers both products without the combined generation. Absent ortrue= today’s behaviour, which is exactly what an old cached widget does anyway (bundleCombinable()defaults totrue).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
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.