@nitida/sdk 0.26.0 → 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
 
@@ -221,7 +221,7 @@ export function videoUrl(sha: string, tenantId = TENANT_ID): string {
221
221
  Output: `https://8ok.uk/<tenantId.toString(36)>/v/<sha16>-v.mp4`.
222
222
 
223
223
  **Do NOT:**
224
- - ❌ 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.)
225
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.
226
226
 
227
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.
@@ -384,7 +384,11 @@ hls.js, which cannot start there, and fall through to the MP4. The `|| !MediaSou
384
384
  covers it: native if the engine is Apple's **OR** if there is no MSE to fall back on.
385
385
 
386
386
  **When you use hls.js, stop it guessing** — the defaults are how a fast connection still opens at
387
- 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 }`.
388
392
 
389
393
  **The first request can answer `202`** while the background job builds the ladder (1–3 min for a
390
394
  90 s source), then `302`s to the cached master. Keep the progressive MP4 as the fallback `<source>`
@@ -398,7 +402,7 @@ What it does NOT do is prefer Apple's engine where both work: a modern iPhone ge
398
402
  hls.js tuning goes in the `config` prop:
399
403
 
400
404
  ```tsx
401
- const HLS_CONFIG = { capLevelToPlayerSize: false, startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 };
405
+ const HLS_CONFIG = { capLevelToPlayerSize: false, abrEwmaDefaultEstimate: 5_000_000 };
402
406
  <HlsVideo src={getHlsStreamingUrl(asset)} config={HLS_CONFIG} poster={poster} playsInline crossOrigin="anonymous" />
403
407
  ```
404
408
 
@@ -475,6 +479,68 @@ That replaces the older hand-run provisioning scripts and SQL that used to live
475
479
 
476
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`.
477
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
+
478
544
  ## 7. Browser-direct uploads — three things that only fail in a real browser
479
545
 
480
546
  Measured 2026-08-15 on the public bench at <https://media-harness.vercel.app> — no credentials
@@ -507,7 +573,7 @@ needed. Reproduce there before debugging any of these by hand.
507
573
  | Symptom | Cause |
508
574
  |---|---|
509
575
  | **400** on an image URL | width not on `TRANSFORM_WIDTHS` ladder |
510
- | **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')` |
511
577
  | **404** on a video URL | decimal tenant prefix (`/10/v/`) instead of base36 (`/a/v/`) — call `setTenantId` + `getAssetUrl`. Tenant 12 → `/c/v/` |
512
578
  | `process returned no assetId` | fixed in the 2026-08-16 deploy — you are on a server deploy older than that, §7.2 |
513
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 |
@@ -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/index.ts CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  type VariantEntryPreset,
62
62
  type VariantPreset,
63
63
  } from "@nitida/asset-client";
64
+ import { isUniversallyPlayableAudio } from "./audio-compat";
64
65
 
65
66
  // ---------------------------------------------------------------------------
66
67
  // Config
@@ -114,7 +115,9 @@ export type NitidaClientOptions = {
114
115
  * only when calling `aq.transform(asset, opts, { sign: true })`.
115
116
  *
116
117
  * 32 random bytes, generated server-side on tenant creation; fetch
117
- * 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
118
121
  * server-side only** — do not ship in `NEXT_PUBLIC_*` env vars. Sign
119
122
  * URLs from a BFF route handler, or pre-sign at build time.
120
123
  */
@@ -207,6 +210,7 @@ export type {
207
210
  PaletteSwatch,
208
211
  RequestablePreset,
209
212
  ResolveSlotOptions,
213
+ SignAccessOptions,
210
214
  SignedTransformOptions,
211
215
  SlotDTO,
212
216
  SlotResolution,
@@ -218,12 +222,16 @@ export type {
218
222
  TransformWidth,
219
223
  VariantEntryPreset,
220
224
  VariantPreset,
225
+ VisibilityHint,
221
226
  } from "@nitida/asset-client";
222
227
  export {
228
+ accessMessage,
229
+ assertPublic,
223
230
  bestTextContrast,
224
231
  computeVariantDimensions,
225
232
  configureSlotResolver,
226
233
  contrastRatio,
234
+ deriveAccessKey,
227
235
  extractAssetSha,
228
236
  getAmbientGradient,
229
237
  getAssetDimensions,
@@ -234,6 +242,8 @@ export {
234
242
  getHlsStreamingUrl,
235
243
  getPaletteBlurBackground,
236
244
  getPaletteCssVars,
245
+ getPrivateAssetUrl,
246
+ getPrivateTransformUrl,
237
247
  getSignedTransformUrl,
238
248
  getTenantId,
239
249
  getTextColorForBackground,
@@ -255,6 +265,7 @@ export {
255
265
  serializeTransform,
256
266
  setCdnBase,
257
267
  setTenantId,
268
+ signAccessUrl,
258
269
  signTransformUrl,
259
270
  TRANSFORM_WIDTHS,
260
271
  } from "@nitida/asset-client";
@@ -625,9 +636,10 @@ class AssetsApi {
625
636
  }
626
637
 
627
638
  /**
628
- * Add or rebuild variants on an existing asset. Image presets are
629
- * MERGED with what's there passing `{ presets: ["thumb"] }` adds
630
- * 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.
631
643
  *
632
644
  * // Day 0: upload original-only logo
633
645
  * const { assetId } = await aq.upload(logoFile); // defaults to ["original"]
@@ -646,10 +658,18 @@ class AssetsApi {
646
658
  * reading the source bytes from the permanent `original` variant —
647
659
  * no need to re-upload.
648
660
  *
649
- * Video presets are filtered to `["poster","video","aiproxy"]` and
650
- * 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
651
663
  * with a dispatch handle; poll `aq.assets.get(id).status` for
652
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.
653
673
  */
654
674
  async regenerate(
655
675
  assetId: string,
@@ -1158,6 +1178,8 @@ class UsageApi {
1158
1178
  }
1159
1179
  }
1160
1180
 
1181
+ export { isUniversallyPlayableAudio } from "./audio-compat";
1182
+
1161
1183
  export class NitidaClient {
1162
1184
  readonly slots: SlotsApi;
1163
1185
  readonly assets: AssetsApi;
@@ -1257,7 +1279,7 @@ export class NitidaClient {
1257
1279
  if (!this.opts.signingKey) {
1258
1280
  throw new Error(
1259
1281
  "aq.transform({ sign: true }) requires `signingKey` in NitidaClientOptions. " +
1260
- "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.",
1261
1283
  );
1262
1284
  }
1263
1285
  // Signed path — custom (off-ladder) widths allowed. Empty opts → no
@@ -1633,15 +1655,35 @@ export class NitidaClient {
1633
1655
  * `presets` string. Falls back through the preference order
1634
1656
  * lg → md → sm → thumb → original (for images)
1635
1657
  * video → poster (for videos)
1636
- * mp3original (for audio)
1658
+ * originalmp3 (for audio ALREADY playable everywhere)
1659
+ * mp3 → original (for any other audio)
1637
1660
  * so an upload that was processed with e.g. `["original"]` still
1638
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.
1639
1679
  */
1640
1680
  private bestPresetForAsset(asset: AssetDTO, mime: string): VariantPreset {
1641
1681
  const order: VariantPreset[] = mime.startsWith("video/")
1642
1682
  ? ["video", "poster"]
1643
1683
  : mime.startsWith("audio/")
1644
- ? ["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
1645
1687
  : ["lg", "md", "sm", "thumb", "xl", "original"];
1646
1688
  return (
1647
1689
  order.find((p) => hasPreset(asset, p)) ?? this.defaultPresetForMime(mime)
@@ -94,6 +94,8 @@ export {
94
94
  type AssetDTO,
95
95
  type AssetPalette,
96
96
  type AssetVariant,
97
+ accessMessage,
98
+ assertPublic,
97
99
  bestTextContrast,
98
100
  type ComposeMarketingComposition,
99
101
  type ComposeMarketingOptions,
@@ -103,6 +105,7 @@ export {
103
105
  computeVariantDimensions,
104
106
  configureSlotResolver,
105
107
  contrastRatio,
108
+ deriveAccessKey,
106
109
  extractAssetSha,
107
110
  getAmbientGradient,
108
111
  getAssetDimensions,
@@ -113,6 +116,8 @@ export {
113
116
  getHlsStreamingUrl,
114
117
  getPaletteBlurBackground,
115
118
  getPaletteCssVars,
119
+ getPrivateAssetUrl,
120
+ getPrivateTransformUrl,
116
121
  getSignedTransformUrl,
117
122
  getTenantId,
118
123
  getTextColorForBackground,
@@ -123,6 +128,7 @@ export {
123
128
  hasPreset,
124
129
  hlsLadderAlignment,
125
130
  invalidateSlotCache,
131
+ isUniversallyPlayableAudio,
126
132
  iteratePaletteSwatches,
127
133
  mimeFromFileName,
128
134
  type NitidaClientOptions,
@@ -139,6 +145,7 @@ export {
139
145
  relativeLuminance,
140
146
  resolveSlot,
141
147
  resolveSlots,
148
+ type SignAccessOptions,
142
149
  type SignedTransformOptions,
143
150
  type SlotDTO,
144
151
  type SlotHistoryEntry,
@@ -146,6 +153,7 @@ export {
146
153
  serializeTransform,
147
154
  setCdnBase,
148
155
  setTenantId,
156
+ signAccessUrl,
149
157
  signTransformUrl,
150
158
  TRANSFORM_WIDTHS,
151
159
  type TransformEffect,
@@ -164,4 +172,5 @@ export {
164
172
  type UsageWindow,
165
173
  type VariantEntryPreset,
166
174
  type VariantPreset,
175
+ type VisibilityHint,
167
176
  } from "..";
package/src/web/index.ts CHANGED
@@ -111,6 +111,8 @@ export {
111
111
  type AssetDTO,
112
112
  type AssetPalette,
113
113
  type AssetVariant,
114
+ accessMessage,
115
+ assertPublic,
114
116
  bestTextContrast,
115
117
  type ComposeMarketingComposition,
116
118
  type ComposeMarketingOptions,
@@ -120,6 +122,7 @@ export {
120
122
  computeVariantDimensions,
121
123
  configureSlotResolver,
122
124
  contrastRatio,
125
+ deriveAccessKey,
123
126
  extractAssetSha,
124
127
  getAmbientGradient,
125
128
  getAssetDimensions,
@@ -130,6 +133,8 @@ export {
130
133
  getHlsStreamingUrl,
131
134
  getPaletteBlurBackground,
132
135
  getPaletteCssVars,
136
+ getPrivateAssetUrl,
137
+ getPrivateTransformUrl,
133
138
  getSignedTransformUrl,
134
139
  getTenantId,
135
140
  getTextColorForBackground,
@@ -140,6 +145,7 @@ export {
140
145
  hasPreset,
141
146
  hlsLadderAlignment,
142
147
  invalidateSlotCache,
148
+ isUniversallyPlayableAudio,
143
149
  iteratePaletteSwatches,
144
150
  mimeFromFileName,
145
151
  type PaletteSwatch,
@@ -155,6 +161,7 @@ export {
155
161
  relativeLuminance,
156
162
  resolveSlot,
157
163
  resolveSlots,
164
+ type SignAccessOptions,
158
165
  type SignedTransformOptions,
159
166
  type SlotDTO,
160
167
  type SlotHistoryEntry,
@@ -162,6 +169,7 @@ export {
162
169
  serializeTransform,
163
170
  setCdnBase,
164
171
  setTenantId,
172
+ signAccessUrl,
165
173
  signTransformUrl,
166
174
  TRANSFORM_WIDTHS,
167
175
  type TransformEffect,
@@ -180,6 +188,7 @@ export {
180
188
  type UsageWindow,
181
189
  type VariantEntryPreset,
182
190
  type VariantPreset,
191
+ type VisibilityHint,
183
192
  } from "..";
184
193
 
185
194
  // Multipart uploader helpers are NOT re-exported from this subpath.