@nitida/asset-client 0.23.0 → 0.24.1
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/README.md +5 -4
- package/dist/index.cjs +65 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +101 -38
- package/dist/index.d.ts +101 -38
- package/dist/index.js +63 -14
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +2 -0
- package/src/slots.ts +56 -13
- package/src/transform.ts +165 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitida/asset-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
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": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"tsup": "^8.5.1",
|
|
45
|
-
"typescript": "
|
|
45
|
+
"typescript": "npm:@typescript/typescript6@6.0.2"
|
|
46
46
|
},
|
|
47
47
|
"keywords": [
|
|
48
48
|
"media",
|
package/src/index.ts
CHANGED
package/src/slots.ts
CHANGED
|
@@ -61,6 +61,31 @@ let endpoint = "https://api.nitida.gofuture.space";
|
|
|
61
61
|
let apiKey: string | null = null;
|
|
62
62
|
let tenantCode: string | null = null;
|
|
63
63
|
|
|
64
|
+
/** Per-call resolver scope. When passed, it fully overrides the process globals. */
|
|
65
|
+
export type SlotResolverConfig = {
|
|
66
|
+
endpoint?: string;
|
|
67
|
+
apiKey?: string | null;
|
|
68
|
+
tenantCode?: string | null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Effective config for one resolve call: explicit per-client config wins, else
|
|
73
|
+
* the process globals. This is what makes a per-client `slots.resolve()` use ITS
|
|
74
|
+
* OWN key instead of whatever client was constructed last (audit N15).
|
|
75
|
+
*/
|
|
76
|
+
function effectiveConfig(cfg?: SlotResolverConfig): {
|
|
77
|
+
endpoint: string;
|
|
78
|
+
apiKey: string | null;
|
|
79
|
+
tenantCode: string | null;
|
|
80
|
+
} {
|
|
81
|
+
if (!cfg) return { endpoint, apiKey, tenantCode };
|
|
82
|
+
return {
|
|
83
|
+
endpoint: cfg.endpoint ? cfg.endpoint.replace(/\/+$/, "") : endpoint,
|
|
84
|
+
apiKey: cfg.apiKey ?? null,
|
|
85
|
+
tenantCode: cfg.tenantCode ?? null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
64
89
|
/**
|
|
65
90
|
* Configure the resolver process-wide. Call once at boot from your
|
|
66
91
|
* storefront layout / server entry / worker init.
|
|
@@ -93,16 +118,25 @@ export function invalidateSlotCache(slotKey?: string): void {
|
|
|
93
118
|
// Internal fetch helper
|
|
94
119
|
// ---------------------------------------------------------------------------
|
|
95
120
|
|
|
96
|
-
|
|
121
|
+
type ResolvedConfig = {
|
|
122
|
+
endpoint: string;
|
|
123
|
+
apiKey: string | null;
|
|
124
|
+
tenantCode: string | null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const baseHeaders = (cfg: ResolvedConfig): Record<string, string> => {
|
|
97
128
|
const h: Record<string, string> = {};
|
|
98
|
-
if (apiKey) h.Authorization = `Bearer ${apiKey}`;
|
|
99
|
-
if (tenantCode) h["X-Tenant-Code"] = tenantCode;
|
|
129
|
+
if (cfg.apiKey) h.Authorization = `Bearer ${cfg.apiKey}`;
|
|
130
|
+
if (cfg.tenantCode) h["X-Tenant-Code"] = cfg.tenantCode;
|
|
100
131
|
return h;
|
|
101
132
|
};
|
|
102
133
|
|
|
103
|
-
async function fetchSlot(
|
|
104
|
-
|
|
105
|
-
|
|
134
|
+
async function fetchSlot(
|
|
135
|
+
slotKey: string,
|
|
136
|
+
cfg: ResolvedConfig,
|
|
137
|
+
): Promise<SlotDTO | null> {
|
|
138
|
+
const r = await fetch(`${cfg.endpoint}/slots/${encodeURIComponent(slotKey)}`, {
|
|
139
|
+
headers: baseHeaders(cfg),
|
|
106
140
|
});
|
|
107
141
|
if (r.status === 404) return null;
|
|
108
142
|
if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);
|
|
@@ -111,11 +145,12 @@ async function fetchSlot(slotKey: string): Promise<SlotDTO | null> {
|
|
|
111
145
|
|
|
112
146
|
async function fetchSlotsBulk(
|
|
113
147
|
slotKeys: string[],
|
|
148
|
+
cfg: ResolvedConfig,
|
|
114
149
|
): Promise<Record<string, SlotDTO | null>> {
|
|
115
150
|
if (slotKeys.length === 0) return {};
|
|
116
|
-
const r = await fetch(`${endpoint}/slots/resolve`, {
|
|
151
|
+
const r = await fetch(`${cfg.endpoint}/slots/resolve`, {
|
|
117
152
|
method: "POST",
|
|
118
|
-
headers: { ...baseHeaders(), "Content-Type": "application/json" },
|
|
153
|
+
headers: { ...baseHeaders(cfg), "Content-Type": "application/json" },
|
|
119
154
|
body: JSON.stringify({ keys: slotKeys }),
|
|
120
155
|
});
|
|
121
156
|
if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);
|
|
@@ -132,6 +167,12 @@ export type ResolveSlotOptions = {
|
|
|
132
167
|
preset?: VariantPreset;
|
|
133
168
|
/** TTL for the in-process cache. Default 60s. Set 0 to bypass. */
|
|
134
169
|
ttlMs?: number;
|
|
170
|
+
/**
|
|
171
|
+
* Per-client auth/endpoint scope. Omit to use the process globals
|
|
172
|
+
* (configureSlotResolver). A NitidaClient passes its own here so its
|
|
173
|
+
* resolve() never uses another client's key (audit N15).
|
|
174
|
+
*/
|
|
175
|
+
config?: SlotResolverConfig;
|
|
135
176
|
};
|
|
136
177
|
|
|
137
178
|
/**
|
|
@@ -146,14 +187,15 @@ export async function resolveSlot(
|
|
|
146
187
|
opts: ResolveSlotOptions = {},
|
|
147
188
|
): Promise<SlotResolution> {
|
|
148
189
|
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
149
|
-
const
|
|
190
|
+
const cfg = effectiveConfig(opts.config);
|
|
191
|
+
const cacheKey = `${cfg.tenantCode ?? "_"}:${slotKey}`;
|
|
150
192
|
const now = Date.now();
|
|
151
193
|
let dto: SlotDTO | null;
|
|
152
194
|
const hit = cache.get(cacheKey);
|
|
153
195
|
if (hit && now - hit.fetchedAt < ttl) {
|
|
154
196
|
dto = hit.value;
|
|
155
197
|
} else {
|
|
156
|
-
dto = await fetchSlot(slotKey);
|
|
198
|
+
dto = await fetchSlot(slotKey, cfg);
|
|
157
199
|
cache.set(cacheKey, { fetchedAt: now, value: dto });
|
|
158
200
|
}
|
|
159
201
|
return materializeResolution(dto, opts.preset);
|
|
@@ -170,11 +212,12 @@ export async function resolveSlots(
|
|
|
170
212
|
): Promise<Record<string, SlotResolution>> {
|
|
171
213
|
if (slotKeys.length === 0) return {};
|
|
172
214
|
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
215
|
+
const cfg = effectiveConfig(opts.config);
|
|
173
216
|
const now = Date.now();
|
|
174
217
|
const missing: string[] = [];
|
|
175
218
|
const out: Record<string, SlotResolution> = {};
|
|
176
219
|
for (const k of slotKeys) {
|
|
177
|
-
const cacheKey = `${tenantCode ?? "_"}:${k}`;
|
|
220
|
+
const cacheKey = `${cfg.tenantCode ?? "_"}:${k}`;
|
|
178
221
|
const hit = cache.get(cacheKey);
|
|
179
222
|
if (hit && now - hit.fetchedAt < ttl) {
|
|
180
223
|
out[k] = materializeResolution(hit.value, opts.preset);
|
|
@@ -183,10 +226,10 @@ export async function resolveSlots(
|
|
|
183
226
|
}
|
|
184
227
|
}
|
|
185
228
|
if (missing.length > 0) {
|
|
186
|
-
const resolved = await fetchSlotsBulk(missing);
|
|
229
|
+
const resolved = await fetchSlotsBulk(missing, cfg);
|
|
187
230
|
for (const k of missing) {
|
|
188
231
|
const dto = resolved[k] ?? null;
|
|
189
|
-
cache.set(`${tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
|
|
232
|
+
cache.set(`${cfg.tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
|
|
190
233
|
out[k] = materializeResolution(dto, opts.preset);
|
|
191
234
|
}
|
|
192
235
|
}
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
-
*
|
|
123
|
-
*
|
|
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
|
-
*
|
|
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,21 +233,34 @@ export function extractAssetSha(url: string | null | undefined): string | null {
|
|
|
232
233
|
return m ? m[1]! : null;
|
|
233
234
|
}
|
|
234
235
|
|
|
235
|
-
export function serializeTransform(
|
|
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()
|
|
238
|
-
|
|
239
|
-
|
|
240
|
+
const keys = Object.keys(opts).sort();
|
|
241
|
+
// DSL segments are joined with `,` and `/`; a value carrying either (or `=`,
|
|
242
|
+
// `%`, a space) breaks out of its segment and smuggles extra transform ops
|
|
243
|
+
// into the path (audit N17). Every legitimate DSL value is [a-z0-9._-] — reject
|
|
244
|
+
// anything else rather than emit it, keeping the canonical form byte-identical
|
|
245
|
+
// to what the server parses.
|
|
246
|
+
const VALUE_RE = /^[a-z0-9._-]+$/;
|
|
240
247
|
for (const k of keys) {
|
|
241
|
-
const v = opts[k];
|
|
248
|
+
const v = (opts as Record<string, unknown>)[k];
|
|
242
249
|
if (v == null) continue;
|
|
243
250
|
const serialized = typeof v === "string" ? v.toLowerCase() : String(v);
|
|
251
|
+
if (!VALUE_RE.test(serialized)) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`invalid transform value for "${k}": ${JSON.stringify(serialized)} (only [a-z0-9._-] allowed)`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
244
256
|
entries.push([k, serialized]);
|
|
245
257
|
}
|
|
246
258
|
return entries.map(([k, v]) => `${k}=${v}`).join(",");
|
|
247
259
|
}
|
|
248
260
|
|
|
249
|
-
function extForOptions(
|
|
261
|
+
function extForOptions(
|
|
262
|
+
opts: AnyWidthTransformOptions | WithPinnedQuality<AnyWidthTransformOptions>,
|
|
263
|
+
): string {
|
|
250
264
|
// effect=removebg forces PNG output server-side (needs alpha).
|
|
251
265
|
if (opts.effect === "removebg") return "png";
|
|
252
266
|
// effect=genfill defaults to WebP (12× lighter than the raw generated
|
|
@@ -395,7 +409,7 @@ export { buildTransformUrl as getTransformUrlUnchecked };
|
|
|
395
409
|
|
|
396
410
|
function buildTransformUrl(
|
|
397
411
|
asset: Pick<AssetDTO, "sha">,
|
|
398
|
-
opts: AnyWidthTransformOptions
|
|
412
|
+
opts: AnyWidthTransformOptions | WithPinnedQuality<AnyWidthTransformOptions>,
|
|
399
413
|
): string | null {
|
|
400
414
|
const dsl = serializeTransform(opts);
|
|
401
415
|
if (!dsl) return null;
|
|
@@ -426,6 +440,119 @@ export function getTransformUrl(
|
|
|
426
440
|
* Returns `null` only when `opts` serialize to an empty DSL (no transform
|
|
427
441
|
* requested) — same contract as {@link getTransformUrl}.
|
|
428
442
|
*/
|
|
443
|
+
/**
|
|
444
|
+
* Los presets de TAMAÑO — los únicos cuya existencia convierte un `quality`
|
|
445
|
+
* pinneado en una segunda compresión.
|
|
446
|
+
*
|
|
447
|
+
* `original`, `poster`, `video`, `aiproxy`, `hls` y `mp3` NO cuentan: `/t/`
|
|
448
|
+
* nunca los usa como fuente de una imagen redimensionada.
|
|
449
|
+
*/
|
|
450
|
+
const LADDER_PRESETS = ["thumb", "sm", "md", "lg", "xl"] as const;
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* ¿Este asset tiene variantes de tamaño almacenadas, o sea algo a lo que `/t/`
|
|
454
|
+
* pueda saltar?
|
|
455
|
+
*
|
|
456
|
+
* `null` cuando **no se sabe** — un DTO sin `presets` no dice que no las tenga,
|
|
457
|
+
* dice que no lo trae. Tres estados, tres respuestas: un booleano acá mentiría
|
|
458
|
+
* en un tercio de los casos, y mentiría hacia el lado inseguro.
|
|
459
|
+
*
|
|
460
|
+
* ⚠️ **Se pregunta con `hasPreset`, JAMÁS con `presets.includes("s")`.** La
|
|
461
|
+
* cadena lleva tokens de varias letras —`transform-<hex>`, `upscale_…`, `pr`—
|
|
462
|
+
* que donan `s`, `m`, `l` y `o` de regalo. Ese es el falso positivo que
|
|
463
|
+
* `test/presets-false-positives.test.ts` existe para cazar.
|
|
464
|
+
*/
|
|
465
|
+
export function hasSizeLadder(asset: {
|
|
466
|
+
presets?: string | null;
|
|
467
|
+
}): boolean | null {
|
|
468
|
+
const raw = asset.presets;
|
|
469
|
+
if (raw == null || raw.trim() === "") return null;
|
|
470
|
+
return LADDER_PRESETS.some((preset) => hasPreset(asset, preset));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* ⭐ **La ÚNICA puerta por la que se puede pinnear un `quality` numérico** —
|
|
475
|
+
* y está construida para que no se pueda usar mal.
|
|
476
|
+
*
|
|
477
|
+
* ## El problema que resuelve
|
|
478
|
+
*
|
|
479
|
+
* `getTransformUrl` recibe un `Pick<AssetDTO, "sha">`: **un sha y nada más.**
|
|
480
|
+
* Con eso, un `quality: 75` en el call site es CIEGO — la misma línea es un
|
|
481
|
+
* ajuste honesto del encoder sobre un asset sin escalera, y una segunda
|
|
482
|
+
* compresión silenciosa sobre uno con escalera. Nada en el tipo, en el nombre
|
|
483
|
+
* ni en el editor distinguía los dos casos. Un aviso avisa; esto impide.
|
|
484
|
+
*
|
|
485
|
+
* ## Las dos barreras
|
|
486
|
+
*
|
|
487
|
+
* 1. **En COMPILACIÓN**: el parámetro exige `presets: string`. Un
|
|
488
|
+
* `{ sha }` pelado —la llamada ciega— ya no compila. Para pasar por acá hay
|
|
489
|
+
* que tener el DTO en la mano, y tener el DTO es saber la respuesta.
|
|
490
|
+
* 2. **En EJECUCIÓN**: si el asset tiene cualquier preset de tamaño, **tira**.
|
|
491
|
+
* Si `presets` no vino, **tira** — «no sé» nunca se resuelve como «dale».
|
|
492
|
+
*
|
|
493
|
+
* ## Cuándo es legítimo, con el número
|
|
494
|
+
*
|
|
495
|
+
* Un presupuesto de bytes que alguien MIDIÓ, sobre un asset subido con
|
|
496
|
+
* `presets: ["original"]`. Ahí no hay a qué saltar y el pin hace lo que su
|
|
497
|
+
* nombre dice. Medido 2026-08-31 contra producción, ancho 1 920, las seis
|
|
498
|
+
* respuestas `x-transform-source: original`:
|
|
499
|
+
*
|
|
500
|
+
* | quality | bytes | vs auto | PSNR |
|
|
501
|
+
* |---|---|---|---|
|
|
502
|
+
* | 40 | 82 112 | **−33,6 %** | 35,32 dB |
|
|
503
|
+
* | 60 | 106 892 | −13,5 % | 36,85 dB |
|
|
504
|
+
* | *(auto)* | 123 614 | — | 37,65 dB |
|
|
505
|
+
* | 90 | 269 026 | +117,6 % | 41,46 dB |
|
|
506
|
+
*
|
|
507
|
+
* Monótona y sin sorpresas: −33,6 % de peso por −2,32 dB. **Eso sí es un
|
|
508
|
+
* intercambio**, y es la razón por la que el parámetro no se borró del todo.
|
|
509
|
+
*
|
|
510
|
+
* @throws si el asset tiene escalera, si no se sabe si la tiene, o si
|
|
511
|
+
* `quality` no está en 1..100.
|
|
512
|
+
*/
|
|
513
|
+
export function getByteBudgetTransformUrl(
|
|
514
|
+
asset: Pick<AssetDTO, "sha"> & { presets: string } & VisibilityHint,
|
|
515
|
+
opts: WithPinnedQuality<TransformOptions>,
|
|
516
|
+
): string | null {
|
|
517
|
+
assertSha(asset, "getByteBudgetTransformUrl");
|
|
518
|
+
assertPublic(
|
|
519
|
+
asset,
|
|
520
|
+
"getByteBudgetTransformUrl",
|
|
521
|
+
"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })",
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
if (
|
|
525
|
+
!Number.isInteger(opts.quality) ||
|
|
526
|
+
opts.quality < 1 ||
|
|
527
|
+
opts.quality > 100
|
|
528
|
+
) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`getByteBudgetTransformUrl: quality must be an integer 1..100, got ${String(opts.quality)}. ` +
|
|
531
|
+
"If you do not have a measured byte budget, use getTransformUrl and omit quality entirely.",
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const ladder = hasSizeLadder(asset);
|
|
536
|
+
if (ladder === null) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`getByteBudgetTransformUrl: asset ${asset.sha} carries no 'presets', so whether a pinned ` +
|
|
539
|
+
"quality would re-compress it is UNKNOWN — and unknown is not permission. Fetch the full " +
|
|
540
|
+
"DTO (assets.get / assets.byHash) and pass it, or use getTransformUrl with no quality.",
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (ladder) {
|
|
544
|
+
throw new Error(
|
|
545
|
+
`getByteBudgetTransformUrl: asset ${asset.sha} has stored size variants (presets="${asset.presets}"). ` +
|
|
546
|
+
"A pinned quality there does not set the encoder — it makes /t/ decode one of those " +
|
|
547
|
+
"already-compressed variants, so the output is a SECOND lossy generation: measured " +
|
|
548
|
+
"+2..+8% HEAVIER and -0.50..-0.85 dB. Use getTransformUrl with no quality (one pass from " +
|
|
549
|
+
"the master), or upload this asset with presets: [\"original\"] if the byte budget is real.",
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return buildTransformUrl(asset, opts);
|
|
554
|
+
}
|
|
555
|
+
|
|
429
556
|
export function getSignedTransformUrl(
|
|
430
557
|
asset: Pick<AssetDTO, "sha"> & VisibilityHint,
|
|
431
558
|
opts: SignedTransformOptions,
|