@nitida/sdk 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,26 +6,18 @@ description: How to consume the nitida media platform (@nitida/sdk + @nitida/ass
6
6
  # Consuming the nitida media platform
7
7
 
8
8
  > **The product is called nitida.** It used to be called *aquienpz*, and that name survives on
9
- > purpose in three places that were NOT renamed: the GitHub repo (`aquienpz`), the CDN host
10
- > (`8ok.uk`), and three packages that still publish under `@aquienpz/*` (`tenant-config`,
11
- > `asset-uploader-web`, `asset-uploader-expo`). Everything else you install or import is
12
- > `@nitida/*`.
9
+ > purpose in two places that were NOT renamed: the CDN host (`8ok.uk`), and three packages that
10
+ > still publish under `@aquienpz/*` (`tenant-config`, `asset-uploader-web`, `asset-uploader-expo`).
11
+ > Everything else you install or import is `@nitida/*`.
13
12
 
14
- > Durable source of truth: **the published docs at <https://nitida.gofuture.space>** — 187 pages, 166
15
- > of them generated from the types, so the API reference cannot rot. Agents can download it: the site
13
+ > Durable source of truth: **the published docs at <https://nitida.gofuture.space>** — most of it
14
+ > generated from the types, so the API reference cannot rot. Agents can download it: the site
16
15
  > serves `/agents/skill.md`, `/agents/index.json`, `llms.txt` / `llms-small.txt` / `llms-full.txt`,
17
- > and every page as raw `.md`. Source lives in `apps/nitida-docs`.
18
- >
19
- > ⚠️ An earlier version of this line pointed at `docs/SDK_CONSUMER_GUIDE.md`. **That file does not
20
- > exist** (verified 2026-08-17) and did not when the line was written — a citation to nothing is
21
- > worse than a wrong one, because it sends the reader hunting instead of correcting them. The three
22
- > other docs this file cites (`ASSET_MANAGER_V2.md`, `CLIENT_OPERATIONS.md`, `TENANT_ONBOARDING.md`)
23
- > were checked and DO exist. This file is mirrored to `~/.claude/skills/nitida-sdk/SKILL.md` for skill
24
- > activation; update both when these learnings change.
16
+ > and every page as raw `.md`.
25
17
 
26
- The platform stores assets in R2, is driven through the API **`https://api.nitida.gofuture.space`**
27
- and serves bytes through the CDN **`https://8ok.uk`**.
28
- Apps are **external consumers**: install the npm packages, never vendor neo's in-tree copies.
18
+ The platform stores assets in object storage, is driven through the API
19
+ **`https://api.nitida.gofuture.space`** and serves bytes through the CDN **`https://8ok.uk`**.
20
+ Apps are **external consumers**: install the npm packages, never vendor an in-tree copy.
29
21
 
30
22
  ```bash
31
23
  bun add @nitida/sdk @nitida/asset-client
@@ -44,12 +36,11 @@ const nt = new NitidaClient({
44
36
  });
45
37
  ```
46
38
 
47
- :::note[The old Cloud Run address still works, and always will]
48
- Until 2026-08-17 the published endpoint was
49
- `https://aquienpz-asset-manager-nlchzy26qa-uc.a.run.app`. The branded name is an **additional**
50
- mapping onto the *same* Cloud Run service same instance, no extra hop, byte-identical responses —
51
- and Cloud Run keeps a service's generated URL forever. Nothing breaks if you are still on it; point
52
- new work at `api.nitida.gofuture.space` and move the rest whenever it suits you.
39
+ :::note[An older endpoint you were given still works, and always will]
40
+ Until 2026-08-17 the published endpoint was a longer, generated hostname. The branded name is an
41
+ **additional** mapping onto the *same* service — same instance, no extra hop, byte-identical
42
+ responses and the older address keeps working indefinitely. Nothing breaks if you are still on
43
+ it; point new work at `api.nitida.gofuture.space` and move the rest whenever it suits you.
53
44
  :::
54
45
 
55
46
  ## 1. Configure once at module load
@@ -92,6 +83,119 @@ Output URL shape: `https://8ok.uk/t/format=webp,width=640/<sha16>.webp`.
92
83
 
93
84
  ⚠️ **Widths MUST be on the unsigned ladder** `TRANSFORM_WIDTHS` (`160,240,256,320,400,480,600,640,800,960,1080,1200,1280,1440,1600,1920,2560,3840`). 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.)
94
85
 
86
+ ## 2b. A responsive gallery — the widths, with their measured weight
87
+
88
+ > Full page, with the reasoning: <https://nitida.gofuture.space/examples/responsive-gallery/>
89
+ > Every claim below is re-checked against production by a scheduled job, so the
90
+ > numbers cannot quietly rot.
91
+
92
+ **Upload once, serve two ladders.** A phone and a 27-inch monitor need very
93
+ different bytes from the same photo.
94
+
95
+ ```ts
96
+ // ⚠️ `setCdnBase` is NOT re-exported by `@nitida/sdk` (but `setTenantId` is).
97
+ import { setCdnBase, type TransformWidth } from "@nitida/asset-client";
98
+ import { getTransformSrcSet, getTransformUrl } from "@nitida/sdk";
99
+
100
+ await nt.upload(file, {
101
+ fileName: "villa-sunset.jpg",
102
+ presets: ["original", "thumb", "sm", "md", "lg", "xl"],
103
+ });
104
+ ```
105
+
106
+ **Measured** on a 2400×1600 master, WebP `quality=75`:
107
+
108
+ | width | 400 | 600 | 800 | 960 | **1080** | 1600 | **1920** |
109
+ |---|---|---|---|---|---|---|---|
110
+ | **KB** | 21 | 32 | 56 | 74 | **86** | 123 | **175** |
111
+
112
+ A phone served `1080` downloads **86 KB**; served `1920` it downloads **175 KB**
113
+ — double the weight for pixels the screen cannot resolve. That is the whole
114
+ speed/quality argument, as a number.
115
+
116
+ | surface | ask for |
117
+ |---|---|
118
+ | Mobile · 2-col grid | `400`, `480` |
119
+ | Mobile · full screen | `800`, `960`, `1080` |
120
+ | Desktop · grid | `400`, `600`, `800` |
121
+ | Desktop · lightbox | `1600`, `1920` |
122
+ | Full-bleed retina hero | `2560` / `3840` — **only if the master reaches it** |
123
+
124
+ ```tsx
125
+ // No `quality`: auto-quality probes the MASTER ⇒ a single compression.
126
+ <img
127
+ src={getTransformUrl(asset, { format: "webp", width: 800 })}
128
+ srcSet={getTransformSrcSet(asset, [400, 800, 1200, 1600], { format: "webp" })}
129
+ sizes="(max-width: 768px) 100vw, 40vw"
130
+ />
131
+ ```
132
+
133
+ ⚠️ **Note there is NO `quality`.** A numeric `quality` switches on the shortcut
134
+ that reads an already-compressed variant — see the warning below. If you really
135
+ do need to pin it (a hard byte budget), make that a deliberate opt-in, not a
136
+ default copied without reading.
137
+
138
+ **`sizes` is what makes the phone pick 800 and the desktop 1600 from the same
139
+ tag.** Omit it and the browser assumes `100vw`, fetches the large candidate for
140
+ everyone, and the ladder buys you nothing.
141
+
142
+ - ⚠️ **Do not pass `quality`. Omit the key entirely.** Auto-quality probes the
143
+ master and compresses once; a numeric `quality` is precisely what switches on
144
+ the short-circuit that re-compresses an already-compressed variant — see the
145
+ measurements below. Pinning it is a deliberate opt-in for a hard byte budget,
146
+ never the default you copy. Format: **WebP** — the DSL accepts `avif`, this
147
+ platform does not use it.
148
+ - Grid cells: `{ fit: "cover", gravity: "auto" }` — verified to return an exact
149
+ 400×400. Lightbox: `fit: "inside"`, never crops.
150
+ - ⚠️ **There is no upscale — the master is the ceiling.** Measured: a 2400 px
151
+ master asked for `width=2560` returns **2400 px**. Put widths in the `srcSet`
152
+ that your sources can sustain.
153
+ - ⚠️ The **first** request of each width is generated cold, then cached at the
154
+ edge immutably. Warm them after upload if the first visitor matters.
155
+
156
+ ### ⚠️⚠️ On a LADDERED asset, an explicit `quality` compresses the image TWICE
157
+
158
+ > Full measurements, with the method: <https://nitida.gofuture.space/guides/transform-benchmark/>
159
+
160
+ `/t/` short-circuits to the smallest **stored variant** that covers the request
161
+ — and that variant is already a WebP
162
+ that went through one lossy pass. **The short-circuit only fires when the
163
+ request carries an explicit numeric `quality`**, so the `quality: 75` above is
164
+ exactly what turns it on.
165
+
166
+ Measured at `width=800` on five photographs, same photo uploaded twice (full
167
+ ladder vs `["original"]` only):
168
+
169
+ | | source used | bytes | quality vs master |
170
+ |---|---|---|---|
171
+ | ladder + `quality=75` | `md` (1280 px WebP) | **7–9 % smaller** | **worse 5/5** — up to −3.01 dB PSNR, −0.0200 SSIM |
172
+ | `["original"]` only | `original` | baseline | baseline |
173
+ | ladder, **no `quality`** | `original` | −2.6 % … +4.7 % | better than the pinned arm 5/5 |
174
+
175
+ Smaller **and** worse is generation loss, not a saving.
176
+
177
+ - **Want the master's quality? Omit `quality`.** Auto-quality has to probe the
178
+ full-resolution master, so it cannot take the shortcut.
179
+ - **Raising `quality` does not undo it.** Double-compressed at `quality: 80` is
180
+ still worse than single-pass at `quality: 60`, and 31 % heavier.
181
+ - **Worst at 640 / 1280 / 1920** — the widths that match `sm`/`md`/`lg` exactly,
182
+ so there is no downscale in between to hide the first pass. At 640 and 1920
183
+ the laddered output is *heavier* too.
184
+ - **`["original"]`-only assets are unaffected** — one pass, always.
185
+
186
+ ### What running this recipe caught
187
+
188
+ Its first draft was written by reading the source, and executing it found two
189
+ errors: `setCdnBase` imported from the wrong package (it failed on the first
190
+ line), and `width=2560` silently returning 2400. **Neither is visible from
191
+ reading the code** — which is why the verification script exists.
192
+
193
+ A third arrived later, from the benchmark run: the `quality: 75`
194
+ advice above was written without knowing that pinning a numeric `quality` is
195
+ what enables the stored-variant shortcut. The recipe was recommending a second
196
+ lossy pass on exactly the assets it tells you to upload. Reading the source is
197
+ not measuring it.
198
+
95
199
  ## 3. Video URLs — `getAssetUrl(asset, "video")` ← the #1 gotcha
96
200
 
97
201
  Videos are served from the **stored variant**, NOT a transform:
@@ -114,40 +218,34 @@ Output: `https://8ok.uk/<tenantId.toString(36)>/v/<sha16>-v.mp4`.
114
218
 
115
219
  **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.
116
220
 
117
- > ✅ **FIXED 2026-08-17 (aquienpz #230/#231): you no longer have to pass `contentType` to avoid
118
- > `kind:"other"`.** This section used to insist on
119
- > `upload(bytes, { contentType: "audio/mpeg", fileName: "track.mp3" })` as a *defence*. Two
120
- > independent holes made that necessary and both are closed:
221
+ > ✅ **Fixed in the 2026-08-17 deploy: you no longer have to pass `contentType` to avoid
222
+ > `kind:"other"`.** Passing `upload(bytes, { contentType: "audio/mpeg", fileName: "track.mp3" })`
223
+ > used to be a *defence*. Two independent holes made that necessary and both are closed:
121
224
  >
122
225
  > - **The mime round-trip lost the type.** Presign derives the stored extension with
123
226
  > `mime.extension(body.mime)`, so `audio/mpeg` became `.mpga` — and the server's hand-written
124
227
  > ext→mime `switch` did not know `.mpga`, so it came back as `application/octet-stream` ⇒
125
- > `kind:"other"` ⇒ no variants. That swallowed **104 MP3 uploads**. `detectMime` now derives from
126
- > the same `mime-types` table that produced the extension, closing the whole class by construction
127
- > (7 broken round-trips fixed: `mpga`, `heif`, `adts`, `tif`, `bmp`, `svg`, `flac`), and
128
- > `test/mime-roundtrip.test.ts` keeps it closed.
228
+ > `kind:"other"` ⇒ no variants, silently, for every MP3 uploaded that way. The server now derives
229
+ > the mime from the same table that produced the extension, closing the whole class by
230
+ > construction `mpga`, `heif`, `adts`, `tif`, `bmp`, `svg` and `flac` all round-trip.
129
231
  > - **Genuinely untyped uploads are rescued by their bytes.** When the key implies
130
- > `application/octet-stream`, `/process` sniffs the magic bytes (`sniffMagicMime`) and processes
131
- > the file as what it actually is.
232
+ > `application/octet-stream`, the server sniffs the magic bytes and processes the file as what it
233
+ > actually is.
132
234
  >
133
235
  > Passing `contentType` + `fileName` is still *good practice* — it is the cheapest possible signal
134
236
  > and it decides the stored extension. It is no longer load-bearing.
135
237
  >
136
- > ⚠️ **Against an `asset-manager` older than 2026-08-17 this bug is live**, so code that must run
238
+ > ⚠️ **Against a server deploy older than 2026-08-17 this bug is live**, so code that must run
137
239
  > against an old deploy should keep declaring the MIME explicitly. Also note the owner's decision:
138
240
  > the **230 rows already misclassified** as `kind:"other"` are NOT being repaired — the fix is
139
241
  > forward-only.
140
242
 
141
243
  ## 3b. Keeping the ORIGINAL bytes (backup, not delivery) ← learned the hard way 2026-08-07
142
244
 
143
- 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 `apps/asset-manager/src` in the `aquienpz` repo, after a 97-file backup ran "successfully" and produced **zero** recoverable originals.
144
-
145
- **1. `original` is written only if you ask for it.** In `features/assets/process.routes.ts`:
245
+ 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.
146
246
 
147
- ```ts
148
- const effectivePresets: string[] = body.presets ?? ["original"];
149
- const wantsOriginal = effectivePresets.includes("original");
150
- ```
247
+ **1. `original` is written only if you ask for it.** The server's rule is that `presets` defaults to
248
+ `["original"]`, and an `original` variant is written only when that list contains it:
151
249
 
152
250
  - Omit `presets` entirely → **original only**, no ladder (passthrough).
153
251
  - Pass `["thumb"]` → thumb only, **no original**. The bytes you PUT are not retrievable.
@@ -155,17 +253,16 @@ const wantsOriginal = effectivePresets.includes("original");
155
253
 
156
254
  ⚠️ 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.
157
255
 
158
- **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 server key is:
256
+ **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
159
257
 
160
- ```ts
161
- variantKey(tenantId, sha256, "original", ext)
162
- `${tenantPrefix(tenantId)}/v/${shortenSha(sha)}-o.${ext}`
163
- // ext = body.rawKey.split(".").pop() ← the UPLOADED file's extension
258
+ ```
259
+ <tenant id in base36>/v/<sha16>-o.<ext>
260
+ // ↑ the UPLOADED file's extension, not one derived from the mime
164
261
  ```
165
262
 
166
263
  The client's `PRESET_EXT.original` is the literal `"bin"`, so `getAssetUrl({sha}, "original")` **without `mime`** emits `-o.bin` while a PNG is really stored at `-o.png` → 404, always.
167
264
 
168
- > ✅ **FIXED 2026-08-17 (doc 240 §4.3 + §4.4). Pass the whole DTO and it is simply right:**
265
+ > ✅ **Fixed in the 2026-08-17 deploy. Pass the whole DTO and it is simply right:**
169
266
  >
170
267
  > ```ts
171
268
  > const asset = await nt.assets.get(id);
@@ -176,8 +273,8 @@ The client's `PRESET_EXT.original` is the literal `"bin"`, so `getAssetUrl({sha}
176
273
  > the original was really stored under). `getAssetUrl` prefers the stored URL, falls back to
177
274
  > `oext`, and only then guesses from the mime.
178
275
  >
179
- > **Why guessing could never work.** The server keys the original off the **uploaded filename**
180
- > (`rawKey.split(".").pop()`), which the mime does not determine. Measured over the 2 001 stored
276
+ > **Why guessing could never work.** The server keys the original off the **uploaded filename's
277
+ > extension**, which the mime does not determine. Measured over the 2 001 stored
181
278
  > originals in production: all **420** `image/jpeg` originals are `.jpg` and none are `.jpeg`, so
182
279
  > the old mime table 404'd on every JPEG; and **234** originals are `application/octet-stream`
183
280
  > (`.mpga`, `.docx`, `.m4a`), where no mime table can ever produce the right key.
@@ -186,9 +283,8 @@ The client's `PRESET_EXT.original` is the literal `"bin"`, so `getAssetUrl({sha}
186
283
  > field on every response shape), and `getAssetUrl` was always correct for `video`/`poster`, whose
187
284
  > extensions are fixed.
188
285
  >
189
- > ⚠️ **Against an `asset-manager` older than 2026-08-17**, `variants` is `[]` and `oext` is absent
190
- > — the mime guess is all you have, so HEAD the URL before relying on it. Reference implementation
191
- > of the old dance: `apps/media-harness/scripts/seed-fixtures.ts` (`resolveOriginalUrl`).
286
+ > ⚠️ **Against a server deploy older than 2026-08-17**, `variants` is `[]` and `oext` is absent
287
+ > — the mime guess is all you have, so HEAD the URL before relying on it.
192
288
 
193
289
  **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:
194
290
 
@@ -196,9 +292,29 @@ The client's `PRESET_EXT.original` is the literal `"bin"`, so `getAssetUrl({sha}
196
292
  await nt.assets.regenerate(assetId, { presets: ["original"] });
197
293
  ```
198
294
 
199
- ⚠️ **There is a time window.** `admin.routes.ts` notes that once the cleanup job reaps `raw/`, regenerate reads from `variants/o.<ext>` which is exactly what's missing. **If the first ingest didn't request `original`, the raw bytes may already be unrecoverable.** In the 2026-08-07 run, `regenerate({presets:["original"]})` returned without error and the `original` variant still did not appear — consistent with `raw/` already being gone. Get `original` right on the FIRST ingest.
295
+ ⚠️ **Choosing `presets` badly destroys nothingbut it breaks one API and not the other.**
296
+ Verified 2026-08-17 against a live asset ingested two months earlier with `presets:"q"` (thumbnail
297
+ only): its raw upload was still there, HTTP 200.
200
298
 
201
- **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 — all 97 were unrecoverable.
299
+ Cleanup keeps the raw bytes of **any** sha referenced by an asset row, soft-deleted rows included.
300
+ **While the row exists, the original bytes exist.**
301
+
302
+ ⇒ what actually happens if you asked for `["thumb"]` and needed the ladder:
303
+
304
+ | what you call | what you get |
305
+ |---|---|
306
+ | `getAssetUrl(asset, "md")` | **404** — that variant was never generated |
307
+ | `/t/…width=1280/<sha>.webp` | **200** — generated on the fly from the raw |
308
+ | `hasPreset(asset, "lg")` | `false`, correctly |
309
+
310
+ One API tells you it does not exist and the other hands it to you. **That** is the expensive part —
311
+ not the loss, the incoherence — plus a cold compute the first time anyone asks for each size.
312
+
313
+ **It is fixable at any time** with `regenerate({presets:[...]})`, which reads from the raw. No
314
+ window closes.
315
+
316
+ **Verify, don't assume.** Round-trip every archived file: download the `original` URL from the API
317
+ and compare its SHA-256 to the local file. "97 uploaded, 0 failed" was true and meaningless.
202
318
 
203
319
  | Goal | presets | Where the URL comes from |
204
320
  |---|---|---|
@@ -209,18 +325,19 @@ await nt.assets.regenerate(assetId, { presets: ["original"] });
209
325
 
210
326
  `presets` is documented as a concatenation of **one-character** codes, and membership is a
211
327
  one-character `includes`. It used to carry multi-character tokens as well (`transform-3da0019…`,
212
- `probe`), and every one of them answered `true` for presets that do not exist. Counted on live
213
- rows: **16 299** assets were told they had an `original` they did not have sending callers to
214
- exactly the 404 that `oext` came to remove — plus 16 479 phantom `aiproxy` and 487 phantom `sm`/`md`.
328
+ `probe`), and every one of them answered `true` for presets that do not exist. On a corpus written
329
+ by those servers the majority of rows are polluted, and a large minority are told they have an
330
+ `original` they do not — sending callers to exactly the 404 that `oext` came to remove. Phantom
331
+ `aiproxy` is affected at the same order of magnitude; `sm`/`md` far less.
215
332
 
216
- ✅ **Fixed 2026-08-17 (#228) on both sides:** the server emits only the 1-char vocabulary (plus the
333
+ ✅ **Fixed in the 2026-08-17 deploy, on both sides:** the server emits only the 1-char vocabulary (plus the
217
334
  sanctioned `mp3` token, which is deliberate and stays), and `hasPreset` strips multi-char tokens
218
335
  before the `includes`. **`hasPreset(dto, preset)` remains the recommended existence check** — it is
219
336
  the only field present on every response shape, including the slim list/resolver one that carries no
220
337
  `variants` at all.
221
338
 
222
- ⚠️ **Against an `asset-manager` older than 2026-08-17 the contaminated strings are still being sent**
223
- (62 % of live rows had one). The client-side strip covers you; a hand-rolled
339
+ ⚠️ **Against a server deploy older than 2026-08-17 the contaminated strings are still being sent**,
340
+ on the majority of rows. The client-side strip covers you; a hand-rolled
224
341
  `dto.presets.includes("o")` does not.
225
342
 
226
343
  ⚠️ **`mp3` is a real, multi-char token in the vocabulary on purpose.** Any substring test you write
@@ -261,7 +378,7 @@ covers it: native if the engine is Apple's **OR** if there is no MSE to fall bac
261
378
  **When you use hls.js, stop it guessing** — the defaults are how a fast connection still opens at
262
379
  240p: `{ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 }`.
263
380
 
264
- **The first request can answer `202`** while the Cloud Run Job builds the ladder (1–3 min for a
381
+ **The first request can answer `202`** while the background job builds the ladder (1–3 min for a
265
382
  90 s source), then `302`s to the cached master. Keep the progressive MP4 as the fallback `<source>`
266
383
  so a player pointed at HLS too early still shows something.
267
384
 
@@ -289,15 +406,14 @@ step down to. **HLS = many files**: the same video cut into segments and encoded
289
406
  so the player switches mid-playback. That is the whole difference, and it is why only one of them
290
407
  can carry 4K.
291
408
 
292
- The ladder is not fixed at 1080p — the job probes the source and keeps every rung that fits
293
- (`LADDER.filter(r => r.height <= srcHeight)`, up to **2160p**). What it probes decides the ceiling:
409
+ 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:
294
410
 
295
411
  | Ladder built | Source | Ceiling |
296
412
  |---|---|---|
297
413
  | At ingest (automatic, unless `video.hls === false`) | the raw bytes you uploaded | **2160p from a 4K master** |
298
414
  | On demand later | the raw if present, else the `-v.mp4` | 1080p, second-generation |
299
415
 
300
- The progressive MP4 is capped unconditionally (`scale='min(1920,iw)'`), so `getAssetUrl(sha,"video")`
416
+ The progressive MP4 is capped unconditionally at 1920 wide, so `getAssetUrl(sha,"video")`
301
417
  never exceeds 1080p whatever you uploaded. A rung that weighs *more* than its own source (measured:
302
418
  5090 vs 4866 kbps) is the signature of the fallback path, not of a broken ladder.
303
419
 
@@ -314,7 +430,7 @@ A robust app resolver tries, in order: an explicit `sha` on the row → a migrat
314
430
 
315
431
  ## 5. Migrating an app OFF a legacy CDN onto nitida
316
432
 
317
- Pattern proven on carniceria, novatibas, sueños (all off `cdn.espaciofuturo.io`):
433
+ Pattern proven on three storefronts moved off the same legacy CDN:
318
434
 
319
435
  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).
320
436
  2. **Regenerate metadata** (`blur`/`w`/`h`/`aspectRatio`) from the uploaded asset (`assets.get`) — don't carry legacy values.
@@ -328,17 +444,15 @@ Pattern proven on carniceria, novatibas, sueños (all off `cdn.espaciofuturo.io`
328
444
  Google). *Tenants → New* creates the tenant rows, and the tenant's page **issues, shows and rotates**
329
445
  its runtime / admin / CI keys. A key's secret is displayed **once**, at issue time — store it then.
330
446
 
331
- That replaces `apps/asset-manager/scripts/bootstrap-project.ts` and the hand-written SQL that used to
332
- live here. The point is not convenience: **provisioning no longer requires the production database
333
- URL** (`PLATFORM_DATABASE_URL`) or `ASSET_AUTH_SECRET` on somebody's laptop. Reach for the script
334
- only if the console is down, and treat that as an incident, not a workflow.
447
+ That replaces the older hand-run provisioning scripts and SQL that used to live here.
335
448
 
336
- - Endpoint = `https://api.nitida.gofuture.space` (the Cloud Run generated URL keeps working — see the
337
- note at the top). Verify a fresh tenant: `GET /usage` with `Authorization: Bearer <amk_rt_*>` +
449
+ - Endpoint = `https://api.nitida.gofuture.space` (an older endpoint you were given keeps working —
450
+ see the note at the top). Verify a fresh tenant: `GET /usage` with `Authorization: Bearer <amk_rt_*>` +
338
451
  `X-Tenant-Code: <code>` → `{ "tenant": { "id": N } }`.
339
452
  - The `@aquienpz/tenant-config` HTTP layer (still published under the old scope) **caches tenant
340
453
  lookups for 60s** — a fresh tenant resolves in services within a minute.
341
- - Asset-only consumers (a storefront with its own catalog DB) can skip the Qdrant/voice/scraper steps in `docs/TENANT_ONBOARDING.md` — only the two tenant rows + keys + `storefront_origins` are needed.
454
+ - Asset-only consumers (a storefront with its own catalog DB) need only the tenant rows, its keys and
455
+ its allowed origins; see <https://nitida.gofuture.space/start/credentials/>.
342
456
 
343
457
  ## Quick reference
344
458
 
@@ -355,41 +469,51 @@ Variant preset short codes: `thumb=q, sm=s, md=m, lg=l, xl=x, original=o, poster
355
469
 
356
470
  ## 7. Browser-direct uploads — three things that only fail in a real browser
357
471
 
358
- Measured 2026-08-15 building `neo/apps/media-harness`, the bench for this SDK. Run it
359
- (`bun run scripts/verify-run.ts`) before debugging any of these by hand.
472
+ Measured 2026-08-15 on the public bench at <https://media-harness.vercel.app> no credentials
473
+ needed. Reproduce there before debugging any of these by hand.
360
474
 
361
- 1. **The PUT goes browser → R2, so R2 answers the CORS preflight.** An origin missing from the
362
- BUCKET's policy cannot be fixed in your app, in this SDK, or in `tenant_config.storefront_origins`
363
- those govern the API, not the PUT. Symptom: `R2 PUT failed: network error` with compression,
364
- hashing and presign all green. Fix in `aquienpz/scripts/setup-r2-cors.sh`, and ⚠️ `cors set`
365
- **REPLACES** the whole policylist it and diff before applying.
475
+ 1. **The PUT goes browser → object storage directly, so the STORAGE BUCKET answers the CORS
476
+ preflight** not the API. An origin missing from the bucket's policy therefore cannot be fixed
477
+ in your app, in this SDK, or by the API's allowed-origins setting: those govern the API, not the
478
+ PUT. Symptom: the direct PUT fails with a bare network error while compression, hashing and
479
+ presign are all green. **This one is ours to fix ask us to add your origin to the bucket
480
+ policy.**
366
481
  2. **A VIDEO answers `processAndWait` immediately, then transcodes.** `/assets/process` dispatches a
367
- Cloud Run Job and answers `{ ok: true, kind: "video", assetId, status: "processing", dispatch }`;
482
+ background job and answers `{ ok: true, kind: "video", assetId, status: "processing", dispatch }`;
368
483
  the SDK then polls that id until `ready`. Budget for it — a transcode plus the HLS ladder runs
369
484
  1–2 min, so pass a `timeoutMs` of at least `300_000`. (Until 2026-08-16 the response carried **no
370
485
  `assetId`** and the call threw `"process returned no assetId"`, so no video ever registered
371
- through the kit; the bytes still reached R2, which is what made it invisible.)
486
+ through the kit; the bytes still reached storage, which is what made it invisible.)
372
487
  3. **A video never gets an `original` variant.** `/assets/process` filters video presets through
373
488
  `{poster, video, aiproxy, probe}` before dispatch, so `"original"` is silently dropped even when
374
489
  you ask for it. If you need the source bytes back, archive them under a **non-video key** (e.g.
375
490
  `clip.mp4.bin`), which takes the passthrough branch and stores them verbatim.
376
491
 
492
+ ⚠️ **CORRECTED 2026-08-17 — you do NOT need to upload it twice.** The
493
+ `original` *variant* is dropped, but the raw upload is kept and
494
+ **`dto.rawUrl` serves the master**. Verified on a production video:
495
+ **9 197 163 B, `video/mp4`, 200**, range-capable — and *smaller* than the
496
+ deliverable `video` variant (15 875 972 B). Cleanup keeps `raw/` while an
497
+ asset row exists.
498
+
377
499
  | Symptom | Cause |
378
500
  |---|---|
379
501
  | **400** on an image URL | width not on `TRANSFORM_WIDTHS` ladder |
380
502
  | **410** on a video URL | used `/t/` (transform) for a stored video — use `getAssetUrl(...,'video')` |
381
503
  | **404** on a video URL | decimal tenant prefix (`/10/v/`) instead of base36 (`/a/v/`) — call `setTenantId` + `getAssetUrl`. Tenant 12 → `/c/v/` |
382
- | `process returned no assetId` | fixed 2026-08-16 — you are on an `asset-manager` older than that deploy, §7.2 |
504
+ | `process returned no assetId` | fixed in the 2026-08-16 deploy — you are on a server deploy older than that, §7.2 |
383
505
  | `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 |
384
- | `R2 PUT failed: network error` | your origin is not in the R2 BUCKET's CORS policy, §7.1 |
385
- | `nt.assets.variants()` returns `[]` | fixed 2026-08-17 (#225) — you are on an `asset-manager` older than that deploy. Existence from `dto.presets` + `hasPreset` works on every version |
386
- | `hasPreset` says a preset exists and its URL 404s | fixed 2026-08-17 (#228) — an old server sends multi-char tokens in `presets`; a current `@nitida/asset-client` strips them, a hand-rolled `includes` does not, §3b-bis |
506
+ | 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 |
507
+ | `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 |
508
+ | `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 |
387
509
  | `tenant_not_found` from a service | tenant rows missing, or the 60s tenant-config cache is stale |
388
510
  | `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 |
389
511
  | "uploaded OK" but nothing to restore | `["thumb"]`-style presets store no original; verify by SHA-256 round-trip, not by upload count — see §3b |
390
- | audio stored as `kind:"other"` | fixed 2026-08-17 (#230/#231) — the ext→mime round-trip lost `.mpga`, and untyped bytes are now sniffed. Against an older `asset-manager`, upload with an explicit `contentType:"audio/mpeg"`. Rows misclassified **before** the fix stay that way by decision, §3 |
512
+ | 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 |
391
513
 
392
- > Source of truth for deeper detail: `docs/ASSET_MANAGER_V2.md`, `docs/CLIENT_OPERATIONS.md`, `docs/TENANT_ONBOARDING.md`, and `packages/asset-client/src/{transform.ts,index.ts}`.
514
+ > Source of truth for deeper detail: the published docs <https://nitida.gofuture.space/guides/advanced/>,
515
+ > <https://nitida.gofuture.space/guides/troubleshooting/>, <https://nitida.gofuture.space/start/credentials/> —
516
+ > and the API reference at <https://nitida.gofuture.space/api/>, generated from the shipped types.
393
517
 
394
518
  ## 8. The rest of the surface — the gaps this guide used to have
395
519
 
@@ -408,7 +532,7 @@ await nt.upload(file, { presets: ["original", "thumb", "md", "lg"] });
408
532
  | MIME | path | result |
409
533
  |---|---|---|
410
534
  | `image/*` | ladder | WebP variants + palette + dimensions |
411
- | `video/*` | Cloud Run Job | poster · mp4 · HLS · optional `aiproxy`/`probe` |
535
+ | `video/*` | background job | poster · mp4 · HLS · optional `aiproxy`/`probe` |
412
536
  | `audio/*` | inline transcode | `mp3` variant (mono ~96 kbps) **+** untouched original |
413
537
  | anything else | **passthrough** | raw bytes under `original`, `ready` immediately |
414
538
 
@@ -443,8 +567,8 @@ MDN compat data: `canvas.toBlob(type="image/webp")` is **unsupported in
443
567
  Safari**, macOS included; every iOS browser is WebKit, so all inherit it.
444
568
  Measured iOS 27 / Chrome 151, 9.1 MP photo: `2.05 MB → 743.7 kB` as `jpeg`.
445
569
 
446
- **Delivery is WebP regardless** — the server re-encodes every variant with
447
- sharp. The fallback only costs upload bytes (measured: WebP is **41% smaller**
570
+ **Delivery is WebP regardless** — the server re-encodes every variant. The
571
+ fallback only costs upload bytes (measured: WebP is **41% smaller**
448
572
  than JPEG at the same quality) and leaves the archived `original` as JPEG.
449
573
 
450
574
  ### 8e. Palette — free, synchronous, on the DTO
@@ -484,6 +608,6 @@ pipelines get the process OOM-killed on low-end Android, not finished sooner.
484
608
 
485
609
  ### 8i. Failure isolation
486
610
 
487
- Only object storage is a hard dependency. Cloud Run down → video stays
611
+ Only object storage is a hard dependency. Transcode service down → video stays
488
612
  `processing`, images fine. Webhooks down → **nothing**; they are fire-and-forget
489
613
  so an upload never fails because a consumer was unavailable.