@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/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. Present on admin responses
158
- * (`GET /assets/:id`); absent on the slim list shape used by the
159
- * resolver / catalog. Use `presets` for compact existence checks.
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 — `AquienpzClient` does this from its `tenantId` option;
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
- * Extension for the ORIGINAL variant, derived from the asset's mime — the
229
- * original is stored under its real extension (keyed via `extOfMime`), so the
230
- * static `PRESET_EXT.original` sentinel ("bin") only applies when the mime is
231
- * unknown/absent. Mirrors the asset-manager's `extOfMime` (mime-types) for the
232
- * common image/video kinds so the built URL matches the stored R2 key —
233
- * otherwise original-only uploads build a `-o.bin` URL that 404s while the asset
234
- * is served at e.g. `-o.png`.
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": "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
- * Pass the asset's `mime` (present on the full `AssetDTO`) so the `original`
262
- * preset resolves to the correct extension; without it the original falls back
263
- * to the `"bin"` sentinel.
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`, which needs the mime and still deserves a HEAD
317
+ * @example The `original` pass the DTO, not just the sha
275
318
  * ```ts
276
- * getAssetUrl({ sha }, "original"); // → …-o.bin ❌ 404, always
277
- * getAssetUrl({ sha, mime }, "original"); // → …-o.webp ✓
319
+ * const asset = await aq.assets.get(id);
278
320
  *
279
- * // ⚠️ The stored extension comes from the UPLOADED FILENAME, not the mime:
280
- * // "image/jpeg" builds "-o.jpeg" while a camera's ".jpg" was stored as "-o.jpg".
281
- * // When the URL must be right, verify it:
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"> & { mime?: string },
333
+ asset: Pick<AssetDTO, "sha"> & OriginalHints,
293
334
  preset: VariantPreset,
294
335
  ): string {
295
- const ext =
296
- preset === "original" ? originalExtForMime(asset.mime) : PRESET_EXT[preset];
297
- return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${ext}`;
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 always sends. Prefer this over
304
- * the `variants` array, which is documented as present on admin responses and, measured
305
- * 2026-08-15, comes back EMPTY even with an admin key while the database row holds the variants.
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.replace(/mp3/g, "").includes(PRESET_SHORT[preset]);
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
- function textColorForHex(hex: string): "#000000" | "#FFFFFF" {
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
- const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
99
- return L > 0.5 ? "#000000" : "#FFFFFF";
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;