@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 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
- | Where you run | Import | What it bundles |
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
- | Node 20+, Bun, Cloud Run, Lambda, Edge, agents, cron | **`@nitida/sdk/server`** | `NitidaClient` + URL builders + `signTransformUrl`. **No browser-only code.** Hard guarantee against shipping `Blob`-workflow helpers to the wrong runtime. |
21
- | Browser (Next.js client, Vite, CRA, web workers) | **`@nitida/sdk/web`** | `NitidaClient` + everything in `/server` + `compressImage` (HEIC→WebP, compressorjs). Multipart is not exposed — see *Large uploads (web)*. |
22
- | React Native / Expo apps | `@nitida/sdk/native` | `compressImage` for RN (libjpeg-turbo via `react-native-compressor`). Pair with `/expo`. |
23
- | Expo background uploads | `@nitida/sdk/expo` | `createExpoUploader` — native `URLSession` (iOS) / `WorkManager` (Android) sessions that survive app backgrounding. |
24
- | Next.js / React (hooks) | `@nitida/sdk/react` | `NitidaProvider` + `useSlot` / `useSlots` / `useNitidaClient`. |
25
- | Backwards-compat (root) | `@nitida/sdk` | Same shape as today; equivalent to `/web` for the public symbols. **Prefer the explicit subpath in new code.** |
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/native` + `@nitida/sdk/expo`.
36
- - React hooks (any env) → `@nitida/sdk/react`.
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 `@nitida/sdk/expo` | Same BFF model as `/web` — keep keys server-side. |
49
- | Universal React hook | `@nitida/sdk/react` | Wraps the right subpath for the runtime. |
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 — call `aq.transform()`:
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, 1080, 1200,
719
- > 1280, 1440, 1600, 1920, 2560, 3840`, exported as `TRANSFORM_WIDTHS`). An off-ladder
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
- **Admin operations** (system-scope admin key):
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_..." -H "Content-Type: application/json" \
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
- * Inherits the client's endpoint / api key / tenant scope; caller only
43
- * has to supply the file input + any per-upload tuning (partSize,
44
- * concurrency).
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 * Inherits the client's endpoint / api key / tenant scope; caller only\n * has to supply the file input + any per-upload tuning (partSize,\n * concurrency).\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":";AAiCA;AAAA,EACE;AAAA,OAEK;AAmCP;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"]}
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 `GET /admin/tenants/:id` with an admin key. **Keep it
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?: VariantPreset[];
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. Image presets are
330
- * MERGED with what's there passing `{ presets: ["thumb"] }` adds
331
- * the thumb variant without touching `lg`, `sm`, `original`, etc.
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"]` and
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?: VariantPreset[];
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?: VariantPreset[];
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
- * mp3original (for audio)
871
+ * originalmp3 (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 };