@nitida/asset-client 0.14.3 → 0.16.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 +27 -6
- package/dist/index.cjs +51 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -19
- package/dist/index.d.ts +32 -19
- package/dist/index.js +51 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +110 -33
- package/src/palette.ts +64 -3
- package/src/slots.ts +7 -0
package/src/index.ts
CHANGED
|
@@ -154,11 +154,18 @@ export type AssetDTO = {
|
|
|
154
154
|
/** Soft-delete timestamp (ISO). Hidden from catalog when set. */
|
|
155
155
|
deletedAt?: string | null;
|
|
156
156
|
/**
|
|
157
|
-
* Full variant list with URLs + sizes.
|
|
158
|
-
*
|
|
159
|
-
*
|
|
157
|
+
* Full variant list with URLs + sizes. Sent by `GET /assets/:id`; absent on
|
|
158
|
+
* the slim list shape used by the resolver / catalog. Use `presets` for
|
|
159
|
+
* compact existence checks, and this when you need the actual URLs.
|
|
160
160
|
*/
|
|
161
161
|
variants?: AssetVariant[];
|
|
162
|
+
/**
|
|
163
|
+
* The extension the `original` variant was really stored under — the server
|
|
164
|
+
* keys it off the uploaded filename, so it cannot be derived from `mime`.
|
|
165
|
+
* Sent by `GET /assets/:id`; `null` when the asset has no original.
|
|
166
|
+
* {@link getAssetUrl} uses it automatically when you pass the whole DTO.
|
|
167
|
+
*/
|
|
168
|
+
oext?: string | null;
|
|
162
169
|
};
|
|
163
170
|
|
|
164
171
|
export type {
|
|
@@ -201,7 +208,7 @@ export function getCdnBase(): string {
|
|
|
201
208
|
// `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see asset-manager
|
|
202
209
|
// `variantKey`). Variant URL builders MUST include that prefix or every
|
|
203
210
|
// URL 404s. The tenant id is process-global (one tenant per client/app),
|
|
204
|
-
// set once at boot — `
|
|
211
|
+
// set once at boot — `NitidaClient` does this from its `tenantId` option;
|
|
205
212
|
// standalone consumers call `setTenantId()` directly. Left unset, builders
|
|
206
213
|
// fall back to the legacy pre-cutover bare path for back-compat.
|
|
207
214
|
// ---------------------------------------------------------------------------
|
|
@@ -225,17 +232,27 @@ function variantPrefix(): string {
|
|
|
225
232
|
// ---------------------------------------------------------------------------
|
|
226
233
|
|
|
227
234
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
+
* LAST-RESORT guess at the ORIGINAL variant's extension, from the asset's mime.
|
|
236
|
+
*
|
|
237
|
+
* ⚠️ This is a guess. Prefer `oext` or `variants` (see {@link getAssetUrl}) — the
|
|
238
|
+
* server sends both and they ARE the key.
|
|
239
|
+
*
|
|
240
|
+
* The server derives the extension from the MIME with the `mime-types` package
|
|
241
|
+
* (`mime.extension(body.mime)`, at presign). So a table that matched that one
|
|
242
|
+
* exactly would usually be right — and this table did not: it said
|
|
243
|
+
* `image/jpeg` → `jpeg` while `mime-types` says `jpg`, under a comment claiming
|
|
244
|
+
* to mirror it. Usually, but not always: the row's stored `mime` is not always
|
|
245
|
+
* the mime the key was built from, so no client-side table can close the gap.
|
|
246
|
+
*
|
|
247
|
+
* Measured against the 2 001 stored originals in production, 2026-08-17: **all
|
|
248
|
+
* 420 `image/jpeg` originals are stored `.jpg` and none `.jpeg`** — the old
|
|
249
|
+
* entry here 404'd on every single JPEG. And 234 originals carry
|
|
250
|
+
* `application/octet-stream` (`.mpga`, `.docx`, `.m4a`), where no mime table can
|
|
251
|
+
* produce the right key at all — those need `oext`.
|
|
235
252
|
*/
|
|
236
253
|
const ORIGINAL_EXT_BY_MIME: Record<string, string> = {
|
|
237
254
|
"image/png": "png",
|
|
238
|
-
"image/jpeg": "
|
|
255
|
+
"image/jpeg": "jpg",
|
|
239
256
|
"image/webp": "webp",
|
|
240
257
|
"image/gif": "gif",
|
|
241
258
|
"image/avif": "avif",
|
|
@@ -248,19 +265,45 @@ const ORIGINAL_EXT_BY_MIME: Record<string, string> = {
|
|
|
248
265
|
"video/mp4": "mp4",
|
|
249
266
|
"video/webm": "webm",
|
|
250
267
|
"video/quicktime": "mov",
|
|
268
|
+
// Audio — ausentes hasta 2026-08-17, y su ausencia costó un rodeo entero en
|
|
269
|
+
// neo (`withRealOriginalExt`), que existe SÓLO porque esta tabla devolvía el
|
|
270
|
+
// centinela `bin` para toda nota de voz. Medido en producción: `-o.bin` da
|
|
271
|
+
// 404 y `-o.m4a` da 200.
|
|
272
|
+
//
|
|
273
|
+
// ⚠️ Se keyean por el mime COMPLETO, no por el subtipo: `audio/mp4` guarda
|
|
274
|
+
// `.m4a` y `video/mp4` guarda `.mp4`. Un `switch` sobre el subtipo `mp4` no
|
|
275
|
+
// puede distinguirlos — es el error que un consumidor cometió y tuvo que
|
|
276
|
+
// corregir por su cuenta.
|
|
277
|
+
"audio/mpeg": "mpga",
|
|
278
|
+
"audio/mp4": "m4a",
|
|
279
|
+
"audio/x-m4a": "m4a",
|
|
280
|
+
"audio/wav": "wav",
|
|
281
|
+
"audio/webm": "weba",
|
|
282
|
+
"audio/ogg": "oga",
|
|
283
|
+
"audio/aac": "adts",
|
|
251
284
|
};
|
|
252
285
|
function originalExtForMime(mime: string | undefined): string {
|
|
253
286
|
return (mime ? ORIGINAL_EXT_BY_MIME[mime] : undefined) ?? PRESET_EXT.original;
|
|
254
287
|
}
|
|
255
288
|
|
|
289
|
+
/** What the asset itself knows about where its `original` lives. */
|
|
290
|
+
type OriginalHints = {
|
|
291
|
+
mime?: string;
|
|
292
|
+
/** The extension the server actually stored it under. Authoritative. */
|
|
293
|
+
oext?: string | null;
|
|
294
|
+
/** Full variant list — carries the stored URL verbatim. Authoritative. */
|
|
295
|
+
variants?: AssetVariant[];
|
|
296
|
+
};
|
|
297
|
+
|
|
256
298
|
/**
|
|
257
299
|
* Build the public CDN URL for a specific variant of an asset. The variant
|
|
258
300
|
* may not actually exist (regenerate may not have run, or video has no
|
|
259
301
|
* `aiproxy`); call `hasPreset()` first or expect a 404.
|
|
260
302
|
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
303
|
+
* For the `original` preset, pass the whole `AssetDTO` — it carries `variants`
|
|
304
|
+
* and `oext`, either of which gives the EXACT stored key. `mime` alone is only a
|
|
305
|
+
* guess (the server keys the original off the uploaded filename), and with
|
|
306
|
+
* nothing at all the original falls back to the `"bin"` sentinel, which 404s.
|
|
264
307
|
*
|
|
265
308
|
* @example Video — the tenant segment is base36, so let this build the path
|
|
266
309
|
* ```ts
|
|
@@ -271,15 +314,13 @@ function originalExtForMime(mime: string | undefined): string {
|
|
|
271
314
|
* getAssetUrl({ sha }, "poster"); // → https://8ok.uk/c/v/<sha16>-p.webp
|
|
272
315
|
* ```
|
|
273
316
|
*
|
|
274
|
-
* @example The `original
|
|
317
|
+
* @example The `original` — pass the DTO, not just the sha
|
|
275
318
|
* ```ts
|
|
276
|
-
*
|
|
277
|
-
* getAssetUrl({ sha, mime }, "original"); // → …-o.webp ✓
|
|
319
|
+
* const asset = await aq.assets.get(id);
|
|
278
320
|
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
* //
|
|
282
|
-
* const res = await fetch(url, { method: "HEAD" });
|
|
321
|
+
* getAssetUrl({ sha }, "original"); // → …-o.bin ❌ 404, always
|
|
322
|
+
* getAssetUrl({ sha, mime }, "original"); // → a guess from the mime table
|
|
323
|
+
* getAssetUrl(asset, "original"); // ✓ the stored key, verbatim
|
|
283
324
|
* ```
|
|
284
325
|
*
|
|
285
326
|
* @example Check before you link
|
|
@@ -289,20 +330,27 @@ function originalExtForMime(mime: string | undefined): string {
|
|
|
289
330
|
* ```
|
|
290
331
|
*/
|
|
291
332
|
export function getAssetUrl(
|
|
292
|
-
asset: Pick<AssetDTO, "sha"> &
|
|
333
|
+
asset: Pick<AssetDTO, "sha"> & OriginalHints,
|
|
293
334
|
preset: VariantPreset,
|
|
294
335
|
): string {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
336
|
+
if (preset === "original") {
|
|
337
|
+
// The stored URL beats every derivation, because it IS the key. Only fall
|
|
338
|
+
// through to a guess when the caller gave us the sha and nothing else.
|
|
339
|
+
const stored = asset.variants?.find((v) => v.preset === "original")?.url;
|
|
340
|
+
if (stored) return stored;
|
|
341
|
+
const ext = asset.oext || originalExtForMime(asset.mime);
|
|
342
|
+
return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT.original}.${ext}`;
|
|
343
|
+
}
|
|
344
|
+
return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
|
|
298
345
|
}
|
|
299
346
|
|
|
300
347
|
/**
|
|
301
348
|
* Did the processor actually generate this preset?
|
|
302
349
|
*
|
|
303
|
-
* Reads `dto.presets` — the compact 1-char code string the server
|
|
304
|
-
* the `variants`
|
|
305
|
-
*
|
|
350
|
+
* Reads `dto.presets` — the compact 1-char code string the server sends on EVERY shape, including
|
|
351
|
+
* the slim list/resolver one that carries no `variants` at all. That is why this exists and why it
|
|
352
|
+
* stays the right existence check even now that `GET /assets/:id` really does send `variants`
|
|
353
|
+
* (it did not until 2026-08-17; doc 240 §4.3).
|
|
306
354
|
*
|
|
307
355
|
* @example
|
|
308
356
|
* ```ts
|
|
@@ -317,11 +365,40 @@ export function hasPreset(
|
|
|
317
365
|
asset: Pick<AssetDTO, "presets">,
|
|
318
366
|
preset: VariantPreset,
|
|
319
367
|
): boolean {
|
|
320
|
-
// `presets` concatenates 1-char short codes, but the audio `mp3` variant is a
|
|
321
|
-
// literal 3-char token. Query `mp3` against that token; for every other preset
|
|
322
|
-
// strip `mp3` first so its `m`/`p` can't substring-false-match `md`/`poster`.
|
|
323
368
|
if (preset === "mp3") return asset.presets.includes("mp3");
|
|
324
|
-
return asset.presets
|
|
369
|
+
return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Remove every MULTI-character token from a `presets` string, leaving only the
|
|
374
|
+
* 1-char codes that a `.includes` can safely be run against.
|
|
375
|
+
*
|
|
376
|
+
* `presets` is documented as a concatenation of 1-char codes, and membership is
|
|
377
|
+
* a 1-char substring test — so any longer token is a false-positive generator.
|
|
378
|
+
* Servers before the 2026-08-17 deploy emitted several (doc 240 §4.3b):
|
|
379
|
+
*
|
|
380
|
+
* | token in the string | letters it donates | presets it falsely answers |
|
|
381
|
+
* |---|---|---|
|
|
382
|
+
* | `transform-<hex>` | t r a n s f o m + a–f | `sm` `md` `original` `aiproxy` |
|
|
383
|
+
* | `pr` (probe) | p r | `poster` |
|
|
384
|
+
* | `mp3` (audio) | m p | `md` `poster` — stripped here since forever |
|
|
385
|
+
*
|
|
386
|
+
* Measured against production: **62 % of live assets** carried a polluted
|
|
387
|
+
* string, and **16 299 of them were told they have an `original` they do not**
|
|
388
|
+
* — which builds a `-o.<ext>` URL that 404s. (`aiproxy`: 16 479. `sm`/`md`: 487.
|
|
389
|
+
* The `pr` → `poster` collision is real but has 0 instances today.)
|
|
390
|
+
*
|
|
391
|
+
* Current servers no longer emit these, but this stays: an older
|
|
392
|
+
* `asset-manager` keeps sending them, and this is the check every guide points
|
|
393
|
+
* at as the reliable one. Order matters — strip the longest tokens first.
|
|
394
|
+
*/
|
|
395
|
+
function stripMultiCharTokens(presets: string): string {
|
|
396
|
+
return presets
|
|
397
|
+
.replace(/transform-[0-9a-f]*/g, "")
|
|
398
|
+
.replace(/upscale_[a-z0-9_]*/g, "")
|
|
399
|
+
.replace(/mp3/g, "")
|
|
400
|
+
.replace(/u[2-8]|t[1248ghij]/g, "")
|
|
401
|
+
.replace(/pr/g, "");
|
|
325
402
|
}
|
|
326
403
|
|
|
327
404
|
/**
|
package/src/palette.ts
CHANGED
|
@@ -87,7 +87,8 @@ export function getTextColorForBackground(
|
|
|
87
87
|
return textColorForHex(hex);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
/** Luminancia relativa WCAG de un hex. 0 = negro, 1 = blanco. */
|
|
91
|
+
export function relativeLuminance(hex: string): number {
|
|
91
92
|
const h = hex.replace("#", "");
|
|
92
93
|
const r = Number.parseInt(h.slice(0, 2), 16) / 255;
|
|
93
94
|
const g = Number.parseInt(h.slice(2, 4), 16) / 255;
|
|
@@ -95,8 +96,68 @@ function textColorForHex(hex: string): "#000000" | "#FFFFFF" {
|
|
|
95
96
|
// sRGB → linear
|
|
96
97
|
const lin = (c: number) =>
|
|
97
98
|
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
100
|
+
}
|
|
101
|
+
|
|
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.
|
|
105
|
+
*/
|
|
106
|
+
export function contrastRatio(l1: number, l2: number): number {
|
|
107
|
+
const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];
|
|
108
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Negro o blanco, el que **de verdad** contraste más sobre este fondo.
|
|
113
|
+
*
|
|
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:
|
|
116
|
+
*
|
|
117
|
+
* (1 + 0.05) / (L + 0.05) = (L + 0.05) / 0.05
|
|
118
|
+
* (L + 0.05)² = 0.0525
|
|
119
|
+
* L = 0.1791…
|
|
120
|
+
*
|
|
121
|
+
* Entre **0,179 y 0,5** el código viejo elegía BLANCO cuando negro contrastaba
|
|
122
|
+
* más — una 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.
|
|
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.
|
|
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.
|
|
134
|
+
*/
|
|
135
|
+
function textColorForHex(hex: string): "#000000" | "#FFFFFF" {
|
|
136
|
+
return bestTextContrast(hex).color;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
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ó.
|
|
143
|
+
*/
|
|
144
|
+
export function bestTextContrast(hex: string): {
|
|
145
|
+
color: "#000000" | "#FFFFFF";
|
|
146
|
+
ratio: number;
|
|
147
|
+
passesAA: boolean;
|
|
148
|
+
passesAALarge: boolean;
|
|
149
|
+
} {
|
|
150
|
+
const L = relativeLuminance(hex);
|
|
151
|
+
const onBlack = contrastRatio(L, 0);
|
|
152
|
+
const onWhite = contrastRatio(L, 1);
|
|
153
|
+
const useBlack = onBlack >= onWhite;
|
|
154
|
+
const ratio = useBlack ? onBlack : onWhite;
|
|
155
|
+
return {
|
|
156
|
+
color: useBlack ? "#000000" : "#FFFFFF",
|
|
157
|
+
ratio,
|
|
158
|
+
passesAA: ratio >= 4.5,
|
|
159
|
+
passesAALarge: ratio >= 3,
|
|
160
|
+
};
|
|
100
161
|
}
|
|
101
162
|
|
|
102
163
|
/**
|
package/src/slots.ts
CHANGED
|
@@ -53,6 +53,13 @@ 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.
|
|
56
63
|
let endpoint = "https://aquienpz-asset-manager-nlchzy26qa-uc.a.run.app";
|
|
57
64
|
let apiKey: string | null = null;
|
|
58
65
|
let tenantCode: string | null = null;
|