@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 +131 -34
- 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
|
@@ -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/>
|
|
@@ -159,12 +170,10 @@ everyone, and the ladder buys you nothing.
|
|
|
159
170
|
- ⚠️ **`/t/` never upscales — the master is the ceiling.** Measured: a 2400 px
|
|
160
171
|
master asked for `width=2560` returns **2400 px**. Put widths in the `srcSet`
|
|
161
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.
|
|
162
175
|
(There IS a paid, separate `POST /assets/:id/upscale` that genuinely enlarges
|
|
163
176
|
with a model. It is not the transform route and it is not free — §8g.)
|
|
164
|
-
- ⚠️ **There is no upscale — the master is the ceiling.** Measured: a 2400 px
|
|
165
|
-
master asked for `width=2560` returns **2400 px**. Put widths in the `srcSet`
|
|
166
|
-
that your sources can sustain — asking for more is not an error, it is a
|
|
167
|
-
smaller image than you think you requested.
|
|
168
177
|
- ⚠️ The **first** request of each width is generated cold, then cached at the
|
|
169
178
|
edge immutably. Warm them after upload if the first visitor matters.
|
|
170
179
|
|
|
@@ -175,7 +184,7 @@ everyone, and the ladder buys you nothing.
|
|
|
175
184
|
`/t/` short-circuits to the smallest **stored variant** that covers the request
|
|
176
185
|
— and that variant is already a WebP
|
|
177
186
|
that went through one lossy pass. **The short-circuit only fires when the
|
|
178
|
-
request carries an explicit numeric `quality`**, so
|
|
187
|
+
request carries an explicit numeric `quality`**, so **passing any explicit numeric `quality` at all** is
|
|
179
188
|
exactly what turns it on.
|
|
180
189
|
|
|
181
190
|
Measured at `width=800` on five photographs, same photo uploaded twice (full
|
|
@@ -280,9 +289,23 @@ If you are uploading to **archive** something (not just to serve it), three serv
|
|
|
280
289
|
// ↑ the UPLOADED file's extension, not one derived from the mime
|
|
281
290
|
```
|
|
282
291
|
|
|
283
|
-
|
|
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:
|
|
284
295
|
|
|
285
|
-
|
|
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:
|
|
286
309
|
>
|
|
287
310
|
> ```ts
|
|
288
311
|
> const asset = await nt.assets.get(id);
|
|
@@ -353,12 +376,19 @@ Cleanup keeps the raw bytes of **any** sha referenced by an asset row, soft-dele
|
|
|
353
376
|
|
|
354
377
|
| what you call | what you get |
|
|
355
378
|
|---|---|
|
|
356
|
-
| `getAssetUrl(asset, "md")` | **
|
|
357
|
-
| `/t/…width=1280/<sha>.webp` | **200** —
|
|
358
|
-
| `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.
|
|
359
388
|
|
|
360
|
-
|
|
361
|
-
|
|
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.
|
|
362
392
|
|
|
363
393
|
**It is fixable at any time** with `regenerate({presets:[...]})`, which reads from the raw. No
|
|
364
394
|
window closes.
|
|
@@ -674,7 +704,8 @@ await nt.upload(file, { presets: ["original", "thumb", "md", "lg"] });
|
|
|
674
704
|
|---|---|---|
|
|
675
705
|
| `image/*` | ladder | WebP variants + palette + dimensions |
|
|
676
706
|
| `video/*` | background job | poster · mp4 · HLS · optional `aiproxy`/`probe` |
|
|
677
|
-
| `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"` |
|
|
678
709
|
| anything else | **passthrough** | raw bytes under `original`, `ready` immediately |
|
|
679
710
|
|
|
680
711
|
Passthrough covers PDF, xlsx, csv, zip, fonts, glb. You get storage, dedup and
|
|
@@ -737,7 +768,7 @@ job. Skip `aiproxy` when nothing reads it — it is an extra encode per video.
|
|
|
737
768
|
is `number`. Unsigned off-ladder = 400 at the edge (DoS guard). Sign
|
|
738
769
|
server-side.
|
|
739
770
|
- **Upscale** → `POST /assets/:id/upscale`. **A paid add-on, and the SDK does
|
|
740
|
-
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
|
|
741
772
|
helper should make easy to call in a loop. Call it over HTTP, deliberately.
|
|
742
773
|
|
|
743
774
|
```jsonc
|
|
@@ -752,7 +783,7 @@ job. Skip `aiproxy` when nothing reads it — it is an extra encode per video.
|
|
|
752
783
|
"enhanceRealism": true, // mode=enhance
|
|
753
784
|
"provider": "wavespeed-phota-enhance" // €0.09
|
|
754
785
|
| "replicate-p-image-upscale" // €0.01
|
|
755
|
-
| "wavespeed-clarity-flux-upscaler"
|
|
786
|
+
| "wavespeed-clarity-flux-upscaler" // €0.40 ⚠️ the priciest, 4× the default
|
|
756
787
|
}
|
|
757
788
|
```
|
|
758
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
|
}
|