@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
package/AGENTS.md
CHANGED
|
@@ -24,6 +24,40 @@ import { getTransformUrl, setCdnBase, setTenantId } from "@nitida/asset-client";
|
|
|
24
24
|
- **Video URLs** → `getAssetUrl({ sha }, "video")` after `setTenantId(id)`. The path segment is
|
|
25
25
|
**base36** (`tenant 12 → /c/v/…`); a decimal prefix 404s.
|
|
26
26
|
|
|
27
|
+
## Which entry point exports what — read this before your first import
|
|
28
|
+
|
|
29
|
+
**`@nitida/sdk`, `@nitida/sdk/server` and `@nitida/sdk/web` all carry the same
|
|
30
|
+
surface.** Pick the one that matches your runtime and import everything from it:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { NitidaClient, getTransformUrl, getHlsLadder } from "@nitida/sdk/server";
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The other three subpaths are **additive** — they carry only their own
|
|
37
|
+
runtime-specific symbols and you import a complete one alongside:
|
|
38
|
+
|
|
39
|
+
| subpath | carries | |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `/react` | `NitidaProvider`, `useSlot`, `useSlots`, `useNitidaClient` | + `/web` or `/server` |
|
|
42
|
+
| `/native` | `compressImage`, `compressImages` for React Native | + `/web` |
|
|
43
|
+
| `/expo` | `createExpoUploader`, `listResumableSessions`, `cancelResumableSession` | + `/web` |
|
|
44
|
+
|
|
45
|
+
⚠️ **React Native needs two imports.** `/web` for the client and every URL
|
|
46
|
+
builder — it has no top-level browser imports, its compressor is a dynamic
|
|
47
|
+
`import()` — plus `/native` and/or `/expo`. `/native` on its own gives you a
|
|
48
|
+
compressor and no way to build a URL.
|
|
49
|
+
|
|
50
|
+
The single deliberate omission: **`NitidaClientOptions` is not on `/web`**, because
|
|
51
|
+
it carries `apiKey` and this subpath exists so that shape is unreachable from
|
|
52
|
+
browser code. Use `WebClientOptions`.
|
|
53
|
+
|
|
54
|
+
⚠️ **This was not true before 2026-08-21.** `/server` was short **28** of the
|
|
55
|
+
root's 72 exports and `/web` short **37** — the whole palette family, every slot
|
|
56
|
+
helper, the HLS ladder helpers, the preset constants, and on `/web` even
|
|
57
|
+
`setTenantId`. Importing `getHlsLadder` from `/server` failed at **runtime**, not
|
|
58
|
+
at compile time. It is now asserted in CI, so if you are reading an older
|
|
59
|
+
version of this file, verify against the `.d.ts` you actually installed.
|
|
60
|
+
|
|
27
61
|
## The four things that bite hardest
|
|
28
62
|
|
|
29
63
|
1. **`presets` decides what exists forever.** Omit it and you get `original` only. Ask for
|
|
@@ -40,6 +74,68 @@ import { getTransformUrl, setCdnBase, setTenantId } from "@nitida/asset-client";
|
|
|
40
74
|
ASYNCHRONOUSLY: the call answers `{ assetId, status: "processing" }` and a background job flips
|
|
41
75
|
it to `ready` 1–2 min later, so `processAndWait` needs a `timeoutMs` of at least `300_000`.
|
|
42
76
|
|
|
77
|
+
|
|
78
|
+
## Private assets — `visibility`
|
|
79
|
+
|
|
80
|
+
Default is `"public"`. Set `private` and **every public door answers 404** —
|
|
81
|
+
stored variants, the raw original, the HLS ladder, and `/t/` (including the
|
|
82
|
+
poster frame of a private video). The bytes come back only through a signed URL
|
|
83
|
+
that expires.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { getPrivateAssetUrl, getPrivateTransformUrl } from "@nitida/sdk";
|
|
87
|
+
// ON YOUR BACKEND, once you decided this viewer may see it:
|
|
88
|
+
await getPrivateAssetUrl(asset, "lg", signingKey, { expiresInSeconds: 300 });
|
|
89
|
+
await getPrivateTransformUrl(asset, { width: 1280 }, signingKey, { expiresInSeconds: 300 });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **A 404 on a private asset is NOT a missing file.** Check `visibility` on the
|
|
93
|
+
DTO before you check storage. It is the number-one support question.
|
|
94
|
+
- **Seven URL builders throw** rather than hand you a doomed URL — `getAssetUrl`,
|
|
95
|
+
`getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`,
|
|
96
|
+
`getVideoTransformUrl`, `getHlsStreamingUrl`, `getSignedTransformUrl` — but
|
|
97
|
+
only when the value you pass carries `visibility`. `{ sha }` alone is never
|
|
98
|
+
refused.
|
|
99
|
+
- **`exp` is mandatory**; revocation is *"within a minute"* (60 s TTL at the edge).
|
|
100
|
+
- **The signing key is a backend secret** — it mints URLs for every private
|
|
101
|
+
asset the tenant owns.
|
|
102
|
+
|
|
103
|
+
## Two ways an image gets smaller, and only one of them is yours to call
|
|
104
|
+
|
|
105
|
+
This is the question every programmatic caller gets wrong, so it is stated flat:
|
|
106
|
+
|
|
107
|
+
| | who does it | what it shrinks |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| **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 |
|
|
110
|
+
| **Variants + `/t/`** | the backend, on request | the **delivery**: 19 MB JPEG → 170 kB WebP at 1920 |
|
|
111
|
+
|
|
112
|
+
**The backend never recompresses the raw. Ever.** That is deliberate: the raw
|
|
113
|
+
has to stay pristine so variants are *regenerable* — the day you add AVIF or
|
|
114
|
+
raise the max dimension, the pipeline re-runs against it. A client→server lossy
|
|
115
|
+
chain bakes artifacts in forever.
|
|
116
|
+
|
|
117
|
+
So for an API/agent upload there is nothing to "turn on": you were never going
|
|
118
|
+
to compress the raw, and delivery is already optimised two ways.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// Ask for the rungs you will actually render…
|
|
122
|
+
await aq.upload(file, { presets: ["original", "thumb", "md", "lg"] });
|
|
123
|
+
|
|
124
|
+
// …or upload bare and let the transform route make them on demand:
|
|
125
|
+
aq.transform(asset, { width: 1280, format: "webp" }); // → /t/…, cached after the first hit
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**`presets` defaults to `["original"]`** — a bare upload stores the raw and no
|
|
129
|
+
rendition. Since 2026-08-22 that is no longer a trap: when the DTO says a preset
|
|
130
|
+
was never materialised, `getAssetUrl` / `urlFor` **fall back to `/t/`** instead
|
|
131
|
+
of returning a URL that 404s. You still get optimised bytes; you just pay the
|
|
132
|
+
first encode. Prefer naming the presets when you know what you will render.
|
|
133
|
+
|
|
134
|
+
⚠️ `getAssetSrcSet` deliberately does **not** fall back — a srcSet promises
|
|
135
|
+
pixel widths and a transform cannot keep that promise on a source smaller than
|
|
136
|
+
the rung, because the pipeline never enlarges. An empty srcSet degrades to `src`; a lying one
|
|
137
|
+
degrades to a wrong choice.
|
|
138
|
+
|
|
43
139
|
## Related packages
|
|
44
140
|
|
|
45
141
|
| Package | Job |
|
package/README.md
CHANGED
|
@@ -15,14 +15,45 @@ SDK**. Stripe/Cloudinary's split into two separate npm packages is the legacy
|
|
|
15
15
|
shape — modern bundlers tree-shake subpaths perfectly and a single version
|
|
16
16
|
eliminates type drift between server and client surfaces.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
### Two kinds of subpath, and the difference matters
|
|
19
|
+
|
|
20
|
+
**COMPLETE entry points** carry the whole surface. Import one and you are done.
|
|
21
|
+
|
|
22
|
+
| Where you run | Import | |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| Node 20+, Bun, Cloud Run, Lambda, Edge, agents, cron | **`@nitida/sdk/server`** | `NitidaClient` + **every** export of the root. **No browser-only code.** |
|
|
25
|
+
| Browser, and React Native | **`@nitida/sdk/web`** | everything in `/server`, plus `compressImage` (HEIC→WebP). The one deliberate omission is `NitidaClientOptions`, the options type that carries `apiKey` — use `WebClientOptions`. |
|
|
26
|
+
| Anywhere (the root) | `@nitida/sdk` | the full surface, runtime-agnostic. Prefer an explicit subpath in new code. |
|
|
27
|
+
|
|
28
|
+
**ADDITIVE modules** are layered *on top* of one of the above. They carry only
|
|
29
|
+
their own runtime-specific symbols — **you import a complete entry point as
|
|
30
|
+
well**:
|
|
31
|
+
|
|
32
|
+
| For | Import | Carries |
|
|
19
33
|
|---|---|---|
|
|
20
|
-
|
|
|
21
|
-
|
|
|
22
|
-
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
| React hooks | `@nitida/sdk/react` | `NitidaProvider`, `useSlot`, `useSlots`, `useNitidaClient` — and nothing else |
|
|
35
|
+
| RN image compression | `@nitida/sdk/native` | `compressImage` / `compressImages` for RN (`react-native-compressor`) |
|
|
36
|
+
| Expo background uploads | `@nitida/sdk/expo` | `createExpoUploader` — `URLSession` / `WorkManager` sessions that survive backgrounding. ⚠️ **Needs a runtime key on the device** — see below |
|
|
37
|
+
|
|
38
|
+
⚠️ **`/expo` is the one place a runtime key lives on the device.**
|
|
39
|
+
`createExpoUploader` takes `apiKey` off the client and gives it to the native
|
|
40
|
+
session as its `authToken`, because the OS replays that upload from a background
|
|
41
|
+
task hours later and there is no BFF in that loop. It is the opposite call from
|
|
42
|
+
`@nitida/sdk/web`, which hit the same constraint and chose *not* to expose its
|
|
43
|
+
multipart uploader at all. An `amk_rt_*` in an IPA/APK is readable by anyone who
|
|
44
|
+
unzips it, and it is a write key to a paid platform. If background survival is
|
|
45
|
+
not worth that, build the client from `/web` against your own route and use
|
|
46
|
+
`aq.upload(file)` — no key on the device, and the upload dies with the JS thread.
|
|
47
|
+
|
|
48
|
+
⚠️ **React Native needs two imports**, and this table used to imply otherwise:
|
|
49
|
+
`@nitida/sdk/web` for the client and every URL builder — it has no top-level
|
|
50
|
+
browser imports, the compressor behind it is a dynamic `import()` — **plus**
|
|
51
|
+
`/native` and/or `/expo` for the native pieces. `/native` alone gives you a
|
|
52
|
+
compressor and no way to build a URL.
|
|
53
|
+
|
|
54
|
+
That `/server` and `/web` really do mirror the root is **asserted in CI**, not
|
|
55
|
+
maintained by hand. It was not always true: before 2026-08-21 `/server` was
|
|
56
|
+
short 28 of the root's 72 exports and `/web` short 37.
|
|
26
57
|
|
|
27
58
|
Uploaders + compressors are **optional** peer deps (`@aquienpz/asset-uploader-{web,expo}`,
|
|
28
59
|
`@nitida/asset-compressor-{web,native}`). Skip them if your app only resolves
|
|
@@ -32,8 +63,9 @@ slots and reads assets — your bundle stays a few KB.
|
|
|
32
63
|
|
|
33
64
|
- Server-side rendering, API routes, BFF, cron, agents → `@nitida/sdk/server`.
|
|
34
65
|
- Browser components, build-time pre-resolution → `@nitida/sdk/web`.
|
|
35
|
-
- Expo / React Native app → `@nitida/sdk/
|
|
36
|
-
|
|
66
|
+
- Expo / React Native app → `@nitida/sdk/web` (the client and the URL builders)
|
|
67
|
+
**plus** `@nitida/sdk/native` and/or `@nitida/sdk/expo` for the native pieces.
|
|
68
|
+
- React hooks (any env) → `@nitida/sdk/react` **plus** `/web` or `/server`.
|
|
37
69
|
|
|
38
70
|
**Never ship the API key to the browser.** Whichever subpath you import,
|
|
39
71
|
the long-lived `amk_rt_*` key lives on the server. Browser flows hit a BFF
|
|
@@ -45,8 +77,44 @@ route that proxies to aquienpz with the real key.
|
|
|
45
77
|
|---|---|---|
|
|
46
78
|
| Browser (Next.js client component, SPA, Worker, web extension) | `@nitida/sdk/web` | **No `apiKey`** — your BFF injects it. Type omits the field; build error if you try. |
|
|
47
79
|
| Node/Bun server (API route, Server Action, Cloud Run, Fly, BFF) | `@nitida/sdk/server` | Requires `apiKey` at construction. |
|
|
48
|
-
| React Native / Expo | `@nitida/sdk/native` or
|
|
49
|
-
| Universal React hook | `@nitida/sdk/react` |
|
|
80
|
+
| React Native / Expo | `@nitida/sdk/web` **plus** `/native` and/or `/expo` | Same BFF model as `/web` — keep keys server-side. `/native` and `/expo` are additive; on their own they carry no client. |
|
|
81
|
+
| Universal React hook | `@nitida/sdk/react` **plus** `/web` or `/server` | The hooks read a client from context; something has to construct it. |
|
|
82
|
+
|
|
83
|
+
### …and where does each HELPER live?
|
|
84
|
+
|
|
85
|
+
The table above answers *"where do I get the client"*. It did **not** used to
|
|
86
|
+
answer *"where do I get `getHlsLadder`"*, and that is the question that cost the
|
|
87
|
+
most time — an agent evaluating this SDK followed `AGENTS.md` to `/server`,
|
|
88
|
+
imported `getHlsLadder` from there, and got a `SyntaxError` at runtime.
|
|
89
|
+
|
|
90
|
+
**Now there is a one-line answer, and CI keeps it true:**
|
|
91
|
+
|
|
92
|
+
> **`@nitida/sdk`, `/server` and `/web` all carry the same surface.**
|
|
93
|
+
> Import any helper from whichever of the three you already use.
|
|
94
|
+
|
|
95
|
+
| what you want | `@nitida/asset-client` | `@nitida/sdk` · `/server` · `/web` | elsewhere |
|
|
96
|
+
|---|---|---|---|
|
|
97
|
+
| URL builders — `getAssetUrl`, `getTransformUrl`, `getVideoTransformUrl`, `getAssetSrcSet`, `getTransformSrcSet`, `getHlsStreamingUrl`, `serializeTransform` | ✅ | ✅ | |
|
|
98
|
+
| Signing — `signTransformUrl`, `getSignedTransformUrl` | ✅ | ✅ | |
|
|
99
|
+
| Asset facts — `hasPreset`, `extractAssetSha`, `getAssetDimensions`, `computeVariantDimensions` | ✅ | ✅ | |
|
|
100
|
+
| HLS — `getHlsLadder`, `hlsLadderAlignment`, `HlsRung` | ✅ | ✅ | |
|
|
101
|
+
| Palette — `pickAmbientBackground`, `iteratePaletteSwatches`, `bestTextContrast`, `getAmbientGradient`, `getPaletteCssVars`, `getPaletteBlurBackground`, `getTextColorForBackground`, `contrastRatio`, `relativeLuminance` | ✅ | ✅ | |
|
|
102
|
+
| Slots — `resolveSlot`, `resolveSlots`, `configureSlotResolver`, `invalidateSlotCache` | ✅ | ✅ | |
|
|
103
|
+
| Constants — `TRANSFORM_WIDTHS`, `TransformWidth`, `PRESET_EXT`, `PRESET_LONG`, `PRESET_SHORT`, `PRESET_MAX_DIM` | ✅ | ✅ | |
|
|
104
|
+
| Config — `setCdnBase`/`getCdnBase`, `setTenantId`/`getTenantId` | ✅ | ✅ | |
|
|
105
|
+
| `NitidaClient` and its option/result types | | ✅ | |
|
|
106
|
+
| Client-side compression — `compressImage`, `compressImages` | | `/web` only | `/native` (React Native) |
|
|
107
|
+
| React hooks — `NitidaProvider`, `useNitidaClient`, `useSlot`, `useSlots` | | | `/react` only |
|
|
108
|
+
| Expo resumable upload — `createExpoUploader`, `listResumableSessions`, `cancelResumableSession` | | | `/expo` only |
|
|
109
|
+
|
|
110
|
+
The single deliberate exception: **`NitidaClientOptions` is not on `/web`.** That
|
|
111
|
+
is the options type that carries `apiKey`, and this subpath exists so the
|
|
112
|
+
apiKey-bearing shape is unreachable from browser code — use `WebClientOptions`.
|
|
113
|
+
|
|
114
|
+
This is not maintained by hand. `scripts/check-published-doc-symbols.ts` asserts
|
|
115
|
+
that `/server` and `/web` mirror the root, and that the one denial above still
|
|
116
|
+
has a written reason. Before 2026-08-21 it was not true: `/server` was short
|
|
117
|
+
**28** of the root's 72 and `/web` short **37**, `getHlsLadder` among them.
|
|
50
118
|
|
|
51
119
|
### Why subpaths, not a runtime flag
|
|
52
120
|
|
|
@@ -543,6 +611,39 @@ The preset is a **ceiling**, not a target.
|
|
|
543
611
|
| `video` | `v` | original MP4 |
|
|
544
612
|
| `aiproxy` | `a` | low-res proxy for AI captioning / search (opt-in) |
|
|
545
613
|
|
|
614
|
+
### ⚠️ What you can ASK FOR is not what a variant can BE
|
|
615
|
+
|
|
616
|
+
Two different sets, two different types, and confusing them is the most
|
|
617
|
+
expensive type error this package has shipped — three unknown agents evaluating
|
|
618
|
+
the SDK hit it independently in one afternoon.
|
|
619
|
+
|
|
620
|
+
```ts
|
|
621
|
+
aq.assets.regenerate(id, { presets: ["hls"] }); // ← used to compile. HTTP 400.
|
|
622
|
+
aq.upload(file, { presets: ["mp3"] }); // ← used to compile. HTTP 400.
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
Both symbols are real. Neither is orderable.
|
|
626
|
+
|
|
627
|
+
| | `RequestablePreset` — you may ask | `VariantPreset` — a variant may be |
|
|
628
|
+
|---|---|---|
|
|
629
|
+
| `thumb` `sm` `md` `lg` `xl` `original` `poster` `video` `aiproxy` | ✅ | ✅ |
|
|
630
|
+
| `hls` — the adaptive ladder, built when a video transcodes | ❌ | ✅ |
|
|
631
|
+
| `mp3` — emitted automatically alongside any audio original | ❌ | ✅ |
|
|
632
|
+
| `probe` — indexed stills (`-pr0.jpg`), never on the compact `presets` string | ✅ | ❌ |
|
|
633
|
+
|
|
634
|
+
The write methods (`upload`, `presignUploadUrl`, `regenerate`) take
|
|
635
|
+
`RequestablePreset[]`, so all three lines above are now **compile errors**. The
|
|
636
|
+
read helpers (`hasPreset`, `variants[].preset`, `PRESET_SHORT`) keep
|
|
637
|
+
`VariantPreset`, because `hls` and `mp3` genuinely do exist on assets.
|
|
638
|
+
|
|
639
|
+
`probe` was the same bug mirrored: the server has always accepted it and the
|
|
640
|
+
type forbade it. It is requestable now.
|
|
641
|
+
|
|
642
|
+
This is checked in CI against the server itself —
|
|
643
|
+
`scripts/check-preset-contract.ts` reads the Elysia schemas of all four write
|
|
644
|
+
routes and asserts `RequestablePreset` still equals what they accept, so it
|
|
645
|
+
cannot go stale the way a hand-written list would.
|
|
646
|
+
|
|
546
647
|
### Per-upload preset selection
|
|
547
648
|
|
|
548
649
|
**The SDK's default is `["original"]`** — calling `aq.upload(file)`
|
|
@@ -679,7 +780,23 @@ returned shape gives you `{ preset, url, width?, height?, bytes }[]`.
|
|
|
679
780
|
|
|
680
781
|
Pre-generated presets (`thumb`/`sm`/`md`/`lg`/`xl`) cover the common cases.
|
|
681
782
|
For everything else — exact CSS pixel widths, art-directed crops, devicePixelRatio
|
|
682
|
-
ladders, square thumbs from rectangular sources —
|
|
783
|
+
ladders, square thumbs from rectangular sources — build a `/t/` URL.
|
|
784
|
+
|
|
785
|
+
### `aq.transform()` or `getTransformUrl()`? Both, and the rule is which you have
|
|
786
|
+
|
|
787
|
+
Two agents evaluating this SDK picked different ones for the same task and both
|
|
788
|
+
were right, which is a documentation failure rather than a design one. They are
|
|
789
|
+
**not duplicates**: `aq.transform()` is `getTransformUrl()` plus the client's
|
|
790
|
+
context.
|
|
791
|
+
|
|
792
|
+
| you have | use | why |
|
|
793
|
+
|---|---|---|
|
|
794
|
+
| a client (`aq`) | **`aq.transform(asset, opts)`** | it already knows your CDN base and tenant, and it is the only one that can **sign** — `aq.transform(asset, opts, { sign: true })` returns a `?sig=` URL, which is the only way to an off-ladder width |
|
|
795
|
+
| only a DTO — a component, a Server Component, a worker | **`getTransformUrl(asset, opts)`** | no client needed. Call `setCdnBase()` / `setTenantId()` once at module load first |
|
|
796
|
+
|
|
797
|
+
Same builder underneath, same URL out. If you are holding a client, use its
|
|
798
|
+
method — reaching for the standalone one there means passing the tenant twice
|
|
799
|
+
and losing signing.
|
|
683
800
|
|
|
684
801
|
```tsx
|
|
685
802
|
import { NitidaClient } from "@nitida/sdk";
|
|
@@ -715,8 +832,9 @@ const asset = await aq.assets.byHash(sha256);
|
|
|
715
832
|
| `dpr` | `1` / `2` / `3` | `1` |
|
|
716
833
|
|
|
717
834
|
> **`width` is strongly typed.** `TransformOptions.width` is a **`TransformWidth`** — the
|
|
718
|
-
> predefined CDN ladder (`160, 240, 256, 320, 400, 480, 600, 640, 800, 960,
|
|
719
|
-
> 1280, 1440, 1600, 1920, 2560, 3840
|
|
835
|
+
> predefined CDN ladder (`96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960,
|
|
836
|
+
> 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840` — 20 widths, exported as
|
|
837
|
+
> `TRANSFORM_WIDTHS`). An off-ladder
|
|
720
838
|
> width is a **compile error**: the edge whitelists exactly these as a DoS guard and
|
|
721
839
|
> HTTP 400s anything else on unsigned URLs. Need a custom off-ladder width? **Sign it** —
|
|
722
840
|
> `aq.transform(asset, { width: 1490 }, { sign: true })` and `getSignedTransformUrl` take
|
|
@@ -809,16 +927,92 @@ hex-encoded. The server canonicalizes the URL the same way the SDK does
|
|
|
809
927
|
(sort keys, lowercase strings), so two URLs with the same params in
|
|
810
928
|
different order accept the same signature.
|
|
811
929
|
|
|
812
|
-
|
|
930
|
+
## Private assets — `visibility`
|
|
931
|
+
|
|
932
|
+
Every asset carries `visibility`, and the default is `"public"`.
|
|
933
|
+
|
|
934
|
+
| | `"public"` | `"private"` |
|
|
935
|
+
|---|---|---|
|
|
936
|
+
| stored variants, raw, HLS ladder, transforms | served to anyone with the URL | **404**, to everyone |
|
|
937
|
+
| how the bytes come back | the URL | a signed URL under `/a/{tenant}/…?exp&sig` |
|
|
938
|
+
| expiry | none | mandatory |
|
|
939
|
+
| edge caching | shared, effectively free | `private`, per-viewer |
|
|
940
|
+
|
|
941
|
+
```ts
|
|
942
|
+
// Flip it (write scope):
|
|
943
|
+
await fetch(`${endpoint}/assets/${assetId}/visibility`, {
|
|
944
|
+
method: "PATCH",
|
|
945
|
+
headers: { Authorization: `Bearer ${apiKey}`, "X-Tenant-Code": code,
|
|
946
|
+
"Content-Type": "application/json" },
|
|
947
|
+
body: JSON.stringify({ visibility: "private" }),
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
// Hand a viewer the bytes — ON YOUR BACKEND:
|
|
951
|
+
import { getPrivateAssetUrl, getPrivateTransformUrl } from "@nitida/sdk";
|
|
952
|
+
|
|
953
|
+
const url = await getPrivateAssetUrl(asset, "lg", signingKey, {
|
|
954
|
+
expiresInSeconds: 300,
|
|
955
|
+
});
|
|
956
|
+
// → https://8ok.uk/a/5/v/<sha>-l.webp?exp=…&sig=…
|
|
957
|
+
|
|
958
|
+
// …or any width/crop/format, not just the materialised ones:
|
|
959
|
+
const resized = await getPrivateTransformUrl(
|
|
960
|
+
asset,
|
|
961
|
+
{ width: 1280, format: "webp" },
|
|
962
|
+
signingKey,
|
|
963
|
+
{ expiresInSeconds: 300 },
|
|
964
|
+
);
|
|
965
|
+
// → https://8ok.uk/a/5/t/format=webp,width=1280/<sha>.webp?exp=…&sig=…
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
Nothing moves when you flip it: one row changes and the edge cache for that
|
|
969
|
+
asset is purged, so a 1 GB video flips as fast as a thumbnail. It is the same
|
|
970
|
+
single copy behind both doors.
|
|
971
|
+
|
|
972
|
+
**Four things worth knowing before you rely on it:**
|
|
973
|
+
|
|
974
|
+
1. **A 404 on a private asset is the feature, not a missing file.** Everywhere
|
|
975
|
+
else here a 404 means the object was never written. Check `visibility` on
|
|
976
|
+
the DTO before you check storage.
|
|
977
|
+
2. **The SDK refuses instead of handing you a URL that 404s.** `getAssetUrl`,
|
|
978
|
+
`getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`,
|
|
979
|
+
`getVideoTransformUrl` and `getHlsStreamingUrl` all throw when the value you
|
|
980
|
+
pass says `visibility: "private"` — with a message naming
|
|
981
|
+
`getPrivateAssetUrl`. Pass only `{ sha }` and there is nothing to check.
|
|
982
|
+
3. **Revocation is "within a minute".** Flipping back to `private` really does
|
|
983
|
+
kill URLs already handed out — the edge is purged — but the CDN refreshes
|
|
984
|
+
its list of private assets on a 60-second TTL.
|
|
985
|
+
4. **The signing key is a backend secret.** It mints URLs for every private
|
|
986
|
+
asset the tenant owns. It is a different claim from the transform `?sig=`
|
|
987
|
+
above, computed under a separately derived key, so a signature minted to
|
|
988
|
+
resize can never be replayed as one to enter.
|
|
989
|
+
|
|
990
|
+
### ⚠️ Admin operations need the SYSTEM key, not the admin key in your triplet
|
|
991
|
+
|
|
992
|
+
Both are `amk_ad_*`, and that is the whole trap. The `admin` key issued with
|
|
993
|
+
your tenant is **tenant-scoped**: it manages your assets and cannot touch
|
|
994
|
+
`/admin/projects/*`. Verified 2026-08-21:
|
|
995
|
+
|
|
996
|
+
```
|
|
997
|
+
tenant admin key → GET /admin/projects → 403 SYSTEM_KEY_REQUIRED
|
|
998
|
+
"API key … is tenant-scoped; this endpoint requires a
|
|
999
|
+
system-scope key"
|
|
1000
|
+
system admin key → GET /admin/projects → 200
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
There is exactly one system-scope key and we hold it. **These calls are ours to
|
|
1004
|
+
run, not yours** — ask, and we run them. They are documented here so you know
|
|
1005
|
+
what exists and can name what you want done, not so you can copy the `amk_ad_`
|
|
1006
|
+
out of your own triplet and get a 403 that reads like a broken credential.
|
|
813
1007
|
|
|
814
1008
|
```bash
|
|
815
1009
|
# Rotate the signing key — invalidates every URL signed with the old one.
|
|
816
|
-
curl -X POST -H "Authorization: Bearer amk_ad_
|
|
1010
|
+
curl -X POST -H "Authorization: Bearer <SYSTEM amk_ad_...>" \
|
|
817
1011
|
https://api.nitida.gofuture.space/admin/projects/your-tenant/rotate-signing-key
|
|
818
1012
|
# → { ok: true, tenantId, code, signingKey: "<64-hex>" }
|
|
819
1013
|
|
|
820
1014
|
# Flip strict mode on/off.
|
|
821
|
-
curl -X PATCH -H "Authorization: Bearer amk_ad_
|
|
1015
|
+
curl -X PATCH -H "Authorization: Bearer <SYSTEM amk_ad_...>" -H "Content-Type: application/json" \
|
|
822
1016
|
-d '{"enabled":true}' \
|
|
823
1017
|
https://api.nitida.gofuture.space/admin/projects/your-tenant/strict-transforms
|
|
824
1018
|
```
|
package/dist/expo.d.ts
CHANGED
|
@@ -31,6 +31,33 @@ import '@nitida/asset-client';
|
|
|
31
31
|
* const { assetId } = await upload.start();
|
|
32
32
|
* await aq.slots.bind("storefront.tour.video", { assetId, preset: "video" });
|
|
33
33
|
*
|
|
34
|
+
* ⚠️ THIS PATH PUTS A RUNTIME KEY ON THE DEVICE. Read before shipping.
|
|
35
|
+
*
|
|
36
|
+
* `createExpoUploader` reads `client.opts.apiKey` and hands it to the native
|
|
37
|
+
* session as its `authToken` (see below — it is four lines, and they are the
|
|
38
|
+
* whole story). The native uploader talks to the API directly, so it needs a
|
|
39
|
+
* credential the OS can replay hours later from a background task. There is no
|
|
40
|
+
* BFF in that loop to inject one.
|
|
41
|
+
*
|
|
42
|
+
* That is the opposite of what every other surface here does, and the opposite
|
|
43
|
+
* of what `@nitida/sdk/web` chose when it hit the SAME constraint: `/web`
|
|
44
|
+
* deliberately does NOT expose the multipart uploader, because it needs a raw
|
|
45
|
+
* `authToken` a BFF cannot supply. `/expo` exposes it anyway, because
|
|
46
|
+
* background-surviving uploads are the entire reason a native session exists.
|
|
47
|
+
*
|
|
48
|
+
* So, concretely, an `amk_rt_*` key in your app bundle is readable by anyone
|
|
49
|
+
* who unzips the IPA/APK, and it is a WRITE key to a paid platform.
|
|
50
|
+
*
|
|
51
|
+
* - Uploading big video in the background is worth it to you ⇒ use this, and
|
|
52
|
+
* scope the key to one tenant so a leak is contained and revocable.
|
|
53
|
+
* - It is not ⇒ build the client from `@nitida/sdk/web` pointed at your own
|
|
54
|
+
* route and call `aq.upload(file)`. No key on the device. You lose survival
|
|
55
|
+
* across backgrounding and OS kill; the upload dies with the JS thread.
|
|
56
|
+
*
|
|
57
|
+
* The real fix — a BFF-minted short-lived token the native session can carry —
|
|
58
|
+
* is not built. It is the same gap `/web` documents. Ask; there is no public
|
|
59
|
+
* tracker.
|
|
60
|
+
*
|
|
34
61
|
* Peer dep: `@aquienpz/asset-uploader-expo` (lazy — apps that don't
|
|
35
62
|
* use the mobile SDK skip the install).
|
|
36
63
|
* @module @nitida/sdk/expo
|
|
@@ -39,9 +66,11 @@ import '@nitida/asset-client';
|
|
|
39
66
|
type ExpoUploadOptions = Omit<UploadTaskOptions, "tenantCode" | "endpoint" | "authToken">;
|
|
40
67
|
/**
|
|
41
68
|
* Spawn a native-backed `UploadTask` bound to a configured client.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ It reaches into the client for `apiKey` and uses it as the session's
|
|
71
|
+
* `authToken`, so the client you pass MUST have been built with a real runtime
|
|
72
|
+
* key — which means that key is on the device. See the module header for what
|
|
73
|
+
* that costs and what the alternative is.
|
|
45
74
|
*/
|
|
46
75
|
declare function createExpoUploader(client: NitidaClient, options: ExpoUploadOptions): UploadTask;
|
|
47
76
|
|
package/dist/expo.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/expo/index.ts"],"sourcesContent":["/**\n * @nitida/sdk/expo — native multipart uploads for React Native / Expo.\n *\n * Wraps `@aquienpz/asset-uploader-expo`'s `UploadTask`, which delegates\n * the actual byte transfer to a native background session (URLSession\n * on iOS, WorkManager on Android). The upload survives:\n * - JS thread freezing\n * - App backgrounding\n * - OS-initiated kill (low-memory, user swiping away)\n * - Network blips (retries with backoff)\n *\n * Usage:\n *\n * import { createExpoUploader, listResumableSessions } from \"@nitida/sdk/expo\";\n *\n * const aq = new NitidaClient({ ... });\n *\n * // Resume any uploads from a prior app launch on boot.\n * const resumable = await listResumableSessions();\n * // ...show a banner offering to resume them\n *\n * const upload = createExpoUploader(aq, {\n * file: { uri: assetUri, mime: \"video/mp4\", name: \"tour.mp4\" },\n * });\n * upload.on(\"progress\", ({ ratio }) => setProgress(ratio));\n * const { assetId } = await upload.start();\n * await aq.slots.bind(\"storefront.tour.video\", { assetId, preset: \"video\" });\n *\n * Peer dep: `@aquienpz/asset-uploader-expo` (lazy — apps that don't\n * use the mobile SDK skip the install).\n * @module @nitida/sdk/expo\n */\n\nimport {\n UploadTask,\n type UploadTaskOptions,\n} from \"@aquienpz/asset-uploader-expo\";\nimport type { NitidaClient } from \"..\";\n\nexport type ExpoUploadOptions = Omit<\n UploadTaskOptions,\n \"tenantCode\" | \"endpoint\" | \"authToken\"\n>;\n\n/**\n * Spawn a native-backed `UploadTask` bound to a configured client.\n *
|
|
1
|
+
{"version":3,"sources":["../src/expo/index.ts"],"sourcesContent":["/**\n * @nitida/sdk/expo — native multipart uploads for React Native / Expo.\n *\n * Wraps `@aquienpz/asset-uploader-expo`'s `UploadTask`, which delegates\n * the actual byte transfer to a native background session (URLSession\n * on iOS, WorkManager on Android). The upload survives:\n * - JS thread freezing\n * - App backgrounding\n * - OS-initiated kill (low-memory, user swiping away)\n * - Network blips (retries with backoff)\n *\n * Usage:\n *\n * import { createExpoUploader, listResumableSessions } from \"@nitida/sdk/expo\";\n *\n * const aq = new NitidaClient({ ... });\n *\n * // Resume any uploads from a prior app launch on boot.\n * const resumable = await listResumableSessions();\n * // ...show a banner offering to resume them\n *\n * const upload = createExpoUploader(aq, {\n * file: { uri: assetUri, mime: \"video/mp4\", name: \"tour.mp4\" },\n * });\n * upload.on(\"progress\", ({ ratio }) => setProgress(ratio));\n * const { assetId } = await upload.start();\n * await aq.slots.bind(\"storefront.tour.video\", { assetId, preset: \"video\" });\n *\n * ⚠️ THIS PATH PUTS A RUNTIME KEY ON THE DEVICE. Read before shipping.\n *\n * `createExpoUploader` reads `client.opts.apiKey` and hands it to the native\n * session as its `authToken` (see below — it is four lines, and they are the\n * whole story). The native uploader talks to the API directly, so it needs a\n * credential the OS can replay hours later from a background task. There is no\n * BFF in that loop to inject one.\n *\n * That is the opposite of what every other surface here does, and the opposite\n * of what `@nitida/sdk/web` chose when it hit the SAME constraint: `/web`\n * deliberately does NOT expose the multipart uploader, because it needs a raw\n * `authToken` a BFF cannot supply. `/expo` exposes it anyway, because\n * background-surviving uploads are the entire reason a native session exists.\n *\n * So, concretely, an `amk_rt_*` key in your app bundle is readable by anyone\n * who unzips the IPA/APK, and it is a WRITE key to a paid platform.\n *\n * - Uploading big video in the background is worth it to you ⇒ use this, and\n * scope the key to one tenant so a leak is contained and revocable.\n * - It is not ⇒ build the client from `@nitida/sdk/web` pointed at your own\n * route and call `aq.upload(file)`. No key on the device. You lose survival\n * across backgrounding and OS kill; the upload dies with the JS thread.\n *\n * The real fix — a BFF-minted short-lived token the native session can carry —\n * is not built. It is the same gap `/web` documents. Ask; there is no public\n * tracker.\n *\n * Peer dep: `@aquienpz/asset-uploader-expo` (lazy — apps that don't\n * use the mobile SDK skip the install).\n * @module @nitida/sdk/expo\n */\n\nimport {\n UploadTask,\n type UploadTaskOptions,\n} from \"@aquienpz/asset-uploader-expo\";\nimport type { NitidaClient } from \"..\";\n\nexport type ExpoUploadOptions = Omit<\n UploadTaskOptions,\n \"tenantCode\" | \"endpoint\" | \"authToken\"\n>;\n\n/**\n * Spawn a native-backed `UploadTask` bound to a configured client.\n *\n * ⚠️ It reaches into the client for `apiKey` and uses it as the session's\n * `authToken`, so the client you pass MUST have been built with a real runtime\n * key — which means that key is on the device. See the module header for what\n * that costs and what the alternative is.\n */\nexport function createExpoUploader(\n client: NitidaClient,\n options: ExpoUploadOptions,\n): UploadTask {\n const access = client as unknown as {\n opts: { endpoint: string; apiKey: string; tenantCode: string };\n };\n return new UploadTask({\n ...options,\n endpoint: access.opts.endpoint,\n authToken: access.opts.apiKey,\n tenantCode: access.opts.tenantCode,\n });\n}\n\nexport type {\n UploadEvents,\n UploadFileInput,\n UploadSessionState,\n UploadTaskOptions,\n} from \"@aquienpz/asset-uploader-expo\";\nexport {\n cancelResumableSession,\n listResumableSessions,\n UploadTask,\n} from \"@aquienpz/asset-uploader-expo\";\n"],"mappings":";AA4DA;AAAA,EACE;AAAA,OAEK;AAqCP;AAAA,EACE;AAAA,EACA;AAAA,EACA,cAAAA;AAAA,OACK;AAzBA,SAAS,mBACd,QACA,SACY;AACZ,QAAM,SAAS;AAGf,SAAO,IAAI,WAAW;AAAA,IACpB,GAAG;AAAA,IACH,UAAU,OAAO,KAAK;AAAA,IACtB,WAAW,OAAO,KAAK;AAAA,IACvB,YAAY,OAAO,KAAK;AAAA,EAC1B,CAAC;AACH;","names":["UploadTask"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
|
-
import { ResolveSlotOptions, SlotResolution, SlotDTO, VariantPreset, AssetDTO, AssetVariant, TransformOptions, SignedTransformOptions } from '@nitida/asset-client';
|
|
2
|
-
export { AssetDTO, AssetPalette, AssetVariant, HlsRung, PRESET_EXT, PRESET_LONG, PRESET_MAX_DIM, PRESET_SHORT, PaletteSwatch, ResolveSlotOptions, SignedTransformOptions, SlotDTO, SlotResolution, TRANSFORM_WIDTHS, TransformEffect, TransformFit, TransformFormat, TransformGravity, TransformOptions, TransformWidth, VariantPreset, bestTextContrast, computeVariantDimensions, configureSlotResolver, contrastRatio, extractAssetSha, getAmbientGradient, getAssetDimensions, getAssetSrcSet, getAssetUrl, getCdnBase, getHlsLadder, getHlsStreamingUrl, getPaletteBlurBackground, getPaletteCssVars, getSignedTransformUrl, getTenantId, getTextColorForBackground, getTransformSrcSet, getTransformUrl, getVideoTransformUrl, hasPreset, hlsLadderAlignment, invalidateSlotCache, iteratePaletteSwatches, pickAmbientBackground, relativeLuminance, resolveSlot, resolveSlots, serializeTransform, setCdnBase, setTenantId, signTransformUrl } from '@nitida/asset-client';
|
|
1
|
+
import { ResolveSlotOptions, SlotResolution, SlotDTO, VariantPreset, AssetDTO, AssetVariant, RequestablePreset, TransformOptions, SignedTransformOptions } from '@nitida/asset-client';
|
|
2
|
+
export { AssetDTO, AssetPalette, AssetVariant, HlsRung, PRESET_EXT, PRESET_LONG, PRESET_MAX_DIM, PRESET_SHORT, PaletteSwatch, RequestablePreset, ResolveSlotOptions, SignAccessOptions, SignedTransformOptions, SlotDTO, SlotResolution, TRANSFORM_WIDTHS, TransformEffect, TransformFit, TransformFormat, TransformGravity, TransformOptions, TransformWidth, VariantEntryPreset, VariantPreset, VisibilityHint, accessMessage, assertPublic, bestTextContrast, computeVariantDimensions, configureSlotResolver, contrastRatio, deriveAccessKey, extractAssetSha, getAmbientGradient, getAssetDimensions, getAssetSrcSet, getAssetUrl, getCdnBase, getHlsLadder, getHlsStreamingUrl, getPaletteBlurBackground, getPaletteCssVars, getPrivateAssetUrl, getPrivateTransformUrl, getSignedTransformUrl, getTenantId, getTextColorForBackground, getTransformSrcSet, getTransformUrl, getVideoTransformUrl, hasPreset, hlsLadderAlignment, invalidateSlotCache, iteratePaletteSwatches, pickAmbientBackground, relativeLuminance, resolveSlot, resolveSlots, serializeTransform, setCdnBase, setTenantId, signAccessUrl, signTransformUrl } from '@nitida/asset-client';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which audio uploads already play everywhere — the single source of truth for
|
|
6
|
+
* both the server's transcode guard and the client's delivery-preset choice.
|
|
7
|
+
*
|
|
8
|
+
* It lives in its own module, rather than beside the preset order that uses it,
|
|
9
|
+
* for a mechanical reason: `apps/asset-manager` imports it by relative path.
|
|
10
|
+
* The SDK is a PUBLISHED package, so its `exports` point at `dist/`, and
|
|
11
|
+
* `dist/` is gitignored — it does not exist inside the Docker image, which
|
|
12
|
+
* copies `packages/` as source. A bare `@nitida/sdk` import type-checks on any
|
|
13
|
+
* machine that built the package once and then fails the image build. (It did,
|
|
14
|
+
* on 2026-08-22, with `@nitida/asset-client`.) A single small file keeps that
|
|
15
|
+
* relative import from dragging the whole client into the server.
|
|
16
|
+
*
|
|
17
|
+
* Two copies of this list drifting apart is how a platform ends up generating
|
|
18
|
+
* a variant its own client refuses to use — which is exactly the bug this
|
|
19
|
+
* predicate was introduced to end.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* `true` when a browser can play these bytes as uploaded, so re-encoding them
|
|
23
|
+
* to MP3 buys nothing.
|
|
24
|
+
*
|
|
25
|
+
* - `audio/mpeg` — unambiguous.
|
|
26
|
+
* - `audio/mp4` / `audio/aac` — plays in Safari, Chrome, Firefox and Edge on
|
|
27
|
+
* desktop and mobile. The historical worry was old AOSP builds without
|
|
28
|
+
* proprietary codecs; the call to treat AAC as universal was made with the
|
|
29
|
+
* platform's one real AAC consumer, whose viewer is WebGL — a device that
|
|
30
|
+
* cannot decode AAC cannot run that product at all, so the fallback would
|
|
31
|
+
* only ever protect a device that had already lost.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately NOT here: `audio/webm` and `audio/ogg` (Opus). iOS Safari
|
|
34
|
+
* cannot decode them, and that is the case the MP3 fallback exists for.
|
|
35
|
+
*/
|
|
36
|
+
declare function isUniversallyPlayableAudio(mime: string): boolean;
|
|
3
37
|
|
|
4
38
|
/**
|
|
5
39
|
* @nitida/sdk — universal client for the aquienpz multi-tenant asset
|
|
@@ -86,7 +120,9 @@ type NitidaClientOptions = {
|
|
|
86
120
|
* only when calling `aq.transform(asset, opts, { sign: true })`.
|
|
87
121
|
*
|
|
88
122
|
* 32 random bytes, generated server-side on tenant creation; fetch
|
|
89
|
-
* via `
|
|
123
|
+
* via `POST /admin/projects/:code/rotate-signing-key`, which needs a
|
|
124
|
+
* SYSTEM-scope credential the platform operator holds — your own admin key
|
|
125
|
+
* answers `403 SYSTEM_KEY_REQUIRED`. Ask for it. **Keep it
|
|
90
126
|
* server-side only** — do not ship in `NEXT_PUBLIC_*` env vars. Sign
|
|
91
127
|
* URLs from a BFF route handler, or pre-sign at build time.
|
|
92
128
|
*/
|
|
@@ -235,8 +271,11 @@ type PresignUploadUrlOptions = {
|
|
|
235
271
|
/**
|
|
236
272
|
* Variant ladder to generate after `/assets/process`. Defaults to
|
|
237
273
|
* `["original"]` server-side when omitted — same contract as `aq.upload`.
|
|
274
|
+
*
|
|
275
|
+
* {@link RequestablePreset}, not {@link VariantPreset}: `hls` and `mp3` are
|
|
276
|
+
* things a variant can BE, never things you can ask for, and asking is a 400.
|
|
238
277
|
*/
|
|
239
|
-
presets?:
|
|
278
|
+
presets?: RequestablePreset[];
|
|
240
279
|
/**
|
|
241
280
|
* Pre-compression size of the source (useful when the browser ran
|
|
242
281
|
* compressorjs / heic2any before computing `bytes`). Recorded
|
|
@@ -326,9 +365,10 @@ declare class AssetsApi {
|
|
|
326
365
|
*/
|
|
327
366
|
variants(assetId: string): Promise<AssetVariant[]>;
|
|
328
367
|
/**
|
|
329
|
-
* Add or rebuild variants on an existing asset.
|
|
330
|
-
*
|
|
331
|
-
* the thumb variant without touching
|
|
368
|
+
* Add or rebuild variants on an existing asset. Presets are MERGED
|
|
369
|
+
* with what's there, for images AND for video — passing
|
|
370
|
+
* `{ presets: ["thumb"] }` adds the thumb variant without touching
|
|
371
|
+
* `lg`, `sm`, `original`, etc.
|
|
332
372
|
*
|
|
333
373
|
* // Day 0: upload original-only logo
|
|
334
374
|
* const { assetId } = await aq.upload(logoFile); // defaults to ["original"]
|
|
@@ -347,13 +387,21 @@ declare class AssetsApi {
|
|
|
347
387
|
* reading the source bytes from the permanent `original` variant —
|
|
348
388
|
* no need to re-upload.
|
|
349
389
|
*
|
|
350
|
-
* Video presets are filtered to `["poster","video","aiproxy"]`
|
|
351
|
-
* dispatched to a background job (the call returns immediately
|
|
390
|
+
* Video presets are filtered to `["poster","video","aiproxy","probe"]`
|
|
391
|
+
* and dispatched to a background job (the call returns immediately
|
|
352
392
|
* with a dispatch handle; poll `aq.assets.get(id).status` for
|
|
353
393
|
* completion).
|
|
394
|
+
*
|
|
395
|
+
* ⚠️ Before 2026-08-22 the video path REPLACED the whole variant
|
|
396
|
+
* registry instead of merging, so a partial regenerate silently
|
|
397
|
+
* deregistered `poster`, `video` and — irrecoverably — `hls`, which
|
|
398
|
+
* is not a {@link RequestablePreset} and therefore cannot be asked
|
|
399
|
+
* for again. The objects kept serving from the CDN; only the
|
|
400
|
+
* registry died. Fixed server-side; a client on an older server
|
|
401
|
+
* still loses them.
|
|
354
402
|
*/
|
|
355
403
|
regenerate(assetId: string, opts?: {
|
|
356
|
-
presets?:
|
|
404
|
+
presets?: RequestablePreset[];
|
|
357
405
|
}): Promise<RegenerateResult>;
|
|
358
406
|
/** Merge metadata into an asset (role / slot / description / tags). */
|
|
359
407
|
patchMetadata(assetId: string, metadata: Record<string, unknown>): Promise<{
|
|
@@ -505,8 +553,12 @@ type UploadOptions = {
|
|
|
505
553
|
* Idempotent: you can always add missing variants later via
|
|
506
554
|
* `aq.assets.regenerate(id, { presets: [...] })`. The platform
|
|
507
555
|
* stores the source so regeneration doesn't require re-uploading.
|
|
556
|
+
*
|
|
557
|
+
* {@link RequestablePreset}, not {@link VariantPreset}. `hls` and `mp3` are
|
|
558
|
+
* produced FOR you — the ladder when a video transcodes, the mp3 alongside
|
|
559
|
+
* any audio original — and asking for either is a 400.
|
|
508
560
|
*/
|
|
509
|
-
presets?:
|
|
561
|
+
presets?: RequestablePreset[];
|
|
510
562
|
/**
|
|
511
563
|
* Max time to wait for the asset to transition to `ready` (or `failed`)
|
|
512
564
|
* after dispatch. Default `5 * 60_000` (5 min). Bump higher for large
|
|
@@ -640,6 +692,7 @@ declare class UsageApi {
|
|
|
640
692
|
}>;
|
|
641
693
|
private headers;
|
|
642
694
|
}
|
|
695
|
+
|
|
643
696
|
declare class NitidaClient {
|
|
644
697
|
readonly slots: SlotsApi;
|
|
645
698
|
readonly assets: AssetsApi;
|
|
@@ -815,11 +868,29 @@ declare class NitidaClient {
|
|
|
815
868
|
* `presets` string. Falls back through the preference order
|
|
816
869
|
* lg → md → sm → thumb → original (for images)
|
|
817
870
|
* video → poster (for videos)
|
|
818
|
-
*
|
|
871
|
+
* original → mp3 (for audio ALREADY playable everywhere)
|
|
872
|
+
* mp3 → original (for any other audio)
|
|
819
873
|
* so an upload that was processed with e.g. `["original"]` still
|
|
820
874
|
* returns a non-404 URL in `aq.upload`'s result.
|
|
875
|
+
*
|
|
876
|
+
* ⭐ Why audio branches on the source mime (changed 2026-08-22).
|
|
877
|
+
*
|
|
878
|
+
* It used to be `mp3 → original` unconditionally, so `upload().cdnUrl`
|
|
879
|
+
* handed back the server's auto-generated mp3 — libmp3lame, mono ~96 kbps —
|
|
880
|
+
* even when the caller had uploaded an MP3 or an AAC that already plays in
|
|
881
|
+
* every target browser. A consumer asking for "my file" silently received a
|
|
882
|
+
* re-encoded, lower-quality one, with no error to notice.
|
|
883
|
+
*
|
|
884
|
+
* Measured across the platform: of 126 audio assets carrying an mp3 variant,
|
|
885
|
+
* **105 had an `audio/mpeg` source** — an MP3 re-encoded into an MP3, for
|
|
886
|
+
* zero compatibility gain.
|
|
887
|
+
*
|
|
888
|
+
* The mp3 still wins for `audio/webm`/Opus and anything exotic, which is the
|
|
889
|
+
* case it was built for: Chrome records webm/Opus, which iOS Safari cannot
|
|
890
|
+
* decode. That guarantee is preserved exactly; only the needless downgrade
|
|
891
|
+
* is gone.
|
|
821
892
|
*/
|
|
822
893
|
private bestPresetForAsset;
|
|
823
894
|
}
|
|
824
895
|
|
|
825
|
-
export { type ComposeMarketingComposition, type ComposeMarketingOptions, type ComposeMarketingResult, type ComposeMarketingSegment, type CompressOptions, NitidaClient, type NitidaClientOptions, type PresignUploadUrlOptions, type RegenerateResult, type SlotHistoryEntry, type UploadOptions, type UploadResult, type UploadUrlResult, type UploadVideoOptions, type UsageDailyPoint, type UsagePerKey, type UsageSnapshot, type UsageWindow, mimeFromFileName };
|
|
896
|
+
export { type ComposeMarketingComposition, type ComposeMarketingOptions, type ComposeMarketingResult, type ComposeMarketingSegment, type CompressOptions, NitidaClient, type NitidaClientOptions, type PresignUploadUrlOptions, type RegenerateResult, type SlotHistoryEntry, type UploadOptions, type UploadResult, type UploadUrlResult, type UploadVideoOptions, type UsageDailyPoint, type UsagePerKey, type UsageSnapshot, type UsageWindow, isUniversallyPlayableAudio, mimeFromFileName };
|