@nitida/sdk 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -74,6 +74,68 @@ version of this file, verify against the `.d.ts` you actually installed.
74
74
  ASYNCHRONOUSLY: the call answers `{ assetId, status: "processing" }` and a background job flips
75
75
  it to `ready` 1–2 min later, so `processAndWait` needs a `timeoutMs` of at least `300_000`.
76
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
+
77
139
  ## Related packages
78
140
 
79
141
  | Package | Job |
package/README.md CHANGED
@@ -832,8 +832,9 @@ const asset = await aq.assets.byHash(sha256);
832
832
  | `dpr` | `1` / `2` / `3` | `1` |
833
833
 
834
834
  > **`width` is strongly typed.** `TransformOptions.width` is a **`TransformWidth`** — the
835
- > predefined CDN ladder (`160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200,
836
- > 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
837
838
  > width is a **compile error**: the edge whitelists exactly these as a DoS guard and
838
839
  > HTTP 400s anything else on unsigned URLs. Need a custom off-ladder width? **Sign it** —
839
840
  > `aq.transform(asset, { width: 1490 }, { sign: true })` and `getSignedTransformUrl` take
@@ -926,6 +927,66 @@ hex-encoded. The server canonicalizes the URL the same way the SDK does
926
927
  (sort keys, lowercase strings), so two URLs with the same params in
927
928
  different order accept the same signature.
928
929
 
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
+
929
990
  ### ⚠️ Admin operations need the SYSTEM key, not the admin key in your triplet
930
991
 
931
992
  Both are `amk_ad_*`, and that is the whole trap. The `admin` key issued with
package/dist/index.d.ts CHANGED
@@ -1,5 +1,39 @@
1
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, SignedTransformOptions, SlotDTO, SlotResolution, TRANSFORM_WIDTHS, TransformEffect, TransformFit, TransformFormat, TransformGravity, TransformOptions, TransformWidth, VariantEntryPreset, 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';
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
  */
@@ -329,9 +365,10 @@ declare class AssetsApi {
329
365
  */
330
366
  variants(assetId: string): Promise<AssetVariant[]>;
331
367
  /**
332
- * Add or rebuild variants on an existing asset. Image presets are
333
- * MERGED with what's there passing `{ presets: ["thumb"] }` adds
334
- * 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.
335
372
  *
336
373
  * // Day 0: upload original-only logo
337
374
  * const { assetId } = await aq.upload(logoFile); // defaults to ["original"]
@@ -350,10 +387,18 @@ declare class AssetsApi {
350
387
  * reading the source bytes from the permanent `original` variant —
351
388
  * no need to re-upload.
352
389
  *
353
- * Video presets are filtered to `["poster","video","aiproxy"]` and
354
- * 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
355
392
  * with a dispatch handle; poll `aq.assets.get(id).status` for
356
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.
357
402
  */
358
403
  regenerate(assetId: string, opts?: {
359
404
  presets?: RequestablePreset[];
@@ -647,6 +692,7 @@ declare class UsageApi {
647
692
  }>;
648
693
  private headers;
649
694
  }
695
+
650
696
  declare class NitidaClient {
651
697
  readonly slots: SlotsApi;
652
698
  readonly assets: AssetsApi;
@@ -822,11 +868,29 @@ declare class NitidaClient {
822
868
  * `presets` string. Falls back through the preference order
823
869
  * lg → md → sm → thumb → original (for images)
824
870
  * video → poster (for videos)
825
- * mp3original (for audio)
871
+ * originalmp3 (for audio ALREADY playable everywhere)
872
+ * mp3 → original (for any other audio)
826
873
  * so an upload that was processed with e.g. `["original"]` still
827
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.
828
892
  */
829
893
  private bestPresetForAsset;
830
894
  }
831
895
 
832
- 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 };
package/dist/index.js CHANGED
@@ -15,11 +15,22 @@ import {
15
15
  setCdnBase,
16
16
  setTenantId
17
17
  } from "@nitida/asset-client";
18
+
19
+ // src/audio-compat.ts
20
+ function isUniversallyPlayableAudio(mime) {
21
+ const base = (mime.split(";")[0] ?? "").trim().toLowerCase();
22
+ return base === "audio/mpeg" || base === "audio/mp4" || base === "audio/aac";
23
+ }
24
+
25
+ // src/index.ts
18
26
  import {
27
+ accessMessage,
28
+ assertPublic,
19
29
  bestTextContrast,
20
30
  computeVariantDimensions,
21
31
  configureSlotResolver as configureSlotResolver2,
22
32
  contrastRatio,
33
+ deriveAccessKey,
23
34
  extractAssetSha,
24
35
  getAmbientGradient,
25
36
  getAssetDimensions,
@@ -30,6 +41,8 @@ import {
30
41
  getHlsStreamingUrl as getHlsStreamingUrl2,
31
42
  getPaletteBlurBackground,
32
43
  getPaletteCssVars,
44
+ getPrivateAssetUrl,
45
+ getPrivateTransformUrl,
33
46
  getSignedTransformUrl as getSignedTransformUrl2,
34
47
  getTenantId,
35
48
  getTextColorForBackground,
@@ -51,6 +64,7 @@ import {
51
64
  serializeTransform,
52
65
  setCdnBase as setCdnBase2,
53
66
  setTenantId as setTenantId2,
67
+ signAccessUrl,
54
68
  signTransformUrl,
55
69
  TRANSFORM_WIDTHS
56
70
  } from "@nitida/asset-client";
@@ -256,9 +270,10 @@ var AssetsApi = class {
256
270
  return Array.isArray(dto.variants) ? dto.variants : [];
257
271
  }
258
272
  /**
259
- * Add or rebuild variants on an existing asset. Image presets are
260
- * MERGED with what's there passing `{ presets: ["thumb"] }` adds
261
- * the thumb variant without touching `lg`, `sm`, `original`, etc.
273
+ * Add or rebuild variants on an existing asset. Presets are MERGED
274
+ * with what's there, for images AND for video passing
275
+ * `{ presets: ["thumb"] }` adds the thumb variant without touching
276
+ * `lg`, `sm`, `original`, etc.
262
277
  *
263
278
  * // Day 0: upload original-only logo
264
279
  * const { assetId } = await aq.upload(logoFile); // defaults to ["original"]
@@ -277,10 +292,18 @@ var AssetsApi = class {
277
292
  * reading the source bytes from the permanent `original` variant —
278
293
  * no need to re-upload.
279
294
  *
280
- * Video presets are filtered to `["poster","video","aiproxy"]` and
281
- * dispatched to a background job (the call returns immediately
295
+ * Video presets are filtered to `["poster","video","aiproxy","probe"]`
296
+ * and dispatched to a background job (the call returns immediately
282
297
  * with a dispatch handle; poll `aq.assets.get(id).status` for
283
298
  * completion).
299
+ *
300
+ * ⚠️ Before 2026-08-22 the video path REPLACED the whole variant
301
+ * registry instead of merging, so a partial regenerate silently
302
+ * deregistered `poster`, `video` and — irrecoverably — `hls`, which
303
+ * is not a {@link RequestablePreset} and therefore cannot be asked
304
+ * for again. The objects kept serving from the CDN; only the
305
+ * registry died. Fixed server-side; a client on an older server
306
+ * still loses them.
284
307
  */
285
308
  async regenerate(assetId, opts = {}) {
286
309
  const r = await fetch(
@@ -548,7 +571,7 @@ var NitidaClient = class {
548
571
  }
549
572
  if (!this.opts.signingKey) {
550
573
  throw new Error(
551
- "aq.transform({ sign: true }) requires `signingKey` in NitidaClientOptions. Pull the tenant's signing key from /admin/tenants/:id and pass it to the SDK constructor on a SERVER-side instance only."
574
+ "aq.transform({ sign: true }) requires `signingKey` in NitidaClientOptions. No signingKey on this client. The key is minted by POST /admin/projects/:code/rotate-signing-key, which needs a system-scope credential the platform operator holds \u2014 your own admin key gets 403 SYSTEM_KEY_REQUIRED, so ask for it. Then pass it to the SDK constructor on a SERVER-side instance only."
552
575
  );
553
576
  }
554
577
  return getSignedTransformUrl(asset, opts, this.opts.signingKey) ?? Promise.resolve(this.urlFor(asset, "lg"));
@@ -800,12 +823,30 @@ var NitidaClient = class {
800
823
  * `presets` string. Falls back through the preference order
801
824
  * lg → md → sm → thumb → original (for images)
802
825
  * video → poster (for videos)
803
- * mp3original (for audio)
826
+ * originalmp3 (for audio ALREADY playable everywhere)
827
+ * mp3 → original (for any other audio)
804
828
  * so an upload that was processed with e.g. `["original"]` still
805
829
  * returns a non-404 URL in `aq.upload`'s result.
830
+ *
831
+ * ⭐ Why audio branches on the source mime (changed 2026-08-22).
832
+ *
833
+ * It used to be `mp3 → original` unconditionally, so `upload().cdnUrl`
834
+ * handed back the server's auto-generated mp3 — libmp3lame, mono ~96 kbps —
835
+ * even when the caller had uploaded an MP3 or an AAC that already plays in
836
+ * every target browser. A consumer asking for "my file" silently received a
837
+ * re-encoded, lower-quality one, with no error to notice.
838
+ *
839
+ * Measured across the platform: of 126 audio assets carrying an mp3 variant,
840
+ * **105 had an `audio/mpeg` source** — an MP3 re-encoded into an MP3, for
841
+ * zero compatibility gain.
842
+ *
843
+ * The mp3 still wins for `audio/webm`/Opus and anything exotic, which is the
844
+ * case it was built for: Chrome records webm/Opus, which iOS Safari cannot
845
+ * decode. That guarantee is preserved exactly; only the needless downgrade
846
+ * is gone.
806
847
  */
807
848
  bestPresetForAsset(asset, mime) {
808
- const order = mime.startsWith("video/") ? ["video", "poster"] : mime.startsWith("audio/") ? ["mp3", "original"] : ["lg", "md", "sm", "thumb", "xl", "original"];
849
+ const order = mime.startsWith("video/") ? ["video", "poster"] : mime.startsWith("audio/") ? isUniversallyPlayableAudio(mime) ? ["original", "mp3"] : ["mp3", "original"] : ["lg", "md", "sm", "thumb", "xl", "original"];
809
850
  return order.find((p) => hasPreset(asset, p)) ?? this.defaultPresetForMime(mime);
810
851
  }
811
852
  };
@@ -816,10 +857,13 @@ export {
816
857
  PRESET_MAX_DIM,
817
858
  PRESET_SHORT,
818
859
  TRANSFORM_WIDTHS,
860
+ accessMessage,
861
+ assertPublic,
819
862
  bestTextContrast,
820
863
  computeVariantDimensions,
821
864
  configureSlotResolver2 as configureSlotResolver,
822
865
  contrastRatio,
866
+ deriveAccessKey,
823
867
  extractAssetSha,
824
868
  getAmbientGradient,
825
869
  getAssetDimensions,
@@ -830,6 +874,8 @@ export {
830
874
  getHlsStreamingUrl2 as getHlsStreamingUrl,
831
875
  getPaletteBlurBackground,
832
876
  getPaletteCssVars,
877
+ getPrivateAssetUrl,
878
+ getPrivateTransformUrl,
833
879
  getSignedTransformUrl2 as getSignedTransformUrl,
834
880
  getTenantId,
835
881
  getTextColorForBackground,
@@ -839,6 +885,7 @@ export {
839
885
  hasPreset2 as hasPreset,
840
886
  hlsLadderAlignment,
841
887
  invalidateSlotCache2 as invalidateSlotCache,
888
+ isUniversallyPlayableAudio,
842
889
  iteratePaletteSwatches,
843
890
  mimeFromFileName,
844
891
  pickAmbientBackground,
@@ -848,6 +895,7 @@ export {
848
895
  serializeTransform,
849
896
  setCdnBase2 as setCdnBase,
850
897
  setTenantId2 as setTenantId,
898
+ signAccessUrl,
851
899
  signTransformUrl
852
900
  };
853
901
  //# sourceMappingURL=index.js.map