@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.
@@ -0,0 +1,560 @@
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
+ /** Compact wire shape for an asset's palette. All swatches optional except dominant. */
15
+ type AssetPalette = {
16
+ /** dominant hex (always present when palette exists) */
17
+ d: string;
18
+ /** vibrant */ v?: string;
19
+ /** muted */ m?: string;
20
+ /** darkVibrant */ dv?: string;
21
+ /** lightVibrant */ lv?: string;
22
+ /** darkMuted */ dm?: string;
23
+ /** lightMuted */ lm?: string;
24
+ };
25
+ /** Backwards-compat alias for older callers that referenced PaletteSwatch. */
26
+ type PaletteSwatch = {
27
+ hex: string;
28
+ textColor: "#000000" | "#FFFFFF";
29
+ };
30
+ /**
31
+ * Pick the swatch best suited for an ambient surface behind the image.
32
+ * Prefers muted/light tones — too vibrant a background fights the image.
33
+ *
34
+ * Order: lightMuted → muted → lightVibrant → dominant.
35
+ */
36
+ declare function pickAmbientBackground(palette: AssetPalette | null | undefined): PaletteSwatch | null;
37
+ /**
38
+ * Build a CSS linear-gradient from the palette. Useful for hero / detail
39
+ * backgrounds.
40
+ */
41
+ declare function getAmbientGradient(palette: AssetPalette | null | undefined, opts?: {
42
+ angle?: string;
43
+ from?: keyof AssetPalette;
44
+ to?: keyof AssetPalette;
45
+ }): string | undefined;
46
+ /**
47
+ * Recommended text color (#000 or #FFF) for any background hex,
48
+ * computed via WCAG relative luminance.
49
+ */
50
+ declare function getTextColorForBackground(swatch: PaletteSwatch | string | null | undefined): string;
51
+ /**
52
+ * CSS variables for a wrapper so a subtree can read --asset-bg / --asset-fg /
53
+ * --asset-dominant / --asset-vibrant / etc.
54
+ */
55
+ declare function getPaletteCssVars(palette: AssetPalette | null | undefined): Record<string, string>;
56
+ /**
57
+ * Iterate the palette in display order (dominant first, then vibrant +
58
+ * muted families). Useful for rendering a swatch strip in admin UIs.
59
+ */
60
+ declare function iteratePaletteSwatches(palette: AssetPalette | null | undefined): Array<{
61
+ key: keyof AssetPalette;
62
+ label: string;
63
+ hex: string;
64
+ }>;
65
+ /**
66
+ * Build a multi-radial-gradient CSS `background` string from the palette
67
+ * swatches. Acts as a zero-extra-bytes alternative to the WebP LQIP: the
68
+ * palette is already in the DTO, so this placeholder costs nothing extra
69
+ * to ship. Renders as a smooth abstract "color cloud" reminiscent of the
70
+ * source image's vibe.
71
+ *
72
+ * Strategy: anchor 4 radial gradients at fixed corners using vibrant/muted
73
+ * pairs, layered over the dominant fill. Skips missing swatches gracefully.
74
+ */
75
+ declare function getPaletteBlurBackground(palette: AssetPalette | null | undefined): string | undefined;
76
+
77
+ /**
78
+ * @nitida/asset-client/slots — slot resolver for tenant-named assets.
79
+ *
80
+ * Slots give tenants a way to attach stable, human-readable names
81
+ * ("webapp.wizard.pool-type.icon-1", "storefront.cr.hero-video.landscape_hd_16x9.mp4")
82
+ * to assets they uploaded. Consumers resolve names → AssetDTOs at
83
+ * build / runtime so their source never hardcodes a CDN URL; the
84
+ * admin rebinds a slot from `asset-lab-web` and every consumer picks
85
+ * up the swap on cache refresh.
86
+ *
87
+ * Two layers in this package:
88
+ * - `resolveSlot` / `resolveSlots` — universal (server, edge,
89
+ * workers) fetch helpers. Cache 60s by default.
90
+ * - React hooks live in `@nitida/asset-client/react/use-slot`
91
+ * (kept out of this module so the SSR-safe core stays
92
+ * dependency-free of react).
93
+ */
94
+
95
+ type SlotDTO = {
96
+ slotKey: string;
97
+ /** Preset hint set when the slot was bound (e.g. `thumb` for icon slots). */
98
+ preset: VariantPreset | null;
99
+ description: string | null;
100
+ updatedAt: string;
101
+ asset: AssetDTO;
102
+ };
103
+ type SlotResolution = {
104
+ /** The resolved DTO (`null` when the slot is unbound or asset missing). */
105
+ slot: SlotDTO | null;
106
+ /**
107
+ * Effective preset — what `url` below was built with. Resolution order:
108
+ * 1. caller's `preset` override
109
+ * 2. slot's `preset` hint
110
+ * 3. `lg` for images, `video` for video kind
111
+ */
112
+ preset: VariantPreset;
113
+ /** The CDN URL the consumer should use. */
114
+ url: string | null;
115
+ };
116
+ /**
117
+ * Configure the resolver process-wide. Call once at boot from your
118
+ * storefront layout / server entry / worker init.
119
+ *
120
+ * configureSlotResolver({
121
+ * endpoint: process.env.AQUIENPZ_URL,
122
+ * apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY,
123
+ * tenantCode: "realtyone-cr",
124
+ * });
125
+ */
126
+ declare function configureSlotResolver(opts: {
127
+ endpoint?: string;
128
+ apiKey?: string;
129
+ tenantCode?: string;
130
+ }): void;
131
+ /** Wipe the in-process cache (test helper or forced refresh). */
132
+ declare function invalidateSlotCache(slotKey?: string): void;
133
+ type ResolveSlotOptions = {
134
+ /** Override preset (caller knows the use case better than the slot binding). */
135
+ preset?: VariantPreset;
136
+ /** TTL for the in-process cache. Default 60s. Set 0 to bypass. */
137
+ ttlMs?: number;
138
+ };
139
+ /**
140
+ * Resolve a single slot to a CDN URL. Returns `{slot: null, url: null}`
141
+ * when the slot is unbound — callers fall back to a placeholder.
142
+ *
143
+ * Cached for `ttlMs` (default 60s). Slot rebindings propagate within the
144
+ * TTL window without an app restart.
145
+ */
146
+ declare function resolveSlot(slotKey: string, opts?: ResolveSlotOptions): Promise<SlotResolution>;
147
+ /**
148
+ * Bulk-resolve N slot keys in one round-trip. The SDK's `useSlots`
149
+ * React hook calls this so every storefront header (logo + tagline +
150
+ * nav cover + …) loads as one request.
151
+ */
152
+ declare function resolveSlots(slotKeys: string[], opts?: ResolveSlotOptions): Promise<Record<string, SlotResolution>>;
153
+
154
+ /**
155
+ * On-the-fly transform URL builder.
156
+ *
157
+ * Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated
158
+ * here hashes to the same R2 cache key as the server's canonical form.
159
+ *
160
+ * Canonicalization rules (keep in sync with
161
+ * `apps/asset-manager/src/features/assets/transform.dsl.ts`):
162
+ * - Drop entries whose value is `undefined`
163
+ * - Sort keys alphabetically
164
+ * - Numbers rendered without leading zeros or trailing dots
165
+ * - String values lowercased
166
+ *
167
+ * URL shape:
168
+ * <cdnBase>/t/<dsl>/<sha>.<ext>
169
+ *
170
+ * The `.ext` is informational (browser content-sniff hint); the server
171
+ * decides the actual output format from the DSL `format` param + the
172
+ * request's Accept header.
173
+ */
174
+
175
+ /**
176
+ * Widths the CDN edge whitelists (DoS guard — see `apps/cdn-proxy` WHITELIST_WIDTHS).
177
+ * Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the
178
+ * 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import
179
+ * this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.
180
+ */
181
+ declare const TRANSFORM_WIDTHS: readonly [96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840];
182
+ /**
183
+ * A CDN-whitelisted transform width — the only widths `TransformOptions.width`
184
+ * accepts. Off-ladder widths are a compile error; for signed URLs that need a
185
+ * custom width, use {@link SignedTransformOptions} (number) via
186
+ * {@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`.
187
+ */
188
+ type TransformWidth = (typeof TRANSFORM_WIDTHS)[number];
189
+ type TransformFit = "cover" | "contain" | "fill" | "inside" | "outside";
190
+ type TransformGravity = "auto" | "face" | "center" | "north" | "south" | "east" | "west";
191
+ type TransformFormat = "auto" | "avif" | "webp" | "jpeg" | "png" | "mp4" | "webm" | "hls";
192
+ type TransformEffect = "removebg" | "genfill";
193
+ type TransformOptions = {
194
+ /**
195
+ * Target max-side width in CSS pixels (multiplied by `dpr` server-side).
196
+ * MUST be a {@link TRANSFORM_WIDTHS} value — off-ladder widths are rejected
197
+ * (HTTP 400) by the edge whitelist for unsigned URLs, so the type forbids
198
+ * them at compile time. For SIGNED URLs with a custom width, use
199
+ * {@link SignedTransformOptions} (which widens this to `number`).
200
+ */
201
+ width?: TransformWidth;
202
+ /** Target max-side height. Multiplied by `dpr` server-side. */
203
+ height?: number;
204
+ /** Resize fit mode. Default `cover` server-side. */
205
+ fit?: TransformFit;
206
+ /** Crop gravity. `auto` uses sharp's `attention` strategy. */
207
+ gravity?: TransformGravity;
208
+ /** Output format. `auto` → policy decides (see asset-manager bench). */
209
+ format?: TransformFormat;
210
+ /** Output quality. `auto` → format-specific default. */
211
+ quality?: "auto" | number;
212
+ /** Device pixel ratio. Width/height are multiplied by this before resize. */
213
+ dpr?: 1 | 2 | 3;
214
+ /**
215
+ * AI effect applied before resize/encode.
216
+ *
217
+ * - `removebg`: remove the background; output is a transparent PNG
218
+ * of the foreground subject. Forces `format=png` regardless of
219
+ * other format hints. Runs U²-Net ONNX locally (or BRIA via
220
+ * Replicate when `BG_REMOVAL_BACKEND=replicate`). Single cache
221
+ * miss per (sha, dsl) tuple; subsequent identical DSLs serve
222
+ * from R2 — no inference, no per-image cost.
223
+ *
224
+ * - `genfill`: aspect-extension outpaint via Flux-Fill Pro on
225
+ * Replicate. Requires BOTH `width` and `height` — the server
226
+ * fits the source centered into the target canvas and outpaints
227
+ * the gutters. Output is PNG (forced) at exactly target dims.
228
+ * ~$0.05/image first time; same R2 cache as removebg after.
229
+ * Primary use case: building OG cards (1200×630) from portrait
230
+ * listing photos without awkward edge mirroring.
231
+ */
232
+ effect?: TransformEffect;
233
+ /**
234
+ * Video-only: clip start in seconds. Image transforms ignore.
235
+ * Accepts decimals (e.g. 1.5 for sub-second seek).
236
+ */
237
+ start?: number;
238
+ /**
239
+ * Video-only: clip duration in seconds (1..300). Image transforms
240
+ * ignore. With `start`, lets a single request grab a sub-clip.
241
+ */
242
+ duration?: number;
243
+ };
244
+ /**
245
+ * Like {@link TransformOptions} but with `width` widened to any `number` —
246
+ * the escape hatch for SIGNED URLs that need an off-ladder custom width.
247
+ *
248
+ * The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid
249
+ * `?sig=` earns the whitelist bypass at the worker (the asset-manager still
250
+ * does the real HMAC check). So a custom width is ONLY safe when the URL is
251
+ * signed — hence this type is accepted exclusively by the signing helpers
252
+ * ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),
253
+ * never by the plain unsigned {@link getTransformUrl}.
254
+ */
255
+ type SignedTransformOptions = Omit<TransformOptions, "width"> & {
256
+ /** Off-ladder width — valid ONLY on signed URLs (edge whitelist bypass). */
257
+ width?: number;
258
+ };
259
+ /**
260
+ * Serialize transform options into the canonical DSL path segment.
261
+ * Empty options return an empty string (caller should fall back to a
262
+ * variant URL instead of a transform URL in that case).
263
+ *
264
+ * Accepts {@link SignedTransformOptions} (the wider type) so it also covers
265
+ * custom-width signed URLs; {@link TransformOptions} is assignable to it.
266
+ */
267
+ /**
268
+ * Extract the 16-hex short sha from an aquienpz CDN URL, regardless of
269
+ * shape:
270
+ * - tenant-prefixed variant: `https://8ok.uk/4/v/<sha16>-<preset>.<ext>`
271
+ * - legacy variant: `https://8ok.uk/<sha16>-<preset>.<ext>`
272
+ * - on-the-fly transform: `https://8ok.uk/t/<dsl>/<sha16>.<ext>`
273
+ * - streaming HLS: `https://8ok.uk/t/format=hls/<sha16>.m3u8`
274
+ *
275
+ * Returns `null` for non-aquienpz URLs (pexels, googleusercontent, raw
276
+ * uploaded URLs to other CDNs) so call sites can fall back to the URL
277
+ * with a plain `<img>` instead of generating a broken transform URL.
278
+ *
279
+ * Useful when a value reaches the component as a pre-built URL string
280
+ * (legacy data, site-config JSON, third-party feeds) but you want to
281
+ * drop in `getTransformSrcSet` for the responsive ladder if it happens
282
+ * to be an aquienpz asset.
283
+ */
284
+ declare function extractAssetSha(url: string | null | undefined): string | null;
285
+ declare function serializeTransform(opts: SignedTransformOptions): string;
286
+ /**
287
+ * Build a transform URL for a VIDEO asset. Same DSL shape as image
288
+ * transforms; the server branches on the asset's `kind` column. Video
289
+ * URLs use `.mp4` (default) or `.webm` extension and on cache miss the
290
+ * server returns 202 Accepted while a Cloud Run Job encodes the clip;
291
+ * subsequent GETs return 302 to the cached R2 object.
292
+ *
293
+ * <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}
294
+ * autoPlay muted loop playsInline />
295
+ */
296
+ declare function getVideoTransformUrl(asset: Pick<AssetDTO, "sha">, opts: TransformOptions): string | null;
297
+ /**
298
+ * Build an HLS streaming URL for a VIDEO asset (Phase 5). Returns the
299
+ * master.m3u8 entry point — HLS-aware players (Video.js's
300
+ * @videojs/http-streaming, hls.js, native iOS Safari) follow it to
301
+ * fetch the variant playlist + segments at the appropriate bitrate
302
+ * for the connection.
303
+ *
304
+ * ⚠️ A master.m3u8 is NOT a video file. Assigning it to `<video src>`
305
+ * works only where the engine has native HLS; everywhere else it needs
306
+ * an MSE player. And the classic feature test is now WRONG: Chrome 147
307
+ * (April 2026) added native HLS, so `canPlayType("application/vnd.apple.mpegurl")`
308
+ * answers "maybe" there and routes Chrome to the native branch, where it
309
+ * opened a measured 17 s hero at 426x240 for ~8 s. Branch on the ENGINE:
310
+ *
311
+ * @example
312
+ * ```ts
313
+ * function prefersNativeHls(video: HTMLVideoElement): boolean {
314
+ * if (video.canPlayType("application/vnd.apple.mpegurl") === "") return false;
315
+ * // Apple's engine, or an engine with no MSE to fall back on (iOS < 17.1).
316
+ * return "ManagedMediaSource" in globalThis || !("MediaSource" in globalThis);
317
+ * }
318
+ *
319
+ * const src = getHlsStreamingUrl(asset);
320
+ * if (prefersNativeHls(video)) {
321
+ * video.src = src;
322
+ * } else {
323
+ * // Defaults open at a fixed low rung — measure instead of guessing.
324
+ * const hls = new Hls({ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 });
325
+ * hls.loadSource(src);
326
+ * hls.attachMedia(video);
327
+ * }
328
+ * ```
329
+ *
330
+ * On first request the server returns 202 Accepted while a Cloud Run
331
+ * Job transcodes the ladder (typically 1-3 min for a 90 s source);
332
+ * subsequent requests get 302 to the cached master.m3u8. Keep the
333
+ * progressive MP4 as a fallback source for that window.
334
+ *
335
+ * The ladder's ceiling is the source the job probes: built at ingest it
336
+ * reads the RAW upload and a 4K master yields 1440p/2160p rungs; rebuilt
337
+ * on demand after the raw is unavailable it reads the `-v.mp4`, which is
338
+ * capped at 1920 wide. `getAssetUrl(sha, "video")` is always <= 1080p.
339
+ */
340
+ declare function getHlsStreamingUrl(asset: Pick<AssetDTO, "sha">, opts?: Omit<TransformOptions, "format">): string;
341
+ declare function getTransformUrl(asset: Pick<AssetDTO, "sha">, opts: TransformOptions): string | null;
342
+ /**
343
+ * Build AND sign a transform URL, allowing an off-ladder custom `width`.
344
+ *
345
+ * This is the escape hatch for {@link SignedTransformOptions}: off-ladder
346
+ * widths only pass the edge whitelist when the URL is signed, so building one
347
+ * and signing it must happen together. For on-ladder widths prefer the plain
348
+ * {@link getTransformUrl} (+ {@link signTransformUrl} if you need a signature).
349
+ *
350
+ * Returns `null` only when `opts` serialize to an empty DSL (no transform
351
+ * requested) — same contract as {@link getTransformUrl}.
352
+ */
353
+ declare function getSignedTransformUrl(asset: Pick<AssetDTO, "sha">, opts: SignedTransformOptions, signingKey: string): Promise<string> | null;
354
+ /**
355
+ * Sign a transform URL with the tenant's HMAC signing key. Appends
356
+ * `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).
357
+ *
358
+ * Must agree byte-for-byte with the server's `verifyTransformSignature`.
359
+ * Uses WebCrypto, so works in browsers, Node ≥ 16, Bun, and Workers.
360
+ *
361
+ * The canonical DSL is the one already produced by `serializeTransform`
362
+ * (sort keys + lowercase strings), so signing a URL built by `getTransformUrl`
363
+ * is automatic — the same canonical form is in the URL path.
364
+ */
365
+ declare function signTransformUrl(unsignedUrl: string, signingKey: string): Promise<string>;
366
+ /**
367
+ * Build a responsive `srcSet` string by generating one transform URL per
368
+ * width. All other options apply to every URL.
369
+ *
370
+ * <img
371
+ * src={aq.transform(asset, { width: 800 })!}
372
+ * srcSet={aq.transformSrcSet(asset, [320, 640, 960, 1280])}
373
+ * sizes="(max-width: 768px) 100vw, 50vw"
374
+ * />
375
+ */
376
+ declare function getTransformSrcSet(asset: Pick<AssetDTO, "sha">, widths: number[], extraOpts?: Omit<TransformOptions, "width">): string;
377
+
378
+ /**
379
+ * @nitida/asset-client — read helpers for asset URLs.
380
+ *
381
+ * Universal CDN model (May 2026): the API ships a compact AssetDTO with a
382
+ * 16-char SHA prefix + a `presets` string of 1-char codes; client helpers
383
+ * construct CDN URLs deterministically from `(cdnBase, sha, preset, ext)`.
384
+ *
385
+ * Why: catalog sync over Electric SSE shipped 4 nearly-identical full URLs
386
+ * per asset × hundreds of thousands of assets per snapshot. Sending only
387
+ * what differs — sha + presets bitmap — collapses ~600 bytes per asset to
388
+ * ~70 (88% reduction).
389
+ *
390
+ * No runtime dependencies — pure types + pure functions. Safe everywhere.
391
+ * @module @nitida/asset-client
392
+ */
393
+ /**
394
+ * Image presets are size-based (Vercel `next/image` style):
395
+ * - `thumb` is the only square crop — semantic icon use case
396
+ * - `sm/md/lg` are max-side bounding boxes that preserve aspect ratio
397
+ *
398
+ * Naming over the legacy `thumbnail/cover/web/hero` because the new names
399
+ * say what the variant IS (a size class) rather than what it might be
400
+ * USED for, removing the implicit landscape-only assumption that bit us
401
+ * with vertical product photos.
402
+ */
403
+ type VariantPreset = "thumb" | "sm" | "md" | "lg" | "xl" | "original" | "poster" | "video" | "aiproxy" | "mp3";
404
+ /** 1-char alias used in R2 keys / wire `presets` string. */
405
+ declare const PRESET_SHORT: Record<VariantPreset, string>;
406
+ declare const PRESET_LONG: Record<string, VariantPreset>;
407
+ /** Variant extension by preset. Image variants are always WebP, video MP4. */
408
+ declare const PRESET_EXT: Record<VariantPreset, string>;
409
+ /** Max-side dimension by preset; null for video / passthrough. */
410
+ declare const PRESET_MAX_DIM: Record<VariantPreset, number | null>;
411
+ /**
412
+ * One generated variant of an asset. Returned by the admin endpoints
413
+ * (`GET /assets/:id`, `POST /assets/:id/regenerate`).
414
+ */
415
+ type AssetVariant = {
416
+ /** Long name (`thumb` / `sm` / … / `original`) — see {@link VariantPreset}. */
417
+ preset: VariantPreset;
418
+ /** Public CDN URL of this variant. */
419
+ url: string;
420
+ /** Pixel width. Absent for `original`-only assets where sharp was skipped, or for video presets. */
421
+ width?: number;
422
+ /** Pixel height. Same caveat as `width`. */
423
+ height?: number;
424
+ /** Byte size of the variant file on R2. */
425
+ bytes: number;
426
+ /**
427
+ * Where the bytes for this variant came from. Useful for quality
428
+ * traceability — a `thumb` with `sourceFrom: "original"` is the
429
+ * canonical case, while `sourceFrom: "lg"` means it was derived
430
+ * from an already-encoded WebP (slight quality compounding).
431
+ *
432
+ * - `"upload"` → first-write at `/assets/process`. The bytes came
433
+ * straight from the client's PUT.
434
+ * - `VariantPreset` → regenerated from that preset's variant.
435
+ *
436
+ * Absent on variants written before the trace field existed.
437
+ */
438
+ sourceFrom?: VariantPreset | "upload";
439
+ /** ISO timestamp this variant was written. Absent on pre-trace variants. */
440
+ createdAt?: string;
441
+ };
442
+ /**
443
+ * Compact wire shape — what the server actually sends. Aliases (`w`, `h`,
444
+ * `dur`) are intentional to shave bytes per asset on dense lists.
445
+ */
446
+ type AssetDTO = {
447
+ id: string;
448
+ /** First 16 hex chars of sha256 — used to derive CDN URLs. */
449
+ sha: string;
450
+ kind: "image" | "video" | "document" | "audio" | "other";
451
+ mime: string;
452
+ bytes: number;
453
+ /** Source dims (for aspect-ratio calc on the client). Optional for non-images. */
454
+ w?: number | null;
455
+ h?: number | null;
456
+ /** Duration ms for videos. */
457
+ dur?: number | null;
458
+ /** LQIP placeholder (data URL). */
459
+ blur?: string | null;
460
+ palette?: AssetPalette | null;
461
+ /**
462
+ * Compact list of generated variants as their 1-char codes
463
+ * concatenated, e.g. "tcwh" (image) / "pv" (video without aiproxy).
464
+ * Ordered by ascending dimension.
465
+ */
466
+ presets: string;
467
+ status: "processing" | "ready" | "failed";
468
+ /** Soft-delete timestamp (ISO). Hidden from catalog when set. */
469
+ deletedAt?: string | null;
470
+ /**
471
+ * Full variant list with URLs + sizes. Present on admin responses
472
+ * (`GET /assets/:id`); absent on the slim list shape used by the
473
+ * resolver / catalog. Use `presets` for compact existence checks.
474
+ */
475
+ variants?: AssetVariant[];
476
+ };
477
+
478
+ /**
479
+ * Override the CDN base for the entire process (e.g. in tests, or when
480
+ * pointing at a tenant-specific CDN). Storefront layouts call this once at
481
+ * boot.
482
+ */
483
+ declare function setCdnBase(url: string): void;
484
+ declare function getCdnBase(): string;
485
+ /** Set the process-global tenant id used to build tenant-prefixed CDN URLs. */
486
+ declare function setTenantId(id: number | null | undefined): void;
487
+ declare function getTenantId(): number | null;
488
+ /**
489
+ * Build the public CDN URL for a specific variant of an asset. The variant
490
+ * may not actually exist (regenerate may not have run, or video has no
491
+ * `aiproxy`); call `hasPreset()` first or expect a 404.
492
+ *
493
+ * Pass the asset's `mime` (present on the full `AssetDTO`) so the `original`
494
+ * preset resolves to the correct extension; without it the original falls back
495
+ * to the `"bin"` sentinel.
496
+ *
497
+ * @example Video — the tenant segment is base36, so let this build the path
498
+ * ```ts
499
+ * import { getAssetUrl, setTenantId } from "@nitida/asset-client";
500
+ *
501
+ * setTenantId(12); // 12 → "c"; a decimal "/12/v/" 404s
502
+ * getAssetUrl({ sha }, "video"); // → https://8ok.uk/c/v/<sha16>-v.mp4
503
+ * getAssetUrl({ sha }, "poster"); // → https://8ok.uk/c/v/<sha16>-p.webp
504
+ * ```
505
+ *
506
+ * @example The `original`, which needs the mime — and still deserves a HEAD
507
+ * ```ts
508
+ * getAssetUrl({ sha }, "original"); // → …-o.bin ❌ 404, always
509
+ * getAssetUrl({ sha, mime }, "original"); // → …-o.webp ✓
510
+ *
511
+ * // ⚠️ The stored extension comes from the UPLOADED FILENAME, not the mime:
512
+ * // "image/jpeg" builds "-o.jpeg" while a camera's ".jpg" was stored as "-o.jpg".
513
+ * // When the URL must be right, verify it:
514
+ * const res = await fetch(url, { method: "HEAD" });
515
+ * ```
516
+ *
517
+ * @example Check before you link
518
+ * ```ts
519
+ * import { getAssetUrl, hasPreset } from "@nitida/asset-client";
520
+ * const url = hasPreset(asset, "thumb") ? getAssetUrl(asset, "thumb") : null;
521
+ * ```
522
+ */
523
+ declare function getAssetUrl(asset: Pick<AssetDTO, "sha"> & {
524
+ mime?: string;
525
+ }, preset: VariantPreset): string;
526
+ /**
527
+ * Did the processor actually generate this preset?
528
+ *
529
+ * Reads `dto.presets` — the compact 1-char code string the server always sends. Prefer this over
530
+ * the `variants` array, which is documented as present on admin responses and, measured
531
+ * 2026-08-15, comes back EMPTY even with an admin key while the database row holds the variants.
532
+ *
533
+ * @example
534
+ * ```ts
535
+ * import { hasPreset } from "@nitida/asset-client";
536
+ *
537
+ * hasPreset({ presets: "oq" }, "original"); // → true ("o" = original, "q" = thumb)
538
+ * hasPreset({ presets: "oq" }, "lg"); // → false — do not link to it
539
+ * hasPreset({ presets: "pv" }, "aiproxy"); // → false — video without the AI proxy
540
+ * ```
541
+ */
542
+ declare function hasPreset(asset: Pick<AssetDTO, "presets">, preset: VariantPreset): boolean;
543
+ declare function getAssetSrcSet(asset: Pick<AssetDTO, "sha" | "presets">): string;
544
+ /**
545
+ * Compute the dimensions a variant would have given the source asset's
546
+ * width/height and the variant's bounding box. For thumbnails (square
547
+ * smart-crop) the result is always 256×256; for the other presets, scales
548
+ * the max side to the box dimension and the other side proportionally.
549
+ */
550
+ declare function computeVariantDimensions(asset: Pick<AssetDTO, "w" | "h">, preset: VariantPreset): {
551
+ width: number;
552
+ height: number;
553
+ } | null;
554
+ /** Source dimensions, for aspect-ratio sizing. */
555
+ declare function getAssetDimensions(asset: Pick<AssetDTO, "w" | "h">): {
556
+ width: number;
557
+ height: number;
558
+ } | null;
559
+
560
+ export { type AssetDTO, type AssetPalette, type AssetVariant, PRESET_EXT, PRESET_LONG, PRESET_MAX_DIM, PRESET_SHORT, type PaletteSwatch, type ResolveSlotOptions, type SignedTransformOptions, type SlotDTO, type SlotResolution, TRANSFORM_WIDTHS, type TransformEffect, type TransformFit, type TransformFormat, type TransformGravity, type TransformOptions, type TransformWidth, type VariantPreset, computeVariantDimensions, configureSlotResolver, extractAssetSha, getAmbientGradient, getAssetDimensions, getAssetSrcSet, getAssetUrl, getCdnBase, getHlsStreamingUrl, getPaletteBlurBackground, getPaletteCssVars, getSignedTransformUrl, getTenantId, getTextColorForBackground, getTransformSrcSet, getTransformUrl, getVideoTransformUrl, hasPreset, invalidateSlotCache, iteratePaletteSwatches, pickAmbientBackground, resolveSlot, resolveSlots, serializeTransform, setCdnBase, setTenantId, signTransformUrl };