@nitida/sdk 0.26.0 → 0.27.1
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/AGENTS.md +71 -0
- package/README.md +63 -2
- package/dist/index.d.ts +89 -9
- package/dist/index.js +61 -8
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +61 -8
- package/dist/server.js.map +1 -1
- package/dist/web.d.ts +2 -2
- package/dist/web.js +61 -8
- package/dist/web.js.map +1 -1
- package/package.json +2 -2
- package/skills/nitida-sdk/SKILL.md +80 -5
- package/src/audio-compat.ts +37 -0
- package/src/index.ts +71 -9
- package/src/server/index.ts +10 -0
- package/src/web/index.ts +10 -0
|
@@ -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 **
|
|
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
|
|
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,
|
|
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,77 @@ 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 says `visibility: "private"`. A DTO that says
|
|
503
|
+
`"public"`, or a bare `{ sha }`, is never refused — and this is a DIFFERENT
|
|
504
|
+
rule from the missing-preset fallback below, which is about presets, not
|
|
505
|
+
privacy.
|
|
506
|
+
- **`exp` is mandatory**; revocation is *"within a minute"* (60 s TTL at the edge).
|
|
507
|
+
- **The signing key is a backend secret** — it mints URLs for every private
|
|
508
|
+
asset the tenant owns.
|
|
509
|
+
|
|
510
|
+
## Two ways an image gets smaller, and only one of them is yours to call
|
|
511
|
+
|
|
512
|
+
This is the question every programmatic caller gets wrong, so it is stated flat:
|
|
513
|
+
|
|
514
|
+
| | who does it | what it shrinks |
|
|
515
|
+
|---|---|---|
|
|
516
|
+
| **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 |
|
|
517
|
+
| **Variants + `/t/`** | the backend, on request | the **delivery**: 19 MB JPEG → 170 kB WebP at 1920 |
|
|
518
|
+
|
|
519
|
+
**The backend never recompresses the raw. Ever.** That is deliberate: the raw
|
|
520
|
+
has to stay pristine so variants are *regenerable* — the day you add AVIF or
|
|
521
|
+
raise the max dimension, the pipeline re-runs against it. A client→server lossy
|
|
522
|
+
chain bakes artifacts in forever.
|
|
523
|
+
|
|
524
|
+
So for an API/agent upload there is nothing to "turn on": you were never going
|
|
525
|
+
to compress the raw, and delivery is already optimised two ways.
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
await aq.upload(file);
|
|
529
|
+
aq.transform(asset, { width: 1280, format: "webp" }); // → /t/…, generated once, cached after
|
|
530
|
+
|
|
531
|
+
// Only for a rung you KNOW will be rendered over and over:
|
|
532
|
+
await aq.upload(file, { presets: ["original", "thumb"] });
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
**`presets` defaults to `["original"]`** — a bare upload stores the raw and no
|
|
536
|
+
rendition. Since 2026-08-22 that is no longer a trap: when the DTO says a preset
|
|
537
|
+
was never materialised, `getAssetUrl` / `urlFor` **fall back to `/t/`** instead
|
|
538
|
+
of returning a URL that 404s. You still get optimised bytes; you just pay the
|
|
539
|
+
first encode.
|
|
540
|
+
|
|
541
|
+
**On demand is the default, and it is the right one.** A variant exists because
|
|
542
|
+
somebody asked for it; we do not manufacture sizes on the chance that they might
|
|
543
|
+
be wanted. Name presets at upload only for a rung you *know* will be rendered
|
|
544
|
+
over and over — a card thumbnail on every listing page — where paying the encode
|
|
545
|
+
once up front beats paying it once lazily. For everything else, upload bare and
|
|
546
|
+
let `/t/` do it.
|
|
547
|
+
|
|
548
|
+
⚠️ `getAssetSrcSet` deliberately does **not** fall back — a srcSet promises
|
|
549
|
+
pixel widths and a transform cannot keep that promise on a source smaller than
|
|
550
|
+
the rung, because the pipeline never enlarges. An empty srcSet degrades to `src`; a lying one
|
|
551
|
+
degrades to a wrong choice.
|
|
552
|
+
|
|
478
553
|
## 7. Browser-direct uploads — three things that only fail in a real browser
|
|
479
554
|
|
|
480
555
|
Measured 2026-08-15 on the public bench at <https://media-harness.vercel.app> — no credentials
|
|
@@ -507,7 +582,7 @@ needed. Reproduce there before debugging any of these by hand.
|
|
|
507
582
|
| Symptom | Cause |
|
|
508
583
|
|---|---|
|
|
509
584
|
| **400** on an image URL | width not on `TRANSFORM_WIDTHS` ladder |
|
|
510
|
-
| **410** on a video URL |
|
|
585
|
+
| **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
586
|
| **404** on a video URL | decimal tenant prefix (`/10/v/`) instead of base36 (`/a/v/`) — call `setTenantId` + `getAssetUrl`. Tenant 12 → `/c/v/` |
|
|
512
587
|
| `process returned no assetId` | fixed in the 2026-08-16 deploy — you are on a server deploy older than that, §7.2 |
|
|
513
588
|
| `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 `
|
|
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,17 @@ 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,
|
|
230
|
+
assertSha,
|
|
223
231
|
bestTextContrast,
|
|
224
232
|
computeVariantDimensions,
|
|
225
233
|
configureSlotResolver,
|
|
226
234
|
contrastRatio,
|
|
235
|
+
deriveAccessKey,
|
|
227
236
|
extractAssetSha,
|
|
228
237
|
getAmbientGradient,
|
|
229
238
|
getAssetDimensions,
|
|
@@ -234,6 +243,8 @@ export {
|
|
|
234
243
|
getHlsStreamingUrl,
|
|
235
244
|
getPaletteBlurBackground,
|
|
236
245
|
getPaletteCssVars,
|
|
246
|
+
getPrivateAssetUrl,
|
|
247
|
+
getPrivateTransformUrl,
|
|
237
248
|
getSignedTransformUrl,
|
|
238
249
|
getTenantId,
|
|
239
250
|
getTextColorForBackground,
|
|
@@ -255,6 +266,7 @@ export {
|
|
|
255
266
|
serializeTransform,
|
|
256
267
|
setCdnBase,
|
|
257
268
|
setTenantId,
|
|
269
|
+
signAccessUrl,
|
|
258
270
|
signTransformUrl,
|
|
259
271
|
TRANSFORM_WIDTHS,
|
|
260
272
|
} from "@nitida/asset-client";
|
|
@@ -625,9 +637,10 @@ class AssetsApi {
|
|
|
625
637
|
}
|
|
626
638
|
|
|
627
639
|
/**
|
|
628
|
-
* Add or rebuild variants on an existing asset.
|
|
629
|
-
*
|
|
630
|
-
* the thumb variant without touching
|
|
640
|
+
* Add or rebuild variants on an existing asset. Presets are MERGED
|
|
641
|
+
* with what's there, for images AND for video — passing
|
|
642
|
+
* `{ presets: ["thumb"] }` adds the thumb variant without touching
|
|
643
|
+
* `lg`, `sm`, `original`, etc.
|
|
631
644
|
*
|
|
632
645
|
* // Day 0: upload original-only logo
|
|
633
646
|
* const { assetId } = await aq.upload(logoFile); // defaults to ["original"]
|
|
@@ -646,10 +659,18 @@ class AssetsApi {
|
|
|
646
659
|
* reading the source bytes from the permanent `original` variant —
|
|
647
660
|
* no need to re-upload.
|
|
648
661
|
*
|
|
649
|
-
* Video presets are filtered to `["poster","video","aiproxy"]`
|
|
650
|
-
* dispatched to a background job (the call returns immediately
|
|
662
|
+
* Video presets are filtered to `["poster","video","aiproxy","probe"]`
|
|
663
|
+
* and dispatched to a background job (the call returns immediately
|
|
651
664
|
* with a dispatch handle; poll `aq.assets.get(id).status` for
|
|
652
665
|
* completion).
|
|
666
|
+
*
|
|
667
|
+
* ⚠️ Before 2026-08-22 the video path REPLACED the whole variant
|
|
668
|
+
* registry instead of merging, so a partial regenerate silently
|
|
669
|
+
* deregistered `poster`, `video` and — irrecoverably — `hls`, which
|
|
670
|
+
* is not a {@link RequestablePreset} and therefore cannot be asked
|
|
671
|
+
* for again. The objects kept serving from the CDN; only the
|
|
672
|
+
* registry died. Fixed server-side; a client on an older server
|
|
673
|
+
* still loses them.
|
|
653
674
|
*/
|
|
654
675
|
async regenerate(
|
|
655
676
|
assetId: string,
|
|
@@ -998,6 +1019,22 @@ const DEFAULT_UPLOAD_PRESETS: RequestablePreset[] = ["original"];
|
|
|
998
1019
|
export type UploadResult = {
|
|
999
1020
|
assetId: string;
|
|
1000
1021
|
sha256: string;
|
|
1022
|
+
/**
|
|
1023
|
+
* The SAME 16-hex prefix an `AssetDTO` carries, so the result of an upload can
|
|
1024
|
+
* be handed straight to any URL builder.
|
|
1025
|
+
*
|
|
1026
|
+
* ⭐ It exists because it did not, and that cost a real 404. A Haiku agent
|
|
1027
|
+
* evaluating the SDK on 2026-08-23 did the most natural thing there is —
|
|
1028
|
+
* `transform(await upload(file), { width: 1280 })` — and got
|
|
1029
|
+
* `https://8ok.uk/t/width=1280/undefined.webp`. The builders read `sha`; this
|
|
1030
|
+
* type only had `sha256`. TypeScript caught it; running through `bun`, or in
|
|
1031
|
+
* plain JS, nothing did.
|
|
1032
|
+
*
|
|
1033
|
+
* The guard (`assertSha`) is the backstop. This field is the actual fix: the
|
|
1034
|
+
* obvious call is now the correct one, which is worth more than a good error
|
|
1035
|
+
* message about the wrong one.
|
|
1036
|
+
*/
|
|
1037
|
+
sha: string;
|
|
1001
1038
|
cdnUrl: string;
|
|
1002
1039
|
};
|
|
1003
1040
|
|
|
@@ -1158,6 +1195,8 @@ class UsageApi {
|
|
|
1158
1195
|
}
|
|
1159
1196
|
}
|
|
1160
1197
|
|
|
1198
|
+
export { isUniversallyPlayableAudio } from "./audio-compat";
|
|
1199
|
+
|
|
1161
1200
|
export class NitidaClient {
|
|
1162
1201
|
readonly slots: SlotsApi;
|
|
1163
1202
|
readonly assets: AssetsApi;
|
|
@@ -1257,7 +1296,7 @@ export class NitidaClient {
|
|
|
1257
1296
|
if (!this.opts.signingKey) {
|
|
1258
1297
|
throw new Error(
|
|
1259
1298
|
"aq.transform({ sign: true }) requires `signingKey` in NitidaClientOptions. " +
|
|
1260
|
-
"
|
|
1299
|
+
"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
1300
|
);
|
|
1262
1301
|
}
|
|
1263
1302
|
// Signed path — custom (off-ladder) widths allowed. Empty opts → no
|
|
@@ -1541,6 +1580,7 @@ export class NitidaClient {
|
|
|
1541
1580
|
return {
|
|
1542
1581
|
assetId: existing.id,
|
|
1543
1582
|
sha256: sha,
|
|
1583
|
+
sha: sha.slice(0, 16),
|
|
1544
1584
|
cdnUrl: this.urlFor(existing, this.bestPresetForAsset(existing, mime)),
|
|
1545
1585
|
};
|
|
1546
1586
|
}
|
|
@@ -1566,6 +1606,7 @@ export class NitidaClient {
|
|
|
1566
1606
|
return {
|
|
1567
1607
|
assetId: presign.asset.id,
|
|
1568
1608
|
sha256: sha,
|
|
1609
|
+
sha: sha.slice(0, 16),
|
|
1569
1610
|
cdnUrl: this.urlFor(presign.asset, this.defaultPresetForMime(mime)),
|
|
1570
1611
|
};
|
|
1571
1612
|
}
|
|
@@ -1616,6 +1657,7 @@ export class NitidaClient {
|
|
|
1616
1657
|
return {
|
|
1617
1658
|
assetId,
|
|
1618
1659
|
sha256: sha,
|
|
1660
|
+
sha: sha.slice(0, 16),
|
|
1619
1661
|
cdnUrl: this.urlFor(final, this.bestPresetForAsset(final, mime)),
|
|
1620
1662
|
};
|
|
1621
1663
|
}
|
|
@@ -1633,15 +1675,35 @@ export class NitidaClient {
|
|
|
1633
1675
|
* `presets` string. Falls back through the preference order
|
|
1634
1676
|
* lg → md → sm → thumb → original (for images)
|
|
1635
1677
|
* video → poster (for videos)
|
|
1636
|
-
*
|
|
1678
|
+
* original → mp3 (for audio ALREADY playable everywhere)
|
|
1679
|
+
* mp3 → original (for any other audio)
|
|
1637
1680
|
* so an upload that was processed with e.g. `["original"]` still
|
|
1638
1681
|
* returns a non-404 URL in `aq.upload`'s result.
|
|
1682
|
+
*
|
|
1683
|
+
* ⭐ Why audio branches on the source mime (changed 2026-08-22).
|
|
1684
|
+
*
|
|
1685
|
+
* It used to be `mp3 → original` unconditionally, so `upload().cdnUrl`
|
|
1686
|
+
* handed back the server's auto-generated mp3 — libmp3lame, mono ~96 kbps —
|
|
1687
|
+
* even when the caller had uploaded an MP3 or an AAC that already plays in
|
|
1688
|
+
* every target browser. A consumer asking for "my file" silently received a
|
|
1689
|
+
* re-encoded, lower-quality one, with no error to notice.
|
|
1690
|
+
*
|
|
1691
|
+
* Measured across the platform: of 126 audio assets carrying an mp3 variant,
|
|
1692
|
+
* **105 had an `audio/mpeg` source** — an MP3 re-encoded into an MP3, for
|
|
1693
|
+
* zero compatibility gain.
|
|
1694
|
+
*
|
|
1695
|
+
* The mp3 still wins for `audio/webm`/Opus and anything exotic, which is the
|
|
1696
|
+
* case it was built for: Chrome records webm/Opus, which iOS Safari cannot
|
|
1697
|
+
* decode. That guarantee is preserved exactly; only the needless downgrade
|
|
1698
|
+
* is gone.
|
|
1639
1699
|
*/
|
|
1640
1700
|
private bestPresetForAsset(asset: AssetDTO, mime: string): VariantPreset {
|
|
1641
1701
|
const order: VariantPreset[] = mime.startsWith("video/")
|
|
1642
1702
|
? ["video", "poster"]
|
|
1643
1703
|
: mime.startsWith("audio/")
|
|
1644
|
-
?
|
|
1704
|
+
? isUniversallyPlayableAudio(mime)
|
|
1705
|
+
? ["original", "mp3"] // the upload already plays everywhere — don't hand back a re-encode
|
|
1706
|
+
: ["mp3", "original"] // exotic codec: the cross-browser mp3 earns its place
|
|
1645
1707
|
: ["lg", "md", "sm", "thumb", "xl", "original"];
|
|
1646
1708
|
return (
|
|
1647
1709
|
order.find((p) => hasPreset(asset, p)) ?? this.defaultPresetForMime(mime)
|
package/src/server/index.ts
CHANGED
|
@@ -94,6 +94,9 @@ export {
|
|
|
94
94
|
type AssetDTO,
|
|
95
95
|
type AssetPalette,
|
|
96
96
|
type AssetVariant,
|
|
97
|
+
accessMessage,
|
|
98
|
+
assertPublic,
|
|
99
|
+
assertSha,
|
|
97
100
|
bestTextContrast,
|
|
98
101
|
type ComposeMarketingComposition,
|
|
99
102
|
type ComposeMarketingOptions,
|
|
@@ -103,6 +106,7 @@ export {
|
|
|
103
106
|
computeVariantDimensions,
|
|
104
107
|
configureSlotResolver,
|
|
105
108
|
contrastRatio,
|
|
109
|
+
deriveAccessKey,
|
|
106
110
|
extractAssetSha,
|
|
107
111
|
getAmbientGradient,
|
|
108
112
|
getAssetDimensions,
|
|
@@ -113,6 +117,8 @@ export {
|
|
|
113
117
|
getHlsStreamingUrl,
|
|
114
118
|
getPaletteBlurBackground,
|
|
115
119
|
getPaletteCssVars,
|
|
120
|
+
getPrivateAssetUrl,
|
|
121
|
+
getPrivateTransformUrl,
|
|
116
122
|
getSignedTransformUrl,
|
|
117
123
|
getTenantId,
|
|
118
124
|
getTextColorForBackground,
|
|
@@ -123,6 +129,7 @@ export {
|
|
|
123
129
|
hasPreset,
|
|
124
130
|
hlsLadderAlignment,
|
|
125
131
|
invalidateSlotCache,
|
|
132
|
+
isUniversallyPlayableAudio,
|
|
126
133
|
iteratePaletteSwatches,
|
|
127
134
|
mimeFromFileName,
|
|
128
135
|
type NitidaClientOptions,
|
|
@@ -139,6 +146,7 @@ export {
|
|
|
139
146
|
relativeLuminance,
|
|
140
147
|
resolveSlot,
|
|
141
148
|
resolveSlots,
|
|
149
|
+
type SignAccessOptions,
|
|
142
150
|
type SignedTransformOptions,
|
|
143
151
|
type SlotDTO,
|
|
144
152
|
type SlotHistoryEntry,
|
|
@@ -146,6 +154,7 @@ export {
|
|
|
146
154
|
serializeTransform,
|
|
147
155
|
setCdnBase,
|
|
148
156
|
setTenantId,
|
|
157
|
+
signAccessUrl,
|
|
149
158
|
signTransformUrl,
|
|
150
159
|
TRANSFORM_WIDTHS,
|
|
151
160
|
type TransformEffect,
|
|
@@ -164,4 +173,5 @@ export {
|
|
|
164
173
|
type UsageWindow,
|
|
165
174
|
type VariantEntryPreset,
|
|
166
175
|
type VariantPreset,
|
|
176
|
+
type VisibilityHint,
|
|
167
177
|
} from "..";
|
package/src/web/index.ts
CHANGED
|
@@ -111,6 +111,9 @@ export {
|
|
|
111
111
|
type AssetDTO,
|
|
112
112
|
type AssetPalette,
|
|
113
113
|
type AssetVariant,
|
|
114
|
+
accessMessage,
|
|
115
|
+
assertPublic,
|
|
116
|
+
assertSha,
|
|
114
117
|
bestTextContrast,
|
|
115
118
|
type ComposeMarketingComposition,
|
|
116
119
|
type ComposeMarketingOptions,
|
|
@@ -120,6 +123,7 @@ export {
|
|
|
120
123
|
computeVariantDimensions,
|
|
121
124
|
configureSlotResolver,
|
|
122
125
|
contrastRatio,
|
|
126
|
+
deriveAccessKey,
|
|
123
127
|
extractAssetSha,
|
|
124
128
|
getAmbientGradient,
|
|
125
129
|
getAssetDimensions,
|
|
@@ -130,6 +134,8 @@ export {
|
|
|
130
134
|
getHlsStreamingUrl,
|
|
131
135
|
getPaletteBlurBackground,
|
|
132
136
|
getPaletteCssVars,
|
|
137
|
+
getPrivateAssetUrl,
|
|
138
|
+
getPrivateTransformUrl,
|
|
133
139
|
getSignedTransformUrl,
|
|
134
140
|
getTenantId,
|
|
135
141
|
getTextColorForBackground,
|
|
@@ -140,6 +146,7 @@ export {
|
|
|
140
146
|
hasPreset,
|
|
141
147
|
hlsLadderAlignment,
|
|
142
148
|
invalidateSlotCache,
|
|
149
|
+
isUniversallyPlayableAudio,
|
|
143
150
|
iteratePaletteSwatches,
|
|
144
151
|
mimeFromFileName,
|
|
145
152
|
type PaletteSwatch,
|
|
@@ -155,6 +162,7 @@ export {
|
|
|
155
162
|
relativeLuminance,
|
|
156
163
|
resolveSlot,
|
|
157
164
|
resolveSlots,
|
|
165
|
+
type SignAccessOptions,
|
|
158
166
|
type SignedTransformOptions,
|
|
159
167
|
type SlotDTO,
|
|
160
168
|
type SlotHistoryEntry,
|
|
@@ -162,6 +170,7 @@ export {
|
|
|
162
170
|
serializeTransform,
|
|
163
171
|
setCdnBase,
|
|
164
172
|
setTenantId,
|
|
173
|
+
signAccessUrl,
|
|
165
174
|
signTransformUrl,
|
|
166
175
|
TRANSFORM_WIDTHS,
|
|
167
176
|
type TransformEffect,
|
|
@@ -180,6 +189,7 @@ export {
|
|
|
180
189
|
type UsageWindow,
|
|
181
190
|
type VariantEntryPreset,
|
|
182
191
|
type VariantPreset,
|
|
192
|
+
type VisibilityHint,
|
|
183
193
|
} from "..";
|
|
184
194
|
|
|
185
195
|
// Multipart uploader helpers are NOT re-exported from this subpath.
|