@nitida/sdk 0.23.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.
- package/AGENTS.md +10 -9
- package/README.md +41 -33
- package/dist/index.d.ts +57 -29
- package/dist/index.js +19 -19
- package/dist/index.js.map +1 -1
- package/dist/server.js +19 -19
- package/dist/server.js.map +1 -1
- package/dist/web.js +19 -19
- package/dist/web.js.map +1 -1
- package/package.json +2 -2
- package/skills/nitida-sdk/SKILL.md +206 -75
- package/src/index.ts +60 -31
|
@@ -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
|
|
10
|
-
>
|
|
11
|
-
>
|
|
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>** —
|
|
15
|
-
>
|
|
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`.
|
|
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
|
|
27
|
-
and serves bytes through the CDN **`https://8ok.uk`**.
|
|
28
|
-
Apps are **external consumers**: install the npm packages, never vendor
|
|
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[
|
|
48
|
-
Until 2026-08-17 the published endpoint was
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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.**
|
|
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
|
|
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
|
-
```
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
>
|
|
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
|
|
190
|
-
> — the mime guess is all you have, so HEAD the URL before relying on it.
|
|
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
|
-
⚠️ **
|
|
297
|
+
⚠️ **Elegir mal `presets` NO destruye nada — pero 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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` (
|
|
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)
|
|
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
|
|
359
|
-
|
|
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 →
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
|
385
|
-
| `nt.assets.variants()` returns `[]` | fixed 2026-08-17 (#225) — you are on
|
|
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
|
|
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:
|
|
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/*` |
|
|
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
|
|
447
|
-
|
|
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.
|
|
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.
|