# Try-on pipeline

This is the core of Drape: turning one body photo plus a garment into a render of
that garment on that body. The entry point is `POST /api/try-on`
(`generate_try_on`), with the heavy work in `_run_tryon_background`.

## The full path

```mermaid
sequenceDiagram
    autonumber
    participant App
    participant API as generate_try_on
    participant Redis
    participant BG as _run_tryon_background
    participant Mongo
    participant Fal as fal.ai (rembg/SAM)
    participant AI as Fashn.ai / FLUX.2

    App->>API: POST /api/try-on
    API->>API: gate checks (kill switch, rate limits, consent, owner)
    API->>Redis: cache lookup (key includes body_photo_hash)
    alt cache hit
        Redis-->>App: job_id "cached" + URL
    else miss
        API->>Redis: register job {status, user_id}
        API->>BG: dispatch (in-process task or queue)
        BG->>Mongo: fetch body_photo_data server-side
        BG->>BG: route by garment_style
        BG->>Fal: rembg cutout + EVF-SAM mask
        BG->>BG: reframe + composite on neutral canvas
        BG->>AI: generate render
        AI-->>BG: rendered image
        BG->>BG: validate render URL
        BG->>Redis: cache URL (24h) + job result
        App->>Redis: poll status (ownership-checked)
        Redis-->>App: complete + result URL
    end
```

## Gate checks

Before any compute, `generate_try_on` enforces, in order: the `RENDER_ENABLED`
kill switch, an allowlist bypass for testers, the rate limits (below), **BIPA
consent** (403 if `consent_given` is false), and **body-photo ownership** — if
`body_photo_owner_id` does not match the authenticated user, the request is
blocked with **HTTP 409** so a contaminated photo never reaches the AI pipeline.

## Model routing

`determine_tryon_model` picks the provider from the product's `garment_style` and
tags:

```mermaid
graph TD
    Start["garment_style + tags"] --> Hoodie{"hoodie?"}
    Hoodie -->|yes| FashnMax["Fashn tryon-max"]
    Hoodie -->|no| GPT{"style = gpt-image-2?"}
    GPT -->|yes| GPTPath["GPT-Image path"]
    GPT -->|no| Baggy{"baggy / wide-leg tag?"}
    Baggy -->|yes| FashnB["Fashn"]
    Baggy -->|no| Default["Default: fashn-v1.5"]
```

:::warning[FLUX.2 is effectively dormant]
The tech notes describe "baggy/wide-leg → FLUX.2 LoRA", but the **live routing
sends baggy garments to Fashn `tryon-max`**. A `flux2-lora` branch exists in the
code, but no default rule selects it — a product must be explicitly tagged to
reach FLUX.2. Treat FLUX.2 as present-but-inactive until a product is deliberately
routed to it. This is flagged on the [tech-debt page](../audit/tech-debt.md).
:::

## Garment masking & preprocessing

The Fashn path does real image work before the model sees anything:

1. **rembg** (`fal-ai/imageutils/rembg`) removes the background from the body
   photo, with a retry wrapper.
2. **EVF-SAM** (`fal-ai/evf-sam`) produces upper/lower garment masks;
   `apply_garment_mask` fills the existing-garment region with a neutral
   `#EBEBEB` so the model has a clean canvas instead of fighting the old garment.
3. The cutout is **reframed** so the person fills roughly two-thirds of the
   canvas, composited onto the neutral background, and uploaded to the private
   `body-photos-cutout` bucket as a signed URL.
4. `_call_fashn_tryon` runs, capped by a semaphore (`MAX_CONCURRENT_RENDERS = 3`),
   with a 180-second timeout and one retry; bottoms fall back from `tryon-max` to
   `tryon-v1.6`.

The garment category is normalized from merchandising terms (tops / bottoms /
one-pieces / auto) to what Fashn expects.

## Caching

The Redis cache key is composed from the user, product, color, size, **the body
photo hash**, and a hash of the garment URL:

```text
cache_key = generate_cache_key(user_id, product_id, color, size, body_photo_hash, garment_url)
```

Because `body_photo_hash` is part of the key, uploading a new body photo
**auto-busts** stale renders with no manual cache clear. A cache hit returns
immediately with `job_id = "cached"` and the stored URL — no polling. Concurrent
identical requests are de-duplicated via an `inflight:` key that returns the
existing job id. Valid renders are cached for 24 hours.

## Status polling & ownership

`` `GET /api/try-on/status/{job_id}` `` polls every 3 seconds up to a 180-second
timeout. The Redis job payload stores `user_id` at creation, and the poll handler
**verifies the caller owns the job** — a mismatch returns 403. This closed the
original IDOR where any authenticated user who guessed a job id could read another
user's render (derived from their body photo). Missing jobs report as expired.

## Rate limiting {#rate-limiting}

A render must pass all of these Redis-backed counters (fail-closed — a Redis
outage returns 503, never unlimited):

| Limit | Value |
|---|---|
| Daily try-ons per user | 25 |
| Global daily render cap | 1000 (env-configurable) |
| Burst guard | 5 renders / 10 minutes |
| Per-garment cooldown | 8 seconds |
| Status poll | 60 / minute |

Allowlisted testers bypass the daily, burst, and cooldown limits.

## Silent failure modes

Several pipeline steps can return `200 OK` while producing a wrong result — these
are known and tracked for Sentry wiring:

- `rembg` returns `None` → cutout missing, pipeline continues.
- `body_photo_data` was never written → try-on finds no photo.
- Fashn returns a source/marketing image → treated as a successful render.
- The analyze-body-photos pipeline throws → measurements are written but the photo
  is skipped.

`is_valid_render` guards the obvious cases (a result must be an `https://` or
`data:image/` URL, else the cache entry is deleted and a Sentry warning fires),
but semantic wrongness ("this isn't the right garment") is not something the
backend can detect. See the [tech-debt page](../audit/tech-debt.md) for the render
quality issues that stem from this.
