@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 +97 -31
- package/dist/index.js +20 -7
- package/dist/index.js.map +1 -1
- package/dist/server.js +20 -7
- package/dist/server.js.map +1 -1
- package/dist/web.js +20 -7
- package/dist/web.js.map +1 -1
- package/package.json +2 -2
- package/skills/nitida-sdk/SKILL.md +46 -15
- package/src/index.ts +45 -7
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, //
|
|
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/
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
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,
|
|
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
|
-
>
|
|
851
|
-
>
|
|
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(
|
|
1110
|
-
|
|
1111
|
-
width: 1200,
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
// → https://8ok.uk/t/effect=genfill,height=630,width=1200/<sha>.
|
|
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(
|
|
1118
|
-
|
|
1119
|
-
width: 1080,
|
|
1120
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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)
|
|
1281
|
-
base64 LQIP
|
|
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
|
|
1319
|
-
sha
|
|
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:
|
|
801
|
+
assetId: settled.id,
|
|
799
802
|
sha256: sha,
|
|
800
803
|
sha: short,
|
|
801
|
-
mime:
|
|
802
|
-
oext:
|
|
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
|
|
820
|
-
//
|
|
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
|
-
{ ...
|
|
826
|
-
this.
|
|
838
|
+
{ ...settled, sha: short },
|
|
839
|
+
this.bestPresetForAsset(settled, mime)
|
|
827
840
|
)
|
|
828
841
|
};
|
|
829
842
|
}
|