@nitida/asset-client 0.23.0 → 0.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitida/asset-client",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "nitida URL builders — construct image, video and HLS URLs for the nitida CDN. No network, no key, no config beyond a tenant id.",
5
5
  "private": false,
6
6
  "publishConfig": {
package/src/index.ts CHANGED
@@ -1082,6 +1082,8 @@ export {
1082
1082
  type PrivateTransformOptions,
1083
1083
  type SignedTransformOptions,
1084
1084
  type SignTransformOptions,
1085
+ getByteBudgetTransformUrl,
1086
+ hasSizeLadder,
1085
1087
  serializeTransform,
1086
1088
  signTransformUrl,
1087
1089
  TRANSFORM_WIDTHS,
package/src/transform.ts CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { assertPublic, assertSha, type VisibilityHint } from "./access";
22
22
  import type { AssetDTO } from "./index";
23
- import { getCdnBase, getTenantId } from "./index";
23
+ import { getCdnBase, getTenantId, hasPreset } from "./index";
24
24
 
25
25
  /**
26
26
  * Widths the CDN edge whitelists (DoS guard).
@@ -93,43 +93,36 @@ export type TransformOptions = {
93
93
  /** Output format. `auto` → the platform's policy decides. */
94
94
  format?: TransformFormat;
95
95
  /**
96
- * @deprecated **Do not set this.** Omit the key that is the correct call for
97
- * every surface this platform serves. Kept in the type for one narrow case
98
- * (a hard, MEASURED byte budget), and struck through on purpose so reaching
99
- * for it is a decision, not an accident.
96
+ * **`quality` no existe en este tipo, y eso es deliberado.**
100
97
  *
101
- * There are only two things you can pass, and neither is worth having:
98
+ * No es un dial del encoder: elige QUÉ BYTES decodifica el servidor. Con un
99
+ * número, `/t/` salta a la variante almacenada más chica que cubra el pedido
100
+ * — que ya pasó por una compresión — y la salida es una segunda generación.
101
+ * Sobre un asset CON escalera eso es pérdida pura (medido: +2…+8 % de peso y
102
+ * −0,50…−0,85 dB). Y `"auto"` era un no-op: byte a byte idéntico a omitir la
103
+ * clave, mismo sha256.
102
104
  *
103
- * **`"auto"` is a no-op.** Byte-for-byte identical to omitting the key
104
- * measured 2026-08-31 on a production laddered asset at `width=1920`:
105
- * both answered 136 680 B, sha256 `3a9ba57d…`, `x-transform-source:
106
- * original`. It buys nothing but the illusion of having chosen.
105
+ * El único uso legítimo —un presupuesto de bytes medido, sobre un asset SIN
106
+ * escalera— tiene su propia puerta, que no se puede llamar a ciegas:
107
+ * {@link getByteBudgetTransformUrl}.
107
108
  *
108
- * **A NUMBER costs you image quality.** It is not an encoder knob — it
109
- * selects WHICH BYTES the server decodes. With a number in hand, `/t/`
110
- * short-circuits to the smallest STORED variant that covers the request, and
111
- * that variant already went through one lossy pass, so the output is a second
112
- * generation. `"auto"` is a *string*, fails that `typeof` test, and therefore
113
- * keeps the master path. Measured on two corpora:
109
+ * ## Por qué no alcanzaba con documentarlo
114
110
  *
115
- * - 5 photographs, `width=800` 7–9 % smaller **and worse 5 of 5**, down
116
- * to −3.01 dB PSNR (`/guides/transform-benchmark/`).
117
- * - 5056 px architectural renders at 1280 / 1920 / 3840 — the widths that
118
- * MATCH `md`/`lg`/`xl` exactly, so no downscale hides the first pass
119
- * **+2…+8 % HEAVIER and −0.50…−0.85 dB, 3 of 3**. Not even a byte saving
120
- * to trade for it.
111
+ * Estuvo `@deprecated` con la medición al lado durante exactamente una
112
+ * versión, y eso ya era mejor que nada. Pero un aviso **avisa**; no impide.
113
+ * `getTransformUrl` recibe un `Pick<AssetDTO, "sha">` —un sha y nada más—,
114
+ * así que un `quality: 75` en el call site era **ciego**: nadie ahí, humano o
115
+ * modelo, podía saber si ese asset tenía escalera. La misma línea era
116
+ * correcta o dañina según un dato que no estaba en la llamada.
121
117
  *
122
- * Raising the number does not undo it: doubly-compressed at 80 is worse than
123
- * single-pass at 60, and 31 % heavier.
118
+ * Y hay un llamador que no lee tildados: **un modelo generando código.**
119
+ * `quality` es el parámetro que todo el mundo espera encontrar en una API de
120
+ * imágenes, así que se escribe solo. Next.js llegó a la misma conclusión por
121
+ * el mismo camino y dejó de recomendar `quality` por imagen: la configuración
122
+ * ya decide, y un valor por llamada sólo agrega formas de equivocarse.
124
123
  *
125
- * If you truly have a byte budget, pin it AND upload that asset with
126
- * `presets: ["original"]` — with no ladder there is nothing to short-circuit
127
- * to, and the pin becomes an honest encoder setting again.
128
- *
129
- * The one-line check is on the response: `x-transform-source` names the
130
- * variant the edge decoded. `original` = one pass; `md`/`lg`/`xl` = two.
124
+ * El parámetro no se documenta como peligroso. **No se puede escribir.**
131
125
  */
132
- quality?: "auto" | number;
133
126
  /** Device pixel ratio. Width/height are multiplied by this before resize. */
134
127
  dpr?: 1 | 2 | 3;
135
128
  /**
@@ -195,6 +188,14 @@ export type SignedTransformOptions = TransformOptions;
195
188
  export type PrivateTransformOptions = Omit<TransformOptions, "width"> & {
196
189
  width?: number;
197
190
  };
191
+
192
+ /**
193
+ * `TransformOptions` + el `quality` que el tipo público ya no admite.
194
+ *
195
+ * INTERNO. Existe porque el serializador tiene que poder emitir `quality=` para
196
+ * {@link getByteBudgetTransformUrl}; no porque un llamador deba construirlo.
197
+ */
198
+ type WithPinnedQuality<T> = T & { quality: number };
198
199
  /** @deprecated internal alias kept for the builders below. */
199
200
  type AnyWidthTransformOptions = PrivateTransformOptions;
200
201
 
@@ -232,13 +233,13 @@ export function extractAssetSha(url: string | null | undefined): string | null {
232
233
  return m ? m[1]! : null;
233
234
  }
234
235
 
235
- export function serializeTransform(opts: AnyWidthTransformOptions): string {
236
+ export function serializeTransform(
237
+ opts: AnyWidthTransformOptions | WithPinnedQuality<AnyWidthTransformOptions>,
238
+ ): string {
236
239
  const entries: Array<[string, string]> = [];
237
- const keys = Object.keys(opts).sort() as Array<
238
- keyof AnyWidthTransformOptions
239
- >;
240
+ const keys = Object.keys(opts).sort();
240
241
  for (const k of keys) {
241
- const v = opts[k];
242
+ const v = (opts as Record<string, unknown>)[k];
242
243
  if (v == null) continue;
243
244
  const serialized = typeof v === "string" ? v.toLowerCase() : String(v);
244
245
  entries.push([k, serialized]);
@@ -246,7 +247,9 @@ export function serializeTransform(opts: AnyWidthTransformOptions): string {
246
247
  return entries.map(([k, v]) => `${k}=${v}`).join(",");
247
248
  }
248
249
 
249
- function extForOptions(opts: AnyWidthTransformOptions): string {
250
+ function extForOptions(
251
+ opts: AnyWidthTransformOptions | WithPinnedQuality<AnyWidthTransformOptions>,
252
+ ): string {
250
253
  // effect=removebg forces PNG output server-side (needs alpha).
251
254
  if (opts.effect === "removebg") return "png";
252
255
  // effect=genfill defaults to WebP (12× lighter than the raw generated
@@ -395,7 +398,7 @@ export { buildTransformUrl as getTransformUrlUnchecked };
395
398
 
396
399
  function buildTransformUrl(
397
400
  asset: Pick<AssetDTO, "sha">,
398
- opts: AnyWidthTransformOptions,
401
+ opts: AnyWidthTransformOptions | WithPinnedQuality<AnyWidthTransformOptions>,
399
402
  ): string | null {
400
403
  const dsl = serializeTransform(opts);
401
404
  if (!dsl) return null;
@@ -426,6 +429,119 @@ export function getTransformUrl(
426
429
  * Returns `null` only when `opts` serialize to an empty DSL (no transform
427
430
  * requested) — same contract as {@link getTransformUrl}.
428
431
  */
432
+ /**
433
+ * Los presets de TAMAÑO — los únicos cuya existencia convierte un `quality`
434
+ * pinneado en una segunda compresión.
435
+ *
436
+ * `original`, `poster`, `video`, `aiproxy`, `hls` y `mp3` NO cuentan: `/t/`
437
+ * nunca los usa como fuente de una imagen redimensionada.
438
+ */
439
+ const LADDER_PRESETS = ["thumb", "sm", "md", "lg", "xl"] as const;
440
+
441
+ /**
442
+ * ¿Este asset tiene variantes de tamaño almacenadas, o sea algo a lo que `/t/`
443
+ * pueda saltar?
444
+ *
445
+ * `null` cuando **no se sabe** — un DTO sin `presets` no dice que no las tenga,
446
+ * dice que no lo trae. Tres estados, tres respuestas: un booleano acá mentiría
447
+ * en un tercio de los casos, y mentiría hacia el lado inseguro.
448
+ *
449
+ * ⚠️ **Se pregunta con `hasPreset`, JAMÁS con `presets.includes("s")`.** La
450
+ * cadena lleva tokens de varias letras —`transform-<hex>`, `upscale_…`, `pr`—
451
+ * que donan `s`, `m`, `l` y `o` de regalo. Ese es el falso positivo que
452
+ * `test/presets-false-positives.test.ts` existe para cazar.
453
+ */
454
+ export function hasSizeLadder(asset: {
455
+ presets?: string | null;
456
+ }): boolean | null {
457
+ const raw = asset.presets;
458
+ if (raw == null || raw.trim() === "") return null;
459
+ return LADDER_PRESETS.some((preset) => hasPreset(asset, preset));
460
+ }
461
+
462
+ /**
463
+ * ⭐ **La ÚNICA puerta por la que se puede pinnear un `quality` numérico** —
464
+ * y está construida para que no se pueda usar mal.
465
+ *
466
+ * ## El problema que resuelve
467
+ *
468
+ * `getTransformUrl` recibe un `Pick<AssetDTO, "sha">`: **un sha y nada más.**
469
+ * Con eso, un `quality: 75` en el call site es CIEGO — la misma línea es un
470
+ * ajuste honesto del encoder sobre un asset sin escalera, y una segunda
471
+ * compresión silenciosa sobre uno con escalera. Nada en el tipo, en el nombre
472
+ * ni en el editor distinguía los dos casos. Un aviso avisa; esto impide.
473
+ *
474
+ * ## Las dos barreras
475
+ *
476
+ * 1. **En COMPILACIÓN**: el parámetro exige `presets: string`. Un
477
+ * `{ sha }` pelado —la llamada ciega— ya no compila. Para pasar por acá hay
478
+ * que tener el DTO en la mano, y tener el DTO es saber la respuesta.
479
+ * 2. **En EJECUCIÓN**: si el asset tiene cualquier preset de tamaño, **tira**.
480
+ * Si `presets` no vino, **tira** — «no sé» nunca se resuelve como «dale».
481
+ *
482
+ * ## Cuándo es legítimo, con el número
483
+ *
484
+ * Un presupuesto de bytes que alguien MIDIÓ, sobre un asset subido con
485
+ * `presets: ["original"]`. Ahí no hay a qué saltar y el pin hace lo que su
486
+ * nombre dice. Medido 2026-08-31 contra producción, ancho 1 920, las seis
487
+ * respuestas `x-transform-source: original`:
488
+ *
489
+ * | quality | bytes | vs auto | PSNR |
490
+ * |---|---|---|---|
491
+ * | 40 | 82 112 | **−33,6 %** | 35,32 dB |
492
+ * | 60 | 106 892 | −13,5 % | 36,85 dB |
493
+ * | *(auto)* | 123 614 | — | 37,65 dB |
494
+ * | 90 | 269 026 | +117,6 % | 41,46 dB |
495
+ *
496
+ * Monótona y sin sorpresas: −33,6 % de peso por −2,32 dB. **Eso sí es un
497
+ * intercambio**, y es la razón por la que el parámetro no se borró del todo.
498
+ *
499
+ * @throws si el asset tiene escalera, si no se sabe si la tiene, o si
500
+ * `quality` no está en 1..100.
501
+ */
502
+ export function getByteBudgetTransformUrl(
503
+ asset: Pick<AssetDTO, "sha"> & { presets: string } & VisibilityHint,
504
+ opts: WithPinnedQuality<TransformOptions>,
505
+ ): string | null {
506
+ assertSha(asset, "getByteBudgetTransformUrl");
507
+ assertPublic(
508
+ asset,
509
+ "getByteBudgetTransformUrl",
510
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })",
511
+ );
512
+
513
+ if (
514
+ !Number.isInteger(opts.quality) ||
515
+ opts.quality < 1 ||
516
+ opts.quality > 100
517
+ ) {
518
+ throw new Error(
519
+ `getByteBudgetTransformUrl: quality must be an integer 1..100, got ${String(opts.quality)}. ` +
520
+ "If you do not have a measured byte budget, use getTransformUrl and omit quality entirely.",
521
+ );
522
+ }
523
+
524
+ const ladder = hasSizeLadder(asset);
525
+ if (ladder === null) {
526
+ throw new Error(
527
+ `getByteBudgetTransformUrl: asset ${asset.sha} carries no 'presets', so whether a pinned ` +
528
+ "quality would re-compress it is UNKNOWN — and unknown is not permission. Fetch the full " +
529
+ "DTO (assets.get / assets.byHash) and pass it, or use getTransformUrl with no quality.",
530
+ );
531
+ }
532
+ if (ladder) {
533
+ throw new Error(
534
+ `getByteBudgetTransformUrl: asset ${asset.sha} has stored size variants (presets="${asset.presets}"). ` +
535
+ "A pinned quality there does not set the encoder — it makes /t/ decode one of those " +
536
+ "already-compressed variants, so the output is a SECOND lossy generation: measured " +
537
+ "+2..+8% HEAVIER and -0.50..-0.85 dB. Use getTransformUrl with no quality (one pass from " +
538
+ "the master), or upload this asset with presets: [\"original\"] if the byte budget is real.",
539
+ );
540
+ }
541
+
542
+ return buildTransformUrl(asset, opts);
543
+ }
544
+
429
545
  export function getSignedTransformUrl(
430
546
  asset: Pick<AssetDTO, "sha"> & VisibilityHint,
431
547
  opts: SignedTransformOptions,