@nitida/sdk 0.24.0 → 0.24.1

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
+ // Sin `quality`: auto-quality sondea el MÁSTER ⇒ una sola compresión.
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
+ ⚠️ **Fijate que NO lleva `quality`.** Un `quality` numérico activa el atajo que
134
+ lee una variante ya comprimida — ver la advertencia de abajo. Si de verdad
135
+ necesitás fijarlo (un presupuesto de bytes duro), es un opt-in consciente, no el
136
+ default que se copia sin leer.
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,7 +218,7 @@ 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
221
+ > ✅ **FIXED 2026-08-17 (#230/#231): you no longer have to pass `contentType` to avoid
118
222
  > `kind:"other"`.** This section used to insist on
119
223
  > `upload(bytes, { contentType: "audio/mpeg", fileName: "track.mp3" })` as a *defence*. Two
120
224
  > independent holes made that necessary and both are closed:
@@ -133,21 +237,17 @@ Output: `https://8ok.uk/<tenantId.toString(36)>/v/<sha16>-v.mp4`.
133
237
  > Passing `contentType` + `fileName` is still *good practice* — it is the cheapest possible signal
134
238
  > and it decides the stored extension. It is no longer load-bearing.
135
239
  >
136
- > ⚠️ **Against an `asset-manager` older than 2026-08-17 this bug is live**, so code that must run
240
+ > ⚠️ **Against a server deploy older than 2026-08-17 this bug is live**, so code that must run
137
241
  > against an old deploy should keep declaring the MIME explicitly. Also note the owner's decision:
138
242
  > the **230 rows already misclassified** as `kind:"other"` are NOT being repaired — the fix is
139
243
  > forward-only.
140
244
 
141
245
  ## 3b. Keeping the ORIGINAL bytes (backup, not delivery) ← learned the hard way 2026-08-07
142
246
 
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.
247
+ 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.
144
248
 
145
- **1. `original` is written only if you ask for it.** In `features/assets/process.routes.ts`:
146
-
147
- ```ts
148
- const effectivePresets: string[] = body.presets ?? ["original"];
149
- const wantsOriginal = effectivePresets.includes("original");
150
- ```
249
+ **1. `original` is written only if you ask for it.** The server's rule is that `presets` defaults to
250
+ `["original"]`, and an `original` variant is written only when that list contains it:
151
251
 
152
252
  - Omit `presets` entirely → **original only**, no ladder (passthrough).
153
253
  - Pass `["thumb"]` → thumb only, **no original**. The bytes you PUT are not retrievable.
@@ -155,12 +255,11 @@ const wantsOriginal = effectivePresets.includes("original");
155
255
 
156
256
  ⚠️ 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
257
 
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:
258
+ **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
259
 
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
260
+ ```
261
+ <tenant id in base36>/v/<sha16>-o.<ext>
262
+ // ↑ the UPLOADED file's extension, not one derived from the mime
164
263
  ```
165
264
 
166
265
  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.
@@ -176,8 +275,8 @@ The client's `PRESET_EXT.original` is the literal `"bin"`, so `getAssetUrl({sha}
176
275
  > the original was really stored under). `getAssetUrl` prefers the stored URL, falls back to
177
276
  > `oext`, and only then guesses from the mime.
178
277
  >
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
278
+ > **Why guessing could never work.** The server keys the original off the **uploaded filename's
279
+ > extension**, which the mime does not determine. Measured over the 2 001 stored
181
280
  > originals in production: all **420** `image/jpeg` originals are `.jpg` and none are `.jpeg`, so
182
281
  > the old mime table 404'd on every JPEG; and **234** originals are `application/octet-stream`
183
282
  > (`.mpga`, `.docx`, `.m4a`), where no mime table can ever produce the right key.
@@ -186,9 +285,8 @@ The client's `PRESET_EXT.original` is the literal `"bin"`, so `getAssetUrl({sha}
186
285
  > field on every response shape), and `getAssetUrl` was always correct for `video`/`poster`, whose
187
286
  > extensions are fixed.
188
287
  >
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`).
288
+ > ⚠️ **Against a server deploy older than 2026-08-17**, `variants` is `[]` and `oext` is absent
289
+ > — the mime guess is all you have, so HEAD the URL before relying on it.
192
290
 
193
291
  **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
292
 
@@ -196,9 +294,35 @@ The client's `PRESET_EXT.original` is the literal `"bin"`, so `getAssetUrl({sha}
196
294
  await nt.assets.regenerate(assetId, { presets: ["original"] });
197
295
  ```
198
296
 
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.
297
+ ⚠️ **Elegir mal `presets` NO destruye nadapero rompe una API y no la otra.** Verificado
298
+ 2026-08-17 contra producción, sobre un asset del **16-jun** con `presets:"q"` (sólo miniatura):
299
+ **su `raw` sigue existiendo (HTTP 200), dos meses después.**
300
+
301
+ La limpieza conserva el `raw` de **cualquier** sha referenciado por una fila de asset, incluidas las
302
+ borradas en suave. **Mientras exista la fila, los bytes originales están.**
200
303
 
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.
304
+ lo que realmente pasa si pediste `["thumb"]` y necesitabas la escalera:
305
+
306
+ | lo que llamás | qué obtenés |
307
+ |---|---|
308
+ | `getAssetUrl(asset, "md")` | **404** — esa variante nunca se generó |
309
+ | `/t/…width=1280/<sha>.webp` | **200** — se genera al vuelo desde el raw |
310
+ | `hasPreset(asset, "lg")` | `false`, correctamente |
311
+
312
+ Una API te dice que no existe y la otra te la entrega. **Eso** es lo caro: no la pérdida, la
313
+ incoherencia — más un cómputo en frío la primera vez que alguien pide cada tamaño.
314
+
315
+ **Se arregla en cualquier momento** con `regenerate({presets:[...]})`, que lee del raw. No hay
316
+ ventana que se cierre.
317
+
318
+ > ⚠️ **CORREGIDO 2026-08-17. La versión anterior de este párrafo decía que «los bytes del raw pueden
319
+ > ya ser irrecuperables» si la primera ingesta no pidió `original`.** Era falso, y venía de una
320
+ > hipótesis escrita como diagnóstico: la corrida del 2026-08-07 observó que `regenerate` no producía
321
+ > el `original` y concluyó *«consistent with `raw/` already being gone»*. Nadie comprobó el raw. Al
322
+ > comprobarlo, está. Lo que sí sigue en pie de esa corrida es la disciplina de abajo.
323
+
324
+ **Verify, don't assume.** Round-trip every archived file: download the `original` URL from the API
325
+ and compare its SHA-256 to the local file. "97 uploaded, 0 failed" was true and meaningless.
202
326
 
203
327
  | Goal | presets | Where the URL comes from |
204
328
  |---|---|---|
@@ -219,7 +343,7 @@ before the `includes`. **`hasPreset(dto, preset)` remains the recommended existe
219
343
  the only field present on every response shape, including the slim list/resolver one that carries no
220
344
  `variants` at all.
221
345
 
222
- ⚠️ **Against an `asset-manager` older than 2026-08-17 the contaminated strings are still being sent**
346
+ ⚠️ **Against a server deploy older than 2026-08-17 the contaminated strings are still being sent**
223
347
  (62 % of live rows had one). The client-side strip covers you; a hand-rolled
224
348
  `dto.presets.includes("o")` does not.
225
349
 
@@ -261,7 +385,7 @@ covers it: native if the engine is Apple's **OR** if there is no MSE to fall bac
261
385
  **When you use hls.js, stop it guessing** — the defaults are how a fast connection still opens at
262
386
  240p: `{ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 }`.
263
387
 
264
- **The first request can answer `202`** while the Cloud Run Job builds the ladder (1–3 min for a
388
+ **The first request can answer `202`** while the background job builds the ladder (1–3 min for a
265
389
  90 s source), then `302`s to the cached master. Keep the progressive MP4 as the fallback `<source>`
266
390
  so a player pointed at HLS too early still shows something.
267
391
 
@@ -289,15 +413,14 @@ step down to. **HLS = many files**: the same video cut into segments and encoded
289
413
  so the player switches mid-playback. That is the whole difference, and it is why only one of them
290
414
  can carry 4K.
291
415
 
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:
416
+ 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
417
 
295
418
  | Ladder built | Source | Ceiling |
296
419
  |---|---|---|
297
420
  | At ingest (automatic, unless `video.hls === false`) | the raw bytes you uploaded | **2160p from a 4K master** |
298
421
  | On demand later | the raw if present, else the `-v.mp4` | 1080p, second-generation |
299
422
 
300
- The progressive MP4 is capped unconditionally (`scale='min(1920,iw)'`), so `getAssetUrl(sha,"video")`
423
+ The progressive MP4 is capped unconditionally at 1920 wide, so `getAssetUrl(sha,"video")`
301
424
  never exceeds 1080p whatever you uploaded. A rung that weighs *more* than its own source (measured:
302
425
  5090 vs 4866 kbps) is the signature of the fallback path, not of a broken ladder.
303
426
 
@@ -314,7 +437,7 @@ A robust app resolver tries, in order: an explicit `sha` on the row → a migrat
314
437
 
315
438
  ## 5. Migrating an app OFF a legacy CDN onto nitida
316
439
 
317
- Pattern proven on carniceria, novatibas, sueños (all off `cdn.espaciofuturo.io`):
440
+ Pattern proven on three storefronts moved off the same legacy CDN:
318
441
 
319
442
  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
443
  2. **Regenerate metadata** (`blur`/`w`/`h`/`aspectRatio`) from the uploaded asset (`assets.get`) — don't carry legacy values.
@@ -328,17 +451,15 @@ Pattern proven on carniceria, novatibas, sueños (all off `cdn.espaciofuturo.io`
328
451
  Google). *Tenants → New* creates the tenant rows, and the tenant's page **issues, shows and rotates**
329
452
  its runtime / admin / CI keys. A key's secret is displayed **once**, at issue time — store it then.
330
453
 
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.
454
+ That replaces the older hand-run provisioning scripts and SQL that used to live here.
335
455
 
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_*>` +
456
+ - Endpoint = `https://api.nitida.gofuture.space` (an older endpoint you were given keeps working —
457
+ see the note at the top). Verify a fresh tenant: `GET /usage` with `Authorization: Bearer <amk_rt_*>` +
338
458
  `X-Tenant-Code: <code>` → `{ "tenant": { "id": N } }`.
339
459
  - The `@aquienpz/tenant-config` HTTP layer (still published under the old scope) **caches tenant
340
460
  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.
461
+ - Asset-only consumers (a storefront with its own catalog DB) need only the tenant rows, its keys and
462
+ its allowed origins; see <https://nitida.gofuture.space/start/credentials/>.
342
463
 
343
464
  ## Quick reference
344
465
 
@@ -355,41 +476,51 @@ Variant preset short codes: `thumb=q, sm=s, md=m, lg=l, xl=x, original=o, poster
355
476
 
356
477
  ## 7. Browser-direct uploads — three things that only fail in a real browser
357
478
 
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.
479
+ Measured 2026-08-15 on the public bench at <https://media-harness.vercel.app> no credentials
480
+ needed. Reproduce there before debugging any of these by hand.
360
481
 
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.
482
+ 1. **The PUT goes browser → object storage directly, so the STORAGE BUCKET answers the CORS
483
+ preflight** not the API. An origin missing from the bucket's policy therefore cannot be fixed
484
+ in your app, in this SDK, or by the API's allowed-origins setting: those govern the API, not the
485
+ PUT. Symptom: the direct PUT fails with a bare network error while compression, hashing and
486
+ presign are all green. **This one is ours to fix ask us to add your origin to the bucket
487
+ policy.**
366
488
  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 }`;
489
+ background job and answers `{ ok: true, kind: "video", assetId, status: "processing", dispatch }`;
368
490
  the SDK then polls that id until `ready`. Budget for it — a transcode plus the HLS ladder runs
369
491
  1–2 min, so pass a `timeoutMs` of at least `300_000`. (Until 2026-08-16 the response carried **no
370
492
  `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.)
493
+ through the kit; the bytes still reached storage, which is what made it invisible.)
372
494
  3. **A video never gets an `original` variant.** `/assets/process` filters video presets through
373
495
  `{poster, video, aiproxy, probe}` before dispatch, so `"original"` is silently dropped even when
374
496
  you ask for it. If you need the source bytes back, archive them under a **non-video key** (e.g.
375
497
  `clip.mp4.bin`), which takes the passthrough branch and stores them verbatim.
376
498
 
499
+ ⚠️ **CORRECTED 2026-08-17 — you do NOT need to upload it twice.** The
500
+ `original` *variant* is dropped, but the raw upload is kept and
501
+ **`dto.rawUrl` serves the master**. Verified on a production video:
502
+ **9 197 163 B, `video/mp4`, 200**, range-capable — and *smaller* than the
503
+ deliverable `video` variant (15 875 972 B). Cleanup keeps `raw/` while an
504
+ asset row exists.
505
+
377
506
  | Symptom | Cause |
378
507
  |---|---|
379
508
  | **400** on an image URL | width not on `TRANSFORM_WIDTHS` ladder |
380
509
  | **410** on a video URL | used `/t/` (transform) for a stored video — use `getAssetUrl(...,'video')` |
381
510
  | **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 |
511
+ | `process returned no assetId` | fixed 2026-08-16 — you are on a server deploy older than that, §7.2 |
383
512
  | `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 |
513
+ | 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 |
514
+ | `nt.assets.variants()` returns `[]` | fixed 2026-08-17 (#225) — you are on a server deploy older than that. Existence from `dto.presets` + `hasPreset` works on every version |
386
515
  | `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 |
387
516
  | `tenant_not_found` from a service | tenant rows missing, or the 60s tenant-config cache is stale |
388
517
  | `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
518
  | "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 |
519
+ | 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 server deploy, upload with an explicit `contentType:"audio/mpeg"`. Rows misclassified **before** the fix stay that way by decision, §3 |
391
520
 
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}`.
521
+ > Source of truth for deeper detail: the published docs <https://nitida.gofuture.space/guides/advanced/>,
522
+ > <https://nitida.gofuture.space/guides/troubleshooting/>, <https://nitida.gofuture.space/start/credentials/> —
523
+ > and the API reference at <https://nitida.gofuture.space/api/>, generated from the shipped types.
393
524
 
394
525
  ## 8. The rest of the surface — the gaps this guide used to have
395
526
 
@@ -408,7 +539,7 @@ await nt.upload(file, { presets: ["original", "thumb", "md", "lg"] });
408
539
  | MIME | path | result |
409
540
  |---|---|---|
410
541
  | `image/*` | ladder | WebP variants + palette + dimensions |
411
- | `video/*` | Cloud Run Job | poster · mp4 · HLS · optional `aiproxy`/`probe` |
542
+ | `video/*` | background job | poster · mp4 · HLS · optional `aiproxy`/`probe` |
412
543
  | `audio/*` | inline transcode | `mp3` variant (mono ~96 kbps) **+** untouched original |
413
544
  | anything else | **passthrough** | raw bytes under `original`, `ready` immediately |
414
545
 
@@ -443,8 +574,8 @@ MDN compat data: `canvas.toBlob(type="image/webp")` is **unsupported in
443
574
  Safari**, macOS included; every iOS browser is WebKit, so all inherit it.
444
575
  Measured iOS 27 / Chrome 151, 9.1 MP photo: `2.05 MB → 743.7 kB` as `jpeg`.
445
576
 
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**
577
+ **Delivery is WebP regardless** — the server re-encodes every variant. The
578
+ fallback only costs upload bytes (measured: WebP is **41% smaller**
448
579
  than JPEG at the same quality) and leaves the archived `original` as JPEG.
449
580
 
450
581
  ### 8e. Palette — free, synchronous, on the DTO
@@ -484,6 +615,6 @@ pipelines get the process OOM-killed on low-end Android, not finished sooner.
484
615
 
485
616
  ### 8i. Failure isolation
486
617
 
487
- Only object storage is a hard dependency. Cloud Run down → video stays
618
+ Only object storage is a hard dependency. Transcode service down → video stays
488
619
  `processing`, images fine. Webhooks down → **nothing**; they are fire-and-forget
489
620
  so an upload never fails because a consumer was unavailable.