@nitida/asset-client 0.16.1 → 0.16.3

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/src/index.ts CHANGED
@@ -37,12 +37,19 @@ export type VariantPreset =
37
37
  | "poster"
38
38
  | "video"
39
39
  | "aiproxy"
40
+ // The adaptive HLS ladder. Unlike every other preset this is NOT one file:
41
+ // it is a `master.m3u8` plus one media playlist + segment set per rung. It is
42
+ // a preset because the question callers ask about it is the same one they ask
43
+ // about the others — *does this asset have one?* — and `hasPreset` is the
44
+ // place that question is answered. What it HAS, rung by rung, is in the
45
+ // `hls` entry of `variants` ({@link AssetVariant.rungs}).
46
+ | "hls"
40
47
  // audio preset — the cross-browser mp3 transcode of a voice note
41
48
  // (libmp3lame) emitted alongside the original so chat audio plays on
42
49
  // both Chrome/Android (webm/opus) and iOS Safari (which can't decode opus).
43
50
  | "mp3";
44
51
 
45
- /** 1-char alias used in R2 keys / wire `presets` string. */
52
+ /** 1-char alias used in storage keys / wire `presets` string. */
46
53
  export const PRESET_SHORT: Record<VariantPreset, string> = {
47
54
  thumb: "q",
48
55
  sm: "s",
@@ -53,7 +60,12 @@ export const PRESET_SHORT: Record<VariantPreset, string> = {
53
60
  poster: "p",
54
61
  video: "v",
55
62
  aiproxy: "a",
56
- // 3 chars, NOT a 1-char alias: the asset-manager has no short-form for
63
+ // `h` free in both directions: no other preset claims it, and no
64
+ // multi-character token that `stripMultiCharTokens` removes donates one
65
+ // (`transform-<hex>` → t/r/a/n/s/f/o/m + a–f; `pr`; `mp3`). So a server that
66
+ // starts emitting `h` cannot make an OLDER client answer `true` to anything.
67
+ hls: "h",
68
+ // 3 chars, NOT a 1-char alias: the server has no short-form for
57
69
  // audio so its `shortPreset("mp3")` falls through to the literal token,
58
70
  // and the deployed server already writes the `-mp3.mp3` variant + emits
59
71
  // the bare `mp3` token in the wire `presets` string. Must stay in lockstep.
@@ -74,6 +86,9 @@ export const PRESET_EXT: Record<VariantPreset, string> = {
74
86
  poster: "webp",
75
87
  video: "mp4",
76
88
  aiproxy: "mp4",
89
+ // The ladder's ENTRY file. Never used to build a key — see `getAssetUrl`,
90
+ // which refuses to derive `<sha>-h.m3u8` because no such object exists.
91
+ hls: "m3u8",
77
92
  mp3: "mp3",
78
93
  };
79
94
 
@@ -88,6 +103,10 @@ export const PRESET_MAX_DIM: Record<VariantPreset, number | null> = {
88
103
  poster: null,
89
104
  video: null,
90
105
  aiproxy: null,
106
+ // A ladder has no single max side — it has a rung per size. `null` keeps it
107
+ // out of `getAssetSrcSet`, where offering an `.m3u8` as an `<img>` candidate
108
+ // would be nonsense. Its ceiling is `variants.find(v => v.preset === "hls").height`.
109
+ hls: null,
91
110
  // audio has no pixel dimensions; `null` keeps mp3 out of the
92
111
  // dimension-based `getAssetSrcSet` / `computeVariantDimensions` logic.
93
112
  mp3: null,
@@ -102,11 +121,11 @@ export type AssetVariant = {
102
121
  preset: VariantPreset;
103
122
  /** Public CDN URL of this variant. */
104
123
  url: string;
105
- /** Pixel width. Absent for `original`-only assets where sharp was skipped, or for video presets. */
124
+ /** Pixel width. Absent for `original`-only assets where image processing was skipped, or for video presets. */
106
125
  width?: number;
107
126
  /** Pixel height. Same caveat as `width`. */
108
127
  height?: number;
109
- /** Byte size of the variant file on R2. */
128
+ /** Byte size of the stored variant file. */
110
129
  bytes: number;
111
130
  /**
112
131
  * Where the bytes for this variant came from. Useful for quality
@@ -120,11 +139,110 @@ export type AssetVariant = {
120
139
  *
121
140
  * Absent on variants written before the trace field existed.
122
141
  */
123
- sourceFrom?: VariantPreset | "upload";
142
+ sourceFrom?:
143
+ | VariantPreset
144
+ | "upload"
145
+ // Written by the on-demand `/t/` route (`transform-<hash>` entries).
146
+ | "transform-route"
147
+ // The two ways an `hls` entry comes to exist: written by the transcode that
148
+ // produced the ladder, or read back off the CDN by the backfill/self-heal.
149
+ | "hls-transcode"
150
+ | "cdn-probe";
124
151
  /** ISO timestamp this variant was written. Absent on pre-trace variants. */
125
152
  createdAt?: string;
153
+ /**
154
+ * **`preset: "hls"` only** — the ladder's rungs, in MASTER ORDER.
155
+ *
156
+ * The reason this exists: an entry that only says *there is a ladder* leaves
157
+ * a consumer that plans a composition exactly as blind as no entry at all,
158
+ * because the ceiling of a composition is its weakest ingredient and there is
159
+ * no upscale. Before this field the only way to learn a clip's real rungs was
160
+ * to fetch the master playlist — or worse, download the asset.
161
+ *
162
+ * `rungs[0]` is the rung every client OPENS on (RFC 8216 §6.3.4 for native
163
+ * HLS; hls.js with `startLevel` unset uses "the first level in the
164
+ * manifest"), so the order is a delivery fact — do not sort it in place.
165
+ *
166
+ * Use {@link hlsLadderAlignment} rather than eyeballing `segments`: a ladder
167
+ * can be complete and still unable to adapt.
168
+ */
169
+ rungs?: HlsRung[];
170
+ };
171
+
172
+ /**
173
+ * One rung of an adaptive HLS ladder.
174
+ *
175
+ * `segments` / `durationSec` are what make a ladder JUDGEABLE rather than
176
+ * merely present. Measured on a prod ladder of an 87 s 4K source: 240p cut at
177
+ * 14.35 s in 12 segments, 720p at 5.63 s in 12, 2160p at 5.88 s in 14. Cuts
178
+ * that do not line up cannot be swapped, and a swap is what a rendition switch
179
+ * IS — so that ladder looked complete and adapted badly. One segment means zero
180
+ * switch points: whichever rung the player opens on is the rung it finishes on.
181
+ *
182
+ * Both are optional because a rung whose playlist could not be read is recorded
183
+ * WITHOUT them rather than with a zero — unmeasured and none are different
184
+ * facts, and a `0` there would read as the latter.
185
+ */
186
+ export type HlsRung = {
187
+ /** Rung directory / identity — `"720p"`. */
188
+ name: string;
189
+ /** `RESOLUTION` from the master playlist. */
190
+ width: number;
191
+ height: number;
192
+ /** `BANDWIDTH` in bits per second. */
193
+ bandwidth: number;
194
+ /** Absolute URL of this rung's media playlist. */
195
+ url: string;
196
+ /** `#EXTINF` count. */
197
+ segments?: number;
198
+ /** Sum of the `#EXTINF` values, seconds. */
199
+ durationSec?: number;
126
200
  };
127
201
 
202
+ /**
203
+ * The ladder of an asset, or `null` when it has none / the DTO does not carry
204
+ * `variants` (the slim list shape never does — use `hasPreset(a, "hls")` there).
205
+ */
206
+ export function getHlsLadder(
207
+ asset: Pick<AssetDTO, "variants">,
208
+ ): AssetVariant | null {
209
+ return asset.variants?.find((v) => v.preset === "hls") ?? null;
210
+ }
211
+
212
+ /**
213
+ * What the recorded rungs actually support — computed here, never stored, so
214
+ * one rule serves every consumer and a change to it does not need a backfill.
215
+ *
216
+ * - `switchable`: more than one rung AND more than one segment. False means the
217
+ * player is pinned to whatever rung it opens on for the entire clip.
218
+ * - `aligned`: every measured rung reports the same segment count. `null` means
219
+ * fewer than two rungs were measured — **unknown, not false.** Treating that
220
+ * as `false` rejects ladders nobody looked at.
221
+ * - `ceilingHeight`: the tallest rung. The ceiling of any composition using it.
222
+ */
223
+ export function hlsLadderAlignment(rungs: HlsRung[]): {
224
+ rungCount: number;
225
+ ceilingHeight: number;
226
+ floorHeight: number;
227
+ switchable: boolean;
228
+ aligned: boolean | null;
229
+ minSegments: number | null;
230
+ } {
231
+ const heights = rungs.map((r) => r.height);
232
+ const measured = rungs
233
+ .map((r) => r.segments)
234
+ .filter((s): s is number => typeof s === "number" && s > 0);
235
+ const minSegments = measured.length > 0 ? Math.min(...measured) : null;
236
+ return {
237
+ rungCount: rungs.length,
238
+ ceilingHeight: heights.length > 0 ? Math.max(...heights) : 0,
239
+ floorHeight: heights.length > 0 ? Math.min(...heights) : 0,
240
+ switchable: rungs.length > 1 && (minSegments ?? 0) > 1,
241
+ aligned: measured.length >= 2 ? new Set(measured).size === 1 : null,
242
+ minSegments,
243
+ };
244
+ }
245
+
128
246
  /**
129
247
  * Compact wire shape — what the server actually sends. Aliases (`w`, `h`,
130
248
  * `dur`) are intentional to shave bytes per asset on dense lists.
@@ -181,8 +299,8 @@ export {
181
299
  getPaletteCssVars,
182
300
  getTextColorForBackground,
183
301
  iteratePaletteSwatches,
184
- relativeLuminance,
185
302
  pickAmbientBackground,
303
+ relativeLuminance,
186
304
  } from "./palette";
187
305
 
188
306
  import type { AssetPalette } from "./palette";
@@ -208,7 +326,7 @@ export function getCdnBase(): string {
208
326
  // Tenant scope
209
327
  //
210
328
  // Post-May-2026 the CDN serves variants under a tenant-prefixed path
211
- // `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see asset-manager
329
+ // `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see the server
212
330
  // `variantKey`). Variant URL builders MUST include that prefix or every
213
331
  // URL 404s. The tenant id is process-global (one tenant per client/app),
214
332
  // set once at boot — `NitidaClient` does this from its `tenantId` option;
@@ -268,15 +386,13 @@ const ORIGINAL_EXT_BY_MIME: Record<string, string> = {
268
386
  "video/mp4": "mp4",
269
387
  "video/webm": "webm",
270
388
  "video/quicktime": "mov",
271
- // Audio — ausentes hasta 2026-08-17, y su ausencia costó un rodeo entero en
272
- // neo (`withRealOriginalExt`), que existe SÓLO porque esta tabla devolvía el
273
- // centinela `bin` para toda nota de voz. Medido en producción: `-o.bin` da
274
- // 404 y `-o.m4a` da 200.
389
+ // Audio — absent until 2026-08-17. While they were missing this table
390
+ // returned the `bin` sentinel for every voice note, so consumers had to
391
+ // hand-roll the extension themselves. Measured: `-o.bin` 404s, `-o.m4a` 200s.
275
392
  //
276
- // ⚠️ Se keyean por el mime COMPLETO, no por el subtipo: `audio/mp4` guarda
277
- // `.m4a` y `video/mp4` guarda `.mp4`. Un `switch` sobre el subtipo `mp4` no
278
- // puede distinguirloses el error que un consumidor cometió y tuvo que
279
- // corregir por su cuenta.
393
+ // ⚠️ These are keyed by the FULL mime, not the subtype: `audio/mp4` is stored
394
+ // as `.m4a` and `video/mp4` as `.mp4`. A `switch` on the `mp4` subtype cannot
395
+ // tell them apart a mistake worth not repeating.
280
396
  "audio/mpeg": "mpga",
281
397
  "audio/mp4": "m4a",
282
398
  "audio/x-m4a": "m4a",
@@ -344,6 +460,19 @@ export function getAssetUrl(
344
460
  const ext = asset.oext || originalExtForMime(asset.mime);
345
461
  return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT.original}.${ext}`;
346
462
  }
463
+ if (preset === "hls") {
464
+ // A ladder is a PREFIX (`<sha16>-hls<dslHash>/master.m3u8`), not a
465
+ // `<sha16>-h.m3u8` file, and `<dslHash>` is a server-side hash. Deriving a
466
+ // key from the pattern below would produce a URL that 404s on every asset
467
+ // — the same class of polite lie that once pointed a large share of assets
468
+ // at a `-o.<ext>` that was never written. So: the stored URL when the DTO
469
+ // carries it, else the
470
+ // transform route, which 302s to the master and BUILDS the ladder if it is
471
+ // missing. Both are real; neither is a guess.
472
+ const stored = asset.variants?.find((v) => v.preset === "hls")?.url;
473
+ if (stored) return stored;
474
+ return `${cdnBaseUrl}/t/format=hls/${asset.sha}.m3u8`;
475
+ }
347
476
  return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
348
477
  }
349
478
 
@@ -353,7 +482,7 @@ export function getAssetUrl(
353
482
  * Reads `dto.presets` — the compact 1-char code string the server sends on EVERY shape, including
354
483
  * the slim list/resolver one that carries no `variants` at all. That is why this exists and why it
355
484
  * stays the right existence check even now that `GET /assets/:id` really does send `variants`
356
- * (it did not until 2026-08-17; doc 240 §4.3).
485
+ * (it did not until the 2026-08-17 deploy).
357
486
  *
358
487
  * @example
359
488
  * ```ts
@@ -378,7 +507,7 @@ export function hasPreset(
378
507
  *
379
508
  * `presets` is documented as a concatenation of 1-char codes, and membership is
380
509
  * a 1-char substring test — so any longer token is a false-positive generator.
381
- * Servers before the 2026-08-17 deploy emitted several (doc 240 §4.3b):
510
+ * Servers before the 2026-08-17 deploy emitted several:
382
511
  *
383
512
  * | token in the string | letters it donates | presets it falsely answers |
384
513
  * |---|---|---|
@@ -386,14 +515,15 @@ export function hasPreset(
386
515
  * | `pr` (probe) | p r | `poster` |
387
516
  * | `mp3` (audio) | m p | `md` `poster` — stripped here since forever |
388
517
  *
389
- * Measured against production: **62 % of live assets** carried a polluted
390
- * string, and **16 299 of them were told they have an `original` they do not**
391
- * — which builds a `-o.<ext>` URL that 404s. (`aiproxy`: 16 479. `sm`/`md`: 487.
392
- * The `pr` `poster` collision is real but has 0 instances today.)
518
+ * This is not theoretical: on a corpus written by pre-2026-08-17 servers the
519
+ * majority of rows carried a polluted string, and a large minority of those
520
+ * were told they have an `original` they do not — which builds a `-o.<ext>`
521
+ * URL that 404s. `aiproxy` is affected at the same order of magnitude, `sm`
522
+ * and `md` far less, and the `pr` → `poster` collision is real but rare.
393
523
  *
394
- * Current servers no longer emit these, but this stays: an older
395
- * `asset-manager` keeps sending them, and this is the check every guide points
396
- * at as the reliable one. Order matters — strip the longest tokens first.
524
+ * Current servers no longer emit these, but this stays: older server deploys
525
+ * keep sending them, and this is the check every guide points at as the
526
+ * reliable one. Order matters — strip the longest tokens first.
397
527
  */
398
528
  function stripMultiCharTokens(presets: string): string {
399
529
  return presets
package/src/palette.ts CHANGED
@@ -100,8 +100,8 @@ export function relativeLuminance(hex: string): number {
100
100
  }
101
101
 
102
102
  /**
103
- * Razón de contraste WCAG entre dos luminancias: `(L1 + 0.05) / (L2 + 0.05)`,
104
- * con la más clara arriba. 1 = idénticos, 21 = negro contra blanco.
103
+ * WCAG contrast ratio between two luminances: `(L1 + 0.05) / (L2 + 0.05)`,
104
+ * lighter on top. 1 = identical, 21 = black against white.
105
105
  */
106
106
  export function contrastRatio(l1: number, l2: number): number {
107
107
  const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];
@@ -109,37 +109,38 @@ export function contrastRatio(l1: number, l2: number): number {
109
109
  }
110
110
 
111
111
  /**
112
- * Negro o blanco, el que **de verdad** contraste más sobre este fondo.
112
+ * Black or white whichever **actually** contrasts more against this
113
+ * background.
113
114
  *
114
- * ⚠️ CORREGIDO 2026-08-17. Antes decidía con `L > 0.5`, y **0,5 no es el punto
115
- * de empate**. Igualando las dos razones de contraste:
115
+ * The tie point is NOT `L > 0.5`, which is the threshold most implementations
116
+ * reach for. Equating the two contrast ratios gives it exactly:
116
117
  *
117
118
  * (1 + 0.05) / (L + 0.05) = (L + 0.05) / 0.05
118
119
  * (L + 0.05)² = 0.0525
119
120
  * L = 0.1791…
120
121
  *
121
- * Entre **0,179 y 0,5** el código viejo elegía BLANCO cuando negro contrastaba
122
- * másuna banda ancha, y justo donde caen los colores de marca saturados.
123
- * Medido sobre cuatro paletas reales de producción: **8 swatches** en esa banda.
124
- * El peor, el dorado dominante `#CA9528`: devolvía blanco a **2,68:1** cuando
125
- * negro da **7,84:1**. 2,68 no pasa AA ni para texto grande.
122
+ * Between **0.179 and 0.5** a `L > 0.5` test picks WHITE while black contrasts
123
+ * morea wide band, and exactly where saturated brand colours land. A
124
+ * mid-luminance gold in that band can be handed white at **2.68:1** when black
125
+ * would give **7.84:1**; 2.68 does not pass AA even for large text.
126
126
  *
127
- * No se cambia el umbral por 0,179: se **calculan las dos razones y gana la
128
- * mayor**. Un umbral es una constante que hay que mantener correcta; la
129
- * comparación es correcta por construcción.
127
+ * So this does not swap one threshold for another: it **computes both ratios
128
+ * and returns the winner**. A threshold is a constant somebody has to keep
129
+ * correct; the comparison is correct by construction.
130
130
  *
131
- * ⚠️ Esto elige el MEJOR de dos, no garantiza que alcance. Sobre un fondo de
132
- * luminancia media el mejor par puede quedar por debajo de 4,5:1 igual — para
133
- * eso está {@link bestTextContrast}, que además devuelve el número.
131
+ * ⚠️ This picks the BETTER of two it does not guarantee the result is
132
+ * enough. Over a mid-luminance background the best pair can still land under
133
+ * 4.5:1. Use {@link bestTextContrast} when you need to know: it returns the
134
+ * ratio it achieved.
134
135
  */
135
136
  function textColorForHex(hex: string): "#000000" | "#FFFFFF" {
136
137
  return bestTextContrast(hex).color;
137
138
  }
138
139
 
139
140
  /**
140
- * Igual que el color recomendado, pero devuelve también **la razón lograda** y
141
- * si pasa AA para que quien lo use pueda decidir con el número a la vista en
142
- * vez de asumir que alcanzó.
141
+ * Same choice as the recommended text colour, but it also returns **the ratio
142
+ * it achieved** and whether that passes AA so a caller can decide with the
143
+ * number in front of them instead of assuming it was enough.
143
144
  */
144
145
  export function bestTextContrast(hex: string): {
145
146
  color: "#000000" | "#FFFFFF";
package/src/slots.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * Slots give tenants a way to attach stable, human-readable names
5
5
  * ("webapp.wizard.pool-type.icon-1", "storefront.cr.hero-video.landscape_hd_16x9.mp4")
6
6
  * to assets they uploaded. Consumers resolve names → AssetDTOs at
7
- * build / runtime so their source never hardcodes a CDN URL; the
8
- * admin rebinds a slot from `asset-lab-web` and every consumer picks
9
- * up the swap on cache refresh.
7
+ * build / runtime so their source never hardcodes a CDN URL; an
8
+ * admin rebinds a slot in the platform console and every consumer
9
+ * picks up the swap on cache refresh.
10
10
  *
11
11
  * Two layers in this package:
12
12
  * - `resolveSlot` / `resolveSlots` — universal (server, edge,
@@ -20,7 +20,7 @@ import type { AssetDTO, VariantPreset } from "./index";
20
20
  import { getAssetUrl, hasPreset } from "./index";
21
21
 
22
22
  // ---------------------------------------------------------------------------
23
- // Wire shape — matches `apps/asset-manager/src/features/assets/slots.routes.ts`
23
+ // Wire shape — matches the server's slots route.
24
24
  // ---------------------------------------------------------------------------
25
25
 
26
26
  export type SlotDTO = {
@@ -53,14 +53,11 @@ export type SlotResolution = {
53
53
  const DEFAULT_TTL_MS = 60_000;
54
54
  const cache = new Map<string, { fetchedAt: number; value: SlotDTO | null }>();
55
55
 
56
- // Deliberately the generated `run.app` URL, NOT the branded
57
- // `api.nitida.gofuture.space` the docs publish this is the fallback for a
58
- // caller that configured nothing, so it should be the address with the fewest
59
- // moving parts. Cloud Run guarantees the generated URL forever; the branded name
60
- // is an additional domain mapping that depends on DNS and a managed certificate
61
- // (and on 2026-08-17 we learned how many ways those two can go sideways —
62
- // doc 242 §6.3). Both names hit the same service and return identical bodies.
63
- let endpoint = "https://aquienpz-asset-manager-nlchzy26qa-uc.a.run.app";
56
+ // The published API host — the same address the docs give out, so a caller
57
+ // that configured nothing still talks to the documented endpoint. Override it
58
+ // with `configureSlotResolver({ endpoint })` to point at a different
59
+ // deployment.
60
+ let endpoint = "https://api.nitida.gofuture.space";
64
61
  let apiKey: string | null = null;
65
62
  let tenantCode: string | null = null;
66
63
 
@@ -70,8 +67,8 @@ let tenantCode: string | null = null;
70
67
  *
71
68
  * configureSlotResolver({
72
69
  * endpoint: process.env.AQUIENPZ_URL,
73
- * apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY,
74
- * tenantCode: "realtyone-cr",
70
+ * apiKey: process.env.AQUIENPZ_API_KEY, // amk_rt_* — server-only
71
+ * tenantCode: "acme-co",
75
72
  * });
76
73
  */
77
74
  export function configureSlotResolver(opts: {
package/src/transform.ts CHANGED
@@ -2,10 +2,9 @@
2
2
  * On-the-fly transform URL builder.
3
3
  *
4
4
  * Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated
5
- * here hashes to the same R2 cache key as the server's canonical form.
5
+ * here hashes to the same cache key as the server's canonical form.
6
6
  *
7
- * Canonicalization rules (keep in sync with
8
- * `apps/asset-manager/src/features/assets/transform.dsl.ts`):
7
+ * Canonicalization rules (kept in sync with the server):
9
8
  * - Drop entries whose value is `undefined`
10
9
  * - Sort keys alphabetically
11
10
  * - Numbers rendered without leading zeros or trailing dots
@@ -23,7 +22,7 @@ import type { AssetDTO } from "./index";
23
22
  import { getCdnBase } from "./index";
24
23
 
25
24
  /**
26
- * Widths the CDN edge whitelists (DoS guard — see `apps/cdn-proxy` WHITELIST_WIDTHS).
25
+ * Widths the CDN edge whitelists (DoS guard).
27
26
  * Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the
28
27
  * 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import
29
28
  * this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.
@@ -77,9 +76,9 @@ export type TransformOptions = {
77
76
  height?: number;
78
77
  /** Resize fit mode. Default `cover` server-side. */
79
78
  fit?: TransformFit;
80
- /** Crop gravity. `auto` uses sharp's `attention` strategy. */
79
+ /** Crop gravity. `auto` picks the region with the most visual salience. */
81
80
  gravity?: TransformGravity;
82
- /** Output format. `auto` → policy decides (see asset-manager bench). */
81
+ /** Output format. `auto` → the platform's policy decides. */
83
82
  format?: TransformFormat;
84
83
  /** Output quality. `auto` → format-specific default. */
85
84
  quality?: "auto" | number;
@@ -90,16 +89,15 @@ export type TransformOptions = {
90
89
  *
91
90
  * - `removebg`: remove the background; output is a transparent PNG
92
91
  * of the foreground subject. Forces `format=png` regardless of
93
- * other format hints. Runs U²-Net ONNX locally (or BRIA via
94
- * Replicate when `BG_REMOVAL_BACKEND=replicate`). Single cache
95
- * miss per (sha, dsl) tuple; subsequent identical DSLs serve
96
- * from R2 — no inference, no per-image cost.
92
+ * other format hints. A single cache miss per (sha, dsl) tuple;
93
+ * subsequent identical DSLs serve from cache — no inference, no
94
+ * per-image cost.
97
95
  *
98
- * - `genfill`: aspect-extension outpaint via Flux-Fill Pro on
99
- * Replicate. Requires BOTH `width` and `height` — the server
96
+ * - `genfill`: aspect-extension outpaint. Requires BOTH `width`
97
+ * and `height` — the server
100
98
  * fits the source centered into the target canvas and outpaints
101
99
  * the gutters. Output is PNG (forced) at exactly target dims.
102
- * ~$0.05/image first time; same R2 cache as removebg after.
100
+ * ~$0.05/image first time; same cache as removebg after.
103
101
  * Primary use case: building OG cards (1200×630) from portrait
104
102
  * listing photos without awkward edge mirroring.
105
103
  */
@@ -121,7 +119,7 @@ export type TransformOptions = {
121
119
  * the escape hatch for SIGNED URLs that need an off-ladder custom width.
122
120
  *
123
121
  * The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid
124
- * `?sig=` earns the whitelist bypass at the worker (the asset-manager still
122
+ * `?sig=` earns the whitelist bypass at the edge (the server still
125
123
  * does the real HMAC check). So a custom width is ONLY safe when the URL is
126
124
  * signed — hence this type is accepted exclusively by the signing helpers
127
125
  * ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),
@@ -181,10 +179,10 @@ export function serializeTransform(opts: SignedTransformOptions): string {
181
179
  function extForOptions(opts: SignedTransformOptions): string {
182
180
  // effect=removebg forces PNG output server-side (needs alpha).
183
181
  if (opts.effect === "removebg") return "png";
184
- // effect=genfill defaults to WebP (12× lighter than the raw Flux-Fill
182
+ // effect=genfill defaults to WebP (12× lighter than the raw generated
185
183
  // PNG output with no visible loss at q=85). Explicit `format=png`
186
184
  // opts back into lossless for print / marketing fold-outs. The server
187
- // re-encodes Flux's PNG → target format before R2 cache.
185
+ // re-encodes the generated PNG → target format before caching.
188
186
  if (opts.effect === "genfill") {
189
187
  switch (opts.format) {
190
188
  case "png":
@@ -222,8 +220,8 @@ function extForOptions(opts: SignedTransformOptions): string {
222
220
  * Build a transform URL for a VIDEO asset. Same DSL shape as image
223
221
  * transforms; the server branches on the asset's `kind` column. Video
224
222
  * URLs use `.mp4` (default) or `.webm` extension and on cache miss the
225
- * server returns 202 Accepted while a Cloud Run Job encodes the clip;
226
- * subsequent GETs return 302 to the cached R2 object.
223
+ * server returns 202 Accepted while a background job encodes the clip;
224
+ * subsequent GETs return 302 to the cached object.
227
225
  *
228
226
  * <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}
229
227
  * autoPlay muted loop playsInline />
@@ -264,15 +262,30 @@ export function getVideoTransformUrl(
264
262
  * if (prefersNativeHls(video)) {
265
263
  * video.src = src;
266
264
  * } else {
267
- * // Defaults open at a fixed low rung measure instead of guessing.
268
- * const hls = new Hls({ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 });
265
+ * const hls = new Hls({ capLevelToPlayerSize: false, abrEwmaDefaultEstimate: 5_000_000 });
269
266
  * hls.loadSource(src);
270
267
  * hls.attachMedia(video);
271
268
  * }
272
269
  * ```
273
270
  *
274
- * On first request the server returns 202 Accepted while a Cloud Run
275
- * Job transcodes the ladder (typically 1-3 min for a 90 s source);
271
+ * ⚠️ **Do NOT pass `startLevel: -1` with `testBandwidth: true`.** That pair is
272
+ * documented by hls.js as *"forces the player to download a fragment from the
273
+ * lowest level to establish a bandwidth estimate"* — on a clip short enough to
274
+ * be one segment, the probe IS the whole video, and it plays at the bottom
275
+ * rung from first frame to last. (This doc-comment recommended exactly that
276
+ * until 2026-08-18; a 5.042 s 4K asset was measured being delivered at
277
+ * 426x240 because of it.) Leave `startLevel` unset: hls.js then opens on the
278
+ * FIRST level in the manifest, and the server puts the right one there —
279
+ * a mid rung for long video, the top rung for a clip under 18 s, which is the
280
+ * same rung native HLS opens on per RFC 8216 §6.3.4. The ladder decides; the
281
+ * player should not second-guess it.
282
+ *
283
+ * `capLevelToPlayerSize` is worth disabling explicitly: `@videojs/core`
284
+ * defaults it to `true`, which caps quality to the player's rendered pixel box,
285
+ * so a small inline player is pinned to 240p/360p on any connection.
286
+ *
287
+ * On first request the server returns 202 Accepted while a background
288
+ * job transcodes the ladder (typically 1-3 min for a 90 s source);
276
289
  * subsequent requests get 302 to the cached master.m3u8. Keep the
277
290
  * progressive MP4 as a fallback source for that window.
278
291
  *