# `@aquienpz/sdk`

Universal SDK for the [aquienpz](https://aquienpz.com) multi-tenant asset platform.
A Cloudinary-style asset manager with deterministic CDN URLs, content-addressed
dedup, server-side variants (thumb / sm / md / lg / poster / video), and **slot
bindings** — admin-managed names that resolve to assets at runtime.

## One client, every environment

The whole SDK ships as **one `AquienpzClient` class** behind environment-
specific subpath entries. Each subpath bundles the same client PLUS the
helpers safe for that runtime — same pattern as **Vercel Blob** (`@vercel/blob`
vs `@vercel/blob/client`), **Better Auth**, **Uploadthing**, and the **Vercel AI
SDK**. Stripe/Cloudinary's split into two separate npm packages is the legacy
shape — modern bundlers tree-shake subpaths perfectly and a single version
eliminates type drift between server and client surfaces.

| Where you run | Import | What it bundles |
|---|---|---|
| Node 20+, Bun, Cloud Run, Lambda, Edge, agents, cron | **`@aquienpz/sdk/server`** | `AquienpzClient` + URL builders + `signTransformUrl`. **No browser-only code.** Hard guarantee against shipping `Blob`-workflow helpers to the wrong runtime. |
| Browser (Next.js client, Vite, CRA, web workers) | **`@aquienpz/sdk/web`** | `AquienpzClient` + everything in `/server` + `createWebUploader` (multipart, parallel parts, IndexedDB resume) + `compressImage` (HEIC→WebP, compressorjs). |
| React Native / Expo apps | `@aquienpz/sdk/native` | `compressImage` for RN (libjpeg-turbo via `react-native-compressor`). Pair with `/expo`. |
| Expo background uploads | `@aquienpz/sdk/expo` | `createExpoUploader` — native `URLSession` (iOS) / `WorkManager` (Android) sessions that survive app backgrounding. |
| Next.js / React (hooks) | `@aquienpz/sdk/react` | `AquienpzProvider` + `useSlot` / `useSlots` / `useAsset`. |
| Backwards-compat (root) | `@aquienpz/sdk` | Same shape as today; equivalent to `/web` for the public symbols. **Prefer the explicit subpath in new code.** |

Uploaders + compressors are **optional** peer deps (`@aquienpz/asset-uploader-{web,expo}`,
`@aquienpz/asset-compressor-{web,native}`). Skip them if your app only resolves
slots and reads assets — your bundle stays a few KB.

### Quick decision tree

- Server-side rendering, API routes, BFF, cron, agents → `@aquienpz/sdk/server`.
- Browser components, build-time pre-resolution → `@aquienpz/sdk/web`.
- Expo / React Native app → `@aquienpz/sdk/native` + `@aquienpz/sdk/expo`.
- React hooks (any env) → `@aquienpz/sdk/react`.

**Never ship the API key to the browser.** Whichever subpath you import,
the long-lived `amk_rt_*` key lives on the server. Browser flows hit a BFF
route that proxies to aquienpz with the real key.

## Which subpath do I import from?

| Context | Import | Notes |
|---|---|---|
| Browser (Next.js client component, SPA, Worker, web extension) | `@aquienpz/sdk/web` | **No `apiKey`** — your BFF injects it. Type omits the field; build error if you try. |
| Node/Bun server (API route, Server Action, Cloud Run, Fly, BFF) | `@aquienpz/sdk/server` | Requires `apiKey` at construction. |
| React Native / Expo | `@aquienpz/sdk/native` or `@aquienpz/sdk/expo` | Same BFF model as `/web` — keep keys server-side. |
| Universal React hook | `@aquienpz/sdk/react` | Wraps the right subpath for the runtime. |

### Why subpaths, not a runtime flag

If you import `/server` in browser code, the bundler throws a build error.
If we used a runtime `{ mode: "browser" }` flag and you forgot it, your API
key would silently bundle into the client. Subpaths make security mistakes
loud — same pattern as `@vercel/blob/client`, `better-auth/client`, AI SDK's
`/edge` subpath.

`@aquienpz/sdk/web` v0.17+ enforces this physically: the constructor type
is `Omit<AquienpzClientOptions, "apiKey" | "signingKey">`. Passing `apiKey`
won't compile, period.

## BFF-proxy mode (browser → your route → aquienpz)

Browser code constructs the client against a **relative endpoint** that
points at your own route handler. The handler attaches the real bearer
token and forwards to aquienpz. Same pattern Vercel Blob uses for
`@vercel/blob/client.upload`.

### Next.js — client component

```tsx
"use client";
import { AquienpzClient } from "@aquienpz/sdk/web";

const aq = new AquienpzClient({
  endpoint:   "/api/am",              // OK: relative → same-origin BFF
  tenantCode: "realtyone-cr",
  tenantId:   1,
  // apiKey: ... ERROR: TS error: not assignable to WebClientOptions
});

export function HeroPicker() {
  return <input type="file" onChange={async (e) => {
    const file = e.target.files?.[0];
    if (file) await aq.upload(file, { compress: true });
  }} />;
}
```

### Next.js — BFF route handler

```ts
// app/api/am/[...path]/route.ts
import { NextRequest } from "next/server";

const UPSTREAM = process.env.AQUIENPZ_URL!;        // server-only
const API_KEY = process.env.AQUIENPZ_API_KEY!;     // server-only
const TENANT  = process.env.AQUIENPZ_TENANT_CODE!; // e.g. "realtyone-cr"

async function proxy(req: NextRequest, { params }: { params: { path: string[] } }) {
  const search = new URL(req.url).search;
  const url = `${UPSTREAM}/${params.path.join("/")}${search}`;
  const body = ["GET", "HEAD"].includes(req.method) ? undefined : await req.arrayBuffer();
  return fetch(url, {
    method:  req.method,
    body,
    headers: {
      Authorization:    `Bearer ${API_KEY}`,
      "X-Tenant-Code":  TENANT,
      "Content-Type":   req.headers.get("content-type") ?? "application/json",
    },
  });
}

export { proxy as GET, proxy as POST, proxy as PUT, proxy as PATCH, proxy as DELETE };
```

### Bun / Elysia backend

```ts
import { AquienpzClient } from "@aquienpz/sdk/server";

const aq = new AquienpzClient({
  endpoint:   process.env.AQUIENPZ_URL!,
  apiKey:     process.env.AQUIENPZ_API_KEY!,   // OK: required by ServerClientOptions
  tenantCode: "realtyone-cr",
  tenantId:   1,
});
```

### Expo

```ts
import { AquienpzClient } from "@aquienpz/sdk/web"; // same browser-safe type
// Construct against your /api/am proxy; no apiKey in the app bundle.
```

### SSG storefront (build-time)

Use `/server` at build time (Node/Bun) with the absolute aquienpz URL —
no proxy needed because keys never reach the browser bundle. Static
HTML output references the CDN directly.

## Install

```bash
bun add @aquienpz/sdk @aquienpz/asset-client
# or
npm install @aquienpz/sdk @aquienpz/asset-client
```

Optional, only if you need large uploads:

```bash
# Web / Vite / Next.js client uploads
bun add @aquienpz/asset-uploader-web

# Expo / React Native background uploads
bun add @aquienpz/asset-uploader-expo
```

Peer deps: `@aquienpz/asset-client` (URL builders + types) and `react` (only if
you use the `/react` subpath).

## Quick start

```ts
import { AquienpzClient } from "@aquienpz/sdk";

const aq = new AquienpzClient({
  endpoint: "https://aquienpz-asset-manager-xxxx.run.app",
  apiKey:   process.env.ASSET_MANAGER_RUNTIME_KEY!, // amk_rt_* Better Auth API key
  tenantCode: "your-tenant",
  tenantId:   42,                  // numeric tenant id (used in CDN URL prefix)
  cdnBase:    "https://8ok.uk",    // optional, defaults to https://8ok.uk
});

// Slots — the recommended way to reference brand assets in code.
// Source never hardcodes a CDN URL; admin rebinds from asset-lab-web.
const hero = await aq.slots.resolve("storefront.home.hero");
//   → { slot: { asset, preset, … }, preset: "lg", url: "https://8ok.uk/2a/v/<sha>-l.webp" }

// Bulk resolution in one round-trip.
const heroes = await aq.slots.resolveMany([
  "storefront.home.hero",
  "storefront.home.tile-1",
  "storefront.home.tile-2",
]);

// Lower-level operations.
const asset = await aq.assets.byHash("3c…<64 hex>…");
const { assets, nextCursor } = await aq.assets.list({ limit: 50 });

// Uploads — hash-deduped; returns the canonical v2 URL immediately.
const result = await aq.upload(file, { fileName: "cover.jpg" });
// → { assetId, sha256, cdnUrl }

// Uploading raw bytes (Node/Bun, e.g. re-hosting a remote image)? A Uint8Array has no
// inherent MIME, so give it one — otherwise it stores as kind:"other" (NO image variants):
await aq.upload(bytes, { fileName: "cover.webp" });              // MIME inferred from .webp ✓
await aq.upload(bytes, { contentType: "image/webp" });          // or be explicit ✓
// And request the variants you'll render — `presets` DEFAULTS TO ["original"] (just the raw
// bytes), so getAssetUrl(sha, "md") style preset URLs 404 unless you ask for the ladder:
await aq.upload(bytes, { contentType: "image/webp", presets: ["thumb", "sm", "md", "lg", "xl"] });

// Bind a slot (admin operation).
await aq.slots.bind("storefront.home.hero", {
  assetId: result.assetId,
  preset:  "lg",
  description: "Homepage hero — uploaded by admin on 2026-05-16",
});
```

## Client-side compression (browsers)

`aq.upload(file, { compress: true })` runs the file through
compressorjs + heic2any before the PUT, saving the user's bandwidth.
Typical result for an 8 MB iPhone HEIC photo: ~800 KB uploaded after
HEIC → JPEG → WebP @ q=0.80, max-edge 3840 px.

```ts
// Default — uses DEFAULT_COMPRESSION_OPTIONS (webapp-tuned values)
const result = await aq.upload(file, {
  compress: true,
  presets: ["thumb", "sm", "md", "lg"],
});

// Custom tuning per call
await aq.upload(file, {
  compress: { quality: 0.7, maxWidth: 2048 },
});

// Track progress for UI
await aq.upload(file, {
  compress: {
    onProgress: (stage) => console.log(stage),
    // stage ∈ "convertingHeic" | "compressing" | "compressingKeepingDimensions"
  },
});
```

**Defaults** (matched to the realtyone-cr production webapp:
`LISTING_STANDARD_*` constants):

| Option | Default |
|---|---|
| `quality` | `0.80` |
| `mimeType` | `"image/webp"` |
| `maxWidth` / `maxHeight` | `3840` |
| `convertSize` (PNG → JPEG threshold) | `5 MB` |
| `strict` | `true` |

**Caveats**:

- Browser-only. In Node / Bun the call is a silent no-op (warns to
  console) and the raw bytes upload as-is.
- Non-image MIMEs (video, PDF) are passed through regardless of
  `compress: true`. Safe to set blanket-fashion on mixed media batches.
- Adds ~50 KB to the runtime bundle **only when used** — lazy-imported
  from `@aquienpz/sdk/web`.
- HEIC inputs go through heic2any first (additional ~200 KB lazy bundle).
- Expo / React Native: the `/expo` subpath uses native
  `expo-image-manipulator` instead (already wired in the uploader). The
  `compress` option on `aq.upload` is web-only.
- The server-side `assets.client_original_bytes` column is populated
  from the original pre-compression size, surfacing the savings story
  in admin dashboards.

Direct access to the compressor (without going through `aq.upload`):

```ts
import { compressImage, compressImages, DEFAULT_COMPRESSION_OPTIONS }
  from "@aquienpz/sdk/web";

const { blob, originalBytes } = await compressImage(file, { quality: 0.85 });
const results = await compressImages([fileA, fileB, fileC]);
```

## Large uploads (web)

The core `aq.upload()` handles files under 50 MB in a single PUT. For
bigger files use the `/web` subpath — it delegates to
`@aquienpz/asset-uploader-web`'s multipart `UploadTask` (chunks, parallel
parts, retries with backoff, IndexedDB-persisted progress, Web Worker
SHA-256).

```ts
import { createWebUploader } from "@aquienpz/sdk/web";

const task = createWebUploader(aq, { file });
task.on("progress", ({ ratio }) => setProgress(ratio));
task.on("ready",    ({ assetId }) => bindSlot(assetId));
const { assetId, deduped } = await task.start();
```

Peer dep: `@aquienpz/asset-uploader-web` (lazy — apps that only need
small uploads skip it).

## Large uploads (Expo / React Native)

Native background uploads survive app suspend, low-memory kills, and
network blips. iOS uses URLSession's background config; Android uses
WorkManager. Same JS API as the web flavor.

```tsx
import { useEffect, useState } from "react";
import { Image } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { AquienpzClient } from "@aquienpz/sdk";
import {
  createExpoUploader,
  listResumableSessions,
} from "@aquienpz/sdk/expo";

const aq = new AquienpzClient({
  endpoint:   process.env.EXPO_PUBLIC_AQUIENPZ_URL!,
  apiKey:     process.env.EXPO_PUBLIC_AQUIENPZ_API_KEY!, // amk_rt_*
  tenantCode: "your-tenant",
  tenantId:   42,
});

export function UploadHeroScreen() {
  const [progress, setProgress] = useState(0);
  const [url, setUrl] = useState<string | null>(null);

  // Offer to resume anything from a prior app launch on boot.
  useEffect(() => {
    listResumableSessions().then((sessions) => {
      // …show a banner if sessions.length > 0
    });
  }, []);

  async function pickAndUpload() {
    const picked = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Videos,
      allowsMultipleSelection: false,
    });
    if (picked.canceled || !picked.assets[0]) return;
    const { uri, mimeType, fileName } = picked.assets[0];

    const task = createExpoUploader(aq, {
      file: {
        uri,
        mime: mimeType ?? "video/mp4",
        name:  fileName ?? "tour.mp4",
      },
    });
    task.on("progress", ({ ratio }) => setProgress(ratio));

    const { assetId } = await task.start();
    // Bind to a slot so the storefront picks it up without a redeploy.
    await aq.slots.bind("storefront.tour.video", { assetId, preset: "video" });
    const resolved = await aq.slots.resolve("storefront.tour.video");
    setUrl(resolved.url);
  }

  return /* …UI with pickAndUpload + progress bar + <Image source={{ uri: url }}/> */;
}
```

Peer dep: `@aquienpz/asset-uploader-expo`. Ships a config plugin for the Expo
prebuild step (background mode + native module registration); add it to
`app.json` under `plugins` before running `expo prebuild`.

## Next.js App Router (Server Components)

The cheapest path: resolve slots at render time on the server. No client JS,
no provider, no hook. The slot URLs ship as plain `<img>` markup.

```tsx
// app/page.tsx — Server Component
import { AquienpzClient } from "@aquienpz/sdk";

const aq = new AquienpzClient({
  endpoint:   process.env.AQUIENPZ_URL!,
  apiKey:     process.env.AQUIENPZ_API_KEY!, // amk_rt_* — server-only
  tenantCode: "your-tenant",
  tenantId:   42,
});

export default async function Page() {
  const heroes = await aq.slots.resolveMany([
    "storefront.home.hero",
    "storefront.home.tile-1",
    "storefront.home.tile-2",
  ]);

  return (
    <>
      {heroes["storefront.home.hero"].url && (
        <img src={heroes["storefront.home.hero"].url} alt="" />
      )}
      {/* … */}
    </>
  );
}
```

Keep the API key on the server — never expose `amk_rt_*` to `NEXT_PUBLIC_*`.
Uploads from Client Components should go through a thin BFF route handler that
proxies `aq.upload()` server-side.

For images that benefit from `next/image`, use `aq.urlFor()` + `aq.srcSetFor()`
to emit a static URL set; Next then handles its own optimization pipeline.

## React hooks (Client Components / SPA)

Reactive resolution on the client. The provider holds the configured client;
each hook subscribes to the in-process cache.

```tsx
// app/providers.tsx — Client Component
"use client";
import { AquienpzClient } from "@aquienpz/sdk";
import { AquienpzProvider } from "@aquienpz/sdk/react";

// In production, get apiKey from a /session route instead of bundling it.
const client = new AquienpzClient({
  endpoint:   process.env.NEXT_PUBLIC_AQUIENPZ_URL!,
  apiKey:     process.env.NEXT_PUBLIC_AQUIENPZ_API_KEY!,
  tenantCode: "your-tenant",
  tenantId:   42,
});

export function Providers({ children }: { children: React.ReactNode }) {
  return <AquienpzProvider client={client}>{children}</AquienpzProvider>;
}
```

```tsx
// app/hero.tsx
"use client";
import { useSlot } from "@aquienpz/sdk/react";

export function Hero() {
  const { url, isLoading } = useSlot("storefront.home.hero");
  if (isLoading) return <Skeleton />;
  if (!url) return <PlaceholderHero />;
  return <img src={url} alt="" />;
}
```

Works in any React 18+ host: Vite, CRA, Remix, Astro islands, Expo Router,
React Native — wherever `react-dom` (or `react-native`) runs.

## Why slots?

Hardcoding `https://cdn.your-tenant.com/abc123.webp` in source code couples
deploys to brand decisions. With slots:

| Without slots | With slots |
|---|---|
| Edit URL in code | Drag-drop a new asset in asset-lab-web |
| Commit + PR + deploy | Cache refreshes (60s default) |
| 5-30 minute roundtrip | Instant |

The slot key (`storefront.home.hero`, `webapp.wizard.pool-type.icon-1`) is the
**stable contract** between code and brand operations. Tenants own the bindings;
code is a passive consumer.

## URL conventions — variants are tenant-scoped, transforms are not

Two delivery paths, **by design**:

| Kind | Builder | URL shape | Tenant in path? |
|---|---|---|---|
| Variant / preset | `urlFor`, `srcSetFor`, upload `cdnUrl` | `<cdn>/<tenantId b36>/v/<sha>-<preset>.<ext>` | **Yes** — `/4/v/<sha>-lg.webp` |
| On-the-fly transform | `transform`, `transformSrcSet` | `<cdn>/t/<dsl>/<sha>.<ext>` | **No** — `/t/width=1280,.../<sha>.webp` |

The transform service is content-addressed by `sha` and resizes from the source on demand,
so it carries **no tenant segment**. Hand-building a transform URL as `/<tenant>/t/...` **404s**.
(Variants are stored per-tenant, so those DO carry the tenant prefix.) Always use the SDK
builders rather than concatenating paths — and never prepend the tenant to a `/t/` URL.

## Presets

Presets are **platform-wide and fixed** — every tenant gets the same set,
generated server-side by the asset-manager's variant pipeline. A tenant
cannot define custom dimensions through the SDK; they pick which preset a
slot defaults to and emit responsive `srcSet` for browser-side resizing.

### Image presets

| Preset    | Code | Max-side           | Typical use |
|-----------|------|--------------------|---|
| `thumb`   | `q`  | 256×256 smart-crop | avatars, micro-tiles |
| `sm`      | `s`  | 640                | mobile thumbs, list cards |
| `md`      | `m`  | 1280               | desktop cards, modal previews |
| `lg`      | `l`  | 1920 (≈2K)         | hero, full-bleed |
| `xl`      | `x`  | 3840 (4K)          | print, 4K screens |

**Defaults — what actually runs:**

| Caller path                                           | Variants generated on upload |
|-------------------------------------------------------|------------------------------|
| `aq.upload(file)` (SDK, no `presets`)                 | `original` only              |
| `POST /assets/upload-url` (HTTP direct, no `presets`) | `original` only (same as SDK) |
| `presets: ["original"]` (either path)                 | `original` only              |
| `presets: ["thumb","sm","md","lg"]`                   | exactly the four listed      |

Omitting `presets` is always equivalent to `["original"]` — the platform
never auto-generates the responsive ladder. This keeps logo / SVG /
one-shot uploads cheap and avoids surprise R2 writes (the ladder is 4×
the source bytes). Apps that want responsive sizes pass them explicitly:

```ts
await aq.upload(file, {
  presets: ["thumb", "sm", "md", "lg"],   // four WebP variants
});
```

Or add missing variants later without re-uploading:

```ts
await aq.assets.regenerate(assetId, { presets: ["thumb", "sm", "md", "lg"] });
```

Variants are WebP `quality: 75–80`. Total R2 cost when generating the
full responsive ladder ≈ 4× source bytes.

**Variants never upscale.** The pipeline clamps each preset's target to
`min(presetMaxSide, sourceMaxSide)`. A 1080×720 photo asked for `xl`
(3840) yields a 1080×720 `xl` variant, not a blurry 3840-wide stretch.
The preset is a **ceiling**, not a target.

### Non-image / passthrough

| Preset    | Code | Purpose |
|-----------|------|---|
| `original`| `o`  | Raw uploaded bytes, no transformation. Used today for non-image MIMEs (PDFs, audio, etc.). |

### Video presets

| Preset    | Code | Purpose                |
|-----------|------|------------------------|
| `poster`  | `p`  | extracted poster WebP  |
| `video`   | `v`  | original MP4           |
| `aiproxy` | `a`  | low-res proxy for AI captioning / search (opt-in) |

### Per-upload preset selection

**The SDK's default is `["original"]`** — calling `aq.upload(file)`
with no `presets` option stores only the raw bytes. Add the
responsive ladder when you actually need it, or generate it later
with `aq.assets.regenerate()` (no re-upload required).

```ts
// Default: only the original variant lands on the CDN.
const { assetId, cdnUrl } = await aq.upload(logoFile);
// asset.presets === "o"
// cdnUrl = https://8ok.uk/<sha>-o.svg
```

**Responsive ladder (the old default — now explicit):**

```ts
await aq.upload(heroFile, {
  fileName: "homepage-hero.jpg",
  presets: ["thumb", "sm", "md", "lg"],  // classic 4-step
});
```

**4K hero with the full size ladder:**

```ts
await aq.upload(heroFile, {
  presets: ["thumb", "sm", "md", "lg", "xl"],
});
// asset.presets === "qsmlx"; aq.srcSetFor(asset) now emits an xl entry.
```

**Video without the AI proxy transcode:**

```ts
await aq.upload(videoFile, {
  presets: ["poster", "video"],  // skip aiproxy
});
```

**Large video — bump the readiness timeout:**

`aq.upload()` waits up to **5 minutes** by default for the asset to
transition to `ready`. Transcode time scales with input size and CPU,
so videos ≥30 MB (especially HLS ladders) can blow past that. Opt in
to a longer deadline via `timeoutMs`:

```ts
// Large video (>30MB): bump timeout to 15min
await aq.upload(file, { timeoutMs: 15 * 60_000 });
```

The default is unchanged — existing call sites need no migration.

Variants never upscale — each size preset is a **ceiling**, not a
target. A 1080×720 source asked for `xl` (3840) yields a 1080×720 xl
variant.

**Caveat (content-addressed dedup):** if someone already uploaded the
same bytes with different presets, `aq.upload()` returns the existing
asset without re-processing. The result's `cdnUrl` will use whichever
preset is actually available on that asset (the SDK picks `lg → md → sm
→ thumb → xl → original` for images, `video → poster` for videos). To
add variants to an existing asset, use `aq.assets.regenerate()`.

## Adding variants later (no re-upload)

The most common flow with the new default:

```ts
// Day 0 — upload original only.
const { assetId } = await aq.upload(logoFile);

// Day 7 — peek at what's there.
const variants = await aq.assets.variants(assetId);
console.log(variants.map((v) => v.preset));  // → ["original"]

// Day 7 — need a thumb for an avatar slot. Generate it server-side,
// merged with the existing variant set.
await aq.assets.regenerate(assetId, { presets: ["thumb"] });

const after = await aq.assets.variants(assetId);
console.log(after.map((v) => v.preset));     // → ["original", "thumb"]
```

`regenerate()` merges — existing variants you didn't ask for stay put.
You can call it many times; it's idempotent per preset.

### Source-byte lifecycle (why regenerate always works)

Two distinct R2 prefixes; most people only care about one:

| R2 path | Role | Lifetime |
|---|---|---|
| `raw/<sha>.<ext>` | Landing zone for the presigned PUT. `/process` reads it once to verify the SHA. Never CDN-served. | Deleted ~24h after upload by the cleanup job |
| `variants/<sha>-<preset>.<ext>` | The CDN-served files. `<sha>-o.<ext>` is the permanent home of `original`. | **Permanent** — only deleted by explicit DELETE on the asset or variant |

`regenerate()` walks a fidelity-ordered fallback chain until it finds
usable bytes — it **never fails on a still-present asset**:

```
1. variants/<sha>-o.<ext>     ← permanent original (best)
2. raw/<sha>.<ext>            ← exact upload bytes, only during the 24h grace
3. variants/<sha>-x.webp      ← xl
4. variants/<sha>-l.webp      ← lg
5. variants/<sha>-m.webp      ← md
6. variants/<sha>-s.webp      ← sm
7. variants/<sha>-q.webp      ← thumb (last resort — smart-cropped square,
                                  so aspect ratio of derived variants
                                  will inherit the thumb's crop)
```

The no-upscale clamp guarantees we never invent pixels: asking for
`lg` (1920) from a 640 `sm` source yields a 640-side `lg` variant.
The result type's `sourceUsed` field tells you which fallback was
picked so you can decide whether the quality is good enough:

```ts
const result = await aq.assets.regenerate(assetId, { presets: ["xl"] });
if (result.kind === "image" && result.sourceUsed !== "original" && result.sourceUsed !== "raw") {
  console.warn(`xl was derived from ${result.sourceUsed} — quality degraded`);
}
```

**Recommendation:** include `"original"` in the upload preset list
when you want guaranteed lossless future re-derivation. The SDK's
default (`["original"]`) already does this for you.

`aq.assets.variants(id)` and `aq.assets.regenerate(id, opts)` are
fully typed — your editor autocompletes the preset names and the
returned shape gives you `{ preset, url, width?, height?, bytes }[]`.

## On-the-fly transformations

Pre-generated presets (`thumb`/`sm`/`md`/`lg`/`xl`) cover the common cases.
For everything else — exact CSS pixel widths, art-directed crops, devicePixelRatio
ladders, square thumbs from rectangular sources — call `aq.transform()`:

```tsx
import { AquienpzClient } from "@aquienpz/sdk";

const aq = new AquienpzClient({ /* ... */ });
const asset = await aq.assets.byHash(sha256);

// Single URL
<Image
  src={aq.transform(asset!, { width: 1280, format: "auto" })}
  alt="..."
/>

// Responsive — one transform URL per width, all other params shared
<Image
  src={aq.transform(asset!, { width: 1280 })}
  srcSet={aq.transformSrcSet(asset!, [640, 960, 1280, 1920])}
  sizes="(max-width: 768px) 100vw, 50vw"
  alt="..."
/>
```

### DSL params

| Param | Values | Default |
|---|---|---|
| `width` | a **`TransformWidth`** — predefined ladder; off-ladder → compile error (see note) | — |
| `height` | `1`–`7680` (`number`; aspect ratio derived from `width`) | — |
| `fit` | `cover` / `contain` / `fill` / `inside` / `outside` | `cover` |
| `gravity` | `auto` / `face` / `center` / `north` / `south` / `east` / `west` | `center` |
| `format` | `auto` / `avif` / `webp` / `jpeg` / `png` | `auto` (→ `webp`) |
| `quality` | `auto` / `1`–`100` | `auto` (source-complexity-adaptive) |
| `dpr` | `1` / `2` / `3` | `1` |

> **`width` is strongly typed.** `TransformOptions.width` is a **`TransformWidth`** — the
> predefined CDN ladder (`160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200,
> 1280, 1440, 1600, 1920, 2560, 3840`, exported as `TRANSFORM_WIDTHS`). An off-ladder
> width is a **compile error**: the edge whitelists exactly these as a DoS guard and
> HTTP 400s anything else on unsigned URLs. Need a custom off-ladder width? **Sign it** —
> `aq.transform(asset, { width: 1490 }, { sign: true })` and `getSignedTransformUrl` take
> `SignedTransformOptions` (where `width` widens to `number`); a valid `?sig=` earns the
> edge-whitelist bypass. `transformSrcSet` / `getTransformSrcSet` deliberately keep
> `number[]` because a responsive ladder may legitimately include DPR-row widths (e.g.
> `2400`). `height` stays `number` — for the responsive path it's derived from `width`
> by aspect ratio; only fixed-canvas crops / `genfill` set it explicitly.

`format=auto` resolves to **WebP** by default. The bench in `apps/asset-manager/bench/avif-vs-webp-summary.md`
(100 random realtyone-cr `lg.webp` samples, sharp 0.34.5) showed AVIF was 4.5–9.8% **larger**
than WebP in every source-size bracket with virtually identical SSIM. The policy lives
in a single function (`pickAutoFormat` in `transform.format-policy.ts`) so re-evaluation
when libavif improves is a one-file swap.

`gravity=face` runs the source image through a ~1 MB Ultra-Light face
detector (ultraface-RFB-320 via ONNX Runtime) and crops to the
highest-confidence face with sensible padding (50 % of face dimensions
on each side, clamped to source bounds, keeping the requested aspect
ratio). When no face is detected, it falls back to sharp's `attention`
strategy and returns the response with `X-Transform-Face: fallback` so
you can detect the cache state. Cost: ~10 ms inference per image
(warm); ~150 ms cold-start on the first request after a new Cloud Run
instance boots.

`quality=auto` adapts the per-format quality to source complexity
(luminance stddev via sharp `.stats()`):

| Bucket (stddev) | WebP | AVIF | JPEG |
|---|---:|---:|---:|
| simple (< 25) — logos, solids | 55 | 45 | 70 |
| normal (25–55) — most photos | 70 | 60 | 80 |
| complex (≥ 55) — busy textures | 72 | 65 | 82 |

Validated on 100 random realtyone-cr `lg.webp` samples: **+15.4 %
bytes saved vs fixed `quality=80` baseline, |ΔSSIM| 0.0011** (budget
0.005). See `bench/auto-quality-summary.md`. Both the bucket
boundaries and per-format table live in
`transform.quality-policy.ts` — re-tune by editing the constant and
re-running `bun run apps/asset-manager/scripts/bench-auto-quality.ts`.

### Canonicalization & caching

URLs with the same params in different order share the same R2 cache entry:

```ts
aq.transform(asset, { width: 480, fit: "contain" })
// → https://8ok.uk/t/fit=contain,width=480/<sha>.webp

aq.transform(asset, { fit: "contain", width: 480 })
// → https://8ok.uk/t/fit=contain,width=480/<sha>.webp   (same URL)
```

The server canonicalizes incoming DSL the same way the SDK does (sort keys
alphabetically, lowercase string values, drop `undefined`) and hashes the
canonical string into the R2 cache key — so even non-SDK URLs (e.g. typed
by a developer in a browser bar) collapse onto the same cache entry as long
as they specify the same params.

### Signed URLs + strict mode (Phase 3)

Every tenant has an HMAC-SHA256 signing key (`signing_key` column in
`public.tenants`, 32 random bytes generated on tenant creation).
Optionally enable `strict_transforms = true` to reject unsigned URLs
with a 401 — useful when transform URLs leak from a private surface
(internal admin, b2b portal) and you don't want third parties
generating arbitrary crops.

```ts
const aq = new AquienpzClient({
  endpoint:   process.env.AQUIENPZ_URL!,
  apiKey:     process.env.AQUIENPZ_API_KEY!,
  tenantCode: "your-tenant",
  tenantId:   42,
  // Fetch via GET /admin/projects/your-tenant; do NOT ship to the browser.
  signingKey: process.env.AQUIENPZ_SIGNING_KEY!,
});

// Async when { sign: true } is set — overload returns Promise<string>.
const signed = await aq.transform(asset, { width: 1280 }, { sign: true });
// → https://8ok.uk/t/width=1280/<sha>.webp?sig=<64-hex>

// Responsive
const srcset = await aq.transformSrcSet(
  asset,
  [640, 960, 1280, 1920],
  {},
  { sign: true },
);
```

Signature shape: `HMAC-SHA256(signingKey, "<canonical-DSL>/<filename>")`,
hex-encoded. The server canonicalizes the URL the same way the SDK does
(sort keys, lowercase strings), so two URLs with the same params in
different order accept the same signature.

**Admin operations** (system-scope admin key):

```bash
# Rotate the signing key — invalidates every URL signed with the old one.
curl -X POST -H "Authorization: Bearer amk_ad_..." \
  https://aquienpz-asset-manager.../admin/projects/your-tenant/rotate-signing-key
# → { ok: true, tenantId, code, signingKey: "<64-hex>" }

# Flip strict mode on/off.
curl -X PATCH -H "Authorization: Bearer amk_ad_..." -H "Content-Type: application/json" \
  -d '{"enabled":true}' \
  https://aquienpz-asset-manager.../admin/projects/your-tenant/strict-transforms
```

**Rotation cost**: cached transform variants on R2 are NOT re-keyed by
the signature, so they keep serving the same bytes. Only the URLs your
consumers hold need re-signing. Coordinate the rotation with anyone who
pre-signs at build time (e.g. SSG / next-build).

### Background removal (`effect=removebg`)

```ts
// Full-resolution transparent PNG cutout
const url = aq.transform(asset, { effect: "removebg" });
// → https://8ok.uk/t/effect=removebg/<sha>.png

// Cut out + resize in one call
const thumbUrl = aq.transform(asset, { effect: "removebg", width: 400 });
// → https://8ok.uk/t/effect=removebg,width=400/<sha>.png

// Or convert format alone (no resize / no effect) — useful e.g. to
// force a JPEG copy of a WebP source for legacy email clients
const jpegUrl = aq.transform(asset, { format: "jpeg" });
// → https://8ok.uk/t/format=jpeg/<sha>.jpg
```

`effect=removebg` is provider-pluggable via the
`BG_REMOVAL_BACKEND` env on the asset-manager:

- **`local`** (default) — U²-Net ONNX inference, Apache 2.0 weights
  shipped under `/app/models/u2net.onnx` (~176 MB, fetched at
  Docker build time, sha256-verified). ~1-2 s warm CPU inference,
  ~5-7 s cold-start. Free runtime.
- **`replicate`** — proxies to Replicate's `851-labs/background-remover`
  (BRIA-quality, commercially licensed via Replicate). ~3-8 s GPU,
  ~$0.001-0.005 per image. Requires `REPLICATE_API_TOKEN` in Secret
  Manager.

In both cases the route caches the PNG in R2 under the standard
`<sha>-t<dslHash>.png` key, so subsequent identical requests are 302
redirects to the CDN — no inference, no per-image cost. **Always
forces `format=png`** because the entire point is preserving alpha.

The output is the same dimensions as the source. Chain with `width`
to resize the cutout in a single request (cached as one R2 entry per
canonical DSL).

To re-tune or swap the model:
1. Drop a new `.onnx` into `apps/asset-manager/src/features/assets/bg/`
2. Update `MODEL_PATH` + preprocessing constants in `bg/remove.ts`
3. Update the Dockerfile's `COPY src/features/assets/bg/*.onnx /app/models/`
4. Re-bench against a sample set (a CSV in `apps/asset-manager/bench/`
   makes sense once the comparison is non-trivial).

### Generative fill / aspect outpaint (`effect=genfill`)

Extend a source image into a different aspect ratio without the
awkward edge mirroring that classic content-aware fill produces.
Primary use case: building OG cards (1200×630) from portrait listing
photos, or 1:1 social tiles from 16:9 originals.

```ts
// 1200×630 OG card from a portrait listing cover — gutters generated
// by Flux-Fill Pro, source pasted centered.
const ogUrl = aq.transform(asset, {
  effect: "genfill",
  width: 1200,
  height: 630,
});
// → https://8ok.uk/t/effect=genfill,height=630,width=1200/<sha>.png

// 1:1 social tile from a landscape original
const tileUrl = aq.transform(asset, {
  effect: "genfill",
  width: 1080,
  height: 1080,
});
```

**Requires both `width` and `height`.** Without them the route returns
422 — the effect needs an explicit target canvas to know what to
outpaint.

**Output defaults to WebP** at q=85 (~150KB for a 1200×630 OG card —
12× lighter than raw Flux-Fill PNG output). Honors `format=` for
explicit overrides:

| Format | Bytes (typical 1200×630) | Use case |
|---|---|---|
| `format=webp` (default) | ~150KB | OG cards, social tiles, storefront cards |
| `format=png` | ~1.7MB | Lossless — print, marketing fold-outs |
| `format=avif` | ~120KB | Modern browsers, even better compression |
| `format=jpeg` | ~180KB | Legacy email clients |

**Real-estate caveat**: outpainting is mediocre when the target aspect
differs heavily from the source (1:1 from horizontal photo → tiled
artifacts because the model has to invent rooftops and floors).
Reserve `genfill` for SMALL aspect deltas (OG card 1200×630 from
landscape source ✓). For bigger crops, prefer `gravity=auto` smart-crop
which is deterministic and free (no Replicate cost, no AI invention).

Powered by Replicate's `black-forest-labs/flux-fill-pro`. ~$0.05 per
first request per (sha, dsl, format) tuple; subsequent identical
requests are 302 redirects to the R2 cache — zero Replicate cost.

Requires `REPLICATE_API_TOKEN` in the asset-manager's Secret Manager
secrets. Same token as `BG_REMOVAL_BACKEND=replicate`; no separate
provisioning needed.

**Short-circuit**: when the source already matches the target aspect
exactly (resized to fill the canvas with zero padding), the server
returns the resized PNG without calling Replicate — you don't pay
$0.05 for an effective no-op.

### Video transforms (`aq.transformVideo`)

The same DSL works for videos too — the server branches on the asset's
`kind` column. Image params (`width`, `height`, `fit`) carry over;
video adds `start` (seconds, decimal OK) + `duration` (seconds, 1..300).

```ts
// 16:9 source → 9:16 mobile clip, first 15 s, h.264 mp4
const portraitUrl = aq.transformVideo(asset, {
  width: 1080,
  height: 1920,
  fit: "cover",
  start: 0,
  duration: 15,
});

// WebM output for bandwidth-conscious storefronts
const webmUrl = aq.transformVideo(asset, { format: "webm", width: 1280 });
```

**First request is async**. Cache miss → Cloud Run Job runs ffmpeg
(typically 5-30 s for short clips) → output written to R2. The route
returns **202 Accepted** with `Retry-After: 10` and a `Location`
header pointing at the eventual CDN URL. The response body has
`{ status, message, retryAfterSec, outputUrl }`. Subsequent requests
hit the cache → **302** to the CDN.

Consumer pattern with Video.js v10 / `<video>`:

```tsx
const src = aq.transformVideo(asset, { width: 1080, height: 1920 });
// Pass directly to <video src={src} />. While the Job runs the
// browser sees 202 → retry; once cached, the 302 → CDN. Most players
// retry transparently; if yours doesn't, poll `src` every 5 s until
// `Response.redirected` is true or the body content-type starts with
// "video/".
```

Implementation: extends the EXISTING `asset-processing` Cloud Run Job
with a `transform-video` op. Reuses the same Cloud Tasks dispatch
pattern as `process-video` and `compose-slideshow`. Idempotent —
re-dispatching the same DSL noops if R2 already has the output.

### Adaptive HLS streaming (`aq.streamingUrl`)

For long-form video — property tours, walkthroughs — point an
HLS-aware player at the master playlist:

```tsx
<video
  src={aq.streamingUrl(asset)}
  controls playsInline
  // Video.js v10 ships native HLS via @videojs/http-streaming —
  // no plugin needed. Same with hls.js or iOS Safari.
/>

// Sub-clip (HLS ladder built only for the clipped range)
const teaser = aq.streamingUrl(asset, { start: 0, duration: 30 });
```

First request to a new HLS URL returns **202 Accepted** with
`Retry-After: 20` while a Cloud Run Job builds the multi-rung ladder
(typically 1-3 min for a 90 s source). Subsequent requests get
**302** to the cached `master.m3u8`. The R2 layout:

```
<tid>/v/<sha>-hls<dslHash>/master.m3u8        ← entry point
<tid>/v/<sha>-hls<dslHash>/240p/playlist.m3u8
<tid>/v/<sha>-hls<dslHash>/240p/seg-000.ts
<tid>/v/<sha>-hls<dslHash>/360p/...
…
<tid>/v/<sha>-hls<dslHash>/1080p/...
```

The ladder shrinks to fit the source: a 480p source produces three
rungs (240p / 360p / 480p), a 1080p source produces five, and a 4K
source goes up to 2160p. The player picks the right rung on the fly
based on the current connection — a user on 3G starts at 240p and
climbs to 1080p as bandwidth improves, vs the monolithic MP4 that
either loaded or timed out.

### Billing model

Transforms are billed as **storage**, not as "transformations" the way
Cloudinary does — one R2 PUT per unique canonical DSL, then served from
the CDN cache forever (until manually invalidated). The cache key is
deterministic, so identical DSLs across deploys/tenants don't re-encode;
mounting an existing CDN URL costs zero compute.

## Palette + LQIP (compact placeholder UX)

Every successfully decoded image gets a palette extracted alongside
the upload — a tiny 7-color set you can use for ambient gradients,
fallback backgrounds, or themed UI accents. It survives across all
preset selections (yes, even `presets: ["original"]`), because
palette is metadata derived from the source bytes, not from a
specific resized variant.

```ts
import {
  getPaletteBlurBackground,
  pickAmbientBackground,
  getAmbientGradient,
  getTextColorForBackground,
} from "@aquienpz/sdk";

const asset = await aq.assets.get(assetId);
//   asset.palette = { d: "#1a1a1a", v: "#c5a95e", m: "#8b7d4f", ... }
//   asset.blur    = "data:image/webp;base64,UklGRhAA..."  // tiny LQIP

// Compact 4-stop gradient for hero / card backgrounds:
const bg = getAmbientGradient(asset.palette);
//   bg = "linear-gradient(135deg, oklch(...), oklch(...))"

// Auto-pick text color that contrasts with the chosen ambient:
const fg = getTextColorForBackground(asset.palette);
//   fg = "#fff" | "#000" | similar

// Or just the blurry LQIP for a CSS background placeholder:
const placeholder = getPaletteBlurBackground(asset.palette);
```

The wire format is intentionally tight: `{d, v, m, dv, lv, dm, lm}`
(dominant, vibrant, muted, dark-vibrant, light-vibrant, dark-muted,
light-muted). 7 hex strings per asset — much smaller than a full
base64 LQIP, but composes into nicer ambient UX.

When sharp can't decode the image (SVG sources, exotic formats,
deliberately corrupted bytes), palette + blur silently come back
`null`. The rest of the pipeline still succeeds.

Per-slot default:

```ts
await aq.slots.bind("storefront.hero", { assetId, preset: "lg" });
```

Per-call override (the resolver respects this over the slot's default):

```ts
const { url } = await aq.slots.resolve("storefront.hero", { preset: "md" });
```

Responsive `<img srcSet>` across all available presets:

```tsx
<img
  src={aq.urlFor(asset, "lg")}
  srcSet={aq.srcSetFor(asset)}
  sizes="(max-width: 768px) 100vw, 1280px"
  alt=""
/>
```

If you need a dimension that doesn't exist, two options: use the closest
preset and let the browser scale, or file an issue to add it to the
server-side pipeline (a platform-wide addition, not a per-tenant one).

### CDN URL format

`<cdnBase>/<sha16>-<presetCode>.<ext>`

Example: `https://8ok.uk/c482458e824c730e-q.webp` (the `thumb` preset of
sha `c482458e…` rendered as WebP). The path is content-addressed, so the
same source bytes produce the same URL regardless of which tenant uploaded
them — and the same URL never invalidates.

## Auth

Auth is a Better Auth API key (`amk_rt_*`) emitted by
`aquienpz/bootstrap-project.ts` per tenant. The key's metadata holds the tenant
id, so `X-Tenant-Code` is log-only.

API key tiers:

- `amk_rt_*` — runtime / read-and-write
- `amk_ad_*` — admin (slot bind, asset delete)
- `amk_ci_*` — CI / batch jobs

## API surface

| Namespace | Method | Description |
|---|---|---|
| `aq.slots` | `resolve(key)` / `resolveMany(keys)` | Read-side, cached 60s |
| `aq.slots` | `list({prefix})` | Admin tree view |
| `aq.slots` | `bind(key, {assetId, preset})` | Admin rebind |
| `aq.slots` | `unbind(key)` | Remove binding |
| `aq.slots` | `invalidateCache(key?)` | After admin rebind |
| `aq.assets` | `byHash(sha)` / `byHashes([])` | Lookup |
| `aq.assets` | `list({limit, cursor})` | Paginated |
| `aq.assets` | `get(id)` | Full DTO |
| `aq.assets` | `patchMetadata(id, {…})` | Merge JSON |
| `aq.upload(file, {fileName})` | | Hash-dedup, returns canonical URL |
| `aq.urlFor(asset, preset)` | | Build URL from DTO |
| `aq.srcSetFor(asset)` | | Responsive `<img srcSet>` |
| `aq.usage` | `snapshot()` | Storage + today + last 30 days totals (current tenant) |
| `aq.usage` | `timeseries(days)` | Daily rollup for charts (1..365 days) |
| `aq.usage` | `keys()` | Per-API-key breakdown month-to-date |

## Usage / consumption

Cloudinary-style "where am I in my plan" view, scoped to the API key's
own tenant (no cross-tenant data ever leaks):

```ts
const usage = await aq.usage.snapshot();
// {
//   tenant: { id: 4, code: "realtyone-cr" },
//   storage: { totalBytes: 4_810_000_000, assetCount: 15_760 },
//   today: { reads: 3201, writes: 18, processes: 6, bytesIn: 12_400_000, ... },
//   last30Days: { reads: 86_400, writes: 412, ... }
// }

const chart = await aq.usage.timeseries(30); // for a 30-day line chart
const byKey = await aq.usage.keys();         // who's using the most quota
```

Backed by the daily rollup of `assets.api_key_usage` →
`assets.tenant_usage_daily`. The "today" window queries the raw
`api_key_usage` table since the rollup runs once per day at ~00:30 UTC.

## License

UNLICENSED — internal use only.
