@nitida/asset-client 0.14.2

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 ADDED
@@ -0,0 +1,413 @@
1
+ /**
2
+ * @nitida/asset-client — read helpers for asset URLs.
3
+ *
4
+ * Universal CDN model (May 2026): the API ships a compact AssetDTO with a
5
+ * 16-char SHA prefix + a `presets` string of 1-char codes; client helpers
6
+ * construct CDN URLs deterministically from `(cdnBase, sha, preset, ext)`.
7
+ *
8
+ * Why: catalog sync over Electric SSE shipped 4 nearly-identical full URLs
9
+ * per asset × hundreds of thousands of assets per snapshot. Sending only
10
+ * what differs — sha + presets bitmap — collapses ~600 bytes per asset to
11
+ * ~70 (88% reduction).
12
+ *
13
+ * No runtime dependencies — pure types + pure functions. Safe everywhere.
14
+ * @module @nitida/asset-client
15
+ */
16
+
17
+ /**
18
+ * Image presets are size-based (Vercel `next/image` style):
19
+ * - `thumb` is the only square crop — semantic icon use case
20
+ * - `sm/md/lg` are max-side bounding boxes that preserve aspect ratio
21
+ *
22
+ * Naming over the legacy `thumbnail/cover/web/hero` because the new names
23
+ * say what the variant IS (a size class) rather than what it might be
24
+ * USED for, removing the implicit landscape-only assumption that bit us
25
+ * with vertical product photos.
26
+ */
27
+ export type VariantPreset =
28
+ // image presets
29
+ | "thumb" // 256x256 square smart-crop (icon)
30
+ | "sm" // 640 max-side
31
+ | "md" // 1280 max-side
32
+ | "lg" // 1920 max-side
33
+ | "xl" // 3840 max-side (4K) — OPT-IN; not generated by default
34
+ // passthrough for non-image kinds (PDF etc.)
35
+ | "original"
36
+ // video presets — semantic (not size classes)
37
+ | "poster"
38
+ | "video"
39
+ | "aiproxy"
40
+ // audio preset — the cross-browser mp3 transcode of a voice note
41
+ // (libmp3lame) emitted alongside the original so chat audio plays on
42
+ // both Chrome/Android (webm/opus) and iOS Safari (which can't decode opus).
43
+ | "mp3";
44
+
45
+ /** 1-char alias used in R2 keys / wire `presets` string. */
46
+ export const PRESET_SHORT: Record<VariantPreset, string> = {
47
+ thumb: "q",
48
+ sm: "s",
49
+ md: "m",
50
+ lg: "l",
51
+ xl: "x",
52
+ original: "o",
53
+ poster: "p",
54
+ video: "v",
55
+ aiproxy: "a",
56
+ // 3 chars, NOT a 1-char alias: the asset-manager has no short-form for
57
+ // audio so its `shortPreset("mp3")` falls through to the literal token,
58
+ // and the deployed server already writes the `-mp3.mp3` variant + emits
59
+ // the bare `mp3` token in the wire `presets` string. Must stay in lockstep.
60
+ mp3: "mp3",
61
+ };
62
+ export const PRESET_LONG: Record<string, VariantPreset> = Object.fromEntries(
63
+ Object.entries(PRESET_SHORT).map(([k, v]) => [v, k as VariantPreset]),
64
+ );
65
+
66
+ /** Variant extension by preset. Image variants are always WebP, video MP4. */
67
+ export const PRESET_EXT: Record<VariantPreset, string> = {
68
+ thumb: "webp",
69
+ sm: "webp",
70
+ md: "webp",
71
+ lg: "webp",
72
+ xl: "webp",
73
+ original: "bin", // overridden per-asset via mime when needed
74
+ poster: "webp",
75
+ video: "mp4",
76
+ aiproxy: "mp4",
77
+ mp3: "mp3",
78
+ };
79
+
80
+ /** Max-side dimension by preset; null for video / passthrough. */
81
+ export const PRESET_MAX_DIM: Record<VariantPreset, number | null> = {
82
+ thumb: 256,
83
+ sm: 640,
84
+ md: 1280,
85
+ lg: 1920,
86
+ xl: 3840,
87
+ original: null,
88
+ poster: null,
89
+ video: null,
90
+ aiproxy: null,
91
+ // audio has no pixel dimensions; `null` keeps mp3 out of the
92
+ // dimension-based `getAssetSrcSet` / `computeVariantDimensions` logic.
93
+ mp3: null,
94
+ };
95
+
96
+ /**
97
+ * One generated variant of an asset. Returned by the admin endpoints
98
+ * (`GET /assets/:id`, `POST /assets/:id/regenerate`).
99
+ */
100
+ export type AssetVariant = {
101
+ /** Long name (`thumb` / `sm` / … / `original`) — see {@link VariantPreset}. */
102
+ preset: VariantPreset;
103
+ /** Public CDN URL of this variant. */
104
+ url: string;
105
+ /** Pixel width. Absent for `original`-only assets where sharp was skipped, or for video presets. */
106
+ width?: number;
107
+ /** Pixel height. Same caveat as `width`. */
108
+ height?: number;
109
+ /** Byte size of the variant file on R2. */
110
+ bytes: number;
111
+ /**
112
+ * Where the bytes for this variant came from. Useful for quality
113
+ * traceability — a `thumb` with `sourceFrom: "original"` is the
114
+ * canonical case, while `sourceFrom: "lg"` means it was derived
115
+ * from an already-encoded WebP (slight quality compounding).
116
+ *
117
+ * - `"upload"` → first-write at `/assets/process`. The bytes came
118
+ * straight from the client's PUT.
119
+ * - `VariantPreset` → regenerated from that preset's variant.
120
+ *
121
+ * Absent on variants written before the trace field existed.
122
+ */
123
+ sourceFrom?: VariantPreset | "upload";
124
+ /** ISO timestamp this variant was written. Absent on pre-trace variants. */
125
+ createdAt?: string;
126
+ };
127
+
128
+ /**
129
+ * Compact wire shape — what the server actually sends. Aliases (`w`, `h`,
130
+ * `dur`) are intentional to shave bytes per asset on dense lists.
131
+ */
132
+ export type AssetDTO = {
133
+ id: string;
134
+ /** First 16 hex chars of sha256 — used to derive CDN URLs. */
135
+ sha: string;
136
+ kind: "image" | "video" | "document" | "audio" | "other";
137
+ mime: string;
138
+ bytes: number;
139
+ /** Source dims (for aspect-ratio calc on the client). Optional for non-images. */
140
+ w?: number | null;
141
+ h?: number | null;
142
+ /** Duration ms for videos. */
143
+ dur?: number | null;
144
+ /** LQIP placeholder (data URL). */
145
+ blur?: string | null;
146
+ palette?: AssetPalette | null;
147
+ /**
148
+ * Compact list of generated variants as their 1-char codes
149
+ * concatenated, e.g. "tcwh" (image) / "pv" (video without aiproxy).
150
+ * Ordered by ascending dimension.
151
+ */
152
+ presets: string;
153
+ status: "processing" | "ready" | "failed";
154
+ /** Soft-delete timestamp (ISO). Hidden from catalog when set. */
155
+ deletedAt?: string | null;
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.
160
+ */
161
+ variants?: AssetVariant[];
162
+ };
163
+
164
+ export type {
165
+ AssetPalette,
166
+ PaletteSwatch,
167
+ } from "./palette";
168
+
169
+ export {
170
+ getAmbientGradient,
171
+ getPaletteBlurBackground,
172
+ getPaletteCssVars,
173
+ getTextColorForBackground,
174
+ iteratePaletteSwatches,
175
+ pickAmbientBackground,
176
+ } from "./palette";
177
+
178
+ import type { AssetPalette } from "./palette";
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // CDN base
182
+ // ---------------------------------------------------------------------------
183
+
184
+ let cdnBaseUrl = "https://8ok.uk";
185
+ /**
186
+ * Override the CDN base for the entire process (e.g. in tests, or when
187
+ * pointing at a tenant-specific CDN). Storefront layouts call this once at
188
+ * boot.
189
+ */
190
+ export function setCdnBase(url: string): void {
191
+ cdnBaseUrl = url.replace(/\/$/, "");
192
+ }
193
+ export function getCdnBase(): string {
194
+ return cdnBaseUrl;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Tenant scope
199
+ //
200
+ // Post-May-2026 the CDN serves variants under a tenant-prefixed path
201
+ // `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see asset-manager
202
+ // `variantKey`). Variant URL builders MUST include that prefix or every
203
+ // 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;
205
+ // standalone consumers call `setTenantId()` directly. Left unset, builders
206
+ // fall back to the legacy pre-cutover bare path for back-compat.
207
+ // ---------------------------------------------------------------------------
208
+
209
+ let tenantId: number | null = null;
210
+ /** Set the process-global tenant id used to build tenant-prefixed CDN URLs. */
211
+ export function setTenantId(id: number | null | undefined): void {
212
+ tenantId =
213
+ typeof id === "number" && Number.isFinite(id) && id > 0 ? id : null;
214
+ }
215
+ export function getTenantId(): number | null {
216
+ return tenantId;
217
+ }
218
+ /** Variant path prefix `<tid b36>/v/`, or "" when no tenant is configured. */
219
+ function variantPrefix(): string {
220
+ return tenantId != null ? `${tenantId.toString(36)}/v/` : "";
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // URL builders
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
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
+ */
236
+ const ORIGINAL_EXT_BY_MIME: Record<string, string> = {
237
+ "image/png": "png",
238
+ "image/jpeg": "jpeg",
239
+ "image/webp": "webp",
240
+ "image/gif": "gif",
241
+ "image/avif": "avif",
242
+ "image/svg+xml": "svg",
243
+ "image/heic": "heic",
244
+ "image/heif": "heif",
245
+ "image/bmp": "bmp",
246
+ "image/tiff": "tiff",
247
+ "application/pdf": "pdf",
248
+ "video/mp4": "mp4",
249
+ "video/webm": "webm",
250
+ "video/quicktime": "mov",
251
+ };
252
+ function originalExtForMime(mime: string | undefined): string {
253
+ return (mime ? ORIGINAL_EXT_BY_MIME[mime] : undefined) ?? PRESET_EXT.original;
254
+ }
255
+
256
+ /**
257
+ * Build the public CDN URL for a specific variant of an asset. The variant
258
+ * may not actually exist (regenerate may not have run, or video has no
259
+ * `aiproxy`); call `hasPreset()` first or expect a 404.
260
+ *
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.
264
+ *
265
+ * @example Video — the tenant segment is base36, so let this build the path
266
+ * ```ts
267
+ * import { getAssetUrl, setTenantId } from "@nitida/asset-client";
268
+ *
269
+ * setTenantId(12); // 12 → "c"; a decimal "/12/v/" 404s
270
+ * getAssetUrl({ sha }, "video"); // → https://8ok.uk/c/v/<sha16>-v.mp4
271
+ * getAssetUrl({ sha }, "poster"); // → https://8ok.uk/c/v/<sha16>-p.webp
272
+ * ```
273
+ *
274
+ * @example The `original`, which needs the mime — and still deserves a HEAD
275
+ * ```ts
276
+ * getAssetUrl({ sha }, "original"); // → …-o.bin ❌ 404, always
277
+ * getAssetUrl({ sha, mime }, "original"); // → …-o.webp ✓
278
+ *
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" });
283
+ * ```
284
+ *
285
+ * @example Check before you link
286
+ * ```ts
287
+ * import { getAssetUrl, hasPreset } from "@nitida/asset-client";
288
+ * const url = hasPreset(asset, "thumb") ? getAssetUrl(asset, "thumb") : null;
289
+ * ```
290
+ */
291
+ export function getAssetUrl(
292
+ asset: Pick<AssetDTO, "sha"> & { mime?: string },
293
+ preset: VariantPreset,
294
+ ): string {
295
+ const ext =
296
+ preset === "original" ? originalExtForMime(asset.mime) : PRESET_EXT[preset];
297
+ return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${ext}`;
298
+ }
299
+
300
+ /**
301
+ * Did the processor actually generate this preset?
302
+ *
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.
306
+ *
307
+ * @example
308
+ * ```ts
309
+ * import { hasPreset } from "@nitida/asset-client";
310
+ *
311
+ * hasPreset({ presets: "oq" }, "original"); // → true ("o" = original, "q" = thumb)
312
+ * hasPreset({ presets: "oq" }, "lg"); // → false — do not link to it
313
+ * hasPreset({ presets: "pv" }, "aiproxy"); // → false — video without the AI proxy
314
+ * ```
315
+ */
316
+ export function hasPreset(
317
+ asset: Pick<AssetDTO, "presets">,
318
+ preset: VariantPreset,
319
+ ): 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
+ if (preset === "mp3") return asset.presets.includes("mp3");
324
+ return asset.presets.replace(/mp3/g, "").includes(PRESET_SHORT[preset]);
325
+ }
326
+
327
+ /**
328
+ * Build a srcSet string for responsive `<img>`. Walks the available image
329
+ * presets in size order and only includes the ones the asset actually has.
330
+ *
331
+ * <img
332
+ * src={getAssetUrl(asset, 'web')}
333
+ * srcSet={getAssetSrcSet(asset)}
334
+ * sizes="(max-width: 768px) 100vw, 800px"
335
+ * />
336
+ */
337
+ const IMAGE_PRESETS: VariantPreset[] = ["thumb", "sm", "md", "lg", "xl"];
338
+ export function getAssetSrcSet(
339
+ asset: Pick<AssetDTO, "sha" | "presets">,
340
+ ): string {
341
+ return IMAGE_PRESETS.filter(
342
+ (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null,
343
+ )
344
+ .map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`)
345
+ .join(", ");
346
+ }
347
+
348
+ /**
349
+ * Compute the dimensions a variant would have given the source asset's
350
+ * width/height and the variant's bounding box. For thumbnails (square
351
+ * smart-crop) the result is always 256×256; for the other presets, scales
352
+ * the max side to the box dimension and the other side proportionally.
353
+ */
354
+ export function computeVariantDimensions(
355
+ asset: Pick<AssetDTO, "w" | "h">,
356
+ preset: VariantPreset,
357
+ ): { width: number; height: number } | null {
358
+ const cap = PRESET_MAX_DIM[preset];
359
+ if (cap == null) return null;
360
+ if (preset === "thumb") return { width: cap, height: cap };
361
+ if (!asset.w || !asset.h) return null;
362
+ const scale = Math.min(cap / asset.w, cap / asset.h, 1);
363
+ return {
364
+ width: Math.round(asset.w * scale),
365
+ height: Math.round(asset.h * scale),
366
+ };
367
+ }
368
+
369
+ /** Source dimensions, for aspect-ratio sizing. */
370
+ export function getAssetDimensions(
371
+ asset: Pick<AssetDTO, "w" | "h">,
372
+ ): { width: number; height: number } | null {
373
+ if (asset.w && asset.h) return { width: asset.w, height: asset.h };
374
+ return null;
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Slot system — tenant-named asset bindings.
379
+ // See ./slots for full docs.
380
+ // ---------------------------------------------------------------------------
381
+
382
+ export {
383
+ configureSlotResolver,
384
+ invalidateSlotCache,
385
+ type ResolveSlotOptions,
386
+ resolveSlot,
387
+ resolveSlots,
388
+ type SlotDTO,
389
+ type SlotResolution,
390
+ } from "./slots";
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // On-the-fly transforms — see ./transform for docs.
394
+ // ---------------------------------------------------------------------------
395
+
396
+ export {
397
+ extractAssetSha,
398
+ getHlsStreamingUrl,
399
+ getSignedTransformUrl,
400
+ getTransformSrcSet,
401
+ getTransformUrl,
402
+ getVideoTransformUrl,
403
+ type SignedTransformOptions,
404
+ serializeTransform,
405
+ signTransformUrl,
406
+ TRANSFORM_WIDTHS,
407
+ type TransformEffect,
408
+ type TransformFit,
409
+ type TransformFormat,
410
+ type TransformGravity,
411
+ type TransformOptions,
412
+ type TransformWidth,
413
+ } from "./transform";
package/src/palette.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Color palette helpers — render harmonious ambient backgrounds behind
3
+ * product images, inspired by Spotify Now Playing / Apple Music / Pico.
4
+ *
5
+ * Wire format is intentionally compact: only the hex per swatch, only the
6
+ * swatches the source actually had. The full names map to 1-2 letter aliases
7
+ * (`d` dominant, `v` vibrant, `m` muted, `dv` darkVibrant, `lv` lightVibrant,
8
+ * `dm` darkMuted, `lm` lightMuted) to shave bytes for catalog-sized payloads
9
+ * (palette was 62% of asset DTO before this).
10
+ *
11
+ * Population + RGB array + textColor are derivable client-side; we don't
12
+ * ship them. textColor is computed via WCAG relative luminance on demand.
13
+ */
14
+
15
+ /** Compact wire shape for an asset's palette. All swatches optional except dominant. */
16
+ export type AssetPalette = {
17
+ /** dominant hex (always present when palette exists) */
18
+ d: string;
19
+ /** vibrant */ v?: string;
20
+ /** muted */ m?: string;
21
+ /** darkVibrant */ dv?: string;
22
+ /** lightVibrant */ lv?: string;
23
+ /** darkMuted */ dm?: string;
24
+ /** lightMuted */ lm?: string;
25
+ };
26
+
27
+ /** Backwards-compat alias for older callers that referenced PaletteSwatch. */
28
+ export type PaletteSwatch = { hex: string; textColor: "#000000" | "#FFFFFF" };
29
+
30
+ /**
31
+ * Resolve a palette key to its hex value if present.
32
+ */
33
+ function resolveSwatch(
34
+ palette: AssetPalette | null | undefined,
35
+ ...keys: (keyof AssetPalette)[]
36
+ ): string | null {
37
+ if (!palette) return null;
38
+ for (const k of keys) {
39
+ const v = palette[k];
40
+ if (v) return v;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Pick the swatch best suited for an ambient surface behind the image.
47
+ * Prefers muted/light tones — too vibrant a background fights the image.
48
+ *
49
+ * Order: lightMuted → muted → lightVibrant → dominant.
50
+ */
51
+ export function pickAmbientBackground(
52
+ palette: AssetPalette | null | undefined,
53
+ ): PaletteSwatch | null {
54
+ const hex = resolveSwatch(palette, "lm", "m", "lv", "d");
55
+ if (!hex) return null;
56
+ return { hex, textColor: textColorForHex(hex) };
57
+ }
58
+
59
+ /**
60
+ * Build a CSS linear-gradient from the palette. Useful for hero / detail
61
+ * backgrounds.
62
+ */
63
+ export function getAmbientGradient(
64
+ palette: AssetPalette | null | undefined,
65
+ opts: {
66
+ angle?: string;
67
+ from?: keyof AssetPalette;
68
+ to?: keyof AssetPalette;
69
+ } = {},
70
+ ): string | undefined {
71
+ if (!palette) return undefined;
72
+ const fromHex = palette[opts.from ?? "lm"] ?? palette.m ?? palette.d;
73
+ const toHex = palette[opts.to ?? "m"] ?? palette.dm ?? palette.d;
74
+ if (!fromHex || !toHex) return undefined;
75
+ return `linear-gradient(${opts.angle ?? "135deg"}, ${fromHex}, ${toHex})`;
76
+ }
77
+
78
+ /**
79
+ * Recommended text color (#000 or #FFF) for any background hex,
80
+ * computed via WCAG relative luminance.
81
+ */
82
+ export function getTextColorForBackground(
83
+ swatch: PaletteSwatch | string | null | undefined,
84
+ ): string {
85
+ if (!swatch) return "#000000";
86
+ const hex = typeof swatch === "string" ? swatch : swatch.hex;
87
+ return textColorForHex(hex);
88
+ }
89
+
90
+ function textColorForHex(hex: string): "#000000" | "#FFFFFF" {
91
+ const h = hex.replace("#", "");
92
+ const r = Number.parseInt(h.slice(0, 2), 16) / 255;
93
+ const g = Number.parseInt(h.slice(2, 4), 16) / 255;
94
+ const b = Number.parseInt(h.slice(4, 6), 16) / 255;
95
+ // sRGB → linear
96
+ const lin = (c: number) =>
97
+ 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";
100
+ }
101
+
102
+ /**
103
+ * CSS variables for a wrapper so a subtree can read --asset-bg / --asset-fg /
104
+ * --asset-dominant / --asset-vibrant / etc.
105
+ */
106
+ export function getPaletteCssVars(
107
+ palette: AssetPalette | null | undefined,
108
+ ): Record<string, string> {
109
+ if (!palette) return {};
110
+ const bg = pickAmbientBackground(palette);
111
+ return {
112
+ "--asset-bg": bg?.hex ?? "transparent",
113
+ "--asset-fg": bg ? bg.textColor : "#000000",
114
+ "--asset-dominant": palette.d,
115
+ ...(palette.v && { "--asset-vibrant": palette.v }),
116
+ ...(palette.m && { "--asset-muted": palette.m }),
117
+ ...(palette.lv && { "--asset-light-vibrant": palette.lv }),
118
+ ...(palette.dv && { "--asset-dark-vibrant": palette.dv }),
119
+ ...(palette.lm && { "--asset-light-muted": palette.lm }),
120
+ ...(palette.dm && { "--asset-dark-muted": palette.dm }),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Iterate the palette in display order (dominant first, then vibrant +
126
+ * muted families). Useful for rendering a swatch strip in admin UIs.
127
+ */
128
+ export function iteratePaletteSwatches(
129
+ palette: AssetPalette | null | undefined,
130
+ ): Array<{ key: keyof AssetPalette; label: string; hex: string }> {
131
+ if (!palette) return [];
132
+ const order: Array<{ key: keyof AssetPalette; label: string }> = [
133
+ { key: "d", label: "dominant" },
134
+ { key: "v", label: "vibrant" },
135
+ { key: "lv", label: "lightVibrant" },
136
+ { key: "dv", label: "darkVibrant" },
137
+ { key: "m", label: "muted" },
138
+ { key: "lm", label: "lightMuted" },
139
+ { key: "dm", label: "darkMuted" },
140
+ ];
141
+ return order
142
+ .map(({ key, label }) => {
143
+ const hex = palette[key];
144
+ return hex ? { key, label, hex } : null;
145
+ })
146
+ .filter(
147
+ (s): s is { key: keyof AssetPalette; label: string; hex: string } =>
148
+ s != null,
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Build a multi-radial-gradient CSS `background` string from the palette
154
+ * swatches. Acts as a zero-extra-bytes alternative to the WebP LQIP: the
155
+ * palette is already in the DTO, so this placeholder costs nothing extra
156
+ * to ship. Renders as a smooth abstract "color cloud" reminiscent of the
157
+ * source image's vibe.
158
+ *
159
+ * Strategy: anchor 4 radial gradients at fixed corners using vibrant/muted
160
+ * pairs, layered over the dominant fill. Skips missing swatches gracefully.
161
+ */
162
+ export function getPaletteBlurBackground(
163
+ palette: AssetPalette | null | undefined,
164
+ ): string | undefined {
165
+ if (!palette) return undefined;
166
+ const corners: Array<{ pos: string; key: keyof AssetPalette }> = [
167
+ { pos: "20% 20%", key: "lv" },
168
+ { pos: "80% 25%", key: "v" },
169
+ { pos: "25% 80%", key: "lm" },
170
+ { pos: "80% 80%", key: "dv" },
171
+ ];
172
+ const layers = corners
173
+ .map(({ pos, key }) => {
174
+ const hex = palette[key];
175
+ if (!hex) return null;
176
+ return `radial-gradient(circle at ${pos}, ${hex} 0%, transparent 55%)`;
177
+ })
178
+ .filter(Boolean) as string[];
179
+ // Fallback fill = dominant (or muted if dominant is missing — shouldn't happen)
180
+ const base = palette.d ?? palette.m ?? "#888";
181
+ return layers.length > 0 ? `${layers.join(", ")}, ${base}` : base;
182
+ }