@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.
@@ -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 the `quality: 75` above is
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
- The client's `PRESET_EXT.original` is the literal `"bin"`, so `getAssetUrl({sha}, "original")` **without `mime`** emits `-o.bin` while a PNG is really stored at `-o.png` → 404, always.
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
- > ✅ **Fixed in the 2026-08-17 deploy. Pass the whole DTO and it is simply right:**
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")` | **404** — that variant was never generated |
357
- | `/t/…width=1280/<sha>.webp` | **200** — generated on the fly from the raw |
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
- One API tells you it does not exist and the other hands it to you. **That** is the expensive part —
361
- not the loss, the incoherence plus a cold compute the first time anyone asks for each size.
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/*` | inline transcode | `mp3` variant (mono ~96 kbps) **+** untouched original |
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** — 0.10 a metered run, so it is never something a
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: presign.asset.id,
1745
+ assetId: settled.id,
1718
1746
  sha256: sha,
1719
1747
  sha: short,
1720
- mime: presign.asset.mime ?? mime,
1721
- oext: presign.asset.oext ?? null,
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 — so
1739
- // ordinary uploads never touched it and no test did either.
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
- { ...presign.asset, sha: short },
1745
- this.defaultPresetForMime(mime),
1782
+ { ...settled, sha: short },
1783
+ this.bestPresetForAsset(settled, mime),
1746
1784
  ),
1747
1785
  };
1748
1786
  }