---
name: nitida-sdk
description: How to consume the nitida media platform (@nitida/sdk + @nitida/asset-client, API api.nitida.gofuture.space, CDN 8ok.uk) from an app — build image/video URLs, resolve stored refs, migrate an app off a legacy CDN, and provision a tenant. Use when wiring images/videos through nitida, debugging 400/404/410 on 8ok.uk URLs, or onboarding a new tenant. Also covers PRIVATE assets: `visibility`, the signed `/a/{tenant}/…?exp&sig` tree, `getPrivateAssetUrl` / `getPrivateTransformUrl`, and why a 404 on a private asset is the feature and not a missing file. Captures the gotchas that bit real migrations (base36 video prefix, /t/ vs /v/, the width ladder, audio, the 0-cdn build gate).
---

# Consuming the nitida media platform

> **The product is called nitida.** It used to be called *aquienpz*, and that name survives on
> purpose in the CDN host (`8ok.uk`), which was NOT renamed. **Everything you install is
> `@nitida/*`.**
>
> ⚠️ `@aquienpz/sdk` and `@aquienpz/asset-client` still exist on npm but are **frozen** — 0.20.0
> (2026-06-28) and 0.14.2 (2026-07-18). They receive nothing. If you are pinned to either, you are
> on a line that predates the `variants`/`oext` fixes, the HLS ladder registration, and a
> `hasPreset` that does not report an `original` that is not there. Move to `@nitida/*`.
> `@nitida/asset-uploader-web` and `@nitida/asset-uploader-expo` ARE on npm since 2026-08-23 —
> they were workspace-only until another repo started consuming them as `workspace:*` copies, which
> is public in effect without being auditable. `@aquienpz/tenant-config` is still internal.

> Durable source of truth: **the published docs at <https://nitida.gofuture.space>** — most of it
> generated from the types, so the API reference cannot rot. Agents can download it: the site
> serves `/agents/skill.md`, `/agents/index.json`, `llms.txt` / `llms-small.txt` / `llms-full.txt`,
> and every page as raw `.md`.

The platform stores assets in object storage, is driven through the API
**`https://api.nitida.gofuture.space`** and serves bytes through the CDN **`https://8ok.uk`**.
Apps are **external consumers**: install the npm packages, never vendor an in-tree copy.

```bash
bun add @nitida/sdk @nitida/asset-client
```

Both packages export the same URL builders (`@nitida/sdk` re-exports `@nitida/asset-client`). For plain URL-building in a Next.js app, importing from `@nitida/asset-client` is enough; use `@nitida/sdk`'s `NitidaClient` only when you also upload.

```ts
import { NitidaClient } from "@nitida/sdk";

const nt = new NitidaClient({
  endpoint: "https://api.nitida.gofuture.space",
  apiKey: process.env.NITIDA_API_KEY!,   // amk_rt_… — server-only
  tenantCode: "your-code",
  tenantId: 12,
});
```

:::note[An older endpoint you were given still works, and always will]
Until 2026-08-17 the published endpoint was a longer, generated hostname. The branded name is an
**additional** mapping onto the *same* service — same instance, no extra hop, byte-identical
responses — and the older address keeps working indefinitely. Nothing breaks if you are still on
it; point new work at `api.nitida.gofuture.space` and move the rest whenever it suits you.
:::

## 1. Configure once at module load

The URL builders read **process-global** config. Pin it where your helpers live (a `lib/nitida-images.ts`), so every Server/Client Component that imports a builder is configured:

```ts
// ℹ️ WHICH PACKAGE TO IMPORT FROM: both work.
// `@nitida/sdk` re-exports everything `@nitida/asset-client` has — including
// `setCdnBase` and `setTenantId` — so if you installed the SDK, import from
// `@nitida/sdk` and never name `asset-client` at all (it is there as a peer
// dependency). The examples below say `@nitida/asset-client` because this
// skill also serves consumers who use that package on its own, without the SDK.
import { setCdnBase, setTenantId } from "@nitida/asset-client";

const TENANT_ID = Number(process.env.NEXT_PUBLIC_NITIDA_TENANT_ID) || /* your id */ 0;

setCdnBase("https://8ok.uk"); // OPTIONAL — this is already the default; set it only if you were given another host
setTenantId(TENANT_ID);       // REQUIRED for EVERY variant URL — images too (base36 prefix), see §3
```

`NEXT_PUBLIC_*` so the tenant id reaches the client bundle (hero/about videos render client-side). The runtime read-only consumer needs only these public vars — the `amk_rt_*` runtime key is for **uploads** (server-only), not for building URLs.

## 2. Image URLs — `getTransformUrl` / `getTransformSrcSet`

On-the-fly transforms. **Lock `format` to your project's policy** (these projects use `webp`, never `auto`/`avif`):

```ts
import {
  getTransformUrl, getTransformSrcSet, TRANSFORM_WIDTHS, type TransformWidth,
} from "@nitida/asset-client";

const FORMAT = "webp" as const;

export const imgUrl = (sha: string, width: TransformWidth, extra?) =>
  getTransformUrl({ sha }, { format: FORMAT, width, ...extra });

export const imgSrcSet = (sha: string, widths: readonly TransformWidth[], extra?) =>
  getTransformSrcSet({ sha }, widths, { format: FORMAT, ...extra });

// cover-crop for fixed-aspect cards/thumbs:
const COVER_CROP = { fit: "cover", gravity: "auto" } as const;
```

Output URL shape: `https://8ok.uk/t/format=webp,width=640/<sha16>.webp`.

⚠️ **Widths MUST be on the unsigned ladder** `TRANSFORM_WIDTHS` — generated from the source, do not
edit by hand:

<!-- BEGIN GENERATED: transform-widths · bun run gen:docs -->
`96, 128, 160, 180, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840` — 21 widths.
<!-- END GENERATED: transform-widths -->

Any other width → **HTTP 400** at the edge (DoS guard). The `TransformWidth` type makes an off-ladder
width a compile error — import the type, don't hardcode magic numbers. (For a one-off custom width
you'd need signed URLs; not used here.)

⚠️ **`getTransformSrcSet` is NOT protected by that type.** Its parameter is `number[]` on purpose —
a responsive ladder may legitimately carry DPR widths — so `[641, 999]` compiles clean and fails in
production, one 400 per candidate. There the check is yours:
`widths.every((w) => TRANSFORM_WIDTHS.includes(w as TransformWidth))`.

## 2b. A responsive gallery — the widths, with their measured weight

> Full page, with the reasoning: <https://nitida.gofuture.space/examples/responsive-gallery/>
> Every claim below is re-checked against production by a scheduled job, so the
> numbers cannot quietly rot.

**Upload once, serve two ladders.** A phone and a 27-inch monitor need very
different bytes from the same photo.

```ts
// ✅ Both `setCdnBase` and `setTenantId` ARE re-exported by `@nitida/sdk`
// and `@nitida/sdk/server` (measured 2026-08-24 on 0.31.0). An older note here
// said `setCdnBase` was not — it is.
import { setCdnBase, type TransformWidth } from "@nitida/asset-client";
import { getTransformSrcSet, getTransformUrl } from "@nitida/sdk";

await nt.upload(file, {
  fileName: "villa-sunset.jpg",
  presets: ["original", "thumb", "sm", "md", "lg", "xl"],
});
```

**Measured** on a 2400×1600 master, WebP `quality=75`:

| width | 400 | 600 | 800 | 960 | **1080** | 1600 | **1920** |
|---|---|---|---|---|---|---|---|
| **KB** | 21 | 32 | 56 | 74 | **86** | 123 | **175** |

A phone served `1080` downloads **86 KB**; served `1920` it downloads **175 KB**
— double the weight for pixels the screen cannot resolve. That is the whole
speed/quality argument, as a number.

| surface | ask for |
|---|---|
| Mobile · 2-col grid | `400`, `480` |
| Mobile · full screen | `800`, `960`, `1080` |
| Desktop · grid | `400`, `600`, `800` |
| Desktop · lightbox | `1600`, `1920` |
| Full-bleed retina hero | `2560` / `3840` — **only if the master reaches it** |

```tsx
// No `quality`: auto-quality probes the MASTER ⇒ a single compression.
<img
  src={getTransformUrl(asset, { format: "webp", width: 800 })}
  srcSet={getTransformSrcSet(asset, [400, 800, 1200, 1600], { format: "webp" })}
  sizes="(max-width: 768px) 100vw, 40vw"
/>
```

⚠️ **Note there is NO `quality`.** A numeric `quality` switches on the shortcut
that reads an already-compressed variant — see the warning below. If you really
do need to pin it (a hard byte budget), make that a deliberate opt-in, not a
default copied without reading.

**`sizes` is what makes the phone pick 800 and the desktop 1600 from the same
tag.** Omit it and the browser assumes `100vw`, fetches the large candidate for
everyone, and the ladder buys you nothing.

- ⚠️ **Do not pass `quality`. Omit the key entirely.** Auto-quality probes the
  master and compresses once; a numeric `quality` is precisely what switches on
  the short-circuit that re-compresses an already-compressed variant — see the
  measurements below. Pinning it is a deliberate opt-in for a hard byte budget,
  never the default you copy. Format: **WebP** — the DSL accepts `avif`, this
  platform does not use it.
- Grid cells: `{ fit: "cover", gravity: "auto" }` — verified to return an exact
  400×400. Lightbox: `fit: "inside"`, never crops.
- ⚠️ **`/t/` never upscales — the master is the ceiling.** Measured: a 2400 px
  master asked for `width=2560` returns **2400 px**. Put widths in the `srcSet`
  that your sources can sustain.
  Asking for more is **not an error**: it is a smaller image than you think you
  requested, which is worse than a failure because nothing tells you.
  (There IS a paid, separate `POST /assets/:id/upscale` that genuinely enlarges
  with a model. It is not the transform route and it is not free — §8g.)
- ⚠️ The **first** request of each width is generated cold, then cached at the
  edge immutably. Warm them after upload if the first visitor matters.

### `xl` is the base itself when the base is already ≤ 4K

4K (3840 px long side) is the cap for listing photos, and a base inside it is
already final. So at upload the server stores a **clean WebP base ≤ 3840 px**
(single frame, no EXIF orientation, no EXIF/XMP block) as `xl`
**byte-identical**: same sha256, no decode, no second lossy pass. Anything else
is re-encoded to WebP q82 as before: a base larger than 3840 px (resized to
3840), a JPEG/PNG/HEIC, an animated WebP, or a WebP carrying EXIF/XMP (the
re-encode strips metadata, so GPS never reaches a public URL).

- The default browser compressor (`compressImage`, 3840 px WebP via canvas,
  which writes no EXIF) produces exactly such a base. Upload with `"xl"` in
  `presets` and `xl` costs no quality.
- URLs, keys (`…/x/<sha>.webp`), `presets` and response shapes are unchanged.
  `xl` is still its own stored object (a copy of the base's bytes), so it
  weighs the same as the base, not less.
- This applies to uploads processed on or after the server change. Assets
  already stored keep the `xl` they have. `regenerate(id, { presets: ["xl"] })`
  applies the new rule.

### ⚠️⚠️ On a LADDERED asset, an explicit `quality` compresses the image TWICE

> Full measurements, with the method: <https://nitida.gofuture.space/guides/transform-benchmark/>

`/t/` can short-circuit to the smallest **stored variant** that covers the request
— and that variant is already a WebP that went through one lossy pass.

🔴 **Until 2026-08-31 that shortcut fired on `typeof quality === "number"`**, so
the parameter named "quality" was the one that chose WHICH BYTES to decode, and
pinning it *lowered* quality. **That is fixed at the server.** `quality` now only
sets the encoder; the cheap source has to be asked for by name, `source=nearest`
(default `master`), and a bogus value is a 400.

Verified against production the day it shipped, same asset, cold DSLs:
`quality=61` → `x-transform-source: lg` before · `quality=62` → `original` after ·
`quality=64,source=nearest` → `lg`, deliberately.

⇒ **You cannot trigger a second lossy pass by accident any more.** The numbers
below are the honest cost of one, i.e. what `source=nearest` buys today. And
`source` is deliberately NOT in `TransformOptions`: the only legitimate consumer
is a bulk pipeline that hand-builds URLs, and keeping it off the typed surface is
what stops an ordinary caller from landing there.

Measured at `width=800` on five photographs, same photo uploaded twice (full
ladder vs `["original"]` only):

| | source used | bytes | quality vs master |
|---|---|---|---|
| ladder + `quality=75` | `md` (1280 px WebP) | **7–9 % smaller** | **worse 5/5** — up to −3.01 dB PSNR, −0.0200 SSIM |
| `["original"]` only | `original` | baseline | baseline |
| ladder, **no `quality`** | `original` | −2.6 % … +4.7 % | better than the pinned arm 5/5 |

Smaller **and** worse is generation loss, not a saving.

- **Want the master's quality? Omit `quality`.** Auto-quality has to probe the
  full-resolution master, so it cannot take the shortcut.
- **Raising `quality` does not undo it.** Double-compressed at `quality: 80` is
  still worse than single-pass at `quality: 60`, and 31 % heavier.
- **Worst at 640 / 1280 / 1920** — the widths that match `sm`/`md`/`lg` exactly,
  so there is no downscale in between to hide the first pass. At 640 and 1920
  the laddered output is *heavier* too.
- **`["original"]`-only assets are unaffected** — one pass, always.
- 🔴 **Since `@nitida/asset-client` 0.24.0 you CANNOT pass it.** `quality` is gone from
  `TransformOptions`, so `getTransformUrl(a, { width, quality: 75 })` is a **compile error**.
  There is nothing to remember and nothing to get wrong. If you are reading older code or older
  docs that pass it, that code no longer compiles — delete the key, do not look for a replacement.
- **The one narrow legitimate case has its own door, and it cannot be called blind:**
  `getByteBudgetTransformUrl(asset, { …, quality })`. It requires a `presets` string on the asset
  (a bare `{ sha }` does not compile) and **throws** if the asset has any size variant, or if
  `presets` is missing — *unknown is not permission*. Use it only for a byte budget somebody
  measured, on an asset uploaded `presets: ["original"]`.
- **`quality: "auto"` was a NO-OP** — byte-for-byte identical to omitting the key.
  Measured 2026-08-31 on a production laddered asset at `width=1920`: both
  answered **136 680 B, sha256 `3a9ba57d…`, `x-transform-source: original`**.
  ⇒ The whole option is unnecessary, which is why `TransformOptions.quality`
  carries an `@deprecated` tag since `@nitida/asset-client` 0.23.0. Your editor
  strikes it through; that is on purpose.
- **A second corpus, where there is not even a byte saving.** 5056 px
  architectural renders (tenant `suenos-del-mar`, 2026-08-31), referenced
  against `/t/format=png,width=W/` — the lossless auto path at the same width:

  | width | pinned source | bytes | PSNR |
  |---|---|---|---|
  | 3840 | `xl` | **+7.2 %** | **−0.50 dB** |
  | 1920 | `lg` | **+8.1 %** | **−0.64 dB** |
  | 1280 | `md` | **+2.0 %** | **−0.85 dB** |

  Heavier **and** worse, 3 of 3. The photograph corpus above at least came back
  smaller — that is exactly what hid this for four months.

### ✅ The one-line check: `x-transform-source`

**Do not reason about which path you got — read it off the response.** The
header names the variant the edge actually decoded:

```bash
curl -sI 'https://8ok.uk/t/format=webp,width=1920/<sha16>.webp' \
  | grep -i x-transform-source
```

| it says | you got |
|---|---|
| `original` | **one** lossy pass ✅ |
| `md` / `lg` / `xl` / `sm` | **two** — a numeric `quality` is in your URL ✖ |
| `poster` | you asked `/t/` for a video sha; it transformed the poster frame |

Run it once per surface after a change. It costs nothing and it is the only
statement about compression that is not an inference.

### What running this recipe caught

Its first draft was written by reading the source, and executing it found two
errors: `setCdnBase` imported from the wrong package (it failed on the first
line), and `width=2560` silently returning 2400. **Neither is visible from
reading the code** — which is why the verification script exists.

A third arrived later, from the benchmark run: the `quality: 75`
advice above was written without knowing that pinning a numeric `quality` is
what enables the stored-variant shortcut. The recipe was recommending a second
lossy pass on exactly the assets it tells you to upload. Reading the source is
not measuring it.

## 3. Video URLs — `getAssetUrl(asset, "video")`  ← the #1 gotcha

Videos are served from the **stored variant**, NOT a transform:

```ts
import { getAssetUrl, setTenantId } from "@nitida/asset-client";

export function videoUrl(sha: string, tenantId = TENANT_ID): string {
  if (!sha) return "";
  setTenantId(tenantId);                 // sets the base36 variant prefix
  return getAssetUrl({ sha }, "video") ?? "";
}
```

Output: `https://8ok.uk/<tenantId.toString(36)>/v/<sha16>-v.mp4`.

**Do NOT:**
- ❌ Use `getVideoTransformUrl` — that builds a `/t/...` transform URL, which never returns playable video. On a video sha `/t/` transforms the **poster frame**: `200 image/webp` with `x-transform-source: poster` when a poster exists, `410` when it does not. (`getVideoTransformUrl` is for on-the-fly re-encodes, a different feature.)
- ❌ Use `getVideoTransformUrl` for the HLS entry — that builds a `/t/…`
  transform URL. ⚠️ **A 410 there means the SOURCE BYTES ARE GONE**
  (`source_unavailable`), **not** that you used the wrong builder. Do not go
  change the call site while data loss goes unnoticed. (`getVideoTransformUrl`
  is for on-the-fly re-encodes — a different feature.)
- ❌ Hand-roll the path with the decimal tenant id. The path segment is **base36**: `tenantId.toString(36)`. **Tenant 10 → `/a/v/`**, and the decimal `/10/v/` **404s**. This is invisible for tenants ≤ 9 (`8`→`8`, `9`→`9`) and bit a real migration only at tenant 10. Always delegate to `getAssetUrl` so the encoding can't drift.

**Audio IS supported (updated 2026-07-01 — verify against the SDK types, this used to say "not supported").** The platform recognizes `kind: "image" | "video" | "document" | "audio" | "other"` and ships an **`mp3`** variant preset (`VariantPreset` in `@nitida/asset-client`). Uploads are hash-deduped (byte-identical re-uploads return the existing sha — that's *byte* dedup, NOT semantic "find a similar track"). `nt.upload` also accepts an `audioTrack` on video-composition calls. Serve via the `mp3` preset / `original`. Confirm the current preset/kind list in `node_modules/@nitida/asset-client/dist/index.d.ts` before relying on a specific ext.

> ✅ **Fixed in the 2026-08-17 deploy: you no longer have to pass `contentType` to avoid
> `kind:"other"`.** Passing `upload(bytes, { contentType: "audio/mpeg", fileName: "track.mp3" })`
> used to be a *defence*. Two independent holes made that necessary and both are closed:
>
> - **The mime round-trip lost the type.** Presign derives the stored extension with
>   `mime.extension(body.mime)`, so `audio/mpeg` became `.mpga` — and the server's hand-written
>   ext→mime `switch` did not know `.mpga`, so it came back as `application/octet-stream` ⇒
>   `kind:"other"` ⇒ no variants, silently, for every MP3 uploaded that way. The server now derives
>   the mime from the same table that produced the extension, closing the whole class by
>   construction — `mpga`, `heif`, `adts`, `tif`, `bmp`, `svg` and `flac` all round-trip.
> - **Genuinely untyped uploads are rescued by their bytes.** When the key implies
>   `application/octet-stream`, the server sniffs the magic bytes and processes the file as what it
>   actually is.
>
> Passing `contentType` + `fileName` is still *good practice* — it is the cheapest possible signal
> and it decides the stored extension. It is no longer load-bearing.
>
> ⚠️ **Against a server deploy older than 2026-08-17 this bug is live**, so code that must run
> against an old deploy should keep declaring the MIME explicitly. Also note the owner's decision:
> the **230 rows already misclassified** as `kind:"other"` are NOT being repaired — the fix is
> forward-only.

## 3b. Keeping the ORIGINAL bytes (backup, not delivery) ← learned the hard way 2026-08-07

If you are uploading to **archive** something (not just to serve it), three server behaviours decide whether you actually can get the bytes back. All three verified against production, after a 97-file backup ran "successfully" and produced zero `original` variants. ⚠️ Those bytes were **not** actually unrecoverable — nobody checked `raw`, which cleanup keeps while the asset row exists (verified 2026-08-17 on a 2-month-old thumb-only asset). The lesson that survives is that *"N uploaded, 0 failed"* measures the upload, not the outcome.

**1. `original` is written only if you ask for it.** The server's rule is that `presets` defaults to
`["original"]`, and an `original` variant is written only when that list contains it:

- Omit `presets` entirely → **original only**, no ladder (passthrough).
- Pass `["thumb"]` → thumb only, **no original**. The bytes you PUT are not retrievable.
- For a backup you want `presets: ["original", "thumb"]`.

⚠️ The migration precedent in consumer repos (`migrate-*-images.ts`) uses `["thumb"]` because its goal is *delivery*. Copying it into a backup script silently produces a non-backup.

**2. Building the `original` URL client-side is possible but easy to get wrong** (this section said "impossible" until 2026-08-15 — see the correction below). The stored key looks like

```
<tenant id in base36>/v/<sha16>-o.<ext>
//                                 ↑ the UPLOADED file's extension, not one derived from the mime
```

✅ **Since `@nitida/sdk@0.30.0` the obvious call is the correct one.** Pass the
result of `upload()` — or a DTO from `assets.get(id)` — and `original` resolves
to the real extension:

```ts
const up = await nt.upload(file, { fileName, presets: ["original", "md"] });
getAssetUrl(up, "original");      // → -o.jpg, not -o.bin
```

⚠️ **The failure below is what happens when you hand it a BARE `{ sha }`** — no
`mime`, no `oext`, no variant list. `PRESET_EXT.original` is the literal
`"bin"`, so `getAssetUrl({ sha }, "original")` emits `-o.bin` while a PNG is
really stored at `-o.png` → 404. That is the only case left; it is not the
general one.

> **History, kept because the symptom still gets reported.** Before the
> 2026-08-17 deploy this happened even with a full DTO:
>
> ```ts
> const asset = await nt.assets.get(id);
> getAssetUrl(asset, "original");   // → the stored key, verbatim
> ```
>
> ⭐ **And since `@nitida/sdk@0.30.0`, the result of `upload()` works too** —
> it carries `mime` and `oext`, so the obvious call is the correct one and you
> do not have to fetch the DTO first:
>
> ```ts
> const up = await nt.upload(file, { fileName, presets: ["original", "md"] });
> getAssetUrl(up, "original");      // → -o.jpg, not -o.bin
> ```
>
> Before 0.30.0 that call built `-o.bin` and 404'd over a file that was there.
> An agent running the getting-started doc verbatim found it on 2026-08-23.
>
> `GET /assets/:id` now sends `variants` (the full list, URLs included) and `oext` (the extension
> the original was really stored under). `getAssetUrl` prefers the stored URL, falls back to
> `oext`, and only then guesses from the mime.
>
> **Why guessing could never work.** The server keys the original off the **uploaded filename's
> extension**, which the mime does not determine. `image/jpeg` originals are stored `.jpg` and
> never `.jpeg`, so a mime table that guessed `.jpeg` 404'd on every one of them.
>
> And a tail exists that **no** mime table can close: originals stored as
> `application/octet-stream`, where the mime says nothing about the extension.
> Re-measured 2026-08-24 over 3 211 live originals: **113** — and the split is
> the part worth carrying, because the total hides it:
>
> | | | |
> |---|---|---|
> | **109** | `.bin` in `sdk-e2e` | 29-byte degenerate PNG fixtures — test residue, and `bin` IS their extension, so the fallback is right |
> | **4** | `.docx` in `realtyone-cr` | the fallback builds `-o.bin` → **404**, while `-o.docx` → **200** |
>
> ⚠️ **This number used to read 234, and that was true until it wasn't.**
> `heal-misclassified-assets` (#245) reclassified 121 of them to their real
> mime — MP3s that had round-tripped through `.mpga`, WebPs uploaded with no
> declared type. 234 − 121 = 113. A published measurement with no date is a
> claim that decays silently; this one is dated, and so should the next.
>
> ⭐ **A fallback that is right 109 times out of 113 is the shape of bug that
> survives for months**, because almost every sample agrees with it. Only the
> four `.docx` ever fail, and only `oext` rescues them.
>
> Two things that did NOT change: existence still comes from `dto.presets` + `hasPreset` (the only
> field on every response shape), and `getAssetUrl` was always correct for `video`/`poster`, whose
> extensions are fixed.
>
> ⚠️ **Against a server deploy older than 2026-08-17**, `variants` is `[]` and `oext` is absent
> — the mime guess is all you have, so HEAD the URL before relying on it.

**3. Re-uploading does NOT repair a missing preset.** Ingest dedupes by hash and returns `deduped: true` with the existing asset; no variants are regenerated. To add a preset to an existing asset use `regenerate`, which MERGES:

```ts
await nt.assets.regenerate(assetId, { presets: ["original"] });
```

⚠️ **Choosing `presets` badly destroys nothing — but it breaks one API and not the other.**
Verified 2026-08-17 against a live asset ingested two months earlier with `presets:"q"` (thumbnail
only): its raw upload was still there, HTTP 200.

Cleanup keeps the raw bytes of **any** sha referenced by an asset row, soft-deleted rows included.
**While the row exists, the original bytes exist.**

⇒ what actually happens if you asked for `["thumb"]` and needed the ladder:

| what you call | what you get |
|---|---|
| `getAssetUrl(asset, "md")` | **200** — since 2026-08-22 it returns the `/t/` URL and the edge generates from the raw |
| `/t/…width=1280/<sha>.webp` | **200** — the same thing, written by hand |
| `hasPreset(asset, "lg")` | `false`, correctly — the *variant* really is absent |

⚠️ **The incoherence this block described is fixed, and in the good direction.** For **image**
presets (`thumb·sm·md·lg·xl`) a missing preset does NOT 404: `getAssetUrl` returns the `/t/` route
and the edge generates from the raw. Measured today on an asset with `presets:"lmoqs"` —
`getAssetUrl(a,"xl")` → `/t/format=webp,width=3840/<sha>.webp` → **200 image/webp**, while the
variant key `-x.webp` does 404.

What stays true: `hasPreset` is still the truth about whether the **materialised variant** exists,
and the first request of each width pays a cold compute. And for **video/audio** presets
(`poster·video·aiproxy·hls·mp3·original`) there is **no fallback** — there, `hasPreset` first or 404.

**It is fixable at any time** with `regenerate({presets:[...]})`, which reads from the raw. No
window closes.

**Verify, don't assume.** Round-trip every archived file: download the `original` URL from the API
and compare its SHA-256 to the local file. "97 uploaded, 0 failed" was true and meaningless.

| Goal | presets | Where the URL comes from |
|---|---|---|
| Deliver images on a site | `["thumb"]` (+ what you need) | `getTransformUrl` / `getTransformSrcSet` |
| **Archive the source file** | **`["original", "thumb"]`** | **`getAssetUrl(await nt.assets.get(id), "original")`** |

### 3b-bis. `hasPreset` is the existence check, and it stopped lying too

`presets` is documented as a concatenation of **one-character** codes, and membership is a
one-character `includes`. It used to carry multi-character tokens as well (`transform-3da0019…`,
`probe`), and every one of them answered `true` for presets that do not exist. On a corpus written
by those servers the majority of rows are polluted, and a large minority are told they have an
`original` they do not — sending callers to exactly the 404 that `oext` came to remove. Phantom
`aiproxy` is affected at the same order of magnitude; `sm`/`md` far less.

✅ **Fixed in the 2026-08-17 deploy, on both sides:** the server emits only the 1-char vocabulary (plus the
sanctioned `mp3` token, which is deliberate and stays), and `hasPreset` strips multi-char tokens
before the `includes`. **`hasPreset(dto, preset)` remains the recommended existence check** — it is
the only field present on every response shape, including the slim list/resolver one that carries no
`variants` at all.

⚠️ **Against a server deploy older than 2026-08-17 the contaminated strings are still being sent**,
on the majority of rows. The client-side strip covers you; a hand-rolled
`dto.presets.includes("o")` does not.

⚠️ **`mp3` is a real, multi-char token in the vocabulary on purpose.** Any substring test you write
yourself must discount it — otherwise the `m` of `mp3` reads as `md`. That is a live bug class in
consumer code, not in the SDK.

## 3c. PLAYING a video — HLS, and the snippet that went stale in April 2026

`getHlsStreamingUrl({ sha })` returns a `master.m3u8`. **That is not a video file**: it plays
natively only where the browser has HLS built in, and everywhere else it needs an MSE player
(hls.js, or Video.js's `@videojs/http-streaming`). So every integration has to pick a branch, and
the test the entire web repeats is now wrong:

```ts
if (video.canPlayType("application/vnd.apple.mpegurl")) { /* native */ }   // ← the bug
```

**Chrome 147 (April 2026) added native HLS**, so `canPlayType` answers `"maybe"` there and this
sends Chrome down the native branch. It doesn't throw — it **degrades silently**. Measured on
Chromium 151 against a real 17 s hero loop: **426×240 for the first ~8 s** (the first segment is
8.33 s, and Chrome's ABR can't revise its guess until one finishes), i.e. half the loop at 240p
full-screen. That branch also skips whatever progressive-MP4 fallback you wrote.

**Branch on the ENGINE, not on codec support:**

```ts
function prefersNativeHls(video: HTMLVideoElement): boolean {
  if (video.canPlayType("application/vnd.apple.mpegurl") === "") return false;  // no native HLS
  return "ManagedMediaSource" in globalThis || !("MediaSource" in globalThis);
}
```

⚠️ **Don't use hls.js's own recommended check** (`canPlayType` + `ManagedMediaSource`): it breaks
iOS < 17.1, which has native HLS, no `ManagedMediaSource` and **no MSE** — it would be routed to
hls.js, which cannot start there, and fall through to the MP4. The `|| !MediaSource` arm is what
covers it: native if the engine is Apple's **OR** if there is no MSE to fall back on.

**When you use hls.js, stop it guessing** — the defaults are how a fast connection still opens at
240p. ⚠️ **Do NOT pass `startLevel: -1` together with `testBandwidth: true`** — measured,
that pair is the *cause*: on a clip short enough to be one segment the bandwidth probe IS
the whole video, so it plays at the bottom rung start to finish (a 5.04 s 4K asset was
delivered at 426×240 because of it). Leave the start level unset and raise the estimate:
`{ abrEwmaDefaultEstimate: 5_000_000 }`.

**The first request can answer `202`** while the background job builds the ladder (1–3 min for a
90 s source), then `302`s to the cached master. Keep the progressive MP4 as the fallback `<source>`
so a player pointed at HLS too early still shows something.

**With Video.js you don't write that predicate.** `@videojs/react`'s `HlsVideo` resolves the engine
itself — `useMse = Hls.isSupported() && type === M3U8 && preferPlayback !== "native"`, and
`preferPlayback` defaults to `"mse"`. That already covers iOS < 17.1 (no MSE ⇒ falls to native).
What it does NOT do is prefer Apple's engine where both work: a modern iPhone gets hls.js over
`ManagedMediaSource`. Pass `preferPlayback="native"` to buy hardware decode / battery / AirPlay.
hls.js tuning goes in the `config` prop:

```tsx
const HLS_CONFIG = { capLevelToPlayerSize: false, abrEwmaDefaultEstimate: 5_000_000 };
<HlsVideo src={getHlsStreamingUrl(asset)} config={HLS_CONFIG} poster={poster} playsInline crossOrigin="anonymous" />
```

⚠️ **Fall back to the MP4 on a TIMER, never on an `error` event.** A `202` is a success: no `error`
ever fires, so an error-only handler spins forever while the ladder transcodes. Race a timer against
`canplay`, and cancel it the moment `canplay` fires so healthy HLS keeps its adaptive bitrate.

### 4K is an HLS-only capability

**"Progressive" = one file, one resolution, fixed at encode time** (`-v.mp4`): the browser downloads
it end-to-end and plays as it goes, and if the connection degrades it stalls — there is nothing to
step down to. **HLS = many files**: the same video cut into segments and encoded at several rungs,
so the player switches mid-playback. That is the whole difference, and it is why only one of them
can carry 4K.

The ladder is not fixed at 1080p — the job probes the source and keeps every rung that fits, up to **2160p**. What it probes decides the ceiling:

| Ladder built | Source | Ceiling |
|---|---|---|
| At ingest (automatic, unless `video.hls === false`) | the raw bytes you uploaded | **2160p from a 4K master** |
| On demand later | the raw if present, else the `-v.mp4` | 1080p, second-generation |

The progressive MP4 is capped unconditionally at 1920 wide, so `getAssetUrl(sha,"video")`
never exceeds 1080p whatever you uploaded. A rung that weighs *more* than its own source (measured:
5090 vs 4866 kbps) is the signature of the fallback path, not of a broken ladder.

### 3c. ⭐ Dedup is CROSS-TENANT, and three things follow from it

The sha hashes the **bytes**, so two customers who upload the same logo, stock
photo or placeholder share a candidate row. A `/t/…` URL names content and
carries **no tenant segment**. Measured behaviour as of 2026-08-25:

| | |
|---|---|
| **signed** `/t/` | the **signature** picks the tenant — it proves possession of one key, which is the identity the URL lacks. (Before this, the lowest tenant id won and a valid signature could answer `invalid_signature`.) |
| **`strict_transforms`** | fails **closed** across every tenant sharing that sha. If any of them requires signing, the unsigned request is refused. |
| **`visibility: "private"`** | retracts **your** row only. Another tenant's public copy of the same bytes keeps serving, and `/t/` resolves to it. |

⚠️ **Do not read `private` as "these bytes are unreachable".** It means
"reachable through me only by signature". Nothing new leaks — those bytes were
already public via the other tenant — but the promise is narrower than it looks.

⚠️ And the cost of failing closed, stated plainly: a tenant that does **not**
use `strict_transforms` but shares content with one that does will need signed
URLs for that sha. Failing open would let a stranger cancel someone's policy;
a 401 you fix by signing is the cheaper of the two mistakes.

⭐ **Anything unique to you has a unique sha and never collides** — a customer
photo, a document, a render. Collisions are for generic assets, which is exactly
the content where sharing costs you nothing.

## 4. Resolving a stored reference — `extractAssetSha`

When a stored value is already an `8ok.uk` URL, pull its sha:

```ts
import { extractAssetSha } from "@nitida/asset-client";
const sha = extractAssetSha(storedUrl); // → "e0ef99988f4a3c9b" | null
```

A robust app resolver tries, in order: an explicit `sha` on the row → a migrated-metadata blob → a migration-map lookup by old URL → `extractAssetSha` → raw fallback.

## 5. Migrating an app OFF a legacy CDN onto nitida

Pattern proven on three storefronts moved off the same legacy CDN:

1. **Re-ingest is required, not a host rename.** Legacy hashes (64-hex sha256) ≠ nitida (16-hex sha). You must **fetch each legacy asset's bytes and re-upload** to the tenant (SDK server client + `amk_rt_*` key), producing a NEW sha. Emit an idempotent `old-URL → { sha, w, h, blur, kind }` map (`scripts/asset-migration-map.json`). Upload presets: images→`thumb`, videos→`poster`+`video`. Dedupe by sha; checkpoint (re-runnable).
2. **Regenerate metadata** (`blur`/`w`/`h`/`aspectRatio`) from the uploaded asset (`assets.get`) — don't carry legacy values.
3. **Render: bake resolved `8ok.uk` URLs at author time.** Do NOT `import` the map JSON into runtime code — its old-URL *keys* ship into the client/server bundle and fail a "0 legacy refs in built output" check. Resolve at build/author time and write the literal `8ok.uk` URL.
4. **Next.js**: add `8ok.uk` to `images.remotePatterns`. With `images.unoptimized: true`, pass the CDN srcSet straight through. With Next optimization ON, set `unoptimized` **per slot** for CDN-backed images to avoid paying for double optimization.
5. **Completeness sweep before declaring done**: `grep cdn-host` must be 0 in (a) rendered HTML of every route, (b) serialized/embedded JSON payloads (this is where a stale ref hides even when the *image* renders correctly), (c) JSON-LD / OG / preconnect, (d) any **DB mirror table** (an app DB's `assets`/asset tables are easy to miss), and (e) **cached API responses** (a CDN/data cache can persist a pre-cutover payload across deploys → bust the tag, don't assume redeploy clears it). "Images load from 8ok.uk" is NOT sufficient proof.

## 6. Provisioning a new tenant (ops)

**Use the console: [`https://console.gofuture.space`](https://console.gofuture.space)** (sign in with
Google). *Tenants → New* creates the tenant rows, and the tenant's page **issues, shows and rotates**
its runtime / admin / CI keys. A key's secret is displayed **once**, at issue time — store it then.

That replaces the older hand-run provisioning scripts and SQL that used to live here.

- Endpoint = `https://api.nitida.gofuture.space` (an older endpoint you were given keeps working —
  see the note at the top). Verify a fresh tenant: `GET /usage` with `Authorization: Bearer <amk_rt_*>` +
  `X-Tenant-Code: <code>` → `{ "tenant": { "id": N } }`.
- The `@aquienpz/tenant-config` HTTP layer (internal, not on npm) **caches tenant
  lookups for 60s** — a fresh tenant resolves in services within a minute.
- Asset-only consumers (a storefront with its own catalog DB) need only the tenant rows, its keys and
  its allowed origins; see <https://nitida.gofuture.space/start/credentials/>.

## Quick reference

| Need | Call | URL shape |
|---|---|---|
| Responsive image | `getTransformSrcSet({sha}, widths, {format:'webp'})` | `/t/format=webp,width=W/<sha>.webp` |
| Single image | `getTransformUrl({sha}, {format:'webp', width})` | same |
| Cover-crop card | `…, { fit:'cover', gravity:'auto' }` | `/t/fit=cover,gravity=auto,format=webp,width=W/<sha>.webp` |
| **Video** | `getAssetUrl({sha}, 'video')` (+ `setTenantId`) | `/<tid b36>/v/<sha>-v.mp4` |
| Poster | `getAssetUrl({sha}, 'poster')` | `/<tid b36>/v/<sha>-p.webp` |
| Sha from URL | `extractAssetSha(url)` | — |

Variant preset short codes — **all of them**: `thumb=q, sm=s, md=m, lg=l, xl=x, original=o, poster=p, video=v, aiproxy=a, mp3=mp3`. ⚠️ `mp3` is the one irregular case: three characters, not one. Exts: images `webp`, video `mp4`.

⚠️ **`hls` is deliberately NOT in that list.** It has an internal short code (`h`), but it is never a URL you can fetch: a ladder is a PREFIX (`<sha16>-hls<dslHash>/master.m3u8`) whose `<dslHash>` is computed server-side, so a hand-built `<sha16>-h.m3u8` **404s on every asset** (measured). Use `getAssetUrl(asset, "hls")` or `getHlsStreamingUrl({ sha })` — see §3c — and never derive the path yourself.


## Private assets — `visibility`

Default is `"public"`. Set `private` and **every public door answers 404** —
stored variants, the raw original, the HLS ladder, and `/t/` (including the
poster frame of a private video). The bytes come back only through a signed URL
that expires.

```ts
import { getPrivateAssetUrl, getPrivateTransformUrl } from "@nitida/sdk";
// ON YOUR BACKEND, once you decided this viewer may see it:
await getPrivateAssetUrl(asset, "lg", signingKey, { expiresInSeconds: 300 });
await getPrivateTransformUrl(asset, { width: 1280 }, signingKey, { expiresInSeconds: 300 });
```

- **A 404 on a private asset is NOT a missing file.** Check `visibility` on the
  DTO before you check storage. It is the number-one support question.
- **Seven URL builders throw** rather than hand you a doomed URL — `getAssetUrl`,
  `getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`,
  `getVideoTransformUrl`, `getHlsStreamingUrl`, `getSignedTransformUrl` — but
  only when the value you pass says `visibility: "private"`. A DTO that says
  `"public"`, or a bare `{ sha }`, is never refused — and this is a DIFFERENT
  rule from the missing-preset fallback below, which is about presets, not
  privacy.
- **`exp` is mandatory and CAPPED at 7 days.** `expiresInSeconds` over that
  throws in the SDK and 401s (`exp_too_far`) at the platform — an expiry that
  never arrives is not an expiry. Revocation is *"within a minute"* (60 s TTL
  at the edge).
- **⚠️ A signature does NOT widen the width ladder.** Off-ladder widths are 400
  whether or not the URL is signed. Signing buys IDENTITY (which tenant asked,
  until `exp`), which is what `strict_transforms` and `effect=genfill` need.
- **The signing key is a backend secret** — it mints URLs for every private
  asset the tenant owns.
- **⭐ Where the signing key comes from: the response that CREATED your
  project, once.** `POST /admin/projects` returns `signingKey` next to the
  three API keys, and the console shows it in the same panel. Nothing else
  hands it out — `GET /admin/projects/:code` does **not** include it. If it is
  lost, the only endpoint that returns a key is
  `POST /admin/projects/:code/rotate-signing-key`, and it needs the
  system-scope key the platform operator holds.
- **⭐ Rotating no longer kills your `/t/` URLs.** Since `@nitida/sdk` 0.31.0 a
  signed transform URL carries `kid` (which key signed it) and `exp`, and the
  OUTGOING key keeps verifying for 14 days after a rotation. ⚠️ The exception
  is `/a/…` ACCESS URLs: their key is derived from `signing_key` with no `kid`,
  so those 401 immediately on rotation. They are minted per view with
  minute-scale expiries, so the burst heals itself — but rotate at a quiet hour
  if the tenant serves private assets.
- **⭐ Already have a project and never saw a signing key?** Then you never got
  one — projects created before 2026-08-23 were not handed it, and no endpoint
  shows you the current one. **If you have not signed any URLs yet, ask us to
  rotate: with nothing in flight, rotation invalidates nothing and is free.**
  If you already have signed URLs circulating, ask us for the current key
  instead. This is the wall the T5 agent hit, and the sentence that was
  missing.

## ⚠️ EXIF orientation — two dimension pairs, and mixing them stretches the photo

**A phone taking a portrait photo does not store portrait pixels.** It writes the
sensor buffer landscape and tags it `Orientation` 5–8 meaning "rotate before
showing". So every such file has **two** pairs, transposes of each other:

```
4032×3024   the STORED buffer (what a header parser reads)
3024×4032   what it DISPLAYS as (what every decoder hands you)
```

Use the **stored** pair only for orientation-invariant quantities — area, a
megapixel budget. Use the **displayed** pair for anything geometric: a resize
target, an aspect ratio, a layout box, a coordinate you denormalise.

**This exact confusion shipped twice, on both sides of the wire:**

| where | what happened | fixed |
|---|---|---|
| `@nitida/asset-compressor-web` 0.6.0–0.6.2 | resize target from the stored pair, fed to a decode that had already rotated ⇒ **1.78× stretch**, measured | **0.6.3** |
| `asset-manager` (server), until 2026-08-28 | recorded the stored pair as the asset's `w`/`h` while serving rotated variants ⇒ transposed aspect on the DTO | 2026-08-28 |

Needed all three, which is why it looked random: a parseable header
(**HEIC is immune** — the ISOBMFF box walk isn't implemented, so no dims, so no
forced resize), orientation **5–8**, and a long side **above** the box.

⇒ **Damage is pre-upload and unrecoverable.** The raw bucket faithfully stores the
already-stretched bytes. Affected photos must be re-uploaded from the original;
no backfill exists.

### The two traps, concretely

**Browser — `createImageBitmap` with BOTH resize axes does not preserve ratio.**

```ts
// 🔴 any disagreement with the decoded bitmap becomes a stretch
createImageBitmap(blob, { imageOrientation: "from-image", resizeWidth: w, resizeHeight: h })
// ✅ one axis: the spec derives the other, ratio survives BY CONSTRUCTION
createImageBitmap(blob, { imageOrientation: "from-image", resizeWidth: w })
```

**Node — `sharp(x).rotate().metadata()` does NOT apply the rotation.** `.rotate()`
queues an operation; `.metadata()` still reads the input. Measured, sharp 0.34.5:

```
sharp(buf).metadata()                     → 4032×3024, orientation 6
sharp(buf).rotate().metadata()            → 4032×3024   ← the false idiom
sharp(buf).rotate().toBuffer() → info     → 3024×4032   ← the truth
```

It read so plausibly it was written three times in one repo, once directly under
the comment *"Source dims (EXIF-rotated for correctness)"*. To get display dims:
transpose the raw pair yourself when `orientation` is 5–8, or measure the output
buffer.

**And the lesson that cost the most:** the stretch had been suspected once and
declared *"refuted with production data"* because storage held four distinct
aspect ratios — "a pipeline that forced a ratio would have produced ONE". It
never forced one ratio; it forced **each photo's own transpose**. Counting
distinct ratios could not detect it, so finding several was evidence of nothing.
**An invariant you cannot state is an invariant you cannot test** — compare
against the *displayed* ratio, or you are comparing stored ratios to each other.

## Two ways an image gets smaller, and only one of them is yours to call

This is the question every programmatic caller gets wrong, so it is stated flat:

| | who does it | what it shrinks |
|---|---|---|
| **Client compressor** (browser / Expo) | the UI, before the PUT | the **upload**: quality 0.85, max 2880 px, WebP. iPhone 9.1 MB → 1.8 MB |
| **Variants + `/t/`** | the backend, on request | the **delivery**: 19 MB JPEG → 170 kB WebP at 1920 |

**The backend never recompresses the raw. Ever.** That is deliberate: the raw
has to stay pristine so variants are *regenerable* — the day you add AVIF or
raise the max dimension, the pipeline re-runs against it. A client→server lossy
chain bakes artifacts in forever.

So for an API/agent upload there is nothing to "turn on": you were never going
to compress the raw, and delivery is already optimised two ways.

```ts
await aq.upload(file);
aq.transform(asset, { width: 1280, format: "webp" });  // → /t/…, generated once, cached after

// Only for a rung you KNOW will be rendered over and over:
await aq.upload(file, { presets: ["original", "thumb"] });
```

**`presets` defaults to `["original"]`** — a bare upload stores the raw and no
rendition. Since 2026-08-22 that is no longer a trap: when the DTO says a preset
was never materialised, `getAssetUrl` / `urlFor` **fall back to `/t/`** instead
of returning a URL that 404s. You still get optimised bytes; you just pay the
first encode.

**On demand is the default, and it is the right one.** A variant exists because
somebody asked for it; we do not manufacture sizes on the chance that they might
be wanted. Name presets at upload only for a rung you *know* will be rendered
over and over — a card thumbnail on every listing page — where paying the encode
once up front beats paying it once lazily. For everything else, upload bare and
let `/t/` do it.

⚠️ `getAssetSrcSet` deliberately does **not** fall back — a srcSet promises
pixel widths and a transform cannot keep that promise on a source smaller than
the rung, because the pipeline never enlarges. An empty srcSet degrades to `src`; a lying one
degrades to a wrong choice.

## 7. Browser-direct uploads — three things that only fail in a real browser

Measured 2026-08-15 on the public bench at <https://media-harness.vercel.app> — no credentials
needed. Reproduce there before debugging any of these by hand.

1. **The PUT goes browser → object storage directly, so the STORAGE BUCKET answers the CORS
   preflight** — not the API. An origin missing from the bucket's policy therefore cannot be fixed
   in your app, in this SDK, or by the API's allowed-origins setting: those govern the API, not the
   PUT. Symptom: the direct PUT fails with a bare network error while compression, hashing and
   presign are all green. **This one is ours to fix — ask us to add your origin to the bucket
   policy.**
2. **A VIDEO answers `processAndWait` immediately, then transcodes.** `/assets/process` dispatches a
   background job and answers `{ ok: true, kind: "video", assetId, status: "processing", dispatch }`;
   the SDK then polls that id until `ready`. Budget for it — a transcode plus the HLS ladder runs
   1–2 min, so pass a `timeoutMs` of at least `300_000`. (Until 2026-08-16 the response carried **no
   `assetId`** and the call threw `"process returned no assetId"`, so no video ever registered
   through the kit; the bytes still reached storage, which is what made it invisible.)
3. **A video never gets an `original` variant.** `/assets/process` filters video presets through
   `{poster, video, aiproxy, probe}` before dispatch, so `"original"` is silently dropped even when
   you ask for it. If you need the source bytes back, archive them under a **non-video key** (e.g.
   `clip.mp4.bin`), which takes the passthrough branch and stores them verbatim.

   ⚠️ **CORRECTED 2026-08-17 — you do NOT need to upload it twice.** The
   `original` *variant* is dropped, but the raw upload is kept and
   **`dto.rawUrl` serves the master**. Verified on a production video:
   **9 197 163 B, `video/mp4`, 200**, range-capable — and *smaller* than the
   deliverable `video` variant (15 875 972 B). Cleanup keeps `raw/` while an
   asset row exists.

| Symptom | Cause |
|---|---|
| **a portrait photo comes back stretched wide**, and only some do | `@nitida/asset-compressor-web` **0.6.0–0.6.2**: stored (pre-EXIF) dims forced onto an already-rotated decode ⇒ 1.78×. Needs a parseable header (**HEIC immune**) + orientation 5–8 + long side above the box. **Fixed in 0.6.3**; pre-upload damage, so re-upload, don't repair — see §EXIF orientation |
| **`w`/`h` on the DTO disagree with the image you receive** | `asset-manager` older than 2026-08-28 recorded the stored pair for EXIF-rotated sources. The **variant** dims were always right — trust those until the asset is re-processed |
| **400** on an image URL | width not on `TRANSFORM_WIDTHS` ladder |
| **410** on a video `/t/` URL | that video has no `poster`. With one, `/t/` returns the poster as an image, never the video — use `getAssetUrl(...,'video')` |
| **404** on a video URL | decimal tenant prefix (`/10/v/`) instead of base36 (`/a/v/`) — call `setTenantId` + `getAssetUrl`. Tenant 12 → `/c/v/` |
| `process returned no assetId` | fixed in the 2026-08-16 deploy — you are on a server deploy older than that, §7.2 |
| `waitReady timeout` on a video | a transcode + HLS ladder takes 1–2 min; the default `timeoutMs` is 5 min but a 4K source can beat it. Raise it, §7.2 |
| the direct PUT fails with a bare network error | your origin is not in the storage bucket's CORS policy — ours to fix, §7.1 |
| `nt.assets.variants()` returns `[]` | fixed in the 2026-08-17 deploy — you are on a server deploy older than that. Existence from `dto.presets` + `hasPreset` works on every version |
| `hasPreset` says a preset exists and its URL 404s | fixed in the 2026-08-17 deploy — an old server sends multi-char tokens in `presets`; a current `@nitida/asset-client` strips them, a hand-rolled `includes` does not, §3b-bis |
| `tenant_not_found` from a service | tenant rows missing, or the 60s tenant-config cache is stale |
| `original` 404s | (a) never written — `presets` omitted `"original"` at ingest, or the asset is a VIDEO (always dropped, §7.3); (b) `getAssetUrl` called with a bare `{sha}` → the `-o.bin` sentinel; (c) called with `{sha, mime}` against an old server, so it guessed the extension. Pass the whole DTO from `nt.assets.get(id)` — see §3b |
| "uploaded OK" but nothing to restore | `["thumb"]`-style presets store no original; verify by SHA-256 round-trip, not by upload count — see §3b |
| audio stored as `kind:"other"` | fixed in the 2026-08-17 deploy — the ext→mime round-trip lost `.mpga`, and untyped bytes are now sniffed. Against an older server deploy, upload with an explicit `contentType:"audio/mpeg"`. Rows misclassified **before** the fix stay that way by decision, §3 |

> Source of truth for deeper detail: the published docs — <https://nitida.gofuture.space/guides/advanced/>,
> <https://nitida.gofuture.space/guides/troubleshooting/>, <https://nitida.gofuture.space/start/credentials/> —
> and the API reference at <https://nitida.gofuture.space/api/>, generated from the shipped types.

## 8. The rest of the surface — the gaps this guide used to have

### 8a. `presets` defaults to `["original"]`, NOT the ladder

Both the SDK (`DEFAULT_UPLOAD_PRESETS`) and the server default to **original
only** — a bare upload must never silently spend 4× the storage budget. Ask for
the ladder by name or `srcSetFor()` will come back nearly empty:

```ts
await nt.upload(file, { presets: ["original", "thumb", "md", "lg"] });
```

### 8b. Non-image uploads: four paths, decided by MIME

| MIME | path | result |
|---|---|---|
| `image/*` | ladder | WebP variants + palette + dimensions |
| `video/*` | background job | poster · mp4 · HLS · optional `aiproxy`/`probe` |
| `audio/*` **already playable everywhere** (`audio/mpeg`, `audio/mp4`, `audio/aac`) | none | original ONLY — `presets: "o"`. ⚠️ `getAssetUrl(a,"mp3")` **404s**: re-encoding an MP3 into an MP3 only makes it worse, so we stopped (2026-08-22). |
| `audio/*` anything else (`wav`, `webm`/opus, `flac`, …) | inline transcode | `mp3` variant (mono ~96 kbps) **+** untouched original — `presets: "mp3o"` |
| anything else | **passthrough** | raw bytes under `original`, `ready` immediately |

Passthrough covers PDF, xlsx, csv, zip, fonts, glb. You get storage, dedup and
CDN delivery — and **no** thumbnail, **no** text extraction, **no** transforms,
**no** virus scan. `kind` is `document` for PDF, `other` for the rest.

⚠️ For non-images the stored extension comes from the **filename**, and
`original` is the only variant there is. `upload(bytes)` with no `fileName` and
no `contentType` → `application/octet-stream` → `-o.bin`, which no browser
knows how to open. Resolution order: `Blob.type` → `contentType` → `fileName`
extension → octet-stream.

**Archiving a video's source bytes:** video presets silently drop `original`.
Upload it under a non-video name (`master.mp4.bin`) to take the passthrough
branch — in ADDITION to the normal video upload, not instead of it.

### 8c. Transparency

`acceptedEncodings` returns `["image/png"]` for a PNG request — **no JPEG
fallback exists on that path**, because asking for PNG means lossless-or-alpha
on purpose. Any JPEG encode composites onto **white** first; without that,
transparent pixels encode as BLACK.

- keep alpha → `compress: { mimeType: "image/png" }`
- WebP keeps alpha too — but WebKit cannot encode WebP at all (see 8d), so a
  WebP request there falls back to JPEG and **flattens**.

### 8d. WebKit cannot encode WebP — and it mostly does not matter

MDN compat data: `canvas.toBlob(type="image/webp")` is **unsupported in
Safari**, macOS included; every iOS browser is WebKit, so all inherit it.
Measured iOS 27 / Chrome 151, 9.1 MP photo: `2.05 MB → 743.7 kB` as `jpeg`.

**Delivery is WebP regardless** — the server re-encodes every variant. The
fallback only costs upload bytes (measured: WebP is **41% smaller**
than JPEG at the same quality) and leaves the archived `original` as JPEG.

### 8e. Palette — free, synchronous, on the DTO

Extracted during processing for every image; by the time an asset is `ready`
the palette is **already on the DTO** as `asset.palette` — there is no call to
make and nothing to download. `iteratePaletteSwatches(asset.palette)` expands it
to dominant + up to 6 swatches; `pickAmbientBackground` and `getAmbientGradient`
take the same value, and `bestTextContrast(hex)` gives the text colour **with
the ratio it achieved**, so you can decide instead of assuming. Use for ambient
backgrounds and load placeholders.

### 8f. AI proxies — we produce, YOU analyse

`aiproxy` (5 fps, 720p, CRF 28) and `probe` (stills at even offsets) are
**artifacts, not features**. The platform generates them and fires a
fire-and-forget webhook carrying `aiProxyUrl` + probe URLs. **Nothing here runs
a model.** Captioning, moderation, embeddings and storage are the consumer's
job. Skip `aiproxy` when nothing reads it — it is an extra encode per video.

### 8g. Signed URLs, upscale, usage

- **Custom width** → only via `getSignedTransformUrl` / `transform(..., {sign:true})`.
  `TransformOptions.width` is the ladder union; `SignedTransformOptions.width`
  is `number`. Unsigned off-ladder = 400 at the edge (DoS guard). Sign
  server-side.
- **Upscale** → `POST /assets/:id/upscale`. **A paid add-on, and the SDK does
  not expose it on purpose** — **€0.01–€0.40 per run depending on provider**, so it is never something a
  helper should make easy to call in a loop. Call it over HTTP, deliberately.

  ```jsonc
  // POST /assets/:id/upscale     Authorization: Bearer amk_mt_…
  {
    "mode": "target" | "factor" | "enhance",   // REQUIRED
    "targetMp":  1..128,        // mode=target — megapixels to reach
    "factor":    1..8,          // mode=factor — multiplier
    "outputFormat":  "jpg" | "png" | "webp",
    "outputQuality": 0..100,
    "enhanceDetails": true,     // mode=enhance
    "enhanceRealism": true,     // mode=enhance
    "provider": "wavespeed-phota-enhance"        // €0.09
              | "replicate-p-image-upscale"      // €0.01
              | "wavespeed-clarity-flux-upscaler"  // €0.40 ⚠️ the priciest, 4× the default
  }
  ```

  `mode` is the only required field; `enhance` ignores `targetMp` and `factor`.
  Idempotent on `(tenant, sha, preset, provider)`, async, with `/poll` as the
  missed-webhook fallback.

  ⚠️ **It needs an `amk_mt_*` key, not your runtime key.** Since 2026-08-21 a
  runtime key carries no metered op and is refused with zero allowance — the
  credential that can spend money is issued separately, rate-limited 60/min, and
  revocable on its own. Ask for one; it is not part of the default triplet.
- **Usage** → `nt.usage.snapshot() | timeseries(days) | keys()`. Wire `keys()`
  early: per-key attribution is the cheap answer to "what caused this spike".

### 8h. Mobile

`createExpoUploader(client, opts)` delegates transport to a **native background
session** (URLSession / WorkManager), so the upload survives backgrounding,
screen lock and JS suspension. `@nitida/sdk/native` mirrors the web compressor's
API exactly. **Mobile batches sequentially (concurrency 1)** — four concurrent
pipelines get the process OOM-killed on low-end Android, not finished sooner.

### 8i. Failure isolation

Only object storage is a hard dependency. Transcode service down → video stays
`processing`, images fine. Webhooks down → **nothing**; they are fire-and-forget
so an upload never fails because a consumer was unavailable.
