@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.
- package/AGENTS.md +96 -0
- package/README.md +211 -17
- package/dist/expo.d.ts +32 -3
- package/dist/expo.js.map +1 -1
- package/dist/index.d.ts +84 -13
- package/dist/index.js +56 -8
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +79 -8
- package/dist/server.js.map +1 -1
- package/dist/web.d.ts +2 -2
- package/dist/web.js +81 -8
- package/dist/web.js.map +1 -1
- package/package.json +3 -7
- package/skills/nitida-sdk/SKILL.md +102 -9
- package/src/audio-compat.ts +37 -0
- package/src/expo/index.ts +32 -3
- package/src/index.ts +75 -14
- package/src/server/index.ts +54 -1
- package/src/web/index.ts +66 -3
|
@@ -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
|
-
- ⚠️
|
|
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 **
|
|
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
|
|
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,
|
|
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 |
|
|
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
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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 `
|
|
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?:
|
|
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.
|
|
622
|
-
*
|
|
623
|
-
* the thumb variant without touching
|
|
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"]`
|
|
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?:
|
|
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?:
|
|
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
|
-
/**
|
|
977
|
-
|
|
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
|
-
"
|
|
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
|
-
*
|
|
1658
|
+
* original → mp3 (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
|
-
?
|
|
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)
|
package/src/server/index.ts
CHANGED
|
@@ -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 "..";
|