@nitida/sdk 0.31.1 → 0.31.5

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/README.md CHANGED
@@ -246,14 +246,14 @@ const aq = new NitidaClient({
246
246
  endpoint: "https://api.nitida.gofuture.space",
247
247
  apiKey: process.env.AQUIENPZ_API_KEY!, // amk_rt_* runtime API key
248
248
  tenantCode: "your-tenant",
249
- tenantId: 42, // numeric tenant id (used in CDN URL prefix)
249
+ tenantId: 42, // ⚠️ va en la URL en BASE36: 42 → "16". Ni decimal, ni hex.
250
250
  cdnBase: "https://8ok.uk", // optional, defaults to https://8ok.uk
251
251
  });
252
252
 
253
253
  // Slots — the recommended way to reference brand assets in code.
254
254
  // Source never hardcodes a CDN URL; an admin rebinds it from the console.
255
255
  const hero = await aq.slots.resolve("storefront.home.hero");
256
- // → { slot: { asset, preset, … }, preset: "lg", url: "https://8ok.uk/2a/v/<sha>-l.webp" }
256
+ // → { slot: { asset, preset, … }, preset: "lg", url: "https://8ok.uk/16/v/<sha>-l.webp" }
257
257
 
258
258
  // Bulk resolution in one round-trip.
259
259
  const heroes = await aq.slots.resolveMany([
@@ -271,7 +271,12 @@ const { assets, nextCursor } = await aq.assets.list({ limit: 50 });
271
271
 
272
272
  // Uploads — hash-deduped; returns the canonical v2 URL immediately.
273
273
  const result = await aq.upload(file, { fileName: "cover.jpg" });
274
- // → { assetId, sha256, cdnUrl }
274
+ // → { assetId, sha256, sha, mime, oext, cdnUrl }
275
+ // `sha` (16 hex), `mime` y `oext` existen desde 0.30.0 para que el resultado
276
+ // se pueda pasar DIRECTO a cualquier builder, sin volver a buscar el DTO:
277
+ // getAssetUrl(result, "original") // → -o.jpg, no -o.bin
278
+ // Un objeto armado a mano con sólo { assetId, sha256, cdnUrl } NO sirve:
279
+ // los builders leen `sha`, no `sha256`, y `assertSha` lo rechaza.
275
280
 
276
281
  // Uploading raw bytes (Node/Bun, e.g. re-hosting a remote image)? A Uint8Array has no
277
282
  // inherent MIME, so give it one — otherwise it stores as kind:"other" (NO image variants):
@@ -437,10 +442,10 @@ export function UploadHeroScreen() {
437
442
  }
438
443
  ```
439
444
 
440
- ⚠️ The Expo background-upload path depends on `@nitida/asset-uploader-expo`,
441
- which is **not on npm**. `aq.upload()` works on Expo today without it; the
442
- background/resumable variant is not something an external consumer can install
443
- yet.
445
+ ℹ️ `@nitida/asset-uploader-expo` **is on npm** (`0.2.0`, MIT) and installs
446
+ normally. `aq.upload()` works on Expo without it; you want the uploader when you
447
+ need a **resumable, background** upload the only way a transfer survives iOS
448
+ suspending the app. See the note above on when an uploader is worth adding.
444
449
 
445
450
  ## Next.js App Router (Server Components)
446
451
 
@@ -634,7 +639,7 @@ Both symbols are real. Neither is orderable.
634
639
  |---|---|---|
635
640
  | `thumb` `sm` `md` `lg` `xl` `original` `poster` `video` `aiproxy` | ✅ | ✅ |
636
641
  | `hls` — the adaptive ladder, built when a video transcodes | ❌ | ✅ |
637
- | `mp3` — emitted automatically alongside any audio original | ❌ | ✅ |
642
+ | `mp3` — emitted alongside an audio original **only when the original is not already universally playable** (i.e. NOT `audio/mpeg`, `audio/mp4`, `audio/aac`) | ❌ | ✅ |
638
643
  | `probe` — indexed stills (`-pr0.jpg`), never on the compact `presets` string | ✅ | ❌ |
639
644
 
640
645
  The write methods (`upload`, `presignUploadUrl`, `regenerate`) take
@@ -803,7 +808,29 @@ context.
803
808
 
804
809
  | you have | use | why |
805
810
  |---|---|---|
806
- | a client (`aq`) | **`aq.transform(asset, opts)`** | it already knows your CDN base and tenant, and it is the only one that can **sign** — `aq.transform(asset, opts, { sign: true })` returns a `?sig=` URL, which is the only way to an off-ladder width |
811
+ | a client (`aq`) | **`aq.transform(asset, opts)`** | it already knows your CDN base and tenant (⚠️ see the multi-tenant warning below), and it is the only one that can **sign** — `aq.transform(asset, opts, { sign: true })` returns a `?sig=` URL, the only way to an **arbitrary** width |
812
+
813
+ > ⚠️ **THE TENANT IS ONE MODULE-WIDE GLOBAL, NOT PER-CLIENT STATE.**
814
+ >
815
+ > Constructing a `NitidaClient` calls `setTenantId()` — the same global the standalone
816
+ > setter writes. **The last client constructed wins**, and every client made before it
817
+ > silently starts emitting the other tenant's URLs. Measured:
818
+ >
819
+ > ```ts
820
+ > const a = new NitidaClient({ …, tenantId: 15 });
821
+ > a.urlFor(asset, "md"); // → https://8ok.uk/f/v/<sha>-m.webp ✅
822
+ >
823
+ > const b = new NitidaClient({ …, tenantId: 4 });
824
+ > a.urlFor(asset, "md"); // → https://8ok.uk/4/v/<sha>-m.webp ❌ 404, no error
825
+ > ```
826
+ >
827
+ > It affects everything that carries a tenant segment — `urlFor`, `srcSetFor`,
828
+ > `getAssetUrl`, and the private `/a/{tenant}/…` tree. Public `transform()` has no tenant
829
+ > segment, so it is unaffected.
830
+ >
831
+ > In a multi-tenant process (a BFF, a cron, a migration) use **one process per tenant**,
832
+ > or call `setTenantId(n)` immediately before each block of URL building. Holding two
833
+ > clients and trusting each to remember its own tenant does not work.
807
834
  | only a DTO — a component, a Server Component, a worker | **`getTransformUrl(asset, opts)`** | no client needed. Call `setCdnBase()` / `setTenantId()` once at module load first |
808
835
 
809
836
  Same builder underneath, same URL out. If you are holding a client, use its
@@ -846,12 +873,30 @@ const asset = await aq.assets.byHash(sha256);
846
873
  > **`width` is strongly typed.** `TransformOptions.width` is a **`TransformWidth`** — the
847
874
  > predefined CDN ladder (`96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960,
848
875
  > 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840` — 20 widths, exported as
849
- > `TRANSFORM_WIDTHS`). An off-ladder
850
- > width is a **compile error**: the edge whitelists exactly these as a DoS guard and
851
- > HTTP 400s anything else on unsigned URLs. Need a custom off-ladder width? **Sign it** —
876
+ > `TRANSFORM_WIDTHS`). An off-ladder width is a **compile error**.
877
+ >
878
+ > ⚠️ **The type is narrower than the edge, on purpose.** The edge whitelists a
879
+ > longer list (28 today — its own 400 response enumerates them, and `512`, `768`,
880
+ > `1800`, `2160`, `2400`, `2700`, `2880` and `3600` all serve 200 unsigned). So a
881
+ > width outside the TYPE is not automatically a 400; a width outside the EDGE's
882
+ > list is. Signing is the only way to an **arbitrary** width — not to every width
883
+ > outside `TransformWidth`. Need an arbitrary one? **Sign it** —
852
884
  > `aq.transform(asset, { width: 1490 }, { sign: true })` and `getSignedTransformUrl` take
853
885
  > `SignedTransformOptions` (where `width` widens to `number`); a valid `?sig=` earns the
854
- > edge-whitelist bypass. `transformSrcSet` / `getTransformSrcSet` deliberately keep
886
+ > edge-whitelist bypass.
887
+ >
888
+ > ⚠️ **Signed widths have two ceilings the edge does not announce** (measured
889
+ > 2026-08-24):
890
+ >
891
+ > 1. **7680 is the hard cap.** `7680` → 200, `7681` → 400. Unsigned, the real
892
+ > cap is 3840 — so `3841…4320` is a dead band the old 400 message called
893
+ > "in range", and `4321…7680` works signed while that message called it
894
+ > impossible.
895
+ > 2. **Above the source width it CLAMPS and still answers 200.** `/t/` never
896
+ > upscales. Ask for 5000 on an 800 px master and you get **800 px of bytes
897
+ > with HTTP 200** — no error, no warning. Read `x-transform-dsl` on the
898
+ > response (it names the width actually used) or size against the source, if
899
+ > the exact width matters. `transformSrcSet` / `getTransformSrcSet` deliberately keep
855
900
  > `number[]` because a responsive ladder may legitimately include DPR-row widths (e.g.
856
901
  > `2400`). `height` stays `number` — for the responsive path it's derived from `width`
857
902
  > by aspect ratio; only fixed-canvas crops / `genfill` set it explicitly.
@@ -865,8 +910,14 @@ point of asking for `auto` instead of naming a format.
865
910
  `gravity=face` detects the highest-confidence face and crops to it with
866
911
  sensible padding (50 % of face dimensions on each side, clamped to source
867
912
  bounds, keeping the requested aspect ratio). When no face is detected it
868
- falls back to saliency-based cropping and returns the response with
869
- `X-Transform-Face: fallback`, so you can tell the two apart. Cost to you:
913
+ falls back to saliency-based cropping. The response **always** carries
914
+ `X-Transform-Face`, with one of two values: **`matched`** when a face was found,
915
+ **`fallback`** when it saliency-cropped.
916
+
917
+ ⚠️ Check the VALUE, not the presence. The header is emitted **only for
918
+ `gravity=face`** — with `center` or `auto` it is absent, and that absence does
919
+ not mean "no face". Code that tests for presence is right 0 % of the time on
920
+ photos that do contain a face. Cost to you:
870
921
  ~10 ms warm; ~150 ms on the first request after a cold start.
871
922
 
872
923
  `quality=auto` adapts the per-format quality to source complexity
@@ -1098,6 +1149,14 @@ the cache semantics are the contract.
1098
1149
 
1099
1150
  ### Generative fill / aspect outpaint (`effect=genfill`)
1100
1151
 
1152
+ ⚠️ **`genfill` only works on SIGNED URLs.** It is a cost guard: the effect runs
1153
+ a generative model per unique tuple. Without `{ sign: true }` the edge answers
1154
+ `401 {"error":"signature_required"}` — whether or not your tenant has
1155
+ `strict_transforms`, and unlike `width=` or `effect=removebg`, which serve 200
1156
+ unsigned. You need the project's **signing key** (see *Signed URLs*); for a
1157
+ project created before 2026-08-23, ask your operator — it cannot be recovered
1158
+ afterwards. Every example below therefore passes `{ sign: true }`.
1159
+
1101
1160
  Extend a source image into a different aspect ratio without the
1102
1161
  awkward edge mirroring that classic content-aware fill produces.
1103
1162
  Primary use case: building OG cards (1200×630) from portrait listing
@@ -1106,24 +1165,26 @@ photos, or 1:1 social tiles from 16:9 originals.
1106
1165
  ```ts
1107
1166
  // 1200×630 OG card from a portrait listing cover — the gutters are
1108
1167
  // generated, the source is pasted centered.
1109
- const ogUrl = aq.transform(asset, {
1110
- effect: "genfill",
1111
- width: 1200,
1112
- height: 630,
1113
- });
1114
- // → https://8ok.uk/t/effect=genfill,height=630,width=1200/<sha>.png
1168
+ const ogUrl = await aq.transform(
1169
+ asset,
1170
+ { effect: "genfill", width: 1200, height: 630 },
1171
+ { sign: true }, // ⚠️ sin esto: 401 signature_required
1172
+ );
1173
+ // → https://8ok.uk/t/effect=genfill,height=630,width=1200/<sha>.webp
1115
1174
 
1116
1175
  // 1:1 social tile from a landscape original
1117
- const tileUrl = aq.transform(asset, {
1118
- effect: "genfill",
1119
- width: 1080,
1120
- height: 1080,
1121
- });
1176
+ const tileUrl = await aq.transform(
1177
+ asset,
1178
+ { effect: "genfill", width: 1080, height: 1080 },
1179
+ { sign: true },
1180
+ );
1122
1181
  ```
1123
1182
 
1124
1183
  **Requires both `width` and `height`.** Without them the route returns
1125
1184
  422 — the effect needs an explicit target canvas to know what to
1126
- outpaint.
1185
+ outpaint. ⚠️ You only ever see that 422 **after** signing: unsigned, the 401
1186
+ comes first, so a missing `{ sign: true }` looks like a different bug than it
1187
+ is.
1127
1188
 
1128
1189
  **Output defaults to WebP** at q=85 (~150KB for a 1200×630 OG card —
1129
1190
  12× lighter than the raw generated PNG). Honors `format=` for
@@ -1144,7 +1205,19 @@ landscape source ✓). For bigger crops, prefer `gravity=auto` smart-crop,
1144
1205
  which is deterministic and free — no generation, no invention.
1145
1206
 
1146
1207
  **What it costs you: ~$0.05 for the first request** per (sha, dsl, format)
1147
- tuple. Subsequent identical requests are 302 redirects to the cached PNG —
1208
+ ⚠️ **The signature guards GENERATION, not DELIVERY.** Once a signed transform
1209
+ runs, its result is written to `/{tenant}/v/<sha>-t<dslHash>.<ext>` and served
1210
+ there **with HTTP 200 and no `?sig=`**. `<dslHash>` is `sha256(canonical DSL)`
1211
+ truncated to 16 — **no secret in it**, so anyone who guesses the DSL derives the
1212
+ URL. Measured: a signed `width=641` (unreachable unsigned) went from 404 to 200
1213
+ at the derived path after a single signed GET.
1214
+
1215
+ Two consequences worth planning around: turning on `strict_transforms` does not
1216
+ un-publish anything already generated, and for `genfill` — whose entire cost
1217
+ guard is the signature — the result you paid for stays publicly readable. Treat
1218
+ a signed transform as "pay once, publish forever", not as an access control.
1219
+
1220
+ tuple. Subsequent identical requests are 302 redirects to the cached WebP —
1148
1221
  zero generative cost, forever. The model is server-side and may be swapped
1149
1222
  for a better one without any change on your side; the DSL, the output and
1150
1223
  the cache semantics are the contract.
@@ -1229,7 +1302,15 @@ First request to a new HLS URL returns **202 Accepted** with
1229
1302
 
1230
1303
  The ladder shrinks to fit the source: a 480p source produces three
1231
1304
  rungs (240p / 360p / 480p), a 1080p source produces five, and a 4K
1232
- source goes up to 2160p. The player picks the right rung on the fly
1305
+ source goes up to 2160p.
1306
+
1307
+ > ⚠️ **Clips under 18 seconds are the exception, and they are common.**
1308
+ > A short source is restricted to the 720p–1080p band on purpose, so an 8 s
1309
+ > 720p clip gets **one rung**, not four — the rung-switching a ladder exists
1310
+ > for cannot happen inside a clip that short, and building five of them just
1311
+ > burns transcode budget. Read `switchable` on the ladder rather than
1312
+ > `rungs.length`: it is `false` exactly when there is nothing to switch
1313
+ > between. A test asserting `rungs.length > 1` will fail on every short clip. The player picks the right rung on the fly
1233
1314
  based on the current connection — a user on 3G starts at 240p and
1234
1315
  climbs to 1080p as bandwidth improves, vs the monolithic MP4 that
1235
1316
  either loaded or timed out.
@@ -1268,7 +1349,10 @@ const bg = getAmbientGradient(asset.palette);
1268
1349
  // bg = "linear-gradient(135deg, oklch(...), oklch(...))"
1269
1350
 
1270
1351
  // Auto-pick text color that contrasts with the chosen ambient:
1271
- const fg = getTextColorForBackground(asset.palette);
1352
+ // ⚠️ `getTextColorForBackground` toma UN SWATCH, no la paleta entera.
1353
+ // Pasarle `asset.palette` tira `TypeError: … evaluating 'hex.replace'`.
1354
+ const bg = pickAmbientBackground(asset.palette); // PaletteSwatch | null
1355
+ const fg = getTextColorForBackground(bg);
1272
1356
  // fg = "#fff" | "#000" | similar
1273
1357
 
1274
1358
  // Or just the blurry LQIP for a CSS background placeholder:
@@ -1277,8 +1361,16 @@ const placeholder = getPaletteBlurBackground(asset.palette);
1277
1361
 
1278
1362
  The wire format is intentionally tight: `{d, v, m, dv, lv, dm, lm}`
1279
1363
  (dominant, vibrant, muted, dark-vibrant, light-vibrant, dark-muted,
1280
- light-muted). 7 hex strings per asset much smaller than a full
1281
- base64 LQIP, but composes into nicer ambient UX.
1364
+ light-muted) — **up to** 7 hex strings per asset, much smaller than a full
1365
+ base64 LQIP but composing into nicer ambient UX.
1366
+
1367
+ ⚠️ **Only `d` (dominant) is guaranteed. Every other key is optional**, because
1368
+ only the swatches the source actually had get extracted — measured across 30
1369
+ assets, palettes carry anywhere from 2 to 7 keys. Never index one directly:
1370
+ `palette.dv` on a 2-swatch palette emits
1371
+ `linear-gradient(135deg, #1a1a1a, undefined)`. Use the helpers
1372
+ (`pickAmbientBackground`, `getAmbientGradient`, `getPaletteCssVars`), which skip
1373
+ the missing ones.
1282
1374
 
1283
1375
  When the image can't be decoded (SVG sources, exotic formats,
1284
1376
  deliberately corrupted bytes), palette + blur silently come back
@@ -1315,8 +1407,13 @@ server-side pipeline (a platform-wide addition, not a per-tenant one).
1315
1407
 
1316
1408
  `<cdnBase>/<tenantId base36>/v/<sha16>-<presetCode>.<ext>`
1317
1409
 
1318
- Example: `https://8ok.uk/f/v/c482458e824c730e-q.webp` — the `thumb` preset of
1319
- sha `c482458e…` as WebP, for **tenant 15** (`15` in base36 is `f`).
1410
+ Example: `https://8ok.uk/f/v/<sha16>-q.webp` — the `thumb` preset of a 16-hex
1411
+ sha as WebP, for **tenant 15** (`15` in base36 is `f`).
1412
+
1413
+ > `<sha16>` is a placeholder on purpose. A concrete sha pinned here rots: the
1414
+ > previous example pasted a **real tenant-4 URL with the prefix swapped to
1415
+ > `f`**, so it 404'd on every preset while the identical path under `/4/` served
1416
+ > 200. Substitute a sha from your own tenant — `upload()` returns it as `sha`.
1320
1417
 
1321
1418
  ⚠️ **The tenant segment is not optional, and it is base36.** `/15/…` 404s;
1322
1419
  so does a bare `/<sha16>-q.webp` with no tenant at all. Nothing serves that
package/dist/index.js CHANGED
@@ -794,12 +794,15 @@ var NitidaClient = class {
794
794
  });
795
795
  if (presign.deduped) {
796
796
  const short = sha.slice(0, 16);
797
+ const settled = presign.asset.status === "ready" ? presign.asset : await this.assets.waitReady(presign.asset.id, opts.timeoutMs);
798
+ if (settled.status !== "ready")
799
+ throw new Error(`upload: asset ended status=${settled.status}`);
797
800
  return {
798
- assetId: presign.asset.id,
801
+ assetId: settled.id,
799
802
  sha256: sha,
800
803
  sha: short,
801
- mime: presign.asset.mime ?? mime,
802
- oext: presign.asset.oext ?? null,
804
+ mime: settled.mime ?? mime,
805
+ oext: settled.oext ?? null,
803
806
  // ⭐ `{ ...asset, sha: short }`, never `asset` alone.
804
807
  //
805
808
  // The presign route answers a dedup hit with `sha256` and NO `sha` —
@@ -816,14 +819,24 @@ var NitidaClient = class {
816
819
  // latent defect loud.
817
820
  //
818
821
  // This branch is only reached when the asset EXISTS but is not
819
- // `ready` — the `byHash` branch above returns first otherwise — so
820
- // ordinary uploads never touched it and no test did either.
822
+ // `ready` — the `byHash` branch above returns first otherwise.
823
+ //
824
+ // That sentence used to end with "so ordinary uploads never touched it
825
+ // and no test did either", and that was the whole problem: it read as
826
+ // reassurance when it was the risk. Two callers uploading the same
827
+ // bytes at once land here, and a retry after a timeout is exactly that.
828
+ // Covered now by `every-upload-branch-waits-for-ready.test.ts`.
821
829
  //
822
830
  // The sha is not the DTO's to supply: we hashed the bytes ourselves at
823
831
  // the top of this method. Build from what we KNOW.
832
+ //
833
+ // And the preset comes from `bestPresetForAsset` — what this asset
834
+ // ACTUALLY has, now that we waited for it — not from
835
+ // `defaultPresetForMime`, which is a guess made before anything exists.
836
+ // Guessing was safe only while this branch never ran.
824
837
  cdnUrl: this.urlFor(
825
- { ...presign.asset, sha: short },
826
- this.defaultPresetForMime(mime)
838
+ { ...settled, sha: short },
839
+ this.bestPresetForAsset(settled, mime)
827
840
  )
828
841
  };
829
842
  }