# Smart Gallery — AI route surface

The Smart Gallery embeds `@photo-gallery/sdk` into the CRM. The SDK never talks to a
model directly: it calls a pluggable `AIProvider`, which the CRM supplies via
`createCrmAIProvider()` in [`src/lib/gallery-ai.ts`](../src/lib/gallery-ai.ts).

Roughly half the intelligence runs **in the browser** for free, and the other half is
proxied through **five server routes** under `/api/gallery/ai/*` so the RunPod API key
never reaches the client.

---

## 1. Where each capability runs

| Capability | Where | Backend | Needs a key? |
| --- | --- | --- | --- |
| Object detection (default) | Browser | TensorFlow.js COCO-SSD (80 COCO classes) | No |
| Object detection (opt-in) | **Server** | RunPod YOLO → `/classify` | Yes |
| Face detection + recognition | Browser | `@vladmandic/face-api` (128-D descriptors → People) | No |
| OCR / document search | Browser | `tesseract.js` | No |
| Semantic ("beach photos") search | Browser | CLIP via `@huggingface/transformers` | No |
| Background removal (default) | Browser | `@imgly/background-removal` (WASM) | No |
| Background removal (opt-in) | **Server** | RunPod U²-Net → `/edit` | Yes |
| Generative edits, restore, upscale, outpaint | **Server** | `/edit` | Yes |
| Speech-to-text | **Server** | `/transcribe` | Yes |
| Audio denoise | **Server** | `/denoise` | Yes |
| Camera tilt / auto-straighten | **Server** | `/tilt` | Yes |

Every in-browser model is loaded with `await import(...)` on first use, so none of it
lands in the initial bundle. **Every capability degrades gracefully** — a model that
fails to load returns `[]` / `''` / `null` with a `console.warn` rather than throwing,
so a blocked CDN never breaks the gallery UI.

---

## 2. Auth model

All five routes share one gate: `requireGallerySession()` in
[`src/lib/server/session.ts`](../src/lib/server/session.ts).

These routes proxy a **paid, rate-limited GPU backend** using a secret held only on the
server. An unauthenticated route here is not just an information leak — it is an open
invitation to spend the operator's GPU budget. (The upstream SDK demo's routes are
completely unauthenticated; that is the single biggest thing this port fixes.)

**When the Shell is configured** (`NEXT_PUBLIC_SUPABASE_URL` is set): the incoming
`cookie` header is forwarded to `${BFF_ORIGIN}/api/session/context` and only a `200`
is accepted.

- `401` upstream → `401 { error: "Not signed in" }`
- any other non-200, or a network/timeout failure → `503 { error: "Session service unavailable" }`
  (**fail closed** — if we cannot prove a session, we do not spend GPU budget)
- **Nothing is cached.** A cached "yes" would keep a revoked session alive, so every AI
  request costs one BFF round trip. Correctness over latency for a spend gate.
- The BFF is trusted absolutely — `BFF_ORIGIN` must only ever point at an origin the
  operator controls.

**When the Shell is NOT configured** (local demo mode): the request is allowed and a
warning is logged once. **Never deploy to a public origin with the Shell unconfigured
and a real `RUNPOD_API_KEY` present** — that combination is an open, billable endpoint.

This is authentication only, not authorization. Per-resource gallery policy lives in
be-crm behind the `crm.gallery` resource.

---

## 3. Rate limits

[`src/lib/server/rate-limit.ts`](../src/lib/server/rate-limit.ts) — a fixed-window
counter keyed by the authenticated principal when known, otherwise the first hop of
`x-forwarded-for`. Each route has its own namespace, so spending your `edit` budget
does not consume your `classify` budget. Exceeding it returns
`429 { error: "Too many requests — slow down." }` with a `Retry-After` header.

| Route | Limit |
| --- | --- |
| `/classify` | 30 / min |
| `/tilt` | 30 / min |
| `/transcribe` | 20 / min |
| `/denoise` | 20 / min |
| `/edit` | 12 / min (most expensive) |

> **This limiter is per-instance and in-memory.** With N instances behind a load
> balancer a caller gets up to N x the budget, and a restart clears all counters. It is
> a cost guard, not a security boundary. **Replace it with Redis before running more
> than one instance.** The `x-forwarded-for` key is also client-controlled unless a
> trusted proxy overwrites it — the session gate, not this, is the security boundary.

---

## 4. The routes

All five are `POST` only, and all declare `runtime = "nodejs"`, `maxDuration = 60`,
`dynamic = "force-dynamic"`. Failures always return `{ error: string }`.

Shared status codes: `400` invalid body/params · `401` not signed in · `413` payload too
large · `429` rate limited · `500` server misconfigured (e.g. missing `RUNPOD_API_KEY`) ·
`502` upstream failed · `503` not configured / session service unavailable ·
`504` upstream timed out (the RunPod client's budget is 55s, under the 60s cap).

Upstream error bodies are truncated to a **160-character excerpt**; the RunPod key is
never included in any response.

### `POST /api/gallery/ai/classify` — object detection
Backed by `RUNPOD_YOLO_URL`. Max ~4 MB of base64. Returns boxes as **fractions 0..1**
of the image, matching the SDK's `DetectedObject`.

```jsonc
// request
{ "imageBase64": "…", "width": 1280, "height": 853 }
// response
{ "objects": [ { "label": "excavator", "confidence": 0.91,
                 "box": { "x": 0.12, "y": 0.30, "width": 0.25, "height": 0.40 } } ] }
```

### `POST /api/gallery/ai/edit` — generative image editing
The backend is pluggable via `AI_EDIT_PROVIDER` (`auto` | `runpod` | `local` |
`huggingface` | `gemini` | `none`). Image + mask are budgeted **together** against the
~4 MB cap. Prompts come from an allow-listed op set — arbitrary server-side prompts are
never accepted, and free-text is clamped to 500 chars.

```jsonc
// request
{ "imageBase64": "…", "mimeType": "image/jpeg",
  "op": { "type": "restore" },            // or prompt | colorize | replace-sky |
                                          // magic-eraser | generative-fill | upscale |
                                          // remove-background
  "maskBase64": "…",                      // required for magic-eraser / generative-fill
  "params": { "strength": 0.8 } }
// response
{ "imageBase64": "…", "mimeType": "image/png" }
```

Op → RunPod endpoint: `restore`/`upscale` → `RUNPOD_UPSCALE_URL` ·
`prompt`/`colorize` → `RUNPOD_SD_IMG2IMG_URL` · `replace-sky`/`magic-eraser`/
`generative-fill` → `RUNPOD_SD_INPAINT_URL` · `remove-background` →
`RUNPOD_BG_REMOVE_URL`. `upscale`, `magic-eraser` and `generative-fill` are RunPod-only
and return `400` under another backend. Outpaint is client-side padding plus a
`generative-fill` call — it needs no separate route.

### `POST /api/gallery/ai/tilt` — camera tilt
Backed by `RUNPOD_TILT_URL`. Max ~4 MB (`413` over). Only reachable when
`NEXT_PUBLIC_APG_RUNPOD_TILT=true`, which also reveals the editor's Auto-straighten button.

```jsonc
{ "image": "…" }  →  { "rollDegrees": -2.4, "pitchDegrees": 1.1, "fovDegrees": 68.2 }
```

### `POST /api/gallery/ai/transcribe` — speech to text
Backed by `RUNPOD_STT_URL`. Expects base64 **WAV 16 kHz mono PCM16**. Max ~8 MB (`413`).

```jsonc
{ "audio": "…", "language": "en" }
→ { "transcript": "…", "segments": [ { "text": "…", "startSec": 0, "endSec": 1.8 } ] }
```

### `POST /api/gallery/ai/denoise` — audio noise removal
Backed by `RUNPOD_AUDIO_DENOISE_URL`. Expects base64 **WAV 48 kHz mono PCM16**. Max
~12 MB (`413`). Used before transcription on noisy sites.

```jsonc
{ "audio": "…" }  →  { "audio": "…" }
```

---

## 5. Environment variables

See [`.env.local.example`](../.env.local.example) for the fully commented template.

| Var | Backs |
| --- | --- |
| `RUNPOD_API_KEY` | all five routes (server-only — never `NEXT_PUBLIC_`) |
| `RUNPOD_YOLO_URL` | `/classify` |
| `RUNPOD_SD_IMG2IMG_URL` | `/edit` — prompt, colorize, maskless replace-sky |
| `RUNPOD_SD_INPAINT_URL` | `/edit` — magic-eraser, generative-fill, outpaint, masked replace-sky |
| `RUNPOD_UPSCALE_URL` | `/edit` — restore, upscale |
| `RUNPOD_BG_REMOVE_URL` | `/edit` — remove-background |
| `RUNPOD_STT_URL` | `/transcribe` |
| `RUNPOD_AUDIO_DENOISE_URL` | `/denoise` |
| `RUNPOD_TILT_URL` | `/tilt` |
| `AI_EDIT_PROVIDER` | which backend `/edit` uses |
| `BFF_ORIGIN` | the session gate |

Client switches (`NEXT_PUBLIC_`, **inlined at build time** — a change needs a rebuild,
and each must be the literal string `"true"`):
`NEXT_PUBLIC_APG_RUNPOD_DETECT`, `NEXT_PUBLIC_APG_RUNPOD_BG`, `NEXT_PUBLIC_APG_RUNPOD_TILT`.

> The SDK's standalone demo also reads a family of `NEXT_PUBLIC_APG_*` **theming** vars
> (`_THEME`, `_ACCENT`, `_RADIUS`, `_BG_DARK`, `_SIDEBAR_BG_*`, …). Those are **not used
> here** — the CRM passes `themeTokens` to the gallery directly so it inherits the
> dashboard's design tokens. Setting them in this app has no effect.

---

## 6. CSP / CDN hosts

**The CRM ships no Content-Security-Policy today**, so the in-browser models load
without any allow-listing. This section is what would need to be permitted **if a CSP
is ever added** — miss any of these and the affected capability silently degrades
(returns empty + `console.warn`) rather than erroring visibly, which makes it easy to
misdiagnose.

| Host | Needed by | Directive |
| --- | --- | --- |
| `https://cdn.jsdelivr.net` | face-api models; tesseract worker, WASM core **and** language data | `script-src`, `connect-src`, `worker-src` |
| `https://huggingface.co`, `https://cdn-lfs.huggingface.co` | CLIP model weights (transformers.js) | `connect-src` |
| `https://storage.googleapis.com` | TensorFlow.js COCO-SSD model | `connect-src` |
| `https://staticimgly.com` | `@imgly/background-removal` WASM + assets | `connect-src`, `worker-src` |

Also required by the ML runtimes themselves:

- **`wasm-unsafe-eval` in `script-src`** — ONNX Runtime (CLIP), tesseract-core and the
  `@imgly` remover are all WebAssembly. Without it, semantic search, OCR and in-browser
  background removal all fail.
- **`blob:` in `worker-src`/`child_src`** — tesseract and the WASM runtimes spawn
  workers from blob URLs.
- **`data:` and `blob:` in `img-src`** — canvas round-trips and generated results.

Notes:
- All three tesseract assets (worker, core, tessdata) are pinned to **jsDelivr** on
  purpose. Left at its defaults, tesseract fetches language data from
  `tessdata.projectnaptha.com`, which would be a second host to allow-list.
- `tesseract.js` is pinned to **exactly `5.1.1`** (no caret) in `package.json` because
  the worker CDN URL embeds the version — a floating range would let the worker drift
  out of sync with the installed main-thread code.
- The tfjs providers prefer the **WebGL** backend, which avoids `eval` and is therefore
  CSP-friendly; they fall back to the default backend if WebGL is unavailable.
- The SDK's own demo app runs a strict per-request nonce CSP with these hosts already
  allow-listed — see that repo's middleware for a working reference.
