@nitida/sdk 0.31.1 → 0.31.4

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,9 +873,14 @@ 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
886
  > edge-whitelist bypass. `transformSrcSet` / `getTransformSrcSet` deliberately keep
@@ -1098,6 +1130,14 @@ the cache semantics are the contract.
1098
1130
 
1099
1131
  ### Generative fill / aspect outpaint (`effect=genfill`)
1100
1132
 
1133
+ ⚠️ **`genfill` only works on SIGNED URLs.** It is a cost guard: the effect runs
1134
+ a generative model per unique tuple. Without `{ sign: true }` the edge answers
1135
+ `401 {"error":"signature_required"}` — whether or not your tenant has
1136
+ `strict_transforms`, and unlike `width=` or `effect=removebg`, which serve 200
1137
+ unsigned. You need the project's **signing key** (see *Signed URLs*); for a
1138
+ project created before 2026-08-23, ask your operator — it cannot be recovered
1139
+ afterwards. Every example below therefore passes `{ sign: true }`.
1140
+
1101
1141
  Extend a source image into a different aspect ratio without the
1102
1142
  awkward edge mirroring that classic content-aware fill produces.
1103
1143
  Primary use case: building OG cards (1200×630) from portrait listing
@@ -1106,24 +1146,26 @@ photos, or 1:1 social tiles from 16:9 originals.
1106
1146
  ```ts
1107
1147
  // 1200×630 OG card from a portrait listing cover — the gutters are
1108
1148
  // 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
1149
+ const ogUrl = await aq.transform(
1150
+ asset,
1151
+ { effect: "genfill", width: 1200, height: 630 },
1152
+ { sign: true }, // ⚠️ sin esto: 401 signature_required
1153
+ );
1154
+ // → https://8ok.uk/t/effect=genfill,height=630,width=1200/<sha>.webp
1115
1155
 
1116
1156
  // 1:1 social tile from a landscape original
1117
- const tileUrl = aq.transform(asset, {
1118
- effect: "genfill",
1119
- width: 1080,
1120
- height: 1080,
1121
- });
1157
+ const tileUrl = await aq.transform(
1158
+ asset,
1159
+ { effect: "genfill", width: 1080, height: 1080 },
1160
+ { sign: true },
1161
+ );
1122
1162
  ```
1123
1163
 
1124
1164
  **Requires both `width` and `height`.** Without them the route returns
1125
1165
  422 — the effect needs an explicit target canvas to know what to
1126
- outpaint.
1166
+ outpaint. ⚠️ You only ever see that 422 **after** signing: unsigned, the 401
1167
+ comes first, so a missing `{ sign: true }` looks like a different bug than it
1168
+ is.
1127
1169
 
1128
1170
  **Output defaults to WebP** at q=85 (~150KB for a 1200×630 OG card —
1129
1171
  12× lighter than the raw generated PNG). Honors `format=` for
@@ -1144,7 +1186,7 @@ landscape source ✓). For bigger crops, prefer `gravity=auto` smart-crop,
1144
1186
  which is deterministic and free — no generation, no invention.
1145
1187
 
1146
1188
  **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
1189
+ tuple. Subsequent identical requests are 302 redirects to the cached WebP
1148
1190
  zero generative cost, forever. The model is server-side and may be swapped
1149
1191
  for a better one without any change on your side; the DSL, the output and
1150
1192
  the cache semantics are the contract.
@@ -1229,7 +1271,15 @@ First request to a new HLS URL returns **202 Accepted** with
1229
1271
 
1230
1272
  The ladder shrinks to fit the source: a 480p source produces three
1231
1273
  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
1274
+ source goes up to 2160p.
1275
+
1276
+ > ⚠️ **Clips under 18 seconds are the exception, and they are common.**
1277
+ > A short source is restricted to the 720p–1080p band on purpose, so an 8 s
1278
+ > 720p clip gets **one rung**, not four — the rung-switching a ladder exists
1279
+ > for cannot happen inside a clip that short, and building five of them just
1280
+ > burns transcode budget. Read `switchable` on the ladder rather than
1281
+ > `rungs.length`: it is `false` exactly when there is nothing to switch
1282
+ > between. A test asserting `rungs.length > 1` will fail on every short clip. The player picks the right rung on the fly
1233
1283
  based on the current connection — a user on 3G starts at 240p and
1234
1284
  climbs to 1080p as bandwidth improves, vs the monolithic MP4 that
1235
1285
  either loaded or timed out.
@@ -1268,7 +1318,10 @@ const bg = getAmbientGradient(asset.palette);
1268
1318
  // bg = "linear-gradient(135deg, oklch(...), oklch(...))"
1269
1319
 
1270
1320
  // Auto-pick text color that contrasts with the chosen ambient:
1271
- const fg = getTextColorForBackground(asset.palette);
1321
+ // ⚠️ `getTextColorForBackground` toma UN SWATCH, no la paleta entera.
1322
+ // Pasarle `asset.palette` tira `TypeError: … evaluating 'hex.replace'`.
1323
+ const bg = pickAmbientBackground(asset.palette); // PaletteSwatch | null
1324
+ const fg = getTextColorForBackground(bg);
1272
1325
  // fg = "#fff" | "#000" | similar
1273
1326
 
1274
1327
  // Or just the blurry LQIP for a CSS background placeholder:
@@ -1277,8 +1330,16 @@ const placeholder = getPaletteBlurBackground(asset.palette);
1277
1330
 
1278
1331
  The wire format is intentionally tight: `{d, v, m, dv, lv, dm, lm}`
1279
1332
  (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.
1333
+ light-muted) — **up to** 7 hex strings per asset, much smaller than a full
1334
+ base64 LQIP but composing into nicer ambient UX.
1335
+
1336
+ ⚠️ **Only `d` (dominant) is guaranteed. Every other key is optional**, because
1337
+ only the swatches the source actually had get extracted — measured across 30
1338
+ assets, palettes carry anywhere from 2 to 7 keys. Never index one directly:
1339
+ `palette.dv` on a 2-swatch palette emits
1340
+ `linear-gradient(135deg, #1a1a1a, undefined)`. Use the helpers
1341
+ (`pickAmbientBackground`, `getAmbientGradient`, `getPaletteCssVars`), which skip
1342
+ the missing ones.
1282
1343
 
1283
1344
  When the image can't be decoded (SVG sources, exotic formats,
1284
1345
  deliberately corrupted bytes), palette + blur silently come back
@@ -1315,8 +1376,13 @@ server-side pipeline (a platform-wide addition, not a per-tenant one).
1315
1376
 
1316
1377
  `<cdnBase>/<tenantId base36>/v/<sha16>-<presetCode>.<ext>`
1317
1378
 
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`).
1379
+ Example: `https://8ok.uk/f/v/<sha16>-q.webp` — the `thumb` preset of a 16-hex
1380
+ sha as WebP, for **tenant 15** (`15` in base36 is `f`).
1381
+
1382
+ > `<sha16>` is a placeholder on purpose. A concrete sha pinned here rots: the
1383
+ > previous example pasted a **real tenant-4 URL with the prefix swapped to
1384
+ > `f`**, so it 404'd on every preset while the identical path under `/4/` served
1385
+ > 200. Substitute a sha from your own tenant — `upload()` returns it as `sha`.
1320
1386
 
1321
1387
  ⚠️ **The tenant segment is not optional, and it is base36.** `/15/…` 404s;
1322
1388
  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
  }