@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/AGENTS.md +25 -0
- package/README.md +100 -0
- package/dist/index.cjs +513 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +560 -0
- package/dist/index.d.ts +560 -0
- package/dist/index.js +455 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/index.ts +413 -0
- package/src/palette.ts +182 -0
- package/src/slots.ts +217 -0
- package/src/transform.ts +410 -0
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
|
+
}
|