@nitida/sdk 0.31.0 → 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 +98 -32
- 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 +49 -16
- package/src/index.ts +45 -7
|
@@ -55,6 +55,12 @@ it; point new work at `api.nitida.gofuture.space` and move the rest whenever it
|
|
|
55
55
|
The URL builders read **process-global** config. Pin it where your helpers live (a `lib/nitida-images.ts`), so every Server/Client Component that imports a builder is configured:
|
|
56
56
|
|
|
57
57
|
```ts
|
|
58
|
+
// ℹ️ WHICH PACKAGE TO IMPORT FROM: both work.
|
|
59
|
+
// `@nitida/sdk` re-exports everything `@nitida/asset-client` has — including
|
|
60
|
+
// `setCdnBase` and `setTenantId` — so if you installed the SDK, import from
|
|
61
|
+
// `@nitida/sdk` and never name `asset-client` at all (it is there as a peer
|
|
62
|
+
// dependency). The examples below say `@nitida/asset-client` because this
|
|
63
|
+
// skill also serves consumers who use that package on its own, without the SDK.
|
|
58
64
|
import { setCdnBase, setTenantId } from "@nitida/asset-client";
|
|
59
65
|
|
|
60
66
|
const TENANT_ID = Number(process.env.NEXT_PUBLIC_NITIDA_TENANT_ID) || /* your id */ 0;
|
|
@@ -90,6 +96,11 @@ Output URL shape: `https://8ok.uk/t/format=webp,width=640/<sha16>.webp`.
|
|
|
90
96
|
|
|
91
97
|
⚠️ **Widths MUST be on the unsigned ladder** `TRANSFORM_WIDTHS` (`96,128,160,240,256,320,400,480,600,640,800,960,1080,1200,1280,1440,1600,1920,2560,3840` — 20 widths). 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.)
|
|
92
98
|
|
|
99
|
+
⚠️ **`getTransformSrcSet` is NOT protected by that type.** Its parameter is `number[]` on purpose —
|
|
100
|
+
a responsive ladder may legitimately carry DPR widths — so `[641, 999]` compiles clean and fails in
|
|
101
|
+
production, one 400 per candidate. There the check is yours:
|
|
102
|
+
`widths.every((w) => TRANSFORM_WIDTHS.includes(w as TransformWidth))`.
|
|
103
|
+
|
|
93
104
|
## 2b. A responsive gallery — the widths, with their measured weight
|
|
94
105
|
|
|
95
106
|
> Full page, with the reasoning: <https://nitida.gofuture.space/examples/responsive-gallery/>
|
|
@@ -100,7 +111,9 @@ Output URL shape: `https://8ok.uk/t/format=webp,width=640/<sha16>.webp`.
|
|
|
100
111
|
different bytes from the same photo.
|
|
101
112
|
|
|
102
113
|
```ts
|
|
103
|
-
//
|
|
114
|
+
// ✅ Both `setCdnBase` and `setTenantId` ARE re-exported by `@nitida/sdk`
|
|
115
|
+
// and `@nitida/sdk/server` (measured 2026-08-24 on 0.31.0). An older note here
|
|
116
|
+
// said `setCdnBase` was not — it is.
|
|
104
117
|
import { setCdnBase, type TransformWidth } from "@nitida/asset-client";
|
|
105
118
|
import { getTransformSrcSet, getTransformUrl } from "@nitida/sdk";
|
|
106
119
|
|
|
@@ -157,12 +170,10 @@ everyone, and the ladder buys you nothing.
|
|
|
157
170
|
- ⚠️ **`/t/` never upscales — the master is the ceiling.** Measured: a 2400 px
|
|
158
171
|
master asked for `width=2560` returns **2400 px**. Put widths in the `srcSet`
|
|
159
172
|
that your sources can sustain.
|
|
173
|
+
Asking for more is **not an error**: it is a smaller image than you think you
|
|
174
|
+
requested, which is worse than a failure because nothing tells you.
|
|
160
175
|
(There IS a paid, separate `POST /assets/:id/upscale` that genuinely enlarges
|
|
161
176
|
with a model. It is not the transform route and it is not free — §8g.)
|
|
162
|
-
- ⚠️ **There is no upscale — the master is the ceiling.** Measured: a 2400 px
|
|
163
|
-
master asked for `width=2560` returns **2400 px**. Put widths in the `srcSet`
|
|
164
|
-
that your sources can sustain — asking for more is not an error, it is a
|
|
165
|
-
smaller image than you think you requested.
|
|
166
177
|
- ⚠️ The **first** request of each width is generated cold, then cached at the
|
|
167
178
|
edge immutably. Warm them after upload if the first visitor matters.
|
|
168
179
|
|
|
@@ -173,7 +184,7 @@ everyone, and the ladder buys you nothing.
|
|
|
173
184
|
`/t/` short-circuits to the smallest **stored variant** that covers the request
|
|
174
185
|
— and that variant is already a WebP
|
|
175
186
|
that went through one lossy pass. **The short-circuit only fires when the
|
|
176
|
-
request carries an explicit numeric `quality`**, so
|
|
187
|
+
request carries an explicit numeric `quality`**, so **passing any explicit numeric `quality` at all** is
|
|
177
188
|
exactly what turns it on.
|
|
178
189
|
|
|
179
190
|
Measured at `width=800` on five photographs, same photo uploaded twice (full
|
|
@@ -278,9 +289,23 @@ If you are uploading to **archive** something (not just to serve it), three serv
|
|
|
278
289
|
// ↑ the UPLOADED file's extension, not one derived from the mime
|
|
279
290
|
```
|
|
280
291
|
|
|
281
|
-
|
|
292
|
+
✅ **Since `@nitida/sdk@0.30.0` the obvious call is the correct one.** Pass the
|
|
293
|
+
result of `upload()` — or a DTO from `assets.get(id)` — and `original` resolves
|
|
294
|
+
to the real extension:
|
|
282
295
|
|
|
283
|
-
|
|
296
|
+
```ts
|
|
297
|
+
const up = await nt.upload(file, { fileName, presets: ["original", "md"] });
|
|
298
|
+
getAssetUrl(up, "original"); // → -o.jpg, not -o.bin
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
⚠️ **The failure below is what happens when you hand it a BARE `{ sha }`** — no
|
|
302
|
+
`mime`, no `oext`, no variant list. `PRESET_EXT.original` is the literal
|
|
303
|
+
`"bin"`, so `getAssetUrl({ sha }, "original")` emits `-o.bin` while a PNG is
|
|
304
|
+
really stored at `-o.png` → 404. That is the only case left; it is not the
|
|
305
|
+
general one.
|
|
306
|
+
|
|
307
|
+
> **History, kept because the symptom still gets reported.** Before the
|
|
308
|
+
> 2026-08-17 deploy this happened even with a full DTO:
|
|
284
309
|
>
|
|
285
310
|
> ```ts
|
|
286
311
|
> const asset = await nt.assets.get(id);
|
|
@@ -351,12 +376,19 @@ Cleanup keeps the raw bytes of **any** sha referenced by an asset row, soft-dele
|
|
|
351
376
|
|
|
352
377
|
| what you call | what you get |
|
|
353
378
|
|---|---|
|
|
354
|
-
| `getAssetUrl(asset, "md")` | **
|
|
355
|
-
| `/t/…width=1280/<sha>.webp` | **200** —
|
|
356
|
-
| `hasPreset(asset, "lg")` | `false`, correctly |
|
|
379
|
+
| `getAssetUrl(asset, "md")` | **200** — since 2026-08-22 it returns the `/t/` URL and the edge generates from the raw |
|
|
380
|
+
| `/t/…width=1280/<sha>.webp` | **200** — the same thing, written by hand |
|
|
381
|
+
| `hasPreset(asset, "lg")` | `false`, correctly — the *variant* really is absent |
|
|
382
|
+
|
|
383
|
+
⚠️ **The incoherence this block described is fixed, and in the good direction.** For **image**
|
|
384
|
+
presets (`thumb·sm·md·lg·xl`) a missing preset does NOT 404: `getAssetUrl` returns the `/t/` route
|
|
385
|
+
and the edge generates from the raw. Measured today on an asset with `presets:"lmoqs"` —
|
|
386
|
+
`getAssetUrl(a,"xl")` → `/t/format=webp,width=3840/<sha>.webp` → **200 image/webp**, while the
|
|
387
|
+
variant key `-x.webp` does 404.
|
|
357
388
|
|
|
358
|
-
|
|
359
|
-
|
|
389
|
+
What stays true: `hasPreset` is still the truth about whether the **materialised variant** exists,
|
|
390
|
+
and the first request of each width pays a cold compute. And for **video/audio** presets
|
|
391
|
+
(`poster·video·aiproxy·hls·mp3·original`) there is **no fallback** — there, `hasPreset` first or 404.
|
|
360
392
|
|
|
361
393
|
**It is fixable at any time** with `regenerate({presets:[...]})`, which reads from the raw. No
|
|
362
394
|
window closes.
|
|
@@ -672,7 +704,8 @@ await nt.upload(file, { presets: ["original", "thumb", "md", "lg"] });
|
|
|
672
704
|
|---|---|---|
|
|
673
705
|
| `image/*` | ladder | WebP variants + palette + dimensions |
|
|
674
706
|
| `video/*` | background job | poster · mp4 · HLS · optional `aiproxy`/`probe` |
|
|
675
|
-
| `audio/*`
|
|
707
|
+
| `audio/*` **already playable everywhere** (`audio/mpeg`, `audio/mp4`, `audio/aac`) | none | original ONLY — `presets: "o"`. ⚠️ `getAssetUrl(a,"mp3")` **404s**: re-encoding an MP3 into an MP3 only makes it worse, so we stopped (2026-08-22). |
|
|
708
|
+
| `audio/*` anything else (`wav`, `webm`/opus, `flac`, …) | inline transcode | `mp3` variant (mono ~96 kbps) **+** untouched original — `presets: "mp3o"` |
|
|
676
709
|
| anything else | **passthrough** | raw bytes under `original`, `ready` immediately |
|
|
677
710
|
|
|
678
711
|
Passthrough covers PDF, xlsx, csv, zip, fonts, glb. You get storage, dedup and
|
|
@@ -735,7 +768,7 @@ job. Skip `aiproxy` when nothing reads it — it is an extra encode per video.
|
|
|
735
768
|
is `number`. Unsigned off-ladder = 400 at the edge (DoS guard). Sign
|
|
736
769
|
server-side.
|
|
737
770
|
- **Upscale** → `POST /assets/:id/upscale`. **A paid add-on, and the SDK does
|
|
738
|
-
not expose it on purpose** —
|
|
771
|
+
not expose it on purpose** — **€0.01–€0.40 per run depending on provider**, so it is never something a
|
|
739
772
|
helper should make easy to call in a loop. Call it over HTTP, deliberately.
|
|
740
773
|
|
|
741
774
|
```jsonc
|
|
@@ -750,7 +783,7 @@ job. Skip `aiproxy` when nothing reads it — it is an extra encode per video.
|
|
|
750
783
|
"enhanceRealism": true, // mode=enhance
|
|
751
784
|
"provider": "wavespeed-phota-enhance" // €0.09
|
|
752
785
|
| "replicate-p-image-upscale" // €0.01
|
|
753
|
-
| "wavespeed-clarity-flux-upscaler"
|
|
786
|
+
| "wavespeed-clarity-flux-upscaler" // €0.40 ⚠️ the priciest, 4× the default
|
|
754
787
|
}
|
|
755
788
|
```
|
|
756
789
|
|
package/src/index.ts
CHANGED
|
@@ -1713,12 +1713,40 @@ export class NitidaClient {
|
|
|
1713
1713
|
});
|
|
1714
1714
|
if (presign.deduped) {
|
|
1715
1715
|
const short = sha.slice(0, 16);
|
|
1716
|
+
// ⭐⭐ A DEDUP HIT ON A STILL-PROCESSING ASSET HAS TO WAIT, LIKE ANY OTHER
|
|
1717
|
+
//
|
|
1718
|
+
// `upload()` documents itself as "wait until the asset is ready", and
|
|
1719
|
+
// `opts.timeoutMs` as "max time to wait for the asset to transition to
|
|
1720
|
+
// `ready`". Both branches around this one honour that: `byHash` above
|
|
1721
|
+
// gates on `existing.status === "ready"`, and the happy path below calls
|
|
1722
|
+
// `waitReady`. This one returned IMMEDIATELY, whatever the status.
|
|
1723
|
+
//
|
|
1724
|
+
// Measured 2026-08-24: upload a video, wait 15 s, upload the same bytes
|
|
1725
|
+
// again. The second call resolved in 15.5 s — while the first was still
|
|
1726
|
+
// 85 s from done — and handed back `…-v.mp4`, which 404s for those 85
|
|
1727
|
+
// seconds. `status: "processing"`, `presets: ""`, `variants: []`.
|
|
1728
|
+
//
|
|
1729
|
+
// ⚠️ It is the FOURTH defect of one family: a result that hands you a URL
|
|
1730
|
+
// which 404s. The other three were missing FIELDS (`sha`, `mime`,
|
|
1731
|
+
// `oext`); this one is a missing WAIT. Same symptom, and the same
|
|
1732
|
+
// reason it survived — the comment right below used to say this branch
|
|
1733
|
+
// was unreachable by ordinary uploads, so nothing exercised it. A branch
|
|
1734
|
+
// nobody reaches is a branch nobody checks.
|
|
1735
|
+
//
|
|
1736
|
+
// Racing two uploads of the same bytes is not exotic: a retry after a
|
|
1737
|
+
// timeout does it, and so does any parallel importer.
|
|
1738
|
+
const settled =
|
|
1739
|
+
presign.asset.status === "ready"
|
|
1740
|
+
? presign.asset
|
|
1741
|
+
: await this.assets.waitReady(presign.asset.id, opts.timeoutMs);
|
|
1742
|
+
if (settled.status !== "ready")
|
|
1743
|
+
throw new Error(`upload: asset ended status=${settled.status}`);
|
|
1716
1744
|
return {
|
|
1717
|
-
assetId:
|
|
1745
|
+
assetId: settled.id,
|
|
1718
1746
|
sha256: sha,
|
|
1719
1747
|
sha: short,
|
|
1720
|
-
mime:
|
|
1721
|
-
oext:
|
|
1748
|
+
mime: settled.mime ?? mime,
|
|
1749
|
+
oext: settled.oext ?? null,
|
|
1722
1750
|
// ⭐ `{ ...asset, sha: short }`, never `asset` alone.
|
|
1723
1751
|
//
|
|
1724
1752
|
// The presign route answers a dedup hit with `sha256` and NO `sha` —
|
|
@@ -1735,14 +1763,24 @@ export class NitidaClient {
|
|
|
1735
1763
|
// latent defect loud.
|
|
1736
1764
|
//
|
|
1737
1765
|
// This branch is only reached when the asset EXISTS but is not
|
|
1738
|
-
// `ready` — the `byHash` branch above returns first otherwise
|
|
1739
|
-
//
|
|
1766
|
+
// `ready` — the `byHash` branch above returns first otherwise.
|
|
1767
|
+
//
|
|
1768
|
+
// That sentence used to end with "so ordinary uploads never touched it
|
|
1769
|
+
// and no test did either", and that was the whole problem: it read as
|
|
1770
|
+
// reassurance when it was the risk. Two callers uploading the same
|
|
1771
|
+
// bytes at once land here, and a retry after a timeout is exactly that.
|
|
1772
|
+
// Covered now by `every-upload-branch-waits-for-ready.test.ts`.
|
|
1740
1773
|
//
|
|
1741
1774
|
// The sha is not the DTO's to supply: we hashed the bytes ourselves at
|
|
1742
1775
|
// the top of this method. Build from what we KNOW.
|
|
1776
|
+
//
|
|
1777
|
+
// And the preset comes from `bestPresetForAsset` — what this asset
|
|
1778
|
+
// ACTUALLY has, now that we waited for it — not from
|
|
1779
|
+
// `defaultPresetForMime`, which is a guess made before anything exists.
|
|
1780
|
+
// Guessing was safe only while this branch never ran.
|
|
1743
1781
|
cdnUrl: this.urlFor(
|
|
1744
|
-
{ ...
|
|
1745
|
-
this.
|
|
1782
|
+
{ ...settled, sha: short },
|
|
1783
|
+
this.bestPresetForAsset(settled, mime),
|
|
1746
1784
|
),
|
|
1747
1785
|
};
|
|
1748
1786
|
}
|