@nitida/sdk 0.25.3 → 0.27.0

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.
@@ -87,7 +87,7 @@ const COVER_CROP = { fit: "cover", gravity: "auto" } as const;
87
87
 
88
88
  Output URL shape: `https://8ok.uk/t/format=webp,width=640/<sha16>.webp`.
89
89
 
90
- ⚠️ **Widths MUST be on the unsigned ladder** `TRANSFORM_WIDTHS` (`160,240,256,320,400,480,600,640,800,960,1080,1200,1280,1440,1600,1920,2560,3840`). 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.)
90
+ ⚠️ **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.)
91
91
 
92
92
  ## 2b. A responsive gallery — the widths, with their measured weight
93
93
 
@@ -153,9 +153,11 @@ everyone, and the ladder buys you nothing.
153
153
  platform does not use it.
154
154
  - Grid cells: `{ fit: "cover", gravity: "auto" }` — verified to return an exact
155
155
  400×400. Lightbox: `fit: "inside"`, never crops.
156
- - ⚠️ **There is no upscale — the master is the ceiling.** Measured: a 2400 px
156
+ - ⚠️ **`/t/` never upscales — the master is the ceiling.** Measured: a 2400 px
157
157
  master asked for `width=2560` returns **2400 px**. Put widths in the `srcSet`
158
158
  that your sources can sustain.
159
+ (There IS a paid, separate `POST /assets/:id/upscale` that genuinely enlarges
160
+ with a model. It is not the transform route and it is not free — §8g.)
159
161
  - ⚠️ The **first** request of each width is generated cold, then cached at the
160
162
  edge immutably. Warm them after upload if the first visitor matters.
161
163
 
@@ -219,7 +221,7 @@ export function videoUrl(sha: string, tenantId = TENANT_ID): string {
219
221
  Output: `https://8ok.uk/<tenantId.toString(36)>/v/<sha16>-v.mp4`.
220
222
 
221
223
  **Do NOT:**
222
- - ❌ Use `getVideoTransformUrl` — that builds a `/t/...` transform URL, which **410s** for a stored video sha. (`getVideoTransformUrl` is for on-the-fly re-encodes, a different feature.)
224
+ - ❌ Use `getVideoTransformUrl` — that builds a `/t/...` transform URL, which never returns playable video. On a video sha `/t/` transforms the **poster frame**: `200 image/webp` with `x-transform-source: poster` when a poster exists, `410` when it does not. (`getVideoTransformUrl` is for on-the-fly re-encodes, a different feature.)
223
225
  - ❌ Hand-roll the path with the decimal tenant id. The path segment is **base36**: `tenantId.toString(36)`. **Tenant 10 → `/a/v/`**, and the decimal `/10/v/` **404s**. This is invisible for tenants ≤ 9 (`8`→`8`, `9`→`9`) and bit a real migration only at tenant 10. Always delegate to `getAssetUrl` so the encoding can't drift.
224
226
 
225
227
  **Audio IS supported (updated 2026-07-01 — verify against the SDK types, this used to say "not supported").** The platform recognizes `kind: "image" | "video" | "document" | "audio" | "other"` and ships an **`mp3`** variant preset (`VariantPreset` in `@nitida/asset-client`). Uploads are hash-deduped (byte-identical re-uploads return the existing sha — that's *byte* dedup, NOT semantic "find a similar track"). `nt.upload` also accepts an `audioTrack` on video-composition calls. Serve via the `mp3` preset / `original`. Confirm the current preset/kind list in `node_modules/@nitida/asset-client/dist/index.d.ts` before relying on a specific ext.
@@ -382,7 +384,11 @@ hls.js, which cannot start there, and fall through to the MP4. The `|| !MediaSou
382
384
  covers it: native if the engine is Apple's **OR** if there is no MSE to fall back on.
383
385
 
384
386
  **When you use hls.js, stop it guessing** — the defaults are how a fast connection still opens at
385
- 240p: `{ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 }`.
387
+ 240p. ⚠️ **Do NOT pass `startLevel: -1` together with `testBandwidth: true`** measured,
388
+ that pair is the *cause*: on a clip short enough to be one segment the bandwidth probe IS
389
+ the whole video, so it plays at the bottom rung start to finish (a 5.04 s 4K asset was
390
+ delivered at 426×240 because of it). Leave the start level unset and raise the estimate:
391
+ `{ abrEwmaDefaultEstimate: 5_000_000 }`.
386
392
 
387
393
  **The first request can answer `202`** while the background job builds the ladder (1–3 min for a
388
394
  90 s source), then `302`s to the cached master. Keep the progressive MP4 as the fallback `<source>`
@@ -396,7 +402,7 @@ What it does NOT do is prefer Apple's engine where both work: a modern iPhone ge
396
402
  hls.js tuning goes in the `config` prop:
397
403
 
398
404
  ```tsx
399
- const HLS_CONFIG = { capLevelToPlayerSize: false, startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 };
405
+ const HLS_CONFIG = { capLevelToPlayerSize: false, abrEwmaDefaultEstimate: 5_000_000 };
400
406
  <HlsVideo src={getHlsStreamingUrl(asset)} config={HLS_CONFIG} poster={poster} playsInline crossOrigin="anonymous" />
401
407
  ```
402
408
 
@@ -473,6 +479,68 @@ That replaces the older hand-run provisioning scripts and SQL that used to live
473
479
 
474
480
  Variant preset short codes: `thumb=q, sm=s, md=m, lg=l, xl=x, original=o, poster=p, video=v`. Exts: images `webp`, video `mp4`.
475
481
 
482
+
483
+ ## Private assets — `visibility`
484
+
485
+ Default is `"public"`. Set `private` and **every public door answers 404** —
486
+ stored variants, the raw original, the HLS ladder, and `/t/` (including the
487
+ poster frame of a private video). The bytes come back only through a signed URL
488
+ that expires.
489
+
490
+ ```ts
491
+ import { getPrivateAssetUrl, getPrivateTransformUrl } from "@nitida/sdk";
492
+ // ON YOUR BACKEND, once you decided this viewer may see it:
493
+ await getPrivateAssetUrl(asset, "lg", signingKey, { expiresInSeconds: 300 });
494
+ await getPrivateTransformUrl(asset, { width: 1280 }, signingKey, { expiresInSeconds: 300 });
495
+ ```
496
+
497
+ - **A 404 on a private asset is NOT a missing file.** Check `visibility` on the
498
+ DTO before you check storage. It is the number-one support question.
499
+ - **Seven URL builders throw** rather than hand you a doomed URL — `getAssetUrl`,
500
+ `getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`,
501
+ `getVideoTransformUrl`, `getHlsStreamingUrl`, `getSignedTransformUrl` — but
502
+ only when the value you pass carries `visibility`. `{ sha }` alone is never
503
+ refused.
504
+ - **`exp` is mandatory**; revocation is *"within a minute"* (60 s TTL at the edge).
505
+ - **The signing key is a backend secret** — it mints URLs for every private
506
+ asset the tenant owns.
507
+
508
+ ## Two ways an image gets smaller, and only one of them is yours to call
509
+
510
+ This is the question every programmatic caller gets wrong, so it is stated flat:
511
+
512
+ | | who does it | what it shrinks |
513
+ |---|---|---|
514
+ | **Client compressor** (browser / Expo) | the UI, before the PUT | the **upload**: quality 0.85, max 2880 px, WebP. iPhone 9.1 MB → 1.8 MB |
515
+ | **Variants + `/t/`** | the backend, on request | the **delivery**: 19 MB JPEG → 170 kB WebP at 1920 |
516
+
517
+ **The backend never recompresses the raw. Ever.** That is deliberate: the raw
518
+ has to stay pristine so variants are *regenerable* — the day you add AVIF or
519
+ raise the max dimension, the pipeline re-runs against it. A client→server lossy
520
+ chain bakes artifacts in forever.
521
+
522
+ So for an API/agent upload there is nothing to "turn on": you were never going
523
+ to compress the raw, and delivery is already optimised two ways.
524
+
525
+ ```ts
526
+ // Ask for the rungs you will actually render…
527
+ await aq.upload(file, { presets: ["original", "thumb", "md", "lg"] });
528
+
529
+ // …or upload bare and let the transform route make them on demand:
530
+ aq.transform(asset, { width: 1280, format: "webp" }); // → /t/…, cached after the first hit
531
+ ```
532
+
533
+ **`presets` defaults to `["original"]`** — a bare upload stores the raw and no
534
+ rendition. Since 2026-08-22 that is no longer a trap: when the DTO says a preset
535
+ was never materialised, `getAssetUrl` / `urlFor` **fall back to `/t/`** instead
536
+ of returning a URL that 404s. You still get optimised bytes; you just pay the
537
+ first encode. Prefer naming the presets when you know what you will render.
538
+
539
+ ⚠️ `getAssetSrcSet` deliberately does **not** fall back — a srcSet promises
540
+ pixel widths and a transform cannot keep that promise on a source smaller than
541
+ the rung, because the pipeline never enlarges. An empty srcSet degrades to `src`; a lying one
542
+ degrades to a wrong choice.
543
+
476
544
  ## 7. Browser-direct uploads — three things that only fail in a real browser
477
545
 
478
546
  Measured 2026-08-15 on the public bench at <https://media-harness.vercel.app> — no credentials
@@ -505,7 +573,7 @@ needed. Reproduce there before debugging any of these by hand.
505
573
  | Symptom | Cause |
506
574
  |---|---|
507
575
  | **400** on an image URL | width not on `TRANSFORM_WIDTHS` ladder |
508
- | **410** on a video URL | used `/t/` (transform) for a stored video — use `getAssetUrl(...,'video')` |
576
+ | **410** on a video `/t/` URL | that video has no `poster`. With one, `/t/` returns the poster as an image, never the video — use `getAssetUrl(...,'video')` |
509
577
  | **404** on a video URL | decimal tenant prefix (`/10/v/`) instead of base36 (`/a/v/`) — call `setTenantId` + `getAssetUrl`. Tenant 12 → `/c/v/` |
510
578
  | `process returned no assetId` | fixed in the 2026-08-16 deploy — you are on a server deploy older than that, §7.2 |
511
579
  | `waitReady timeout` on a video | a transcode + HLS ladder takes 1–2 min; the default `timeoutMs` is 5 min but a 4K source can beat it. Raise it, §7.2 |
@@ -601,9 +669,34 @@ job. Skip `aiproxy` when nothing reads it — it is an extra encode per video.
601
669
  `TransformOptions.width` is the ladder union; `SignedTransformOptions.width`
602
670
  is `number`. Unsigned off-ladder = 400 at the edge (DoS guard). Sign
603
671
  server-side.
604
- - **Upscale** → `POST /assets/:id/upscale`, idempotent on
605
- `(tenant, sha, preset, provider)`, async, with `/poll` as the missed-webhook
606
- fallback.
672
+ - **Upscale** → `POST /assets/:id/upscale`. **A paid add-on, and the SDK does
673
+ not expose it on purpose** — €0.10 a metered run, so it is never something a
674
+ helper should make easy to call in a loop. Call it over HTTP, deliberately.
675
+
676
+ ```jsonc
677
+ // POST /assets/:id/upscale Authorization: Bearer amk_mt_…
678
+ {
679
+ "mode": "target" | "factor" | "enhance", // REQUIRED
680
+ "targetMp": 1..128, // mode=target — megapixels to reach
681
+ "factor": 1..8, // mode=factor — multiplier
682
+ "outputFormat": "jpg" | "png" | "webp",
683
+ "outputQuality": 0..100,
684
+ "enhanceDetails": true, // mode=enhance
685
+ "enhanceRealism": true, // mode=enhance
686
+ "provider": "wavespeed-phota-enhance" // €0.09
687
+ | "replicate-p-image-upscale" // €0.01
688
+ | "wavespeed-clarity-flux-upscaler"
689
+ }
690
+ ```
691
+
692
+ `mode` is the only required field; `enhance` ignores `targetMp` and `factor`.
693
+ Idempotent on `(tenant, sha, preset, provider)`, async, with `/poll` as the
694
+ missed-webhook fallback.
695
+
696
+ ⚠️ **It needs an `amk_mt_*` key, not your runtime key.** Since 2026-08-21 a
697
+ runtime key carries no metered op and is refused with zero allowance — the
698
+ credential that can spend money is issued separately, rate-limited 60/min, and
699
+ revocable on its own. Ask for one; it is not part of the default triplet.
607
700
  - **Usage** → `nt.usage.snapshot() | timeseries(days) | keys()`. Wire `keys()`
608
701
  early: per-key attribution is the cheap answer to "what caused this spike".
609
702
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Which audio uploads already play everywhere — the single source of truth for
3
+ * both the server's transcode guard and the client's delivery-preset choice.
4
+ *
5
+ * It lives in its own module, rather than beside the preset order that uses it,
6
+ * for a mechanical reason: `apps/asset-manager` imports it by relative path.
7
+ * The SDK is a PUBLISHED package, so its `exports` point at `dist/`, and
8
+ * `dist/` is gitignored — it does not exist inside the Docker image, which
9
+ * copies `packages/` as source. A bare `@nitida/sdk` import type-checks on any
10
+ * machine that built the package once and then fails the image build. (It did,
11
+ * on 2026-08-22, with `@nitida/asset-client`.) A single small file keeps that
12
+ * relative import from dragging the whole client into the server.
13
+ *
14
+ * Two copies of this list drifting apart is how a platform ends up generating
15
+ * a variant its own client refuses to use — which is exactly the bug this
16
+ * predicate was introduced to end.
17
+ */
18
+
19
+ /**
20
+ * `true` when a browser can play these bytes as uploaded, so re-encoding them
21
+ * to MP3 buys nothing.
22
+ *
23
+ * - `audio/mpeg` — unambiguous.
24
+ * - `audio/mp4` / `audio/aac` — plays in Safari, Chrome, Firefox and Edge on
25
+ * desktop and mobile. The historical worry was old AOSP builds without
26
+ * proprietary codecs; the call to treat AAC as universal was made with the
27
+ * platform's one real AAC consumer, whose viewer is WebGL — a device that
28
+ * cannot decode AAC cannot run that product at all, so the fallback would
29
+ * only ever protect a device that had already lost.
30
+ *
31
+ * Deliberately NOT here: `audio/webm` and `audio/ogg` (Opus). iOS Safari
32
+ * cannot decode them, and that is the case the MP3 fallback exists for.
33
+ */
34
+ export function isUniversallyPlayableAudio(mime: string): boolean {
35
+ const base = (mime.split(";")[0] ?? "").trim().toLowerCase();
36
+ return base === "audio/mpeg" || base === "audio/mp4" || base === "audio/aac";
37
+ }
package/src/expo/index.ts CHANGED
@@ -26,6 +26,33 @@
26
26
  * const { assetId } = await upload.start();
27
27
  * await aq.slots.bind("storefront.tour.video", { assetId, preset: "video" });
28
28
  *
29
+ * ⚠️ THIS PATH PUTS A RUNTIME KEY ON THE DEVICE. Read before shipping.
30
+ *
31
+ * `createExpoUploader` reads `client.opts.apiKey` and hands it to the native
32
+ * session as its `authToken` (see below — it is four lines, and they are the
33
+ * whole story). The native uploader talks to the API directly, so it needs a
34
+ * credential the OS can replay hours later from a background task. There is no
35
+ * BFF in that loop to inject one.
36
+ *
37
+ * That is the opposite of what every other surface here does, and the opposite
38
+ * of what `@nitida/sdk/web` chose when it hit the SAME constraint: `/web`
39
+ * deliberately does NOT expose the multipart uploader, because it needs a raw
40
+ * `authToken` a BFF cannot supply. `/expo` exposes it anyway, because
41
+ * background-surviving uploads are the entire reason a native session exists.
42
+ *
43
+ * So, concretely, an `amk_rt_*` key in your app bundle is readable by anyone
44
+ * who unzips the IPA/APK, and it is a WRITE key to a paid platform.
45
+ *
46
+ * - Uploading big video in the background is worth it to you ⇒ use this, and
47
+ * scope the key to one tenant so a leak is contained and revocable.
48
+ * - It is not ⇒ build the client from `@nitida/sdk/web` pointed at your own
49
+ * route and call `aq.upload(file)`. No key on the device. You lose survival
50
+ * across backgrounding and OS kill; the upload dies with the JS thread.
51
+ *
52
+ * The real fix — a BFF-minted short-lived token the native session can carry —
53
+ * is not built. It is the same gap `/web` documents. Ask; there is no public
54
+ * tracker.
55
+ *
29
56
  * Peer dep: `@aquienpz/asset-uploader-expo` (lazy — apps that don't
30
57
  * use the mobile SDK skip the install).
31
58
  * @module @nitida/sdk/expo
@@ -44,9 +71,11 @@ export type ExpoUploadOptions = Omit<
44
71
 
45
72
  /**
46
73
  * Spawn a native-backed `UploadTask` bound to a configured client.
47
- * Inherits the client's endpoint / api key / tenant scope; caller only
48
- * has to supply the file input + any per-upload tuning (partSize,
49
- * concurrency).
74
+ *
75
+ * ⚠️ It reaches into the client for `apiKey` and uses it as the session's
76
+ * `authToken`, so the client you pass MUST have been built with a real runtime
77
+ * key — which means that key is on the device. See the module header for what
78
+ * that costs and what the alternative is.
50
79
  */
51
80
  export function createExpoUploader(
52
81
  client: NitidaClient,
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  getVideoTransformUrl,
49
49
  hasPreset,
50
50
  invalidateSlotCache,
51
+ type RequestablePreset,
51
52
  type ResolveSlotOptions,
52
53
  resolveSlot,
53
54
  resolveSlots,
@@ -57,8 +58,10 @@ import {
57
58
  setCdnBase,
58
59
  setTenantId,
59
60
  type TransformOptions,
61
+ type VariantEntryPreset,
60
62
  type VariantPreset,
61
63
  } from "@nitida/asset-client";
64
+ import { isUniversallyPlayableAudio } from "./audio-compat";
62
65
 
63
66
  // ---------------------------------------------------------------------------
64
67
  // Config
@@ -112,7 +115,9 @@ export type NitidaClientOptions = {
112
115
  * only when calling `aq.transform(asset, opts, { sign: true })`.
113
116
  *
114
117
  * 32 random bytes, generated server-side on tenant creation; fetch
115
- * via `GET /admin/tenants/:id` with an admin key. **Keep it
118
+ * via `POST /admin/projects/:code/rotate-signing-key`, which needs a
119
+ * SYSTEM-scope credential the platform operator holds — your own admin key
120
+ * answers `403 SYSTEM_KEY_REQUIRED`. Ask for it. **Keep it
116
121
  * server-side only** — do not ship in `NEXT_PUBLIC_*` env vars. Sign
117
122
  * URLs from a BFF route handler, or pre-sign at build time.
118
123
  */
@@ -203,7 +208,9 @@ export type {
203
208
  AssetVariant,
204
209
  HlsRung,
205
210
  PaletteSwatch,
211
+ RequestablePreset,
206
212
  ResolveSlotOptions,
213
+ SignAccessOptions,
207
214
  SignedTransformOptions,
208
215
  SlotDTO,
209
216
  SlotResolution,
@@ -213,13 +220,18 @@ export type {
213
220
  TransformGravity,
214
221
  TransformOptions,
215
222
  TransformWidth,
223
+ VariantEntryPreset,
216
224
  VariantPreset,
225
+ VisibilityHint,
217
226
  } from "@nitida/asset-client";
218
227
  export {
228
+ accessMessage,
229
+ assertPublic,
219
230
  bestTextContrast,
220
231
  computeVariantDimensions,
221
232
  configureSlotResolver,
222
233
  contrastRatio,
234
+ deriveAccessKey,
223
235
  extractAssetSha,
224
236
  getAmbientGradient,
225
237
  getAssetDimensions,
@@ -230,6 +242,8 @@ export {
230
242
  getHlsStreamingUrl,
231
243
  getPaletteBlurBackground,
232
244
  getPaletteCssVars,
245
+ getPrivateAssetUrl,
246
+ getPrivateTransformUrl,
233
247
  getSignedTransformUrl,
234
248
  getTenantId,
235
249
  getTextColorForBackground,
@@ -251,6 +265,7 @@ export {
251
265
  serializeTransform,
252
266
  setCdnBase,
253
267
  setTenantId,
268
+ signAccessUrl,
254
269
  signTransformUrl,
255
270
  TRANSFORM_WIDTHS,
256
271
  } from "@nitida/asset-client";
@@ -464,8 +479,11 @@ export type PresignUploadUrlOptions = {
464
479
  /**
465
480
  * Variant ladder to generate after `/assets/process`. Defaults to
466
481
  * `["original"]` server-side when omitted — same contract as `aq.upload`.
482
+ *
483
+ * {@link RequestablePreset}, not {@link VariantPreset}: `hls` and `mp3` are
484
+ * things a variant can BE, never things you can ask for, and asking is a 400.
467
485
  */
468
- presets?: VariantPreset[];
486
+ presets?: RequestablePreset[];
469
487
  /**
470
488
  * Pre-compression size of the source (useful when the browser ran
471
489
  * compressorjs / heic2any before computing `bytes`). Recorded
@@ -618,9 +636,10 @@ class AssetsApi {
618
636
  }
619
637
 
620
638
  /**
621
- * Add or rebuild variants on an existing asset. Image presets are
622
- * MERGED with what's there passing `{ presets: ["thumb"] }` adds
623
- * the thumb variant without touching `lg`, `sm`, `original`, etc.
639
+ * Add or rebuild variants on an existing asset. Presets are MERGED
640
+ * with what's there, for images AND for video passing
641
+ * `{ presets: ["thumb"] }` adds the thumb variant without touching
642
+ * `lg`, `sm`, `original`, etc.
624
643
  *
625
644
  * // Day 0: upload original-only logo
626
645
  * const { assetId } = await aq.upload(logoFile); // defaults to ["original"]
@@ -639,14 +658,22 @@ class AssetsApi {
639
658
  * reading the source bytes from the permanent `original` variant —
640
659
  * no need to re-upload.
641
660
  *
642
- * Video presets are filtered to `["poster","video","aiproxy"]` and
643
- * dispatched to a background job (the call returns immediately
661
+ * Video presets are filtered to `["poster","video","aiproxy","probe"]`
662
+ * and dispatched to a background job (the call returns immediately
644
663
  * with a dispatch handle; poll `aq.assets.get(id).status` for
645
664
  * completion).
665
+ *
666
+ * ⚠️ Before 2026-08-22 the video path REPLACED the whole variant
667
+ * registry instead of merging, so a partial regenerate silently
668
+ * deregistered `poster`, `video` and — irrecoverably — `hls`, which
669
+ * is not a {@link RequestablePreset} and therefore cannot be asked
670
+ * for again. The objects kept serving from the CDN; only the
671
+ * registry died. Fixed server-side; a client on an older server
672
+ * still loses them.
646
673
  */
647
674
  async regenerate(
648
675
  assetId: string,
649
- opts: { presets?: VariantPreset[] } = {},
676
+ opts: { presets?: RequestablePreset[] } = {},
650
677
  ): Promise<RegenerateResult> {
651
678
  const r = await fetch(
652
679
  endpointHref(this.opts, `/assets/${assetId}/regenerate`),
@@ -945,8 +972,12 @@ export type UploadOptions = {
945
972
  * Idempotent: you can always add missing variants later via
946
973
  * `aq.assets.regenerate(id, { presets: [...] })`. The platform
947
974
  * stores the source so regeneration doesn't require re-uploading.
975
+ *
976
+ * {@link RequestablePreset}, not {@link VariantPreset}. `hls` and `mp3` are
977
+ * produced FOR you — the ladder when a video transcodes, the mp3 alongside
978
+ * any audio original — and asking for either is a 400.
948
979
  */
949
- presets?: VariantPreset[];
980
+ presets?: RequestablePreset[];
950
981
  /**
951
982
  * Max time to wait for the asset to transition to `ready` (or `failed`)
952
983
  * after dispatch. Default `5 * 60_000` (5 min). Bump higher for large
@@ -973,8 +1004,16 @@ export type UploadOptions = {
973
1004
  video?: UploadVideoOptions;
974
1005
  };
975
1006
 
976
- /** Default preset set the SDK sends to `/assets/upload-url` when the caller omits `presets`. */
977
- const DEFAULT_UPLOAD_PRESETS: VariantPreset[] = ["original"];
1007
+ /**
1008
+ * Default preset set the SDK sends to `/assets/upload-url` when the caller
1009
+ * omits `presets`.
1010
+ *
1011
+ * Typed `RequestablePreset[]`, not `VariantPreset[]` — it is a REQUEST. The
1012
+ * distinction caught this very line the moment it was introduced: it was the
1013
+ * wrong type here, and a `VariantPreset[]` default could have carried `hls`
1014
+ * into a request that answers 400.
1015
+ */
1016
+ const DEFAULT_UPLOAD_PRESETS: RequestablePreset[] = ["original"];
978
1017
 
979
1018
  export type UploadResult = {
980
1019
  assetId: string;
@@ -1139,6 +1178,8 @@ class UsageApi {
1139
1178
  }
1140
1179
  }
1141
1180
 
1181
+ export { isUniversallyPlayableAudio } from "./audio-compat";
1182
+
1142
1183
  export class NitidaClient {
1143
1184
  readonly slots: SlotsApi;
1144
1185
  readonly assets: AssetsApi;
@@ -1238,7 +1279,7 @@ export class NitidaClient {
1238
1279
  if (!this.opts.signingKey) {
1239
1280
  throw new Error(
1240
1281
  "aq.transform({ sign: true }) requires `signingKey` in NitidaClientOptions. " +
1241
- "Pull the tenant's signing key from /admin/tenants/:id and pass it to the SDK constructor on a SERVER-side instance only.",
1282
+ "No signingKey on this client. The key is minted by POST /admin/projects/:code/rotate-signing-key, which needs a system-scope credential the platform operator holds — your own admin key gets 403 SYSTEM_KEY_REQUIRED, so ask for it. Then pass it to the SDK constructor on a SERVER-side instance only.",
1242
1283
  );
1243
1284
  }
1244
1285
  // Signed path — custom (off-ladder) widths allowed. Empty opts → no
@@ -1614,15 +1655,35 @@ export class NitidaClient {
1614
1655
  * `presets` string. Falls back through the preference order
1615
1656
  * lg → md → sm → thumb → original (for images)
1616
1657
  * video → poster (for videos)
1617
- * mp3original (for audio)
1658
+ * originalmp3 (for audio ALREADY playable everywhere)
1659
+ * mp3 → original (for any other audio)
1618
1660
  * so an upload that was processed with e.g. `["original"]` still
1619
1661
  * returns a non-404 URL in `aq.upload`'s result.
1662
+ *
1663
+ * ⭐ Why audio branches on the source mime (changed 2026-08-22).
1664
+ *
1665
+ * It used to be `mp3 → original` unconditionally, so `upload().cdnUrl`
1666
+ * handed back the server's auto-generated mp3 — libmp3lame, mono ~96 kbps —
1667
+ * even when the caller had uploaded an MP3 or an AAC that already plays in
1668
+ * every target browser. A consumer asking for "my file" silently received a
1669
+ * re-encoded, lower-quality one, with no error to notice.
1670
+ *
1671
+ * Measured across the platform: of 126 audio assets carrying an mp3 variant,
1672
+ * **105 had an `audio/mpeg` source** — an MP3 re-encoded into an MP3, for
1673
+ * zero compatibility gain.
1674
+ *
1675
+ * The mp3 still wins for `audio/webm`/Opus and anything exotic, which is the
1676
+ * case it was built for: Chrome records webm/Opus, which iOS Safari cannot
1677
+ * decode. That guarantee is preserved exactly; only the needless downgrade
1678
+ * is gone.
1620
1679
  */
1621
1680
  private bestPresetForAsset(asset: AssetDTO, mime: string): VariantPreset {
1622
1681
  const order: VariantPreset[] = mime.startsWith("video/")
1623
1682
  ? ["video", "poster"]
1624
1683
  : mime.startsWith("audio/")
1625
- ? ["mp3", "original"] // prefer the cross-browser mp3, else the playable original; never image presets
1684
+ ? isUniversallyPlayableAudio(mime)
1685
+ ? ["original", "mp3"] // the upload already plays everywhere — don't hand back a re-encode
1686
+ : ["mp3", "original"] // exotic codec: the cross-browser mp3 earns its place
1626
1687
  : ["lg", "md", "sm", "thumb", "xl", "original"];
1627
1688
  return (
1628
1689
  order.find((p) => hasPreset(asset, p)) ?? this.defaultPresetForMime(mime)
@@ -75,49 +75,102 @@ export class NitidaClient extends BaseNitidaClient {
75
75
  }
76
76
  }
77
77
 
78
+ // ---------------------------------------------------------------------------
79
+ // This subpath MIRRORS THE ROOT. Every export of `@nitida/sdk` is here.
80
+ //
81
+ // It is a COMPLETE entry point, not an additive module — a Node consumer is
82
+ // told to import from here and must never have to reach past it. It did:
83
+ // `getHlsLadder` was on the root and missing here, an example in the docs
84
+ // imported it from `/server`, and an agent evaluating the SDK got a
85
+ // SyntaxError at RUNTIME. Measured on 2026-08-21, this list was short by 28
86
+ // of the root's 72 — the whole palette family, every slot helper, the HLS
87
+ // ladder helpers and the preset constants.
88
+ //
89
+ // The completeness is now ASSERTED by scripts/check-published-doc-symbols.ts,
90
+ // which also owns the deny list: an omission has to be justified there, in
91
+ // writing, or the build fails. Do not hand-edit this list to be shorter.
92
+ // ---------------------------------------------------------------------------
78
93
  export {
79
94
  type AssetDTO,
95
+ type AssetPalette,
80
96
  type AssetVariant,
97
+ accessMessage,
98
+ assertPublic,
99
+ bestTextContrast,
81
100
  type ComposeMarketingComposition,
82
101
  type ComposeMarketingOptions,
83
102
  type ComposeMarketingResult,
84
103
  type ComposeMarketingSegment,
85
104
  type CompressOptions,
86
- // URL builders & related types (re-export from asset-client via root).
87
105
  computeVariantDimensions,
106
+ configureSlotResolver,
107
+ contrastRatio,
108
+ deriveAccessKey,
88
109
  extractAssetSha,
110
+ getAmbientGradient,
89
111
  getAssetDimensions,
90
112
  getAssetSrcSet,
91
113
  getAssetUrl,
114
+ getCdnBase,
115
+ getHlsLadder,
92
116
  getHlsStreamingUrl,
117
+ getPaletteBlurBackground,
118
+ getPaletteCssVars,
119
+ getPrivateAssetUrl,
120
+ getPrivateTransformUrl,
93
121
  getSignedTransformUrl,
94
122
  getTenantId,
123
+ getTextColorForBackground,
95
124
  getTransformSrcSet,
96
125
  getTransformUrl,
97
126
  getVideoTransformUrl,
127
+ type HlsRung,
98
128
  hasPreset,
129
+ hlsLadderAlignment,
130
+ invalidateSlotCache,
131
+ isUniversallyPlayableAudio,
132
+ iteratePaletteSwatches,
133
+ mimeFromFileName,
99
134
  type NitidaClientOptions,
135
+ type PaletteSwatch,
136
+ PRESET_EXT,
137
+ PRESET_LONG,
138
+ PRESET_MAX_DIM,
139
+ PRESET_SHORT,
100
140
  type PresignUploadUrlOptions,
141
+ pickAmbientBackground,
101
142
  type RegenerateResult,
143
+ type RequestablePreset,
102
144
  type ResolveSlotOptions,
145
+ relativeLuminance,
146
+ resolveSlot,
147
+ resolveSlots,
148
+ type SignAccessOptions,
103
149
  type SignedTransformOptions,
104
150
  type SlotDTO,
105
151
  type SlotHistoryEntry,
106
152
  type SlotResolution,
107
153
  serializeTransform,
154
+ setCdnBase,
108
155
  setTenantId,
156
+ signAccessUrl,
109
157
  signTransformUrl,
158
+ TRANSFORM_WIDTHS,
110
159
  type TransformEffect,
111
160
  type TransformFit,
112
161
  type TransformFormat,
113
162
  type TransformGravity,
114
163
  type TransformOptions,
164
+ type TransformWidth,
115
165
  type UploadOptions,
116
166
  type UploadResult,
117
167
  type UploadUrlResult,
168
+ type UploadVideoOptions,
118
169
  type UsageDailyPoint,
119
170
  type UsagePerKey,
120
171
  type UsageSnapshot,
121
172
  type UsageWindow,
173
+ type VariantEntryPreset,
122
174
  type VariantPreset,
175
+ type VisibilityHint,
123
176
  } from "..";