@nitida/asset-client 0.16.1 → 0.16.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 +4 -4
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -21
- package/dist/index.d.ts +19 -21
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +6 -6
- package/src/slots.ts +6 -9
- package/src/transform.ts +18 -20
package/AGENTS.md
CHANGED
|
@@ -25,7 +25,7 @@ Use `hasPreset(asset, preset)` against `dto.presets` (the compact code string).
|
|
|
25
25
|
response shape, including the slim list/resolver one that carries no `variants` at all. **Do not
|
|
26
26
|
hand-roll `dto.presets.includes("o")`** — see below.
|
|
27
27
|
|
|
28
|
-
`variants` is real as of the 2026-08-17 deploy; against an older
|
|
28
|
+
`variants` is real as of the 2026-08-17 deploy; against an older server deploy it comes back
|
|
29
29
|
empty on every response, so code that must survive both reads `presets` for existence and
|
|
30
30
|
`variants` only for URLs.
|
|
31
31
|
|
|
@@ -39,8 +39,8 @@ token that stays in the vocabulary**, so a naive test also reads its `m` as `md`
|
|
|
39
39
|
|
|
40
40
|
## The endpoint
|
|
41
41
|
|
|
42
|
-
The API this client's URLs belong to is `https://api.nitida.gofuture.space`.
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
The API this client's URLs belong to is `https://api.nitida.gofuture.space`. An older, longer
|
|
43
|
+
endpoint you may have been given is the *same service* under a generated name and keeps working
|
|
44
|
+
indefinitely — the branded host is additive, not a migration. The CDN host stays `8ok.uk`,
|
|
45
45
|
and three sibling packages (`@aquienpz/tenant-config`, `@aquienpz/asset-uploader-web`,
|
|
46
46
|
`@aquienpz/asset-uploader-expo`) still publish under the old scope on purpose.
|
package/dist/index.cjs
CHANGED
|
@@ -165,7 +165,7 @@ function getPaletteBlurBackground(palette) {
|
|
|
165
165
|
// src/slots.ts
|
|
166
166
|
var DEFAULT_TTL_MS = 6e4;
|
|
167
167
|
var cache = /* @__PURE__ */ new Map();
|
|
168
|
-
var endpoint = "https://
|
|
168
|
+
var endpoint = "https://api.nitida.gofuture.space";
|
|
169
169
|
var apiKey = null;
|
|
170
170
|
var tenantCode = null;
|
|
171
171
|
function configureSlotResolver(opts) {
|
|
@@ -396,7 +396,7 @@ var PRESET_SHORT = {
|
|
|
396
396
|
poster: "p",
|
|
397
397
|
video: "v",
|
|
398
398
|
aiproxy: "a",
|
|
399
|
-
// 3 chars, NOT a 1-char alias: the
|
|
399
|
+
// 3 chars, NOT a 1-char alias: the server has no short-form for
|
|
400
400
|
// audio so its `shortPreset("mp3")` falls through to the literal token,
|
|
401
401
|
// and the deployed server already writes the `-mp3.mp3` variant + emits
|
|
402
402
|
// the bare `mp3` token in the wire `presets` string. Must stay in lockstep.
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/palette.ts","../src/slots.ts","../src/transform.ts"],"sourcesContent":["/**\n * @nitida/asset-client — read helpers for asset URLs.\n *\n * Universal CDN model (May 2026): the API ships a compact AssetDTO with a\n * 16-char SHA prefix + a `presets` string of 1-char codes; client helpers\n * construct CDN URLs deterministically from `(cdnBase, sha, preset, ext)`.\n *\n * Why: catalog sync over Electric SSE shipped 4 nearly-identical full URLs\n * per asset × hundreds of thousands of assets per snapshot. Sending only\n * what differs — sha + presets bitmap — collapses ~600 bytes per asset to\n * ~70 (88% reduction).\n *\n * No runtime dependencies — pure types + pure functions. Safe everywhere.\n * @module @nitida/asset-client\n */\n\n/**\n * Image presets are size-based (Vercel `next/image` style):\n * - `thumb` is the only square crop — semantic icon use case\n * - `sm/md/lg` are max-side bounding boxes that preserve aspect ratio\n *\n * Naming over the legacy `thumbnail/cover/web/hero` because the new names\n * say what the variant IS (a size class) rather than what it might be\n * USED for, removing the implicit landscape-only assumption that bit us\n * with vertical product photos.\n */\nexport type VariantPreset =\n // image presets\n | \"thumb\" // 256x256 square smart-crop (icon)\n | \"sm\" // 640 max-side\n | \"md\" // 1280 max-side\n | \"lg\" // 1920 max-side\n | \"xl\" // 3840 max-side (4K) — OPT-IN; not generated by default\n // passthrough for non-image kinds (PDF etc.)\n | \"original\"\n // video presets — semantic (not size classes)\n | \"poster\"\n | \"video\"\n | \"aiproxy\"\n // audio preset — the cross-browser mp3 transcode of a voice note\n // (libmp3lame) emitted alongside the original so chat audio plays on\n // both Chrome/Android (webm/opus) and iOS Safari (which can't decode opus).\n | \"mp3\";\n\n/** 1-char alias used in R2 keys / wire `presets` string. */\nexport const PRESET_SHORT: Record<VariantPreset, string> = {\n thumb: \"q\",\n sm: \"s\",\n md: \"m\",\n lg: \"l\",\n xl: \"x\",\n original: \"o\",\n poster: \"p\",\n video: \"v\",\n aiproxy: \"a\",\n // 3 chars, NOT a 1-char alias: the asset-manager has no short-form for\n // audio so its `shortPreset(\"mp3\")` falls through to the literal token,\n // and the deployed server already writes the `-mp3.mp3` variant + emits\n // the bare `mp3` token in the wire `presets` string. Must stay in lockstep.\n mp3: \"mp3\",\n};\nexport const PRESET_LONG: Record<string, VariantPreset> = Object.fromEntries(\n Object.entries(PRESET_SHORT).map(([k, v]) => [v, k as VariantPreset]),\n);\n\n/** Variant extension by preset. Image variants are always WebP, video MP4. */\nexport const PRESET_EXT: Record<VariantPreset, string> = {\n thumb: \"webp\",\n sm: \"webp\",\n md: \"webp\",\n lg: \"webp\",\n xl: \"webp\",\n original: \"bin\", // overridden per-asset via mime when needed\n poster: \"webp\",\n video: \"mp4\",\n aiproxy: \"mp4\",\n mp3: \"mp3\",\n};\n\n/** Max-side dimension by preset; null for video / passthrough. */\nexport const PRESET_MAX_DIM: Record<VariantPreset, number | null> = {\n thumb: 256,\n sm: 640,\n md: 1280,\n lg: 1920,\n xl: 3840,\n original: null,\n poster: null,\n video: null,\n aiproxy: null,\n // audio has no pixel dimensions; `null` keeps mp3 out of the\n // dimension-based `getAssetSrcSet` / `computeVariantDimensions` logic.\n mp3: null,\n};\n\n/**\n * One generated variant of an asset. Returned by the admin endpoints\n * (`GET /assets/:id`, `POST /assets/:id/regenerate`).\n */\nexport type AssetVariant = {\n /** Long name (`thumb` / `sm` / … / `original`) — see {@link VariantPreset}. */\n preset: VariantPreset;\n /** Public CDN URL of this variant. */\n url: string;\n /** Pixel width. Absent for `original`-only assets where sharp was skipped, or for video presets. */\n width?: number;\n /** Pixel height. Same caveat as `width`. */\n height?: number;\n /** Byte size of the variant file on R2. */\n bytes: number;\n /**\n * Where the bytes for this variant came from. Useful for quality\n * traceability — a `thumb` with `sourceFrom: \"original\"` is the\n * canonical case, while `sourceFrom: \"lg\"` means it was derived\n * from an already-encoded WebP (slight quality compounding).\n *\n * - `\"upload\"` → first-write at `/assets/process`. The bytes came\n * straight from the client's PUT.\n * - `VariantPreset` → regenerated from that preset's variant.\n *\n * Absent on variants written before the trace field existed.\n */\n sourceFrom?: VariantPreset | \"upload\";\n /** ISO timestamp this variant was written. Absent on pre-trace variants. */\n createdAt?: string;\n};\n\n/**\n * Compact wire shape — what the server actually sends. Aliases (`w`, `h`,\n * `dur`) are intentional to shave bytes per asset on dense lists.\n */\nexport type AssetDTO = {\n id: string;\n /** First 16 hex chars of sha256 — used to derive CDN URLs. */\n sha: string;\n kind: \"image\" | \"video\" | \"document\" | \"audio\" | \"other\";\n mime: string;\n bytes: number;\n /** Source dims (for aspect-ratio calc on the client). Optional for non-images. */\n w?: number | null;\n h?: number | null;\n /** Duration ms for videos. */\n dur?: number | null;\n /** LQIP placeholder (data URL). */\n blur?: string | null;\n palette?: AssetPalette | null;\n /**\n * Compact list of generated variants as their 1-char codes\n * concatenated, e.g. \"tcwh\" (image) / \"pv\" (video without aiproxy).\n * Ordered by ascending dimension.\n */\n presets: string;\n status: \"processing\" | \"ready\" | \"failed\";\n /** Soft-delete timestamp (ISO). Hidden from catalog when set. */\n deletedAt?: string | null;\n /**\n * Full variant list with URLs + sizes. Sent by `GET /assets/:id`; absent on\n * the slim list shape used by the resolver / catalog. Use `presets` for\n * compact existence checks, and this when you need the actual URLs.\n */\n variants?: AssetVariant[];\n /**\n * The extension the `original` variant was really stored under — the server\n * keys it off the uploaded filename, so it cannot be derived from `mime`.\n * Sent by `GET /assets/:id`; `null` when the asset has no original.\n * {@link getAssetUrl} uses it automatically when you pass the whole DTO.\n */\n oext?: string | null;\n};\n\nexport type {\n AssetPalette,\n PaletteSwatch,\n} from \"./palette\";\n\nexport {\n bestTextContrast,\n contrastRatio,\n getAmbientGradient,\n getPaletteBlurBackground,\n getPaletteCssVars,\n getTextColorForBackground,\n iteratePaletteSwatches,\n relativeLuminance,\n pickAmbientBackground,\n} from \"./palette\";\n\nimport type { AssetPalette } from \"./palette\";\n\n// ---------------------------------------------------------------------------\n// CDN base\n// ---------------------------------------------------------------------------\n\nlet cdnBaseUrl = \"https://8ok.uk\";\n/**\n * Override the CDN base for the entire process (e.g. in tests, or when\n * pointing at a tenant-specific CDN). Storefront layouts call this once at\n * boot.\n */\nexport function setCdnBase(url: string): void {\n cdnBaseUrl = url.replace(/\\/$/, \"\");\n}\nexport function getCdnBase(): string {\n return cdnBaseUrl;\n}\n\n// ---------------------------------------------------------------------------\n// Tenant scope\n//\n// Post-May-2026 the CDN serves variants under a tenant-prefixed path\n// `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see asset-manager\n// `variantKey`). Variant URL builders MUST include that prefix or every\n// URL 404s. The tenant id is process-global (one tenant per client/app),\n// set once at boot — `NitidaClient` does this from its `tenantId` option;\n// standalone consumers call `setTenantId()` directly. Left unset, builders\n// fall back to the legacy pre-cutover bare path for back-compat.\n// ---------------------------------------------------------------------------\n\nlet tenantId: number | null = null;\n/** Set the process-global tenant id used to build tenant-prefixed CDN URLs. */\nexport function setTenantId(id: number | null | undefined): void {\n tenantId =\n typeof id === \"number\" && Number.isFinite(id) && id > 0 ? id : null;\n}\nexport function getTenantId(): number | null {\n return tenantId;\n}\n/** Variant path prefix `<tid b36>/v/`, or \"\" when no tenant is configured. */\nfunction variantPrefix(): string {\n return tenantId != null ? `${tenantId.toString(36)}/v/` : \"\";\n}\n\n// ---------------------------------------------------------------------------\n// URL builders\n// ---------------------------------------------------------------------------\n\n/**\n * LAST-RESORT guess at the ORIGINAL variant's extension, from the asset's mime.\n *\n * ⚠️ This is a guess. Prefer `oext` or `variants` (see {@link getAssetUrl}) — the\n * server sends both and they ARE the key.\n *\n * The server derives the extension from the MIME with the `mime-types` package\n * (`mime.extension(body.mime)`, at presign). So a table that matched that one\n * exactly would usually be right — and this table did not: it said\n * `image/jpeg` → `jpeg` while `mime-types` says `jpg`, under a comment claiming\n * to mirror it. Usually, but not always: the row's stored `mime` is not always\n * the mime the key was built from, so no client-side table can close the gap.\n *\n * Measured against the 2 001 stored originals in production, 2026-08-17: **all\n * 420 `image/jpeg` originals are stored `.jpg` and none `.jpeg`** — the old\n * entry here 404'd on every single JPEG. And 234 originals carry\n * `application/octet-stream` (`.mpga`, `.docx`, `.m4a`), where no mime table can\n * produce the right key at all — those need `oext`.\n */\nconst ORIGINAL_EXT_BY_MIME: Record<string, string> = {\n \"image/png\": \"png\",\n \"image/jpeg\": \"jpg\",\n \"image/webp\": \"webp\",\n \"image/gif\": \"gif\",\n \"image/avif\": \"avif\",\n \"image/svg+xml\": \"svg\",\n \"image/heic\": \"heic\",\n \"image/heif\": \"heif\",\n \"image/bmp\": \"bmp\",\n \"image/tiff\": \"tiff\",\n \"application/pdf\": \"pdf\",\n \"video/mp4\": \"mp4\",\n \"video/webm\": \"webm\",\n \"video/quicktime\": \"mov\",\n // Audio — ausentes hasta 2026-08-17, y su ausencia costó un rodeo entero en\n // neo (`withRealOriginalExt`), que existe SÓLO porque esta tabla devolvía el\n // centinela `bin` para toda nota de voz. Medido en producción: `-o.bin` da\n // 404 y `-o.m4a` da 200.\n //\n // ⚠️ Se keyean por el mime COMPLETO, no por el subtipo: `audio/mp4` guarda\n // `.m4a` y `video/mp4` guarda `.mp4`. Un `switch` sobre el subtipo `mp4` no\n // puede distinguirlos — es el error que un consumidor cometió y tuvo que\n // corregir por su cuenta.\n \"audio/mpeg\": \"mpga\",\n \"audio/mp4\": \"m4a\",\n \"audio/x-m4a\": \"m4a\",\n \"audio/wav\": \"wav\",\n \"audio/webm\": \"weba\",\n \"audio/ogg\": \"oga\",\n \"audio/aac\": \"adts\",\n};\nfunction originalExtForMime(mime: string | undefined): string {\n return (mime ? ORIGINAL_EXT_BY_MIME[mime] : undefined) ?? PRESET_EXT.original;\n}\n\n/** What the asset itself knows about where its `original` lives. */\ntype OriginalHints = {\n mime?: string;\n /** The extension the server actually stored it under. Authoritative. */\n oext?: string | null;\n /** Full variant list — carries the stored URL verbatim. Authoritative. */\n variants?: AssetVariant[];\n};\n\n/**\n * Build the public CDN URL for a specific variant of an asset. The variant\n * may not actually exist (regenerate may not have run, or video has no\n * `aiproxy`); call `hasPreset()` first or expect a 404.\n *\n * For the `original` preset, pass the whole `AssetDTO` — it carries `variants`\n * and `oext`, either of which gives the EXACT stored key. `mime` alone is only a\n * guess (the server keys the original off the uploaded filename), and with\n * nothing at all the original falls back to the `\"bin\"` sentinel, which 404s.\n *\n * @example Video — the tenant segment is base36, so let this build the path\n * ```ts\n * import { getAssetUrl, setTenantId } from \"@nitida/asset-client\";\n *\n * setTenantId(12); // 12 → \"c\"; a decimal \"/12/v/\" 404s\n * getAssetUrl({ sha }, \"video\"); // → https://8ok.uk/c/v/<sha16>-v.mp4\n * getAssetUrl({ sha }, \"poster\"); // → https://8ok.uk/c/v/<sha16>-p.webp\n * ```\n *\n * @example The `original` — pass the DTO, not just the sha\n * ```ts\n * const asset = await aq.assets.get(id);\n *\n * getAssetUrl({ sha }, \"original\"); // → …-o.bin ❌ 404, always\n * getAssetUrl({ sha, mime }, \"original\"); // → a guess from the mime table\n * getAssetUrl(asset, \"original\"); // ✓ the stored key, verbatim\n * ```\n *\n * @example Check before you link\n * ```ts\n * import { getAssetUrl, hasPreset } from \"@nitida/asset-client\";\n * const url = hasPreset(asset, \"thumb\") ? getAssetUrl(asset, \"thumb\") : null;\n * ```\n */\nexport function getAssetUrl(\n asset: Pick<AssetDTO, \"sha\"> & OriginalHints,\n preset: VariantPreset,\n): string {\n if (preset === \"original\") {\n // The stored URL beats every derivation, because it IS the key. Only fall\n // through to a guess when the caller gave us the sha and nothing else.\n const stored = asset.variants?.find((v) => v.preset === \"original\")?.url;\n if (stored) return stored;\n const ext = asset.oext || originalExtForMime(asset.mime);\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT.original}.${ext}`;\n }\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;\n}\n\n/**\n * Did the processor actually generate this preset?\n *\n * Reads `dto.presets` — the compact 1-char code string the server sends on EVERY shape, including\n * the slim list/resolver one that carries no `variants` at all. That is why this exists and why it\n * stays the right existence check even now that `GET /assets/:id` really does send `variants`\n * (it did not until 2026-08-17; doc 240 §4.3).\n *\n * @example\n * ```ts\n * import { hasPreset } from \"@nitida/asset-client\";\n *\n * hasPreset({ presets: \"oq\" }, \"original\"); // → true (\"o\" = original, \"q\" = thumb)\n * hasPreset({ presets: \"oq\" }, \"lg\"); // → false — do not link to it\n * hasPreset({ presets: \"pv\" }, \"aiproxy\"); // → false — video without the AI proxy\n * ```\n */\nexport function hasPreset(\n asset: Pick<AssetDTO, \"presets\">,\n preset: VariantPreset,\n): boolean {\n if (preset === \"mp3\") return asset.presets.includes(\"mp3\");\n return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);\n}\n\n/**\n * Remove every MULTI-character token from a `presets` string, leaving only the\n * 1-char codes that a `.includes` can safely be run against.\n *\n * `presets` is documented as a concatenation of 1-char codes, and membership is\n * a 1-char substring test — so any longer token is a false-positive generator.\n * Servers before the 2026-08-17 deploy emitted several (doc 240 §4.3b):\n *\n * | token in the string | letters it donates | presets it falsely answers |\n * |---|---|---|\n * | `transform-<hex>` | t r a n s f o m + a–f | `sm` `md` `original` `aiproxy` |\n * | `pr` (probe) | p r | `poster` |\n * | `mp3` (audio) | m p | `md` `poster` — stripped here since forever |\n *\n * Measured against production: **62 % of live assets** carried a polluted\n * string, and **16 299 of them were told they have an `original` they do not**\n * — which builds a `-o.<ext>` URL that 404s. (`aiproxy`: 16 479. `sm`/`md`: 487.\n * The `pr` → `poster` collision is real but has 0 instances today.)\n *\n * Current servers no longer emit these, but this stays: an older\n * `asset-manager` keeps sending them, and this is the check every guide points\n * at as the reliable one. Order matters — strip the longest tokens first.\n */\nfunction stripMultiCharTokens(presets: string): string {\n return presets\n .replace(/transform-[0-9a-f]*/g, \"\")\n .replace(/upscale_[a-z0-9_]*/g, \"\")\n .replace(/mp3/g, \"\")\n .replace(/u[2-8]|t[1248ghij]/g, \"\")\n .replace(/pr/g, \"\");\n}\n\n/**\n * Build a srcSet string for responsive `<img>`. Walks the available image\n * presets in size order and only includes the ones the asset actually has.\n *\n * <img\n * src={getAssetUrl(asset, 'web')}\n * srcSet={getAssetSrcSet(asset)}\n * sizes=\"(max-width: 768px) 100vw, 800px\"\n * />\n */\nconst IMAGE_PRESETS: VariantPreset[] = [\"thumb\", \"sm\", \"md\", \"lg\", \"xl\"];\nexport function getAssetSrcSet(\n asset: Pick<AssetDTO, \"sha\" | \"presets\">,\n): string {\n return IMAGE_PRESETS.filter(\n (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null,\n )\n .map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`)\n .join(\", \");\n}\n\n/**\n * Compute the dimensions a variant would have given the source asset's\n * width/height and the variant's bounding box. For thumbnails (square\n * smart-crop) the result is always 256×256; for the other presets, scales\n * the max side to the box dimension and the other side proportionally.\n */\nexport function computeVariantDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n preset: VariantPreset,\n): { width: number; height: number } | null {\n const cap = PRESET_MAX_DIM[preset];\n if (cap == null) return null;\n if (preset === \"thumb\") return { width: cap, height: cap };\n if (!asset.w || !asset.h) return null;\n const scale = Math.min(cap / asset.w, cap / asset.h, 1);\n return {\n width: Math.round(asset.w * scale),\n height: Math.round(asset.h * scale),\n };\n}\n\n/** Source dimensions, for aspect-ratio sizing. */\nexport function getAssetDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n): { width: number; height: number } | null {\n if (asset.w && asset.h) return { width: asset.w, height: asset.h };\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Slot system — tenant-named asset bindings.\n// See ./slots for full docs.\n// ---------------------------------------------------------------------------\n\nexport {\n configureSlotResolver,\n invalidateSlotCache,\n type ResolveSlotOptions,\n resolveSlot,\n resolveSlots,\n type SlotDTO,\n type SlotResolution,\n} from \"./slots\";\n\n// ---------------------------------------------------------------------------\n// On-the-fly transforms — see ./transform for docs.\n// ---------------------------------------------------------------------------\n\nexport {\n extractAssetSha,\n getHlsStreamingUrl,\n getSignedTransformUrl,\n getTransformSrcSet,\n getTransformUrl,\n getVideoTransformUrl,\n type SignedTransformOptions,\n serializeTransform,\n signTransformUrl,\n TRANSFORM_WIDTHS,\n type TransformEffect,\n type TransformFit,\n type TransformFormat,\n type TransformGravity,\n type TransformOptions,\n type TransformWidth,\n} from \"./transform\";\n","/**\n * Color palette helpers — render harmonious ambient backgrounds behind\n * product images, inspired by Spotify Now Playing / Apple Music / Pico.\n *\n * Wire format is intentionally compact: only the hex per swatch, only the\n * swatches the source actually had. The full names map to 1-2 letter aliases\n * (`d` dominant, `v` vibrant, `m` muted, `dv` darkVibrant, `lv` lightVibrant,\n * `dm` darkMuted, `lm` lightMuted) to shave bytes for catalog-sized payloads\n * (palette was 62% of asset DTO before this).\n *\n * Population + RGB array + textColor are derivable client-side; we don't\n * ship them. textColor is computed via WCAG relative luminance on demand.\n */\n\n/** Compact wire shape for an asset's palette. All swatches optional except dominant. */\nexport type AssetPalette = {\n /** dominant hex (always present when palette exists) */\n d: string;\n /** vibrant */ v?: string;\n /** muted */ m?: string;\n /** darkVibrant */ dv?: string;\n /** lightVibrant */ lv?: string;\n /** darkMuted */ dm?: string;\n /** lightMuted */ lm?: string;\n};\n\n/** Backwards-compat alias for older callers that referenced PaletteSwatch. */\nexport type PaletteSwatch = { hex: string; textColor: \"#000000\" | \"#FFFFFF\" };\n\n/**\n * Resolve a palette key to its hex value if present.\n */\nfunction resolveSwatch(\n palette: AssetPalette | null | undefined,\n ...keys: (keyof AssetPalette)[]\n): string | null {\n if (!palette) return null;\n for (const k of keys) {\n const v = palette[k];\n if (v) return v;\n }\n return null;\n}\n\n/**\n * Pick the swatch best suited for an ambient surface behind the image.\n * Prefers muted/light tones — too vibrant a background fights the image.\n *\n * Order: lightMuted → muted → lightVibrant → dominant.\n */\nexport function pickAmbientBackground(\n palette: AssetPalette | null | undefined,\n): PaletteSwatch | null {\n const hex = resolveSwatch(palette, \"lm\", \"m\", \"lv\", \"d\");\n if (!hex) return null;\n return { hex, textColor: textColorForHex(hex) };\n}\n\n/**\n * Build a CSS linear-gradient from the palette. Useful for hero / detail\n * backgrounds.\n */\nexport function getAmbientGradient(\n palette: AssetPalette | null | undefined,\n opts: {\n angle?: string;\n from?: keyof AssetPalette;\n to?: keyof AssetPalette;\n } = {},\n): string | undefined {\n if (!palette) return undefined;\n const fromHex = palette[opts.from ?? \"lm\"] ?? palette.m ?? palette.d;\n const toHex = palette[opts.to ?? \"m\"] ?? palette.dm ?? palette.d;\n if (!fromHex || !toHex) return undefined;\n return `linear-gradient(${opts.angle ?? \"135deg\"}, ${fromHex}, ${toHex})`;\n}\n\n/**\n * Recommended text color (#000 or #FFF) for any background hex,\n * computed via WCAG relative luminance.\n */\nexport function getTextColorForBackground(\n swatch: PaletteSwatch | string | null | undefined,\n): string {\n if (!swatch) return \"#000000\";\n const hex = typeof swatch === \"string\" ? swatch : swatch.hex;\n return textColorForHex(hex);\n}\n\n/** Luminancia relativa WCAG de un hex. 0 = negro, 1 = blanco. */\nexport function relativeLuminance(hex: string): number {\n const h = hex.replace(\"#\", \"\");\n const r = Number.parseInt(h.slice(0, 2), 16) / 255;\n const g = Number.parseInt(h.slice(2, 4), 16) / 255;\n const b = Number.parseInt(h.slice(4, 6), 16) / 255;\n // sRGB → linear\n const lin = (c: number) =>\n c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\n}\n\n/**\n * Razón de contraste WCAG entre dos luminancias: `(L1 + 0.05) / (L2 + 0.05)`,\n * con la más clara arriba. 1 = idénticos, 21 = negro contra blanco.\n */\nexport function contrastRatio(l1: number, l2: number): number {\n const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];\n return (hi + 0.05) / (lo + 0.05);\n}\n\n/**\n * Negro o blanco, el que **de verdad** contraste más sobre este fondo.\n *\n * ⚠️ CORREGIDO 2026-08-17. Antes decidía con `L > 0.5`, y **0,5 no es el punto\n * de empate**. Igualando las dos razones de contraste:\n *\n * (1 + 0.05) / (L + 0.05) = (L + 0.05) / 0.05\n * (L + 0.05)² = 0.0525\n * L = 0.1791…\n *\n * Entre **0,179 y 0,5** el código viejo elegía BLANCO cuando negro contrastaba\n * más — una banda ancha, y justo donde caen los colores de marca saturados.\n * Medido sobre cuatro paletas reales de producción: **8 swatches** en esa banda.\n * El peor, el dorado dominante `#CA9528`: devolvía blanco a **2,68:1** cuando\n * negro da **7,84:1**. 2,68 no pasa AA ni para texto grande.\n *\n * No se cambia el umbral por 0,179: se **calculan las dos razones y gana la\n * mayor**. Un umbral es una constante que hay que mantener correcta; la\n * comparación es correcta por construcción.\n *\n * ⚠️ Esto elige el MEJOR de dos, no garantiza que alcance. Sobre un fondo de\n * luminancia media el mejor par puede quedar por debajo de 4,5:1 igual — para\n * eso está {@link bestTextContrast}, que además devuelve el número.\n */\nfunction textColorForHex(hex: string): \"#000000\" | \"#FFFFFF\" {\n return bestTextContrast(hex).color;\n}\n\n/**\n * Igual que el color recomendado, pero devuelve también **la razón lograda** y\n * si pasa AA — para que quien lo use pueda decidir con el número a la vista en\n * vez de asumir que alcanzó.\n */\nexport function bestTextContrast(hex: string): {\n color: \"#000000\" | \"#FFFFFF\";\n ratio: number;\n passesAA: boolean;\n passesAALarge: boolean;\n} {\n const L = relativeLuminance(hex);\n const onBlack = contrastRatio(L, 0);\n const onWhite = contrastRatio(L, 1);\n const useBlack = onBlack >= onWhite;\n const ratio = useBlack ? onBlack : onWhite;\n return {\n color: useBlack ? \"#000000\" : \"#FFFFFF\",\n ratio,\n passesAA: ratio >= 4.5,\n passesAALarge: ratio >= 3,\n };\n}\n\n/**\n * CSS variables for a wrapper so a subtree can read --asset-bg / --asset-fg /\n * --asset-dominant / --asset-vibrant / etc.\n */\nexport function getPaletteCssVars(\n palette: AssetPalette | null | undefined,\n): Record<string, string> {\n if (!palette) return {};\n const bg = pickAmbientBackground(palette);\n return {\n \"--asset-bg\": bg?.hex ?? \"transparent\",\n \"--asset-fg\": bg ? bg.textColor : \"#000000\",\n \"--asset-dominant\": palette.d,\n ...(palette.v && { \"--asset-vibrant\": palette.v }),\n ...(palette.m && { \"--asset-muted\": palette.m }),\n ...(palette.lv && { \"--asset-light-vibrant\": palette.lv }),\n ...(palette.dv && { \"--asset-dark-vibrant\": palette.dv }),\n ...(palette.lm && { \"--asset-light-muted\": palette.lm }),\n ...(palette.dm && { \"--asset-dark-muted\": palette.dm }),\n };\n}\n\n/**\n * Iterate the palette in display order (dominant first, then vibrant +\n * muted families). Useful for rendering a swatch strip in admin UIs.\n */\nexport function iteratePaletteSwatches(\n palette: AssetPalette | null | undefined,\n): Array<{ key: keyof AssetPalette; label: string; hex: string }> {\n if (!palette) return [];\n const order: Array<{ key: keyof AssetPalette; label: string }> = [\n { key: \"d\", label: \"dominant\" },\n { key: \"v\", label: \"vibrant\" },\n { key: \"lv\", label: \"lightVibrant\" },\n { key: \"dv\", label: \"darkVibrant\" },\n { key: \"m\", label: \"muted\" },\n { key: \"lm\", label: \"lightMuted\" },\n { key: \"dm\", label: \"darkMuted\" },\n ];\n return order\n .map(({ key, label }) => {\n const hex = palette[key];\n return hex ? { key, label, hex } : null;\n })\n .filter(\n (s): s is { key: keyof AssetPalette; label: string; hex: string } =>\n s != null,\n );\n}\n\n/**\n * Build a multi-radial-gradient CSS `background` string from the palette\n * swatches. Acts as a zero-extra-bytes alternative to the WebP LQIP: the\n * palette is already in the DTO, so this placeholder costs nothing extra\n * to ship. Renders as a smooth abstract \"color cloud\" reminiscent of the\n * source image's vibe.\n *\n * Strategy: anchor 4 radial gradients at fixed corners using vibrant/muted\n * pairs, layered over the dominant fill. Skips missing swatches gracefully.\n */\nexport function getPaletteBlurBackground(\n palette: AssetPalette | null | undefined,\n): string | undefined {\n if (!palette) return undefined;\n const corners: Array<{ pos: string; key: keyof AssetPalette }> = [\n { pos: \"20% 20%\", key: \"lv\" },\n { pos: \"80% 25%\", key: \"v\" },\n { pos: \"25% 80%\", key: \"lm\" },\n { pos: \"80% 80%\", key: \"dv\" },\n ];\n const layers = corners\n .map(({ pos, key }) => {\n const hex = palette[key];\n if (!hex) return null;\n return `radial-gradient(circle at ${pos}, ${hex} 0%, transparent 55%)`;\n })\n .filter(Boolean) as string[];\n // Fallback fill = dominant (or muted if dominant is missing — shouldn't happen)\n const base = palette.d ?? palette.m ?? \"#888\";\n return layers.length > 0 ? `${layers.join(\", \")}, ${base}` : base;\n}\n","/**\n * @nitida/asset-client/slots — slot resolver for tenant-named assets.\n *\n * Slots give tenants a way to attach stable, human-readable names\n * (\"webapp.wizard.pool-type.icon-1\", \"storefront.cr.hero-video.landscape_hd_16x9.mp4\")\n * to assets they uploaded. Consumers resolve names → AssetDTOs at\n * build / runtime so their source never hardcodes a CDN URL; the\n * admin rebinds a slot from `asset-lab-web` and every consumer picks\n * up the swap on cache refresh.\n *\n * Two layers in this package:\n * - `resolveSlot` / `resolveSlots` — universal (server, edge,\n * workers) fetch helpers. Cache 60s by default.\n * - React hooks live in `@nitida/asset-client/react/use-slot`\n * (kept out of this module so the SSR-safe core stays\n * dependency-free of react).\n */\n\nimport type { AssetDTO, VariantPreset } from \"./index\";\nimport { getAssetUrl, hasPreset } from \"./index\";\n\n// ---------------------------------------------------------------------------\n// Wire shape — matches `apps/asset-manager/src/features/assets/slots.routes.ts`\n// ---------------------------------------------------------------------------\n\nexport type SlotDTO = {\n slotKey: string;\n /** Preset hint set when the slot was bound (e.g. `thumb` for icon slots). */\n preset: VariantPreset | null;\n description: string | null;\n updatedAt: string;\n asset: AssetDTO;\n};\n\nexport type SlotResolution = {\n /** The resolved DTO (`null` when the slot is unbound or asset missing). */\n slot: SlotDTO | null;\n /**\n * Effective preset — what `url` below was built with. Resolution order:\n * 1. caller's `preset` override\n * 2. slot's `preset` hint\n * 3. `lg` for images, `video` for video kind\n */\n preset: VariantPreset;\n /** The CDN URL the consumer should use. */\n url: string | null;\n};\n\n// ---------------------------------------------------------------------------\n// Config\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_TTL_MS = 60_000;\nconst cache = new Map<string, { fetchedAt: number; value: SlotDTO | null }>();\n\n// Deliberately the generated `run.app` URL, NOT the branded\n// `api.nitida.gofuture.space` the docs publish — this is the fallback for a\n// caller that configured nothing, so it should be the address with the fewest\n// moving parts. Cloud Run guarantees the generated URL forever; the branded name\n// is an additional domain mapping that depends on DNS and a managed certificate\n// (and on 2026-08-17 we learned how many ways those two can go sideways —\n// doc 242 §6.3). Both names hit the same service and return identical bodies.\nlet endpoint = \"https://aquienpz-asset-manager-nlchzy26qa-uc.a.run.app\";\nlet apiKey: string | null = null;\nlet tenantCode: string | null = null;\n\n/**\n * Configure the resolver process-wide. Call once at boot from your\n * storefront layout / server entry / worker init.\n *\n * configureSlotResolver({\n * endpoint: process.env.AQUIENPZ_URL,\n * apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY,\n * tenantCode: \"realtyone-cr\",\n * });\n */\nexport function configureSlotResolver(opts: {\n endpoint?: string;\n apiKey?: string;\n tenantCode?: string;\n}): void {\n if (opts.endpoint) endpoint = opts.endpoint.replace(/\\/+$/, \"\");\n if (opts.apiKey !== undefined) apiKey = opts.apiKey;\n if (opts.tenantCode !== undefined) tenantCode = opts.tenantCode;\n}\n\n/** Wipe the in-process cache (test helper or forced refresh). */\nexport function invalidateSlotCache(slotKey?: string): void {\n if (slotKey === undefined) cache.clear();\n else\n for (const k of cache.keys())\n if (k.endsWith(`:${slotKey}`)) cache.delete(k);\n}\n\n// ---------------------------------------------------------------------------\n// Internal fetch helper\n// ---------------------------------------------------------------------------\n\nconst baseHeaders = (): Record<string, string> => {\n const h: Record<string, string> = {};\n if (apiKey) h.Authorization = `Bearer ${apiKey}`;\n if (tenantCode) h[\"X-Tenant-Code\"] = tenantCode;\n return h;\n};\n\nasync function fetchSlot(slotKey: string): Promise<SlotDTO | null> {\n const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {\n headers: baseHeaders(),\n });\n if (r.status === 404) return null;\n if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);\n return (await r.json()) as SlotDTO;\n}\n\nasync function fetchSlotsBulk(\n slotKeys: string[],\n): Promise<Record<string, SlotDTO | null>> {\n if (slotKeys.length === 0) return {};\n const r = await fetch(`${endpoint}/slots/resolve`, {\n method: \"POST\",\n headers: { ...baseHeaders(), \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ keys: slotKeys }),\n });\n if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);\n const body = (await r.json()) as { resolved: Record<string, SlotDTO | null> };\n return body.resolved;\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport type ResolveSlotOptions = {\n /** Override preset (caller knows the use case better than the slot binding). */\n preset?: VariantPreset;\n /** TTL for the in-process cache. Default 60s. Set 0 to bypass. */\n ttlMs?: number;\n};\n\n/**\n * Resolve a single slot to a CDN URL. Returns `{slot: null, url: null}`\n * when the slot is unbound — callers fall back to a placeholder.\n *\n * Cached for `ttlMs` (default 60s). Slot rebindings propagate within the\n * TTL window without an app restart.\n */\nexport async function resolveSlot(\n slotKey: string,\n opts: ResolveSlotOptions = {},\n): Promise<SlotResolution> {\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const cacheKey = `${tenantCode ?? \"_\"}:${slotKey}`;\n const now = Date.now();\n let dto: SlotDTO | null;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n dto = hit.value;\n } else {\n dto = await fetchSlot(slotKey);\n cache.set(cacheKey, { fetchedAt: now, value: dto });\n }\n return materializeResolution(dto, opts.preset);\n}\n\n/**\n * Bulk-resolve N slot keys in one round-trip. The SDK's `useSlots`\n * React hook calls this so every storefront header (logo + tagline +\n * nav cover + …) loads as one request.\n */\nexport async function resolveSlots(\n slotKeys: string[],\n opts: ResolveSlotOptions = {},\n): Promise<Record<string, SlotResolution>> {\n if (slotKeys.length === 0) return {};\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const now = Date.now();\n const missing: string[] = [];\n const out: Record<string, SlotResolution> = {};\n for (const k of slotKeys) {\n const cacheKey = `${tenantCode ?? \"_\"}:${k}`;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n out[k] = materializeResolution(hit.value, opts.preset);\n } else {\n missing.push(k);\n }\n }\n if (missing.length > 0) {\n const resolved = await fetchSlotsBulk(missing);\n for (const k of missing) {\n const dto = resolved[k] ?? null;\n cache.set(`${tenantCode ?? \"_\"}:${k}`, { fetchedAt: now, value: dto });\n out[k] = materializeResolution(dto, opts.preset);\n }\n }\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction defaultPresetFor(asset: AssetDTO | undefined): VariantPreset {\n if (!asset) return \"lg\";\n return asset.kind === \"video\" ? \"video\" : \"lg\";\n}\n\nfunction materializeResolution(\n dto: SlotDTO | null,\n overridePreset?: VariantPreset,\n): SlotResolution {\n if (!dto) return { slot: null, preset: overridePreset ?? \"lg\", url: null };\n const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);\n // Fall back to \"lg\" when the bound preset doesn't exist on the asset\n // (e.g. slot was bound to a video but caller asked for a thumb).\n const finalPreset = hasPreset(dto.asset, effective)\n ? effective\n : defaultPresetFor(dto.asset);\n return {\n slot: dto,\n preset: finalPreset,\n url: getAssetUrl(dto.asset, finalPreset),\n };\n}\n","/**\n * On-the-fly transform URL builder.\n *\n * Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated\n * here hashes to the same R2 cache key as the server's canonical form.\n *\n * Canonicalization rules (keep in sync with\n * `apps/asset-manager/src/features/assets/transform.dsl.ts`):\n * - Drop entries whose value is `undefined`\n * - Sort keys alphabetically\n * - Numbers rendered without leading zeros or trailing dots\n * - String values lowercased\n *\n * URL shape:\n * <cdnBase>/t/<dsl>/<sha>.<ext>\n *\n * The `.ext` is informational (browser content-sniff hint); the server\n * decides the actual output format from the DSL `format` param + the\n * request's Accept header.\n */\n\nimport type { AssetDTO } from \"./index\";\nimport { getCdnBase } from \"./index\";\n\n/**\n * Widths the CDN edge whitelists (DoS guard — see `apps/cdn-proxy` WHITELIST_WIDTHS).\n * Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the\n * 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import\n * this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.\n */\nexport const TRANSFORM_WIDTHS = [\n 96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280,\n 1440, 1600, 1920, 2560, 3840,\n] as const;\n/**\n * A CDN-whitelisted transform width — the only widths `TransformOptions.width`\n * accepts. Off-ladder widths are a compile error; for signed URLs that need a\n * custom width, use {@link SignedTransformOptions} (number) via\n * {@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`.\n */\nexport type TransformWidth = (typeof TRANSFORM_WIDTHS)[number];\n\nexport type TransformFit = \"cover\" | \"contain\" | \"fill\" | \"inside\" | \"outside\";\nexport type TransformGravity =\n | \"auto\"\n | \"face\"\n | \"center\"\n | \"north\"\n | \"south\"\n | \"east\"\n | \"west\";\nexport type TransformFormat =\n | \"auto\"\n | \"avif\"\n | \"webp\"\n | \"jpeg\"\n | \"png\"\n // Video-only formats (Phase 4). The image path ignores them.\n | \"mp4\"\n | \"webm\"\n // HLS adaptive ladder (Phase 5). Video-only. Output is a directory\n // of m3u8 + .ts segments fronted by master.m3u8; the SDK returns\n // the master URL via `getHlsStreamingUrl`.\n | \"hls\";\nexport type TransformEffect = \"removebg\" | \"genfill\";\n\nexport type TransformOptions = {\n /**\n * Target max-side width in CSS pixels (multiplied by `dpr` server-side).\n * MUST be a {@link TRANSFORM_WIDTHS} value — off-ladder widths are rejected\n * (HTTP 400) by the edge whitelist for unsigned URLs, so the type forbids\n * them at compile time. For SIGNED URLs with a custom width, use\n * {@link SignedTransformOptions} (which widens this to `number`).\n */\n width?: TransformWidth;\n /** Target max-side height. Multiplied by `dpr` server-side. */\n height?: number;\n /** Resize fit mode. Default `cover` server-side. */\n fit?: TransformFit;\n /** Crop gravity. `auto` uses sharp's `attention` strategy. */\n gravity?: TransformGravity;\n /** Output format. `auto` → policy decides (see asset-manager bench). */\n format?: TransformFormat;\n /** Output quality. `auto` → format-specific default. */\n quality?: \"auto\" | number;\n /** Device pixel ratio. Width/height are multiplied by this before resize. */\n dpr?: 1 | 2 | 3;\n /**\n * AI effect applied before resize/encode.\n *\n * - `removebg`: remove the background; output is a transparent PNG\n * of the foreground subject. Forces `format=png` regardless of\n * other format hints. Runs U²-Net ONNX locally (or BRIA via\n * Replicate when `BG_REMOVAL_BACKEND=replicate`). Single cache\n * miss per (sha, dsl) tuple; subsequent identical DSLs serve\n * from R2 — no inference, no per-image cost.\n *\n * - `genfill`: aspect-extension outpaint via Flux-Fill Pro on\n * Replicate. Requires BOTH `width` and `height` — the server\n * fits the source centered into the target canvas and outpaints\n * the gutters. Output is PNG (forced) at exactly target dims.\n * ~$0.05/image first time; same R2 cache as removebg after.\n * Primary use case: building OG cards (1200×630) from portrait\n * listing photos without awkward edge mirroring.\n */\n effect?: TransformEffect;\n /**\n * Video-only: clip start in seconds. Image transforms ignore.\n * Accepts decimals (e.g. 1.5 for sub-second seek).\n */\n start?: number;\n /**\n * Video-only: clip duration in seconds (1..300). Image transforms\n * ignore. With `start`, lets a single request grab a sub-clip.\n */\n duration?: number;\n};\n\n/**\n * Like {@link TransformOptions} but with `width` widened to any `number` —\n * the escape hatch for SIGNED URLs that need an off-ladder custom width.\n *\n * The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid\n * `?sig=` earns the whitelist bypass at the worker (the asset-manager still\n * does the real HMAC check). So a custom width is ONLY safe when the URL is\n * signed — hence this type is accepted exclusively by the signing helpers\n * ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),\n * never by the plain unsigned {@link getTransformUrl}.\n */\nexport type SignedTransformOptions = Omit<TransformOptions, \"width\"> & {\n /** Off-ladder width — valid ONLY on signed URLs (edge whitelist bypass). */\n width?: number;\n};\n\n/**\n * Serialize transform options into the canonical DSL path segment.\n * Empty options return an empty string (caller should fall back to a\n * variant URL instead of a transform URL in that case).\n *\n * Accepts {@link SignedTransformOptions} (the wider type) so it also covers\n * custom-width signed URLs; {@link TransformOptions} is assignable to it.\n */\n/**\n * Extract the 16-hex short sha from an aquienpz CDN URL, regardless of\n * shape:\n * - tenant-prefixed variant: `https://8ok.uk/4/v/<sha16>-<preset>.<ext>`\n * - legacy variant: `https://8ok.uk/<sha16>-<preset>.<ext>`\n * - on-the-fly transform: `https://8ok.uk/t/<dsl>/<sha16>.<ext>`\n * - streaming HLS: `https://8ok.uk/t/format=hls/<sha16>.m3u8`\n *\n * Returns `null` for non-aquienpz URLs (pexels, googleusercontent, raw\n * uploaded URLs to other CDNs) so call sites can fall back to the URL\n * with a plain `<img>` instead of generating a broken transform URL.\n *\n * Useful when a value reaches the component as a pre-built URL string\n * (legacy data, site-config JSON, third-party feeds) but you want to\n * drop in `getTransformSrcSet` for the responsive ladder if it happens\n * to be an aquienpz asset.\n */\nexport function extractAssetSha(url: string | null | undefined): string | null {\n if (!url) return null;\n // Match the canonical aquienpz sha pattern: 16 lowercase hex chars\n // appearing as a path segment, optionally followed by `-<preset>`\n // (variant URL) or `.<ext>` (transform URL).\n const m = url.match(/\\/([0-9a-f]{16})(?:[-.]|$)/);\n return m ? m[1]! : null;\n}\n\nexport function serializeTransform(opts: SignedTransformOptions): string {\n const entries: Array<[string, string]> = [];\n const keys = Object.keys(opts).sort() as Array<keyof SignedTransformOptions>;\n for (const k of keys) {\n const v = opts[k];\n if (v == null) continue;\n const serialized = typeof v === \"string\" ? v.toLowerCase() : String(v);\n entries.push([k, serialized]);\n }\n return entries.map(([k, v]) => `${k}=${v}`).join(\",\");\n}\n\nfunction extForOptions(opts: SignedTransformOptions): string {\n // effect=removebg forces PNG output server-side (needs alpha).\n if (opts.effect === \"removebg\") return \"png\";\n // effect=genfill defaults to WebP (12× lighter than the raw Flux-Fill\n // PNG output with no visible loss at q=85). Explicit `format=png`\n // opts back into lossless for print / marketing fold-outs. The server\n // re-encodes Flux's PNG → target format before R2 cache.\n if (opts.effect === \"genfill\") {\n switch (opts.format) {\n case \"png\":\n return \"png\";\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n default:\n return \"webp\";\n }\n }\n switch (opts.format) {\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n case \"png\":\n return \"png\";\n case \"mp4\":\n return \"mp4\";\n case \"webm\":\n return \"webm\";\n case \"hls\":\n // HLS uses `.m3u8` as the URL extension; the route resolves the\n // master playlist under the cache key prefix.\n return \"m3u8\";\n default:\n // \"webp\" / \"auto\" / undefined / anything new\n return \"webp\";\n }\n}\n\n/**\n * Build a transform URL for a VIDEO asset. Same DSL shape as image\n * transforms; the server branches on the asset's `kind` column. Video\n * URLs use `.mp4` (default) or `.webm` extension and on cache miss the\n * server returns 202 Accepted while a Cloud Run Job encodes the clip;\n * subsequent GETs return 302 to the cached R2 object.\n *\n * <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}\n * autoPlay muted loop playsInline />\n */\nexport function getVideoTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: TransformOptions,\n): string | null {\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = opts.format === \"webm\" ? \"webm\" : \"mp4\";\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\n/**\n * Build an HLS streaming URL for a VIDEO asset (Phase 5). Returns the\n * master.m3u8 entry point — HLS-aware players (Video.js's\n * @videojs/http-streaming, hls.js, native iOS Safari) follow it to\n * fetch the variant playlist + segments at the appropriate bitrate\n * for the connection.\n *\n * ⚠️ A master.m3u8 is NOT a video file. Assigning it to `<video src>`\n * works only where the engine has native HLS; everywhere else it needs\n * an MSE player. And the classic feature test is now WRONG: Chrome 147\n * (April 2026) added native HLS, so `canPlayType(\"application/vnd.apple.mpegurl\")`\n * answers \"maybe\" there and routes Chrome to the native branch, where it\n * opened a measured 17 s hero at 426x240 for ~8 s. Branch on the ENGINE:\n *\n * @example\n * ```ts\n * function prefersNativeHls(video: HTMLVideoElement): boolean {\n * if (video.canPlayType(\"application/vnd.apple.mpegurl\") === \"\") return false;\n * // Apple's engine, or an engine with no MSE to fall back on (iOS < 17.1).\n * return \"ManagedMediaSource\" in globalThis || !(\"MediaSource\" in globalThis);\n * }\n *\n * const src = getHlsStreamingUrl(asset);\n * if (prefersNativeHls(video)) {\n * video.src = src;\n * } else {\n * // Defaults open at a fixed low rung — measure instead of guessing.\n * const hls = new Hls({ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 });\n * hls.loadSource(src);\n * hls.attachMedia(video);\n * }\n * ```\n *\n * On first request the server returns 202 Accepted while a Cloud Run\n * Job transcodes the ladder (typically 1-3 min for a 90 s source);\n * subsequent requests get 302 to the cached master.m3u8. Keep the\n * progressive MP4 as a fallback source for that window.\n *\n * The ladder's ceiling is the source the job probes: built at ingest it\n * reads the RAW upload and a 4K master yields 1440p/2160p rungs; rebuilt\n * on demand after the raw is unavailable it reads the `-v.mp4`, which is\n * capped at 1920 wide. `getAssetUrl(sha, \"video\")` is always <= 1080p.\n */\nexport function getHlsStreamingUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: Omit<TransformOptions, \"format\"> = {},\n): string {\n // Always serialize with format=hls so the server routes correctly.\n const merged: TransformOptions = { ...opts, format: \"hls\" };\n const dsl = serializeTransform(merged);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;\n}\n\n/**\n * Build a transform URL. Returns null when the caller passed no options —\n * callers should prefer the existing variant URL builder in that case so\n * the request hits a pre-generated variant instead of an on-the-fly encode.\n */\nfunction buildTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n): string | null {\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = extForOptions(opts);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\nexport function getTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: TransformOptions,\n): string | null {\n return buildTransformUrl(asset, opts);\n}\n\n/**\n * Build AND sign a transform URL, allowing an off-ladder custom `width`.\n *\n * This is the escape hatch for {@link SignedTransformOptions}: off-ladder\n * widths only pass the edge whitelist when the URL is signed, so building one\n * and signing it must happen together. For on-ladder widths prefer the plain\n * {@link getTransformUrl} (+ {@link signTransformUrl} if you need a signature).\n *\n * Returns `null` only when `opts` serialize to an empty DSL (no transform\n * requested) — same contract as {@link getTransformUrl}.\n */\nexport function getSignedTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n signingKey: string,\n): Promise<string> | null {\n const url = buildTransformUrl(asset, opts);\n if (!url) return null;\n return signTransformUrl(url, signingKey);\n}\n\n/**\n * Sign a transform URL with the tenant's HMAC signing key. Appends\n * `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).\n *\n * Must agree byte-for-byte with the server's `verifyTransformSignature`.\n * Uses WebCrypto, so works in browsers, Node ≥ 16, Bun, and Workers.\n *\n * The canonical DSL is the one already produced by `serializeTransform`\n * (sort keys + lowercase strings), so signing a URL built by `getTransformUrl`\n * is automatic — the same canonical form is in the URL path.\n */\nexport async function signTransformUrl(\n unsignedUrl: string,\n signingKey: string,\n): Promise<string> {\n const u = new URL(unsignedUrl);\n // Path shape: /t/<dsl>/<filename>\n const parts = u.pathname.split(\"/\").filter(Boolean);\n // First segment must be \"t\"; the rest is dsl groups + filename. With\n // Phase 1 we ship a single DSL group; chained groups stay flat for\n // signing purposes (server canonicalizer flattens them too).\n if (parts[0] !== \"t\" || parts.length < 3) {\n throw new Error(`signTransformUrl: unexpected URL shape ${unsignedUrl}`);\n }\n const filename = parts[parts.length - 1]!;\n const dsl = parts.slice(1, -1).join(\"/\");\n const message = `${dsl}/${filename}`;\n const sig = await hmacSha256Hex(signingKey, message);\n u.searchParams.set(\"sig\", sig);\n return u.toString();\n}\n\nasync function hmacSha256Hex(key: string, message: string): Promise<string> {\n const enc = new TextEncoder();\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const buf = await crypto.subtle.sign(\"HMAC\", cryptoKey, enc.encode(message));\n return [...new Uint8Array(buf)]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\n/**\n * Build a responsive `srcSet` string by generating one transform URL per\n * width. All other options apply to every URL.\n *\n * <img\n * src={aq.transform(asset, { width: 800 })!}\n * srcSet={aq.transformSrcSet(asset, [320, 640, 960, 1280])}\n * sizes=\"(max-width: 768px) 100vw, 50vw\"\n * />\n */\nexport function getTransformSrcSet(\n asset: Pick<AssetDTO, \"sha\">,\n widths: number[],\n extraOpts: Omit<TransformOptions, \"width\"> = {},\n): string {\n return widths\n .map((w) => {\n // Build via the internal (number-width) builder: `widths` is an explicit\n // responsive ladder the caller chose, so it stays `number[]`. Off-ladder\n // unsigned widths 400 at the edge — the caller's responsibility, exactly\n // as before the width type was tightened.\n const url = buildTransformUrl(asset, { ...extraOpts, width: w });\n return url ? `${url} ${w}w` : null;\n })\n .filter((s): s is string => s != null)\n .join(\", \");\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/palette.ts","../src/slots.ts","../src/transform.ts"],"sourcesContent":["/**\n * @nitida/asset-client — read helpers for asset URLs.\n *\n * Universal CDN model (May 2026): the API ships a compact AssetDTO with a\n * 16-char SHA prefix + a `presets` string of 1-char codes; client helpers\n * construct CDN URLs deterministically from `(cdnBase, sha, preset, ext)`.\n *\n * Why: catalog sync over Electric SSE shipped 4 nearly-identical full URLs\n * per asset × hundreds of thousands of assets per snapshot. Sending only\n * what differs — sha + presets bitmap — collapses ~600 bytes per asset to\n * ~70 (88% reduction).\n *\n * No runtime dependencies — pure types + pure functions. Safe everywhere.\n * @module @nitida/asset-client\n */\n\n/**\n * Image presets are size-based (Vercel `next/image` style):\n * - `thumb` is the only square crop — semantic icon use case\n * - `sm/md/lg` are max-side bounding boxes that preserve aspect ratio\n *\n * Naming over the legacy `thumbnail/cover/web/hero` because the new names\n * say what the variant IS (a size class) rather than what it might be\n * USED for, removing the implicit landscape-only assumption that bit us\n * with vertical product photos.\n */\nexport type VariantPreset =\n // image presets\n | \"thumb\" // 256x256 square smart-crop (icon)\n | \"sm\" // 640 max-side\n | \"md\" // 1280 max-side\n | \"lg\" // 1920 max-side\n | \"xl\" // 3840 max-side (4K) — OPT-IN; not generated by default\n // passthrough for non-image kinds (PDF etc.)\n | \"original\"\n // video presets — semantic (not size classes)\n | \"poster\"\n | \"video\"\n | \"aiproxy\"\n // audio preset — the cross-browser mp3 transcode of a voice note\n // (libmp3lame) emitted alongside the original so chat audio plays on\n // both Chrome/Android (webm/opus) and iOS Safari (which can't decode opus).\n | \"mp3\";\n\n/** 1-char alias used in storage keys / wire `presets` string. */\nexport const PRESET_SHORT: Record<VariantPreset, string> = {\n thumb: \"q\",\n sm: \"s\",\n md: \"m\",\n lg: \"l\",\n xl: \"x\",\n original: \"o\",\n poster: \"p\",\n video: \"v\",\n aiproxy: \"a\",\n // 3 chars, NOT a 1-char alias: the server has no short-form for\n // audio so its `shortPreset(\"mp3\")` falls through to the literal token,\n // and the deployed server already writes the `-mp3.mp3` variant + emits\n // the bare `mp3` token in the wire `presets` string. Must stay in lockstep.\n mp3: \"mp3\",\n};\nexport const PRESET_LONG: Record<string, VariantPreset> = Object.fromEntries(\n Object.entries(PRESET_SHORT).map(([k, v]) => [v, k as VariantPreset]),\n);\n\n/** Variant extension by preset. Image variants are always WebP, video MP4. */\nexport const PRESET_EXT: Record<VariantPreset, string> = {\n thumb: \"webp\",\n sm: \"webp\",\n md: \"webp\",\n lg: \"webp\",\n xl: \"webp\",\n original: \"bin\", // overridden per-asset via mime when needed\n poster: \"webp\",\n video: \"mp4\",\n aiproxy: \"mp4\",\n mp3: \"mp3\",\n};\n\n/** Max-side dimension by preset; null for video / passthrough. */\nexport const PRESET_MAX_DIM: Record<VariantPreset, number | null> = {\n thumb: 256,\n sm: 640,\n md: 1280,\n lg: 1920,\n xl: 3840,\n original: null,\n poster: null,\n video: null,\n aiproxy: null,\n // audio has no pixel dimensions; `null` keeps mp3 out of the\n // dimension-based `getAssetSrcSet` / `computeVariantDimensions` logic.\n mp3: null,\n};\n\n/**\n * One generated variant of an asset. Returned by the admin endpoints\n * (`GET /assets/:id`, `POST /assets/:id/regenerate`).\n */\nexport type AssetVariant = {\n /** Long name (`thumb` / `sm` / … / `original`) — see {@link VariantPreset}. */\n preset: VariantPreset;\n /** Public CDN URL of this variant. */\n url: string;\n /** Pixel width. Absent for `original`-only assets where image processing was skipped, or for video presets. */\n width?: number;\n /** Pixel height. Same caveat as `width`. */\n height?: number;\n /** Byte size of the stored variant file. */\n bytes: number;\n /**\n * Where the bytes for this variant came from. Useful for quality\n * traceability — a `thumb` with `sourceFrom: \"original\"` is the\n * canonical case, while `sourceFrom: \"lg\"` means it was derived\n * from an already-encoded WebP (slight quality compounding).\n *\n * - `\"upload\"` → first-write at `/assets/process`. The bytes came\n * straight from the client's PUT.\n * - `VariantPreset` → regenerated from that preset's variant.\n *\n * Absent on variants written before the trace field existed.\n */\n sourceFrom?: VariantPreset | \"upload\";\n /** ISO timestamp this variant was written. Absent on pre-trace variants. */\n createdAt?: string;\n};\n\n/**\n * Compact wire shape — what the server actually sends. Aliases (`w`, `h`,\n * `dur`) are intentional to shave bytes per asset on dense lists.\n */\nexport type AssetDTO = {\n id: string;\n /** First 16 hex chars of sha256 — used to derive CDN URLs. */\n sha: string;\n kind: \"image\" | \"video\" | \"document\" | \"audio\" | \"other\";\n mime: string;\n bytes: number;\n /** Source dims (for aspect-ratio calc on the client). Optional for non-images. */\n w?: number | null;\n h?: number | null;\n /** Duration ms for videos. */\n dur?: number | null;\n /** LQIP placeholder (data URL). */\n blur?: string | null;\n palette?: AssetPalette | null;\n /**\n * Compact list of generated variants as their 1-char codes\n * concatenated, e.g. \"tcwh\" (image) / \"pv\" (video without aiproxy).\n * Ordered by ascending dimension.\n */\n presets: string;\n status: \"processing\" | \"ready\" | \"failed\";\n /** Soft-delete timestamp (ISO). Hidden from catalog when set. */\n deletedAt?: string | null;\n /**\n * Full variant list with URLs + sizes. Sent by `GET /assets/:id`; absent on\n * the slim list shape used by the resolver / catalog. Use `presets` for\n * compact existence checks, and this when you need the actual URLs.\n */\n variants?: AssetVariant[];\n /**\n * The extension the `original` variant was really stored under — the server\n * keys it off the uploaded filename, so it cannot be derived from `mime`.\n * Sent by `GET /assets/:id`; `null` when the asset has no original.\n * {@link getAssetUrl} uses it automatically when you pass the whole DTO.\n */\n oext?: string | null;\n};\n\nexport type {\n AssetPalette,\n PaletteSwatch,\n} from \"./palette\";\n\nexport {\n bestTextContrast,\n contrastRatio,\n getAmbientGradient,\n getPaletteBlurBackground,\n getPaletteCssVars,\n getTextColorForBackground,\n iteratePaletteSwatches,\n relativeLuminance,\n pickAmbientBackground,\n} from \"./palette\";\n\nimport type { AssetPalette } from \"./palette\";\n\n// ---------------------------------------------------------------------------\n// CDN base\n// ---------------------------------------------------------------------------\n\nlet cdnBaseUrl = \"https://8ok.uk\";\n/**\n * Override the CDN base for the entire process (e.g. in tests, or when\n * pointing at a tenant-specific CDN). Storefront layouts call this once at\n * boot.\n */\nexport function setCdnBase(url: string): void {\n cdnBaseUrl = url.replace(/\\/$/, \"\");\n}\nexport function getCdnBase(): string {\n return cdnBaseUrl;\n}\n\n// ---------------------------------------------------------------------------\n// Tenant scope\n//\n// Post-May-2026 the CDN serves variants under a tenant-prefixed path\n// `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see the server\n// `variantKey`). Variant URL builders MUST include that prefix or every\n// URL 404s. The tenant id is process-global (one tenant per client/app),\n// set once at boot — `NitidaClient` does this from its `tenantId` option;\n// standalone consumers call `setTenantId()` directly. Left unset, builders\n// fall back to the legacy pre-cutover bare path for back-compat.\n// ---------------------------------------------------------------------------\n\nlet tenantId: number | null = null;\n/** Set the process-global tenant id used to build tenant-prefixed CDN URLs. */\nexport function setTenantId(id: number | null | undefined): void {\n tenantId =\n typeof id === \"number\" && Number.isFinite(id) && id > 0 ? id : null;\n}\nexport function getTenantId(): number | null {\n return tenantId;\n}\n/** Variant path prefix `<tid b36>/v/`, or \"\" when no tenant is configured. */\nfunction variantPrefix(): string {\n return tenantId != null ? `${tenantId.toString(36)}/v/` : \"\";\n}\n\n// ---------------------------------------------------------------------------\n// URL builders\n// ---------------------------------------------------------------------------\n\n/**\n * LAST-RESORT guess at the ORIGINAL variant's extension, from the asset's mime.\n *\n * ⚠️ This is a guess. Prefer `oext` or `variants` (see {@link getAssetUrl}) — the\n * server sends both and they ARE the key.\n *\n * The server derives the extension from the MIME with the `mime-types` package\n * (`mime.extension(body.mime)`, at presign). So a table that matched that one\n * exactly would usually be right — and this table did not: it said\n * `image/jpeg` → `jpeg` while `mime-types` says `jpg`, under a comment claiming\n * to mirror it. Usually, but not always: the row's stored `mime` is not always\n * the mime the key was built from, so no client-side table can close the gap.\n *\n * Measured against the 2 001 stored originals in production, 2026-08-17: **all\n * 420 `image/jpeg` originals are stored `.jpg` and none `.jpeg`** — the old\n * entry here 404'd on every single JPEG. And 234 originals carry\n * `application/octet-stream` (`.mpga`, `.docx`, `.m4a`), where no mime table can\n * produce the right key at all — those need `oext`.\n */\nconst ORIGINAL_EXT_BY_MIME: Record<string, string> = {\n \"image/png\": \"png\",\n \"image/jpeg\": \"jpg\",\n \"image/webp\": \"webp\",\n \"image/gif\": \"gif\",\n \"image/avif\": \"avif\",\n \"image/svg+xml\": \"svg\",\n \"image/heic\": \"heic\",\n \"image/heif\": \"heif\",\n \"image/bmp\": \"bmp\",\n \"image/tiff\": \"tiff\",\n \"application/pdf\": \"pdf\",\n \"video/mp4\": \"mp4\",\n \"video/webm\": \"webm\",\n \"video/quicktime\": \"mov\",\n // Audio — ausentes hasta 2026-08-17, y su ausencia costó un rodeo entero en\n // neo (`withRealOriginalExt`), que existe SÓLO porque esta tabla devolvía el\n // centinela `bin` para toda nota de voz. Medido en producción: `-o.bin` da\n // 404 y `-o.m4a` da 200.\n //\n // ⚠️ Se keyean por el mime COMPLETO, no por el subtipo: `audio/mp4` guarda\n // `.m4a` y `video/mp4` guarda `.mp4`. Un `switch` sobre el subtipo `mp4` no\n // puede distinguirlos — es el error que un consumidor cometió y tuvo que\n // corregir por su cuenta.\n \"audio/mpeg\": \"mpga\",\n \"audio/mp4\": \"m4a\",\n \"audio/x-m4a\": \"m4a\",\n \"audio/wav\": \"wav\",\n \"audio/webm\": \"weba\",\n \"audio/ogg\": \"oga\",\n \"audio/aac\": \"adts\",\n};\nfunction originalExtForMime(mime: string | undefined): string {\n return (mime ? ORIGINAL_EXT_BY_MIME[mime] : undefined) ?? PRESET_EXT.original;\n}\n\n/** What the asset itself knows about where its `original` lives. */\ntype OriginalHints = {\n mime?: string;\n /** The extension the server actually stored it under. Authoritative. */\n oext?: string | null;\n /** Full variant list — carries the stored URL verbatim. Authoritative. */\n variants?: AssetVariant[];\n};\n\n/**\n * Build the public CDN URL for a specific variant of an asset. The variant\n * may not actually exist (regenerate may not have run, or video has no\n * `aiproxy`); call `hasPreset()` first or expect a 404.\n *\n * For the `original` preset, pass the whole `AssetDTO` — it carries `variants`\n * and `oext`, either of which gives the EXACT stored key. `mime` alone is only a\n * guess (the server keys the original off the uploaded filename), and with\n * nothing at all the original falls back to the `\"bin\"` sentinel, which 404s.\n *\n * @example Video — the tenant segment is base36, so let this build the path\n * ```ts\n * import { getAssetUrl, setTenantId } from \"@nitida/asset-client\";\n *\n * setTenantId(12); // 12 → \"c\"; a decimal \"/12/v/\" 404s\n * getAssetUrl({ sha }, \"video\"); // → https://8ok.uk/c/v/<sha16>-v.mp4\n * getAssetUrl({ sha }, \"poster\"); // → https://8ok.uk/c/v/<sha16>-p.webp\n * ```\n *\n * @example The `original` — pass the DTO, not just the sha\n * ```ts\n * const asset = await aq.assets.get(id);\n *\n * getAssetUrl({ sha }, \"original\"); // → …-o.bin ❌ 404, always\n * getAssetUrl({ sha, mime }, \"original\"); // → a guess from the mime table\n * getAssetUrl(asset, \"original\"); // ✓ the stored key, verbatim\n * ```\n *\n * @example Check before you link\n * ```ts\n * import { getAssetUrl, hasPreset } from \"@nitida/asset-client\";\n * const url = hasPreset(asset, \"thumb\") ? getAssetUrl(asset, \"thumb\") : null;\n * ```\n */\nexport function getAssetUrl(\n asset: Pick<AssetDTO, \"sha\"> & OriginalHints,\n preset: VariantPreset,\n): string {\n if (preset === \"original\") {\n // The stored URL beats every derivation, because it IS the key. Only fall\n // through to a guess when the caller gave us the sha and nothing else.\n const stored = asset.variants?.find((v) => v.preset === \"original\")?.url;\n if (stored) return stored;\n const ext = asset.oext || originalExtForMime(asset.mime);\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT.original}.${ext}`;\n }\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;\n}\n\n/**\n * Did the processor actually generate this preset?\n *\n * Reads `dto.presets` — the compact 1-char code string the server sends on EVERY shape, including\n * the slim list/resolver one that carries no `variants` at all. That is why this exists and why it\n * stays the right existence check even now that `GET /assets/:id` really does send `variants`\n * (it did not until 2026-08-17; doc 240 §4.3).\n *\n * @example\n * ```ts\n * import { hasPreset } from \"@nitida/asset-client\";\n *\n * hasPreset({ presets: \"oq\" }, \"original\"); // → true (\"o\" = original, \"q\" = thumb)\n * hasPreset({ presets: \"oq\" }, \"lg\"); // → false — do not link to it\n * hasPreset({ presets: \"pv\" }, \"aiproxy\"); // → false — video without the AI proxy\n * ```\n */\nexport function hasPreset(\n asset: Pick<AssetDTO, \"presets\">,\n preset: VariantPreset,\n): boolean {\n if (preset === \"mp3\") return asset.presets.includes(\"mp3\");\n return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);\n}\n\n/**\n * Remove every MULTI-character token from a `presets` string, leaving only the\n * 1-char codes that a `.includes` can safely be run against.\n *\n * `presets` is documented as a concatenation of 1-char codes, and membership is\n * a 1-char substring test — so any longer token is a false-positive generator.\n * Servers before the 2026-08-17 deploy emitted several (doc 240 §4.3b):\n *\n * | token in the string | letters it donates | presets it falsely answers |\n * |---|---|---|\n * | `transform-<hex>` | t r a n s f o m + a–f | `sm` `md` `original` `aiproxy` |\n * | `pr` (probe) | p r | `poster` |\n * | `mp3` (audio) | m p | `md` `poster` — stripped here since forever |\n *\n * Measured against production: **62 % of live assets** carried a polluted\n * string, and **16 299 of them were told they have an `original` they do not**\n * — which builds a `-o.<ext>` URL that 404s. (`aiproxy`: 16 479. `sm`/`md`: 487.\n * The `pr` → `poster` collision is real but has 0 instances today.)\n *\n * Current servers no longer emit these, but this stays: an older\n * older server deploys keep sending them, and this is the check every guide points\n * at as the reliable one. Order matters — strip the longest tokens first.\n */\nfunction stripMultiCharTokens(presets: string): string {\n return presets\n .replace(/transform-[0-9a-f]*/g, \"\")\n .replace(/upscale_[a-z0-9_]*/g, \"\")\n .replace(/mp3/g, \"\")\n .replace(/u[2-8]|t[1248ghij]/g, \"\")\n .replace(/pr/g, \"\");\n}\n\n/**\n * Build a srcSet string for responsive `<img>`. Walks the available image\n * presets in size order and only includes the ones the asset actually has.\n *\n * <img\n * src={getAssetUrl(asset, 'web')}\n * srcSet={getAssetSrcSet(asset)}\n * sizes=\"(max-width: 768px) 100vw, 800px\"\n * />\n */\nconst IMAGE_PRESETS: VariantPreset[] = [\"thumb\", \"sm\", \"md\", \"lg\", \"xl\"];\nexport function getAssetSrcSet(\n asset: Pick<AssetDTO, \"sha\" | \"presets\">,\n): string {\n return IMAGE_PRESETS.filter(\n (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null,\n )\n .map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`)\n .join(\", \");\n}\n\n/**\n * Compute the dimensions a variant would have given the source asset's\n * width/height and the variant's bounding box. For thumbnails (square\n * smart-crop) the result is always 256×256; for the other presets, scales\n * the max side to the box dimension and the other side proportionally.\n */\nexport function computeVariantDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n preset: VariantPreset,\n): { width: number; height: number } | null {\n const cap = PRESET_MAX_DIM[preset];\n if (cap == null) return null;\n if (preset === \"thumb\") return { width: cap, height: cap };\n if (!asset.w || !asset.h) return null;\n const scale = Math.min(cap / asset.w, cap / asset.h, 1);\n return {\n width: Math.round(asset.w * scale),\n height: Math.round(asset.h * scale),\n };\n}\n\n/** Source dimensions, for aspect-ratio sizing. */\nexport function getAssetDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n): { width: number; height: number } | null {\n if (asset.w && asset.h) return { width: asset.w, height: asset.h };\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Slot system — tenant-named asset bindings.\n// See ./slots for full docs.\n// ---------------------------------------------------------------------------\n\nexport {\n configureSlotResolver,\n invalidateSlotCache,\n type ResolveSlotOptions,\n resolveSlot,\n resolveSlots,\n type SlotDTO,\n type SlotResolution,\n} from \"./slots\";\n\n// ---------------------------------------------------------------------------\n// On-the-fly transforms — see ./transform for docs.\n// ---------------------------------------------------------------------------\n\nexport {\n extractAssetSha,\n getHlsStreamingUrl,\n getSignedTransformUrl,\n getTransformSrcSet,\n getTransformUrl,\n getVideoTransformUrl,\n type SignedTransformOptions,\n serializeTransform,\n signTransformUrl,\n TRANSFORM_WIDTHS,\n type TransformEffect,\n type TransformFit,\n type TransformFormat,\n type TransformGravity,\n type TransformOptions,\n type TransformWidth,\n} from \"./transform\";\n","/**\n * Color palette helpers — render harmonious ambient backgrounds behind\n * product images, inspired by Spotify Now Playing / Apple Music / Pico.\n *\n * Wire format is intentionally compact: only the hex per swatch, only the\n * swatches the source actually had. The full names map to 1-2 letter aliases\n * (`d` dominant, `v` vibrant, `m` muted, `dv` darkVibrant, `lv` lightVibrant,\n * `dm` darkMuted, `lm` lightMuted) to shave bytes for catalog-sized payloads\n * (palette was 62% of asset DTO before this).\n *\n * Population + RGB array + textColor are derivable client-side; we don't\n * ship them. textColor is computed via WCAG relative luminance on demand.\n */\n\n/** Compact wire shape for an asset's palette. All swatches optional except dominant. */\nexport type AssetPalette = {\n /** dominant hex (always present when palette exists) */\n d: string;\n /** vibrant */ v?: string;\n /** muted */ m?: string;\n /** darkVibrant */ dv?: string;\n /** lightVibrant */ lv?: string;\n /** darkMuted */ dm?: string;\n /** lightMuted */ lm?: string;\n};\n\n/** Backwards-compat alias for older callers that referenced PaletteSwatch. */\nexport type PaletteSwatch = { hex: string; textColor: \"#000000\" | \"#FFFFFF\" };\n\n/**\n * Resolve a palette key to its hex value if present.\n */\nfunction resolveSwatch(\n palette: AssetPalette | null | undefined,\n ...keys: (keyof AssetPalette)[]\n): string | null {\n if (!palette) return null;\n for (const k of keys) {\n const v = palette[k];\n if (v) return v;\n }\n return null;\n}\n\n/**\n * Pick the swatch best suited for an ambient surface behind the image.\n * Prefers muted/light tones — too vibrant a background fights the image.\n *\n * Order: lightMuted → muted → lightVibrant → dominant.\n */\nexport function pickAmbientBackground(\n palette: AssetPalette | null | undefined,\n): PaletteSwatch | null {\n const hex = resolveSwatch(palette, \"lm\", \"m\", \"lv\", \"d\");\n if (!hex) return null;\n return { hex, textColor: textColorForHex(hex) };\n}\n\n/**\n * Build a CSS linear-gradient from the palette. Useful for hero / detail\n * backgrounds.\n */\nexport function getAmbientGradient(\n palette: AssetPalette | null | undefined,\n opts: {\n angle?: string;\n from?: keyof AssetPalette;\n to?: keyof AssetPalette;\n } = {},\n): string | undefined {\n if (!palette) return undefined;\n const fromHex = palette[opts.from ?? \"lm\"] ?? palette.m ?? palette.d;\n const toHex = palette[opts.to ?? \"m\"] ?? palette.dm ?? palette.d;\n if (!fromHex || !toHex) return undefined;\n return `linear-gradient(${opts.angle ?? \"135deg\"}, ${fromHex}, ${toHex})`;\n}\n\n/**\n * Recommended text color (#000 or #FFF) for any background hex,\n * computed via WCAG relative luminance.\n */\nexport function getTextColorForBackground(\n swatch: PaletteSwatch | string | null | undefined,\n): string {\n if (!swatch) return \"#000000\";\n const hex = typeof swatch === \"string\" ? swatch : swatch.hex;\n return textColorForHex(hex);\n}\n\n/** Luminancia relativa WCAG de un hex. 0 = negro, 1 = blanco. */\nexport function relativeLuminance(hex: string): number {\n const h = hex.replace(\"#\", \"\");\n const r = Number.parseInt(h.slice(0, 2), 16) / 255;\n const g = Number.parseInt(h.slice(2, 4), 16) / 255;\n const b = Number.parseInt(h.slice(4, 6), 16) / 255;\n // sRGB → linear\n const lin = (c: number) =>\n c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\n}\n\n/**\n * Razón de contraste WCAG entre dos luminancias: `(L1 + 0.05) / (L2 + 0.05)`,\n * con la más clara arriba. 1 = idénticos, 21 = negro contra blanco.\n */\nexport function contrastRatio(l1: number, l2: number): number {\n const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];\n return (hi + 0.05) / (lo + 0.05);\n}\n\n/**\n * Negro o blanco, el que **de verdad** contraste más sobre este fondo.\n *\n * ⚠️ CORREGIDO 2026-08-17. Antes decidía con `L > 0.5`, y **0,5 no es el punto\n * de empate**. Igualando las dos razones de contraste:\n *\n * (1 + 0.05) / (L + 0.05) = (L + 0.05) / 0.05\n * (L + 0.05)² = 0.0525\n * L = 0.1791…\n *\n * Entre **0,179 y 0,5** el código viejo elegía BLANCO cuando negro contrastaba\n * más — una banda ancha, y justo donde caen los colores de marca saturados.\n * Medido sobre cuatro paletas reales de producción: **8 swatches** en esa banda.\n * El peor, el dorado dominante `#CA9528`: devolvía blanco a **2,68:1** cuando\n * negro da **7,84:1**. 2,68 no pasa AA ni para texto grande.\n *\n * No se cambia el umbral por 0,179: se **calculan las dos razones y gana la\n * mayor**. Un umbral es una constante que hay que mantener correcta; la\n * comparación es correcta por construcción.\n *\n * ⚠️ Esto elige el MEJOR de dos, no garantiza que alcance. Sobre un fondo de\n * luminancia media el mejor par puede quedar por debajo de 4,5:1 igual — para\n * eso está {@link bestTextContrast}, que además devuelve el número.\n */\nfunction textColorForHex(hex: string): \"#000000\" | \"#FFFFFF\" {\n return bestTextContrast(hex).color;\n}\n\n/**\n * Igual que el color recomendado, pero devuelve también **la razón lograda** y\n * si pasa AA — para que quien lo use pueda decidir con el número a la vista en\n * vez de asumir que alcanzó.\n */\nexport function bestTextContrast(hex: string): {\n color: \"#000000\" | \"#FFFFFF\";\n ratio: number;\n passesAA: boolean;\n passesAALarge: boolean;\n} {\n const L = relativeLuminance(hex);\n const onBlack = contrastRatio(L, 0);\n const onWhite = contrastRatio(L, 1);\n const useBlack = onBlack >= onWhite;\n const ratio = useBlack ? onBlack : onWhite;\n return {\n color: useBlack ? \"#000000\" : \"#FFFFFF\",\n ratio,\n passesAA: ratio >= 4.5,\n passesAALarge: ratio >= 3,\n };\n}\n\n/**\n * CSS variables for a wrapper so a subtree can read --asset-bg / --asset-fg /\n * --asset-dominant / --asset-vibrant / etc.\n */\nexport function getPaletteCssVars(\n palette: AssetPalette | null | undefined,\n): Record<string, string> {\n if (!palette) return {};\n const bg = pickAmbientBackground(palette);\n return {\n \"--asset-bg\": bg?.hex ?? \"transparent\",\n \"--asset-fg\": bg ? bg.textColor : \"#000000\",\n \"--asset-dominant\": palette.d,\n ...(palette.v && { \"--asset-vibrant\": palette.v }),\n ...(palette.m && { \"--asset-muted\": palette.m }),\n ...(palette.lv && { \"--asset-light-vibrant\": palette.lv }),\n ...(palette.dv && { \"--asset-dark-vibrant\": palette.dv }),\n ...(palette.lm && { \"--asset-light-muted\": palette.lm }),\n ...(palette.dm && { \"--asset-dark-muted\": palette.dm }),\n };\n}\n\n/**\n * Iterate the palette in display order (dominant first, then vibrant +\n * muted families). Useful for rendering a swatch strip in admin UIs.\n */\nexport function iteratePaletteSwatches(\n palette: AssetPalette | null | undefined,\n): Array<{ key: keyof AssetPalette; label: string; hex: string }> {\n if (!palette) return [];\n const order: Array<{ key: keyof AssetPalette; label: string }> = [\n { key: \"d\", label: \"dominant\" },\n { key: \"v\", label: \"vibrant\" },\n { key: \"lv\", label: \"lightVibrant\" },\n { key: \"dv\", label: \"darkVibrant\" },\n { key: \"m\", label: \"muted\" },\n { key: \"lm\", label: \"lightMuted\" },\n { key: \"dm\", label: \"darkMuted\" },\n ];\n return order\n .map(({ key, label }) => {\n const hex = palette[key];\n return hex ? { key, label, hex } : null;\n })\n .filter(\n (s): s is { key: keyof AssetPalette; label: string; hex: string } =>\n s != null,\n );\n}\n\n/**\n * Build a multi-radial-gradient CSS `background` string from the palette\n * swatches. Acts as a zero-extra-bytes alternative to the WebP LQIP: the\n * palette is already in the DTO, so this placeholder costs nothing extra\n * to ship. Renders as a smooth abstract \"color cloud\" reminiscent of the\n * source image's vibe.\n *\n * Strategy: anchor 4 radial gradients at fixed corners using vibrant/muted\n * pairs, layered over the dominant fill. Skips missing swatches gracefully.\n */\nexport function getPaletteBlurBackground(\n palette: AssetPalette | null | undefined,\n): string | undefined {\n if (!palette) return undefined;\n const corners: Array<{ pos: string; key: keyof AssetPalette }> = [\n { pos: \"20% 20%\", key: \"lv\" },\n { pos: \"80% 25%\", key: \"v\" },\n { pos: \"25% 80%\", key: \"lm\" },\n { pos: \"80% 80%\", key: \"dv\" },\n ];\n const layers = corners\n .map(({ pos, key }) => {\n const hex = palette[key];\n if (!hex) return null;\n return `radial-gradient(circle at ${pos}, ${hex} 0%, transparent 55%)`;\n })\n .filter(Boolean) as string[];\n // Fallback fill = dominant (or muted if dominant is missing — shouldn't happen)\n const base = palette.d ?? palette.m ?? \"#888\";\n return layers.length > 0 ? `${layers.join(\", \")}, ${base}` : base;\n}\n","/**\n * @nitida/asset-client/slots — slot resolver for tenant-named assets.\n *\n * Slots give tenants a way to attach stable, human-readable names\n * (\"webapp.wizard.pool-type.icon-1\", \"storefront.cr.hero-video.landscape_hd_16x9.mp4\")\n * to assets they uploaded. Consumers resolve names → AssetDTOs at\n * build / runtime so their source never hardcodes a CDN URL; the\n * admin rebinds a slot from `asset-lab-web` and every consumer picks\n * up the swap on cache refresh.\n *\n * Two layers in this package:\n * - `resolveSlot` / `resolveSlots` — universal (server, edge,\n * workers) fetch helpers. Cache 60s by default.\n * - React hooks live in `@nitida/asset-client/react/use-slot`\n * (kept out of this module so the SSR-safe core stays\n * dependency-free of react).\n */\n\nimport type { AssetDTO, VariantPreset } from \"./index\";\nimport { getAssetUrl, hasPreset } from \"./index\";\n\n// ---------------------------------------------------------------------------\n// Wire shape — matches the server's slots route.\n// ---------------------------------------------------------------------------\n\nexport type SlotDTO = {\n slotKey: string;\n /** Preset hint set when the slot was bound (e.g. `thumb` for icon slots). */\n preset: VariantPreset | null;\n description: string | null;\n updatedAt: string;\n asset: AssetDTO;\n};\n\nexport type SlotResolution = {\n /** The resolved DTO (`null` when the slot is unbound or asset missing). */\n slot: SlotDTO | null;\n /**\n * Effective preset — what `url` below was built with. Resolution order:\n * 1. caller's `preset` override\n * 2. slot's `preset` hint\n * 3. `lg` for images, `video` for video kind\n */\n preset: VariantPreset;\n /** The CDN URL the consumer should use. */\n url: string | null;\n};\n\n// ---------------------------------------------------------------------------\n// Config\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_TTL_MS = 60_000;\nconst cache = new Map<string, { fetchedAt: number; value: SlotDTO | null }>();\n\n// The published API host — the same address the docs give out, so a caller\n// that configured nothing still talks to the documented endpoint. Override it\n// with `configureSlotResolver({ endpoint })` to point at a different\n// deployment.\nlet endpoint = \"https://api.nitida.gofuture.space\";\nlet apiKey: string | null = null;\nlet tenantCode: string | null = null;\n\n/**\n * Configure the resolver process-wide. Call once at boot from your\n * storefront layout / server entry / worker init.\n *\n * configureSlotResolver({\n * endpoint: process.env.AQUIENPZ_URL,\n * apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY,\n * tenantCode: \"realtyone-cr\",\n * });\n */\nexport function configureSlotResolver(opts: {\n endpoint?: string;\n apiKey?: string;\n tenantCode?: string;\n}): void {\n if (opts.endpoint) endpoint = opts.endpoint.replace(/\\/+$/, \"\");\n if (opts.apiKey !== undefined) apiKey = opts.apiKey;\n if (opts.tenantCode !== undefined) tenantCode = opts.tenantCode;\n}\n\n/** Wipe the in-process cache (test helper or forced refresh). */\nexport function invalidateSlotCache(slotKey?: string): void {\n if (slotKey === undefined) cache.clear();\n else\n for (const k of cache.keys())\n if (k.endsWith(`:${slotKey}`)) cache.delete(k);\n}\n\n// ---------------------------------------------------------------------------\n// Internal fetch helper\n// ---------------------------------------------------------------------------\n\nconst baseHeaders = (): Record<string, string> => {\n const h: Record<string, string> = {};\n if (apiKey) h.Authorization = `Bearer ${apiKey}`;\n if (tenantCode) h[\"X-Tenant-Code\"] = tenantCode;\n return h;\n};\n\nasync function fetchSlot(slotKey: string): Promise<SlotDTO | null> {\n const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {\n headers: baseHeaders(),\n });\n if (r.status === 404) return null;\n if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);\n return (await r.json()) as SlotDTO;\n}\n\nasync function fetchSlotsBulk(\n slotKeys: string[],\n): Promise<Record<string, SlotDTO | null>> {\n if (slotKeys.length === 0) return {};\n const r = await fetch(`${endpoint}/slots/resolve`, {\n method: \"POST\",\n headers: { ...baseHeaders(), \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ keys: slotKeys }),\n });\n if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);\n const body = (await r.json()) as { resolved: Record<string, SlotDTO | null> };\n return body.resolved;\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport type ResolveSlotOptions = {\n /** Override preset (caller knows the use case better than the slot binding). */\n preset?: VariantPreset;\n /** TTL for the in-process cache. Default 60s. Set 0 to bypass. */\n ttlMs?: number;\n};\n\n/**\n * Resolve a single slot to a CDN URL. Returns `{slot: null, url: null}`\n * when the slot is unbound — callers fall back to a placeholder.\n *\n * Cached for `ttlMs` (default 60s). Slot rebindings propagate within the\n * TTL window without an app restart.\n */\nexport async function resolveSlot(\n slotKey: string,\n opts: ResolveSlotOptions = {},\n): Promise<SlotResolution> {\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const cacheKey = `${tenantCode ?? \"_\"}:${slotKey}`;\n const now = Date.now();\n let dto: SlotDTO | null;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n dto = hit.value;\n } else {\n dto = await fetchSlot(slotKey);\n cache.set(cacheKey, { fetchedAt: now, value: dto });\n }\n return materializeResolution(dto, opts.preset);\n}\n\n/**\n * Bulk-resolve N slot keys in one round-trip. The SDK's `useSlots`\n * React hook calls this so every storefront header (logo + tagline +\n * nav cover + …) loads as one request.\n */\nexport async function resolveSlots(\n slotKeys: string[],\n opts: ResolveSlotOptions = {},\n): Promise<Record<string, SlotResolution>> {\n if (slotKeys.length === 0) return {};\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const now = Date.now();\n const missing: string[] = [];\n const out: Record<string, SlotResolution> = {};\n for (const k of slotKeys) {\n const cacheKey = `${tenantCode ?? \"_\"}:${k}`;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n out[k] = materializeResolution(hit.value, opts.preset);\n } else {\n missing.push(k);\n }\n }\n if (missing.length > 0) {\n const resolved = await fetchSlotsBulk(missing);\n for (const k of missing) {\n const dto = resolved[k] ?? null;\n cache.set(`${tenantCode ?? \"_\"}:${k}`, { fetchedAt: now, value: dto });\n out[k] = materializeResolution(dto, opts.preset);\n }\n }\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction defaultPresetFor(asset: AssetDTO | undefined): VariantPreset {\n if (!asset) return \"lg\";\n return asset.kind === \"video\" ? \"video\" : \"lg\";\n}\n\nfunction materializeResolution(\n dto: SlotDTO | null,\n overridePreset?: VariantPreset,\n): SlotResolution {\n if (!dto) return { slot: null, preset: overridePreset ?? \"lg\", url: null };\n const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);\n // Fall back to \"lg\" when the bound preset doesn't exist on the asset\n // (e.g. slot was bound to a video but caller asked for a thumb).\n const finalPreset = hasPreset(dto.asset, effective)\n ? effective\n : defaultPresetFor(dto.asset);\n return {\n slot: dto,\n preset: finalPreset,\n url: getAssetUrl(dto.asset, finalPreset),\n };\n}\n","/**\n * On-the-fly transform URL builder.\n *\n * Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated\n * here hashes to the same cache key as the server's canonical form.\n *\n * Canonicalization rules (kept in sync with the server):\n * - Drop entries whose value is `undefined`\n * - Sort keys alphabetically\n * - Numbers rendered without leading zeros or trailing dots\n * - String values lowercased\n *\n * URL shape:\n * <cdnBase>/t/<dsl>/<sha>.<ext>\n *\n * The `.ext` is informational (browser content-sniff hint); the server\n * decides the actual output format from the DSL `format` param + the\n * request's Accept header.\n */\n\nimport type { AssetDTO } from \"./index\";\nimport { getCdnBase } from \"./index\";\n\n/**\n * Widths the CDN edge whitelists (DoS guard).\n * Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the\n * 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import\n * this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.\n */\nexport const TRANSFORM_WIDTHS = [\n 96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280,\n 1440, 1600, 1920, 2560, 3840,\n] as const;\n/**\n * A CDN-whitelisted transform width — the only widths `TransformOptions.width`\n * accepts. Off-ladder widths are a compile error; for signed URLs that need a\n * custom width, use {@link SignedTransformOptions} (number) via\n * {@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`.\n */\nexport type TransformWidth = (typeof TRANSFORM_WIDTHS)[number];\n\nexport type TransformFit = \"cover\" | \"contain\" | \"fill\" | \"inside\" | \"outside\";\nexport type TransformGravity =\n | \"auto\"\n | \"face\"\n | \"center\"\n | \"north\"\n | \"south\"\n | \"east\"\n | \"west\";\nexport type TransformFormat =\n | \"auto\"\n | \"avif\"\n | \"webp\"\n | \"jpeg\"\n | \"png\"\n // Video-only formats (Phase 4). The image path ignores them.\n | \"mp4\"\n | \"webm\"\n // HLS adaptive ladder (Phase 5). Video-only. Output is a directory\n // of m3u8 + .ts segments fronted by master.m3u8; the SDK returns\n // the master URL via `getHlsStreamingUrl`.\n | \"hls\";\nexport type TransformEffect = \"removebg\" | \"genfill\";\n\nexport type TransformOptions = {\n /**\n * Target max-side width in CSS pixels (multiplied by `dpr` server-side).\n * MUST be a {@link TRANSFORM_WIDTHS} value — off-ladder widths are rejected\n * (HTTP 400) by the edge whitelist for unsigned URLs, so the type forbids\n * them at compile time. For SIGNED URLs with a custom width, use\n * {@link SignedTransformOptions} (which widens this to `number`).\n */\n width?: TransformWidth;\n /** Target max-side height. Multiplied by `dpr` server-side. */\n height?: number;\n /** Resize fit mode. Default `cover` server-side. */\n fit?: TransformFit;\n /** Crop gravity. `auto` picks the region with the most visual salience. */\n gravity?: TransformGravity;\n /** Output format. `auto` → the platform's policy decides. */\n format?: TransformFormat;\n /** Output quality. `auto` → format-specific default. */\n quality?: \"auto\" | number;\n /** Device pixel ratio. Width/height are multiplied by this before resize. */\n dpr?: 1 | 2 | 3;\n /**\n * AI effect applied before resize/encode.\n *\n * - `removebg`: remove the background; output is a transparent PNG\n * of the foreground subject. Forces `format=png` regardless of\n * other format hints. A single cache miss per (sha, dsl) tuple;\n * subsequent identical DSLs serve from cache — no inference, no\n * per-image cost.\n *\n * - `genfill`: aspect-extension outpaint. Requires BOTH `width`\n * and `height` — the server\n * fits the source centered into the target canvas and outpaints\n * the gutters. Output is PNG (forced) at exactly target dims.\n * ~$0.05/image first time; same cache as removebg after.\n * Primary use case: building OG cards (1200×630) from portrait\n * listing photos without awkward edge mirroring.\n */\n effect?: TransformEffect;\n /**\n * Video-only: clip start in seconds. Image transforms ignore.\n * Accepts decimals (e.g. 1.5 for sub-second seek).\n */\n start?: number;\n /**\n * Video-only: clip duration in seconds (1..300). Image transforms\n * ignore. With `start`, lets a single request grab a sub-clip.\n */\n duration?: number;\n};\n\n/**\n * Like {@link TransformOptions} but with `width` widened to any `number` —\n * the escape hatch for SIGNED URLs that need an off-ladder custom width.\n *\n * The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid\n * `?sig=` earns the whitelist bypass at the edge (the server still\n * does the real HMAC check). So a custom width is ONLY safe when the URL is\n * signed — hence this type is accepted exclusively by the signing helpers\n * ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),\n * never by the plain unsigned {@link getTransformUrl}.\n */\nexport type SignedTransformOptions = Omit<TransformOptions, \"width\"> & {\n /** Off-ladder width — valid ONLY on signed URLs (edge whitelist bypass). */\n width?: number;\n};\n\n/**\n * Serialize transform options into the canonical DSL path segment.\n * Empty options return an empty string (caller should fall back to a\n * variant URL instead of a transform URL in that case).\n *\n * Accepts {@link SignedTransformOptions} (the wider type) so it also covers\n * custom-width signed URLs; {@link TransformOptions} is assignable to it.\n */\n/**\n * Extract the 16-hex short sha from an aquienpz CDN URL, regardless of\n * shape:\n * - tenant-prefixed variant: `https://8ok.uk/4/v/<sha16>-<preset>.<ext>`\n * - legacy variant: `https://8ok.uk/<sha16>-<preset>.<ext>`\n * - on-the-fly transform: `https://8ok.uk/t/<dsl>/<sha16>.<ext>`\n * - streaming HLS: `https://8ok.uk/t/format=hls/<sha16>.m3u8`\n *\n * Returns `null` for non-aquienpz URLs (pexels, googleusercontent, raw\n * uploaded URLs to other CDNs) so call sites can fall back to the URL\n * with a plain `<img>` instead of generating a broken transform URL.\n *\n * Useful when a value reaches the component as a pre-built URL string\n * (legacy data, site-config JSON, third-party feeds) but you want to\n * drop in `getTransformSrcSet` for the responsive ladder if it happens\n * to be an aquienpz asset.\n */\nexport function extractAssetSha(url: string | null | undefined): string | null {\n if (!url) return null;\n // Match the canonical aquienpz sha pattern: 16 lowercase hex chars\n // appearing as a path segment, optionally followed by `-<preset>`\n // (variant URL) or `.<ext>` (transform URL).\n const m = url.match(/\\/([0-9a-f]{16})(?:[-.]|$)/);\n return m ? m[1]! : null;\n}\n\nexport function serializeTransform(opts: SignedTransformOptions): string {\n const entries: Array<[string, string]> = [];\n const keys = Object.keys(opts).sort() as Array<keyof SignedTransformOptions>;\n for (const k of keys) {\n const v = opts[k];\n if (v == null) continue;\n const serialized = typeof v === \"string\" ? v.toLowerCase() : String(v);\n entries.push([k, serialized]);\n }\n return entries.map(([k, v]) => `${k}=${v}`).join(\",\");\n}\n\nfunction extForOptions(opts: SignedTransformOptions): string {\n // effect=removebg forces PNG output server-side (needs alpha).\n if (opts.effect === \"removebg\") return \"png\";\n // effect=genfill defaults to WebP (12× lighter than the raw generated\n // PNG output with no visible loss at q=85). Explicit `format=png`\n // opts back into lossless for print / marketing fold-outs. The server\n // re-encodes the generated PNG → target format before caching.\n if (opts.effect === \"genfill\") {\n switch (opts.format) {\n case \"png\":\n return \"png\";\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n default:\n return \"webp\";\n }\n }\n switch (opts.format) {\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n case \"png\":\n return \"png\";\n case \"mp4\":\n return \"mp4\";\n case \"webm\":\n return \"webm\";\n case \"hls\":\n // HLS uses `.m3u8` as the URL extension; the route resolves the\n // master playlist under the cache key prefix.\n return \"m3u8\";\n default:\n // \"webp\" / \"auto\" / undefined / anything new\n return \"webp\";\n }\n}\n\n/**\n * Build a transform URL for a VIDEO asset. Same DSL shape as image\n * transforms; the server branches on the asset's `kind` column. Video\n * URLs use `.mp4` (default) or `.webm` extension and on cache miss the\n * server returns 202 Accepted while a background job encodes the clip;\n * subsequent GETs return 302 to the cached object.\n *\n * <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}\n * autoPlay muted loop playsInline />\n */\nexport function getVideoTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: TransformOptions,\n): string | null {\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = opts.format === \"webm\" ? \"webm\" : \"mp4\";\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\n/**\n * Build an HLS streaming URL for a VIDEO asset (Phase 5). Returns the\n * master.m3u8 entry point — HLS-aware players (Video.js's\n * @videojs/http-streaming, hls.js, native iOS Safari) follow it to\n * fetch the variant playlist + segments at the appropriate bitrate\n * for the connection.\n *\n * ⚠️ A master.m3u8 is NOT a video file. Assigning it to `<video src>`\n * works only where the engine has native HLS; everywhere else it needs\n * an MSE player. And the classic feature test is now WRONG: Chrome 147\n * (April 2026) added native HLS, so `canPlayType(\"application/vnd.apple.mpegurl\")`\n * answers \"maybe\" there and routes Chrome to the native branch, where it\n * opened a measured 17 s hero at 426x240 for ~8 s. Branch on the ENGINE:\n *\n * @example\n * ```ts\n * function prefersNativeHls(video: HTMLVideoElement): boolean {\n * if (video.canPlayType(\"application/vnd.apple.mpegurl\") === \"\") return false;\n * // Apple's engine, or an engine with no MSE to fall back on (iOS < 17.1).\n * return \"ManagedMediaSource\" in globalThis || !(\"MediaSource\" in globalThis);\n * }\n *\n * const src = getHlsStreamingUrl(asset);\n * if (prefersNativeHls(video)) {\n * video.src = src;\n * } else {\n * // Defaults open at a fixed low rung — measure instead of guessing.\n * const hls = new Hls({ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 });\n * hls.loadSource(src);\n * hls.attachMedia(video);\n * }\n * ```\n *\n * On first request the server returns 202 Accepted while a background\n * job transcodes the ladder (typically 1-3 min for a 90 s source);\n * subsequent requests get 302 to the cached master.m3u8. Keep the\n * progressive MP4 as a fallback source for that window.\n *\n * The ladder's ceiling is the source the job probes: built at ingest it\n * reads the RAW upload and a 4K master yields 1440p/2160p rungs; rebuilt\n * on demand after the raw is unavailable it reads the `-v.mp4`, which is\n * capped at 1920 wide. `getAssetUrl(sha, \"video\")` is always <= 1080p.\n */\nexport function getHlsStreamingUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: Omit<TransformOptions, \"format\"> = {},\n): string {\n // Always serialize with format=hls so the server routes correctly.\n const merged: TransformOptions = { ...opts, format: \"hls\" };\n const dsl = serializeTransform(merged);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;\n}\n\n/**\n * Build a transform URL. Returns null when the caller passed no options —\n * callers should prefer the existing variant URL builder in that case so\n * the request hits a pre-generated variant instead of an on-the-fly encode.\n */\nfunction buildTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n): string | null {\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = extForOptions(opts);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\nexport function getTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: TransformOptions,\n): string | null {\n return buildTransformUrl(asset, opts);\n}\n\n/**\n * Build AND sign a transform URL, allowing an off-ladder custom `width`.\n *\n * This is the escape hatch for {@link SignedTransformOptions}: off-ladder\n * widths only pass the edge whitelist when the URL is signed, so building one\n * and signing it must happen together. For on-ladder widths prefer the plain\n * {@link getTransformUrl} (+ {@link signTransformUrl} if you need a signature).\n *\n * Returns `null` only when `opts` serialize to an empty DSL (no transform\n * requested) — same contract as {@link getTransformUrl}.\n */\nexport function getSignedTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n signingKey: string,\n): Promise<string> | null {\n const url = buildTransformUrl(asset, opts);\n if (!url) return null;\n return signTransformUrl(url, signingKey);\n}\n\n/**\n * Sign a transform URL with the tenant's HMAC signing key. Appends\n * `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).\n *\n * Must agree byte-for-byte with the server's `verifyTransformSignature`.\n * Uses WebCrypto, so works in browsers, Node ≥ 16, Bun, and Workers.\n *\n * The canonical DSL is the one already produced by `serializeTransform`\n * (sort keys + lowercase strings), so signing a URL built by `getTransformUrl`\n * is automatic — the same canonical form is in the URL path.\n */\nexport async function signTransformUrl(\n unsignedUrl: string,\n signingKey: string,\n): Promise<string> {\n const u = new URL(unsignedUrl);\n // Path shape: /t/<dsl>/<filename>\n const parts = u.pathname.split(\"/\").filter(Boolean);\n // First segment must be \"t\"; the rest is dsl groups + filename. With\n // Phase 1 we ship a single DSL group; chained groups stay flat for\n // signing purposes (server canonicalizer flattens them too).\n if (parts[0] !== \"t\" || parts.length < 3) {\n throw new Error(`signTransformUrl: unexpected URL shape ${unsignedUrl}`);\n }\n const filename = parts[parts.length - 1]!;\n const dsl = parts.slice(1, -1).join(\"/\");\n const message = `${dsl}/${filename}`;\n const sig = await hmacSha256Hex(signingKey, message);\n u.searchParams.set(\"sig\", sig);\n return u.toString();\n}\n\nasync function hmacSha256Hex(key: string, message: string): Promise<string> {\n const enc = new TextEncoder();\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const buf = await crypto.subtle.sign(\"HMAC\", cryptoKey, enc.encode(message));\n return [...new Uint8Array(buf)]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\n/**\n * Build a responsive `srcSet` string by generating one transform URL per\n * width. All other options apply to every URL.\n *\n * <img\n * src={aq.transform(asset, { width: 800 })!}\n * srcSet={aq.transformSrcSet(asset, [320, 640, 960, 1280])}\n * sizes=\"(max-width: 768px) 100vw, 50vw\"\n * />\n */\nexport function getTransformSrcSet(\n asset: Pick<AssetDTO, \"sha\">,\n widths: number[],\n extraOpts: Omit<TransformOptions, \"width\"> = {},\n): string {\n return widths\n .map((w) => {\n // Build via the internal (number-width) builder: `widths` is an explicit\n // responsive ladder the caller chose, so it stays `number[]`. Off-ladder\n // unsigned widths 400 at the edge — the caller's responsibility, exactly\n // as before the width type was tightened.\n const url = buildTransformUrl(asset, { ...extraOpts, width: w });\n return url ? `${url} ${w}w` : null;\n })\n .filter((s): s is string => s != null)\n .join(\", \");\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
CHANGED
|
@@ -173,10 +173,9 @@ declare function resolveSlots(slotKeys: string[], opts?: ResolveSlotOptions): Pr
|
|
|
173
173
|
* On-the-fly transform URL builder.
|
|
174
174
|
*
|
|
175
175
|
* Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated
|
|
176
|
-
* here hashes to the same
|
|
176
|
+
* here hashes to the same cache key as the server's canonical form.
|
|
177
177
|
*
|
|
178
|
-
* Canonicalization rules (
|
|
179
|
-
* `apps/asset-manager/src/features/assets/transform.dsl.ts`):
|
|
178
|
+
* Canonicalization rules (kept in sync with the server):
|
|
180
179
|
* - Drop entries whose value is `undefined`
|
|
181
180
|
* - Sort keys alphabetically
|
|
182
181
|
* - Numbers rendered without leading zeros or trailing dots
|
|
@@ -191,7 +190,7 @@ declare function resolveSlots(slotKeys: string[], opts?: ResolveSlotOptions): Pr
|
|
|
191
190
|
*/
|
|
192
191
|
|
|
193
192
|
/**
|
|
194
|
-
* Widths the CDN edge whitelists (DoS guard
|
|
193
|
+
* Widths the CDN edge whitelists (DoS guard).
|
|
195
194
|
* Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the
|
|
196
195
|
* 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import
|
|
197
196
|
* this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.
|
|
@@ -221,9 +220,9 @@ type TransformOptions = {
|
|
|
221
220
|
height?: number;
|
|
222
221
|
/** Resize fit mode. Default `cover` server-side. */
|
|
223
222
|
fit?: TransformFit;
|
|
224
|
-
/** Crop gravity. `auto`
|
|
223
|
+
/** Crop gravity. `auto` picks the region with the most visual salience. */
|
|
225
224
|
gravity?: TransformGravity;
|
|
226
|
-
/** Output format. `auto` → policy decides
|
|
225
|
+
/** Output format. `auto` → the platform's policy decides. */
|
|
227
226
|
format?: TransformFormat;
|
|
228
227
|
/** Output quality. `auto` → format-specific default. */
|
|
229
228
|
quality?: "auto" | number;
|
|
@@ -234,16 +233,15 @@ type TransformOptions = {
|
|
|
234
233
|
*
|
|
235
234
|
* - `removebg`: remove the background; output is a transparent PNG
|
|
236
235
|
* of the foreground subject. Forces `format=png` regardless of
|
|
237
|
-
* other format hints.
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* from R2 — no inference, no per-image cost.
|
|
236
|
+
* other format hints. A single cache miss per (sha, dsl) tuple;
|
|
237
|
+
* subsequent identical DSLs serve from cache — no inference, no
|
|
238
|
+
* per-image cost.
|
|
241
239
|
*
|
|
242
|
-
* - `genfill`: aspect-extension outpaint
|
|
243
|
-
*
|
|
240
|
+
* - `genfill`: aspect-extension outpaint. Requires BOTH `width`
|
|
241
|
+
* and `height` — the server
|
|
244
242
|
* fits the source centered into the target canvas and outpaints
|
|
245
243
|
* the gutters. Output is PNG (forced) at exactly target dims.
|
|
246
|
-
* ~$0.05/image first time; same
|
|
244
|
+
* ~$0.05/image first time; same cache as removebg after.
|
|
247
245
|
* Primary use case: building OG cards (1200×630) from portrait
|
|
248
246
|
* listing photos without awkward edge mirroring.
|
|
249
247
|
*/
|
|
@@ -264,7 +262,7 @@ type TransformOptions = {
|
|
|
264
262
|
* the escape hatch for SIGNED URLs that need an off-ladder custom width.
|
|
265
263
|
*
|
|
266
264
|
* The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid
|
|
267
|
-
* `?sig=` earns the whitelist bypass at the
|
|
265
|
+
* `?sig=` earns the whitelist bypass at the edge (the server still
|
|
268
266
|
* does the real HMAC check). So a custom width is ONLY safe when the URL is
|
|
269
267
|
* signed — hence this type is accepted exclusively by the signing helpers
|
|
270
268
|
* ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),
|
|
@@ -305,8 +303,8 @@ declare function serializeTransform(opts: SignedTransformOptions): string;
|
|
|
305
303
|
* Build a transform URL for a VIDEO asset. Same DSL shape as image
|
|
306
304
|
* transforms; the server branches on the asset's `kind` column. Video
|
|
307
305
|
* URLs use `.mp4` (default) or `.webm` extension and on cache miss the
|
|
308
|
-
* server returns 202 Accepted while a
|
|
309
|
-
* subsequent GETs return 302 to the cached
|
|
306
|
+
* server returns 202 Accepted while a background job encodes the clip;
|
|
307
|
+
* subsequent GETs return 302 to the cached object.
|
|
310
308
|
*
|
|
311
309
|
* <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}
|
|
312
310
|
* autoPlay muted loop playsInline />
|
|
@@ -345,8 +343,8 @@ declare function getVideoTransformUrl(asset: Pick<AssetDTO, "sha">, opts: Transf
|
|
|
345
343
|
* }
|
|
346
344
|
* ```
|
|
347
345
|
*
|
|
348
|
-
* On first request the server returns 202 Accepted while a
|
|
349
|
-
*
|
|
346
|
+
* On first request the server returns 202 Accepted while a background
|
|
347
|
+
* job transcodes the ladder (typically 1-3 min for a 90 s source);
|
|
350
348
|
* subsequent requests get 302 to the cached master.m3u8. Keep the
|
|
351
349
|
* progressive MP4 as a fallback source for that window.
|
|
352
350
|
*
|
|
@@ -419,7 +417,7 @@ declare function getTransformSrcSet(asset: Pick<AssetDTO, "sha">, widths: number
|
|
|
419
417
|
* with vertical product photos.
|
|
420
418
|
*/
|
|
421
419
|
type VariantPreset = "thumb" | "sm" | "md" | "lg" | "xl" | "original" | "poster" | "video" | "aiproxy" | "mp3";
|
|
422
|
-
/** 1-char alias used in
|
|
420
|
+
/** 1-char alias used in storage keys / wire `presets` string. */
|
|
423
421
|
declare const PRESET_SHORT: Record<VariantPreset, string>;
|
|
424
422
|
declare const PRESET_LONG: Record<string, VariantPreset>;
|
|
425
423
|
/** Variant extension by preset. Image variants are always WebP, video MP4. */
|
|
@@ -435,11 +433,11 @@ type AssetVariant = {
|
|
|
435
433
|
preset: VariantPreset;
|
|
436
434
|
/** Public CDN URL of this variant. */
|
|
437
435
|
url: string;
|
|
438
|
-
/** Pixel width. Absent for `original`-only assets where
|
|
436
|
+
/** Pixel width. Absent for `original`-only assets where image processing was skipped, or for video presets. */
|
|
439
437
|
width?: number;
|
|
440
438
|
/** Pixel height. Same caveat as `width`. */
|
|
441
439
|
height?: number;
|
|
442
|
-
/** Byte size of the variant file
|
|
440
|
+
/** Byte size of the stored variant file. */
|
|
443
441
|
bytes: number;
|
|
444
442
|
/**
|
|
445
443
|
* Where the bytes for this variant came from. Useful for quality
|
package/dist/index.d.ts
CHANGED
|
@@ -173,10 +173,9 @@ declare function resolveSlots(slotKeys: string[], opts?: ResolveSlotOptions): Pr
|
|
|
173
173
|
* On-the-fly transform URL builder.
|
|
174
174
|
*
|
|
175
175
|
* Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated
|
|
176
|
-
* here hashes to the same
|
|
176
|
+
* here hashes to the same cache key as the server's canonical form.
|
|
177
177
|
*
|
|
178
|
-
* Canonicalization rules (
|
|
179
|
-
* `apps/asset-manager/src/features/assets/transform.dsl.ts`):
|
|
178
|
+
* Canonicalization rules (kept in sync with the server):
|
|
180
179
|
* - Drop entries whose value is `undefined`
|
|
181
180
|
* - Sort keys alphabetically
|
|
182
181
|
* - Numbers rendered without leading zeros or trailing dots
|
|
@@ -191,7 +190,7 @@ declare function resolveSlots(slotKeys: string[], opts?: ResolveSlotOptions): Pr
|
|
|
191
190
|
*/
|
|
192
191
|
|
|
193
192
|
/**
|
|
194
|
-
* Widths the CDN edge whitelists (DoS guard
|
|
193
|
+
* Widths the CDN edge whitelists (DoS guard).
|
|
195
194
|
* Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the
|
|
196
195
|
* 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import
|
|
197
196
|
* this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.
|
|
@@ -221,9 +220,9 @@ type TransformOptions = {
|
|
|
221
220
|
height?: number;
|
|
222
221
|
/** Resize fit mode. Default `cover` server-side. */
|
|
223
222
|
fit?: TransformFit;
|
|
224
|
-
/** Crop gravity. `auto`
|
|
223
|
+
/** Crop gravity. `auto` picks the region with the most visual salience. */
|
|
225
224
|
gravity?: TransformGravity;
|
|
226
|
-
/** Output format. `auto` → policy decides
|
|
225
|
+
/** Output format. `auto` → the platform's policy decides. */
|
|
227
226
|
format?: TransformFormat;
|
|
228
227
|
/** Output quality. `auto` → format-specific default. */
|
|
229
228
|
quality?: "auto" | number;
|
|
@@ -234,16 +233,15 @@ type TransformOptions = {
|
|
|
234
233
|
*
|
|
235
234
|
* - `removebg`: remove the background; output is a transparent PNG
|
|
236
235
|
* of the foreground subject. Forces `format=png` regardless of
|
|
237
|
-
* other format hints.
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* from R2 — no inference, no per-image cost.
|
|
236
|
+
* other format hints. A single cache miss per (sha, dsl) tuple;
|
|
237
|
+
* subsequent identical DSLs serve from cache — no inference, no
|
|
238
|
+
* per-image cost.
|
|
241
239
|
*
|
|
242
|
-
* - `genfill`: aspect-extension outpaint
|
|
243
|
-
*
|
|
240
|
+
* - `genfill`: aspect-extension outpaint. Requires BOTH `width`
|
|
241
|
+
* and `height` — the server
|
|
244
242
|
* fits the source centered into the target canvas and outpaints
|
|
245
243
|
* the gutters. Output is PNG (forced) at exactly target dims.
|
|
246
|
-
* ~$0.05/image first time; same
|
|
244
|
+
* ~$0.05/image first time; same cache as removebg after.
|
|
247
245
|
* Primary use case: building OG cards (1200×630) from portrait
|
|
248
246
|
* listing photos without awkward edge mirroring.
|
|
249
247
|
*/
|
|
@@ -264,7 +262,7 @@ type TransformOptions = {
|
|
|
264
262
|
* the escape hatch for SIGNED URLs that need an off-ladder custom width.
|
|
265
263
|
*
|
|
266
264
|
* The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid
|
|
267
|
-
* `?sig=` earns the whitelist bypass at the
|
|
265
|
+
* `?sig=` earns the whitelist bypass at the edge (the server still
|
|
268
266
|
* does the real HMAC check). So a custom width is ONLY safe when the URL is
|
|
269
267
|
* signed — hence this type is accepted exclusively by the signing helpers
|
|
270
268
|
* ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),
|
|
@@ -305,8 +303,8 @@ declare function serializeTransform(opts: SignedTransformOptions): string;
|
|
|
305
303
|
* Build a transform URL for a VIDEO asset. Same DSL shape as image
|
|
306
304
|
* transforms; the server branches on the asset's `kind` column. Video
|
|
307
305
|
* URLs use `.mp4` (default) or `.webm` extension and on cache miss the
|
|
308
|
-
* server returns 202 Accepted while a
|
|
309
|
-
* subsequent GETs return 302 to the cached
|
|
306
|
+
* server returns 202 Accepted while a background job encodes the clip;
|
|
307
|
+
* subsequent GETs return 302 to the cached object.
|
|
310
308
|
*
|
|
311
309
|
* <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}
|
|
312
310
|
* autoPlay muted loop playsInline />
|
|
@@ -345,8 +343,8 @@ declare function getVideoTransformUrl(asset: Pick<AssetDTO, "sha">, opts: Transf
|
|
|
345
343
|
* }
|
|
346
344
|
* ```
|
|
347
345
|
*
|
|
348
|
-
* On first request the server returns 202 Accepted while a
|
|
349
|
-
*
|
|
346
|
+
* On first request the server returns 202 Accepted while a background
|
|
347
|
+
* job transcodes the ladder (typically 1-3 min for a 90 s source);
|
|
350
348
|
* subsequent requests get 302 to the cached master.m3u8. Keep the
|
|
351
349
|
* progressive MP4 as a fallback source for that window.
|
|
352
350
|
*
|
|
@@ -419,7 +417,7 @@ declare function getTransformSrcSet(asset: Pick<AssetDTO, "sha">, widths: number
|
|
|
419
417
|
* with vertical product photos.
|
|
420
418
|
*/
|
|
421
419
|
type VariantPreset = "thumb" | "sm" | "md" | "lg" | "xl" | "original" | "poster" | "video" | "aiproxy" | "mp3";
|
|
422
|
-
/** 1-char alias used in
|
|
420
|
+
/** 1-char alias used in storage keys / wire `presets` string. */
|
|
423
421
|
declare const PRESET_SHORT: Record<VariantPreset, string>;
|
|
424
422
|
declare const PRESET_LONG: Record<string, VariantPreset>;
|
|
425
423
|
/** Variant extension by preset. Image variants are always WebP, video MP4. */
|
|
@@ -435,11 +433,11 @@ type AssetVariant = {
|
|
|
435
433
|
preset: VariantPreset;
|
|
436
434
|
/** Public CDN URL of this variant. */
|
|
437
435
|
url: string;
|
|
438
|
-
/** Pixel width. Absent for `original`-only assets where
|
|
436
|
+
/** Pixel width. Absent for `original`-only assets where image processing was skipped, or for video presets. */
|
|
439
437
|
width?: number;
|
|
440
438
|
/** Pixel height. Same caveat as `width`. */
|
|
441
439
|
height?: number;
|
|
442
|
-
/** Byte size of the variant file
|
|
440
|
+
/** Byte size of the stored variant file. */
|
|
443
441
|
bytes: number;
|
|
444
442
|
/**
|
|
445
443
|
* Where the bytes for this variant came from. Useful for quality
|
package/dist/index.js
CHANGED
|
@@ -105,7 +105,7 @@ function getPaletteBlurBackground(palette) {
|
|
|
105
105
|
// src/slots.ts
|
|
106
106
|
var DEFAULT_TTL_MS = 6e4;
|
|
107
107
|
var cache = /* @__PURE__ */ new Map();
|
|
108
|
-
var endpoint = "https://
|
|
108
|
+
var endpoint = "https://api.nitida.gofuture.space";
|
|
109
109
|
var apiKey = null;
|
|
110
110
|
var tenantCode = null;
|
|
111
111
|
function configureSlotResolver(opts) {
|
|
@@ -336,7 +336,7 @@ var PRESET_SHORT = {
|
|
|
336
336
|
poster: "p",
|
|
337
337
|
video: "v",
|
|
338
338
|
aiproxy: "a",
|
|
339
|
-
// 3 chars, NOT a 1-char alias: the
|
|
339
|
+
// 3 chars, NOT a 1-char alias: the server has no short-form for
|
|
340
340
|
// audio so its `shortPreset("mp3")` falls through to the literal token,
|
|
341
341
|
// and the deployed server already writes the `-mp3.mp3` variant + emits
|
|
342
342
|
// the bare `mp3` token in the wire `presets` string. Must stay in lockstep.
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/palette.ts","../src/slots.ts","../src/transform.ts","../src/index.ts"],"sourcesContent":["/**\n * Color palette helpers — render harmonious ambient backgrounds behind\n * product images, inspired by Spotify Now Playing / Apple Music / Pico.\n *\n * Wire format is intentionally compact: only the hex per swatch, only the\n * swatches the source actually had. The full names map to 1-2 letter aliases\n * (`d` dominant, `v` vibrant, `m` muted, `dv` darkVibrant, `lv` lightVibrant,\n * `dm` darkMuted, `lm` lightMuted) to shave bytes for catalog-sized payloads\n * (palette was 62% of asset DTO before this).\n *\n * Population + RGB array + textColor are derivable client-side; we don't\n * ship them. textColor is computed via WCAG relative luminance on demand.\n */\n\n/** Compact wire shape for an asset's palette. All swatches optional except dominant. */\nexport type AssetPalette = {\n /** dominant hex (always present when palette exists) */\n d: string;\n /** vibrant */ v?: string;\n /** muted */ m?: string;\n /** darkVibrant */ dv?: string;\n /** lightVibrant */ lv?: string;\n /** darkMuted */ dm?: string;\n /** lightMuted */ lm?: string;\n};\n\n/** Backwards-compat alias for older callers that referenced PaletteSwatch. */\nexport type PaletteSwatch = { hex: string; textColor: \"#000000\" | \"#FFFFFF\" };\n\n/**\n * Resolve a palette key to its hex value if present.\n */\nfunction resolveSwatch(\n palette: AssetPalette | null | undefined,\n ...keys: (keyof AssetPalette)[]\n): string | null {\n if (!palette) return null;\n for (const k of keys) {\n const v = palette[k];\n if (v) return v;\n }\n return null;\n}\n\n/**\n * Pick the swatch best suited for an ambient surface behind the image.\n * Prefers muted/light tones — too vibrant a background fights the image.\n *\n * Order: lightMuted → muted → lightVibrant → dominant.\n */\nexport function pickAmbientBackground(\n palette: AssetPalette | null | undefined,\n): PaletteSwatch | null {\n const hex = resolveSwatch(palette, \"lm\", \"m\", \"lv\", \"d\");\n if (!hex) return null;\n return { hex, textColor: textColorForHex(hex) };\n}\n\n/**\n * Build a CSS linear-gradient from the palette. Useful for hero / detail\n * backgrounds.\n */\nexport function getAmbientGradient(\n palette: AssetPalette | null | undefined,\n opts: {\n angle?: string;\n from?: keyof AssetPalette;\n to?: keyof AssetPalette;\n } = {},\n): string | undefined {\n if (!palette) return undefined;\n const fromHex = palette[opts.from ?? \"lm\"] ?? palette.m ?? palette.d;\n const toHex = palette[opts.to ?? \"m\"] ?? palette.dm ?? palette.d;\n if (!fromHex || !toHex) return undefined;\n return `linear-gradient(${opts.angle ?? \"135deg\"}, ${fromHex}, ${toHex})`;\n}\n\n/**\n * Recommended text color (#000 or #FFF) for any background hex,\n * computed via WCAG relative luminance.\n */\nexport function getTextColorForBackground(\n swatch: PaletteSwatch | string | null | undefined,\n): string {\n if (!swatch) return \"#000000\";\n const hex = typeof swatch === \"string\" ? swatch : swatch.hex;\n return textColorForHex(hex);\n}\n\n/** Luminancia relativa WCAG de un hex. 0 = negro, 1 = blanco. */\nexport function relativeLuminance(hex: string): number {\n const h = hex.replace(\"#\", \"\");\n const r = Number.parseInt(h.slice(0, 2), 16) / 255;\n const g = Number.parseInt(h.slice(2, 4), 16) / 255;\n const b = Number.parseInt(h.slice(4, 6), 16) / 255;\n // sRGB → linear\n const lin = (c: number) =>\n c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\n}\n\n/**\n * Razón de contraste WCAG entre dos luminancias: `(L1 + 0.05) / (L2 + 0.05)`,\n * con la más clara arriba. 1 = idénticos, 21 = negro contra blanco.\n */\nexport function contrastRatio(l1: number, l2: number): number {\n const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];\n return (hi + 0.05) / (lo + 0.05);\n}\n\n/**\n * Negro o blanco, el que **de verdad** contraste más sobre este fondo.\n *\n * ⚠️ CORREGIDO 2026-08-17. Antes decidía con `L > 0.5`, y **0,5 no es el punto\n * de empate**. Igualando las dos razones de contraste:\n *\n * (1 + 0.05) / (L + 0.05) = (L + 0.05) / 0.05\n * (L + 0.05)² = 0.0525\n * L = 0.1791…\n *\n * Entre **0,179 y 0,5** el código viejo elegía BLANCO cuando negro contrastaba\n * más — una banda ancha, y justo donde caen los colores de marca saturados.\n * Medido sobre cuatro paletas reales de producción: **8 swatches** en esa banda.\n * El peor, el dorado dominante `#CA9528`: devolvía blanco a **2,68:1** cuando\n * negro da **7,84:1**. 2,68 no pasa AA ni para texto grande.\n *\n * No se cambia el umbral por 0,179: se **calculan las dos razones y gana la\n * mayor**. Un umbral es una constante que hay que mantener correcta; la\n * comparación es correcta por construcción.\n *\n * ⚠️ Esto elige el MEJOR de dos, no garantiza que alcance. Sobre un fondo de\n * luminancia media el mejor par puede quedar por debajo de 4,5:1 igual — para\n * eso está {@link bestTextContrast}, que además devuelve el número.\n */\nfunction textColorForHex(hex: string): \"#000000\" | \"#FFFFFF\" {\n return bestTextContrast(hex).color;\n}\n\n/**\n * Igual que el color recomendado, pero devuelve también **la razón lograda** y\n * si pasa AA — para que quien lo use pueda decidir con el número a la vista en\n * vez de asumir que alcanzó.\n */\nexport function bestTextContrast(hex: string): {\n color: \"#000000\" | \"#FFFFFF\";\n ratio: number;\n passesAA: boolean;\n passesAALarge: boolean;\n} {\n const L = relativeLuminance(hex);\n const onBlack = contrastRatio(L, 0);\n const onWhite = contrastRatio(L, 1);\n const useBlack = onBlack >= onWhite;\n const ratio = useBlack ? onBlack : onWhite;\n return {\n color: useBlack ? \"#000000\" : \"#FFFFFF\",\n ratio,\n passesAA: ratio >= 4.5,\n passesAALarge: ratio >= 3,\n };\n}\n\n/**\n * CSS variables for a wrapper so a subtree can read --asset-bg / --asset-fg /\n * --asset-dominant / --asset-vibrant / etc.\n */\nexport function getPaletteCssVars(\n palette: AssetPalette | null | undefined,\n): Record<string, string> {\n if (!palette) return {};\n const bg = pickAmbientBackground(palette);\n return {\n \"--asset-bg\": bg?.hex ?? \"transparent\",\n \"--asset-fg\": bg ? bg.textColor : \"#000000\",\n \"--asset-dominant\": palette.d,\n ...(palette.v && { \"--asset-vibrant\": palette.v }),\n ...(palette.m && { \"--asset-muted\": palette.m }),\n ...(palette.lv && { \"--asset-light-vibrant\": palette.lv }),\n ...(palette.dv && { \"--asset-dark-vibrant\": palette.dv }),\n ...(palette.lm && { \"--asset-light-muted\": palette.lm }),\n ...(palette.dm && { \"--asset-dark-muted\": palette.dm }),\n };\n}\n\n/**\n * Iterate the palette in display order (dominant first, then vibrant +\n * muted families). Useful for rendering a swatch strip in admin UIs.\n */\nexport function iteratePaletteSwatches(\n palette: AssetPalette | null | undefined,\n): Array<{ key: keyof AssetPalette; label: string; hex: string }> {\n if (!palette) return [];\n const order: Array<{ key: keyof AssetPalette; label: string }> = [\n { key: \"d\", label: \"dominant\" },\n { key: \"v\", label: \"vibrant\" },\n { key: \"lv\", label: \"lightVibrant\" },\n { key: \"dv\", label: \"darkVibrant\" },\n { key: \"m\", label: \"muted\" },\n { key: \"lm\", label: \"lightMuted\" },\n { key: \"dm\", label: \"darkMuted\" },\n ];\n return order\n .map(({ key, label }) => {\n const hex = palette[key];\n return hex ? { key, label, hex } : null;\n })\n .filter(\n (s): s is { key: keyof AssetPalette; label: string; hex: string } =>\n s != null,\n );\n}\n\n/**\n * Build a multi-radial-gradient CSS `background` string from the palette\n * swatches. Acts as a zero-extra-bytes alternative to the WebP LQIP: the\n * palette is already in the DTO, so this placeholder costs nothing extra\n * to ship. Renders as a smooth abstract \"color cloud\" reminiscent of the\n * source image's vibe.\n *\n * Strategy: anchor 4 radial gradients at fixed corners using vibrant/muted\n * pairs, layered over the dominant fill. Skips missing swatches gracefully.\n */\nexport function getPaletteBlurBackground(\n palette: AssetPalette | null | undefined,\n): string | undefined {\n if (!palette) return undefined;\n const corners: Array<{ pos: string; key: keyof AssetPalette }> = [\n { pos: \"20% 20%\", key: \"lv\" },\n { pos: \"80% 25%\", key: \"v\" },\n { pos: \"25% 80%\", key: \"lm\" },\n { pos: \"80% 80%\", key: \"dv\" },\n ];\n const layers = corners\n .map(({ pos, key }) => {\n const hex = palette[key];\n if (!hex) return null;\n return `radial-gradient(circle at ${pos}, ${hex} 0%, transparent 55%)`;\n })\n .filter(Boolean) as string[];\n // Fallback fill = dominant (or muted if dominant is missing — shouldn't happen)\n const base = palette.d ?? palette.m ?? \"#888\";\n return layers.length > 0 ? `${layers.join(\", \")}, ${base}` : base;\n}\n","/**\n * @nitida/asset-client/slots — slot resolver for tenant-named assets.\n *\n * Slots give tenants a way to attach stable, human-readable names\n * (\"webapp.wizard.pool-type.icon-1\", \"storefront.cr.hero-video.landscape_hd_16x9.mp4\")\n * to assets they uploaded. Consumers resolve names → AssetDTOs at\n * build / runtime so their source never hardcodes a CDN URL; the\n * admin rebinds a slot from `asset-lab-web` and every consumer picks\n * up the swap on cache refresh.\n *\n * Two layers in this package:\n * - `resolveSlot` / `resolveSlots` — universal (server, edge,\n * workers) fetch helpers. Cache 60s by default.\n * - React hooks live in `@nitida/asset-client/react/use-slot`\n * (kept out of this module so the SSR-safe core stays\n * dependency-free of react).\n */\n\nimport type { AssetDTO, VariantPreset } from \"./index\";\nimport { getAssetUrl, hasPreset } from \"./index\";\n\n// ---------------------------------------------------------------------------\n// Wire shape — matches `apps/asset-manager/src/features/assets/slots.routes.ts`\n// ---------------------------------------------------------------------------\n\nexport type SlotDTO = {\n slotKey: string;\n /** Preset hint set when the slot was bound (e.g. `thumb` for icon slots). */\n preset: VariantPreset | null;\n description: string | null;\n updatedAt: string;\n asset: AssetDTO;\n};\n\nexport type SlotResolution = {\n /** The resolved DTO (`null` when the slot is unbound or asset missing). */\n slot: SlotDTO | null;\n /**\n * Effective preset — what `url` below was built with. Resolution order:\n * 1. caller's `preset` override\n * 2. slot's `preset` hint\n * 3. `lg` for images, `video` for video kind\n */\n preset: VariantPreset;\n /** The CDN URL the consumer should use. */\n url: string | null;\n};\n\n// ---------------------------------------------------------------------------\n// Config\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_TTL_MS = 60_000;\nconst cache = new Map<string, { fetchedAt: number; value: SlotDTO | null }>();\n\n// Deliberately the generated `run.app` URL, NOT the branded\n// `api.nitida.gofuture.space` the docs publish — this is the fallback for a\n// caller that configured nothing, so it should be the address with the fewest\n// moving parts. Cloud Run guarantees the generated URL forever; the branded name\n// is an additional domain mapping that depends on DNS and a managed certificate\n// (and on 2026-08-17 we learned how many ways those two can go sideways —\n// doc 242 §6.3). Both names hit the same service and return identical bodies.\nlet endpoint = \"https://aquienpz-asset-manager-nlchzy26qa-uc.a.run.app\";\nlet apiKey: string | null = null;\nlet tenantCode: string | null = null;\n\n/**\n * Configure the resolver process-wide. Call once at boot from your\n * storefront layout / server entry / worker init.\n *\n * configureSlotResolver({\n * endpoint: process.env.AQUIENPZ_URL,\n * apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY,\n * tenantCode: \"realtyone-cr\",\n * });\n */\nexport function configureSlotResolver(opts: {\n endpoint?: string;\n apiKey?: string;\n tenantCode?: string;\n}): void {\n if (opts.endpoint) endpoint = opts.endpoint.replace(/\\/+$/, \"\");\n if (opts.apiKey !== undefined) apiKey = opts.apiKey;\n if (opts.tenantCode !== undefined) tenantCode = opts.tenantCode;\n}\n\n/** Wipe the in-process cache (test helper or forced refresh). */\nexport function invalidateSlotCache(slotKey?: string): void {\n if (slotKey === undefined) cache.clear();\n else\n for (const k of cache.keys())\n if (k.endsWith(`:${slotKey}`)) cache.delete(k);\n}\n\n// ---------------------------------------------------------------------------\n// Internal fetch helper\n// ---------------------------------------------------------------------------\n\nconst baseHeaders = (): Record<string, string> => {\n const h: Record<string, string> = {};\n if (apiKey) h.Authorization = `Bearer ${apiKey}`;\n if (tenantCode) h[\"X-Tenant-Code\"] = tenantCode;\n return h;\n};\n\nasync function fetchSlot(slotKey: string): Promise<SlotDTO | null> {\n const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {\n headers: baseHeaders(),\n });\n if (r.status === 404) return null;\n if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);\n return (await r.json()) as SlotDTO;\n}\n\nasync function fetchSlotsBulk(\n slotKeys: string[],\n): Promise<Record<string, SlotDTO | null>> {\n if (slotKeys.length === 0) return {};\n const r = await fetch(`${endpoint}/slots/resolve`, {\n method: \"POST\",\n headers: { ...baseHeaders(), \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ keys: slotKeys }),\n });\n if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);\n const body = (await r.json()) as { resolved: Record<string, SlotDTO | null> };\n return body.resolved;\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport type ResolveSlotOptions = {\n /** Override preset (caller knows the use case better than the slot binding). */\n preset?: VariantPreset;\n /** TTL for the in-process cache. Default 60s. Set 0 to bypass. */\n ttlMs?: number;\n};\n\n/**\n * Resolve a single slot to a CDN URL. Returns `{slot: null, url: null}`\n * when the slot is unbound — callers fall back to a placeholder.\n *\n * Cached for `ttlMs` (default 60s). Slot rebindings propagate within the\n * TTL window without an app restart.\n */\nexport async function resolveSlot(\n slotKey: string,\n opts: ResolveSlotOptions = {},\n): Promise<SlotResolution> {\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const cacheKey = `${tenantCode ?? \"_\"}:${slotKey}`;\n const now = Date.now();\n let dto: SlotDTO | null;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n dto = hit.value;\n } else {\n dto = await fetchSlot(slotKey);\n cache.set(cacheKey, { fetchedAt: now, value: dto });\n }\n return materializeResolution(dto, opts.preset);\n}\n\n/**\n * Bulk-resolve N slot keys in one round-trip. The SDK's `useSlots`\n * React hook calls this so every storefront header (logo + tagline +\n * nav cover + …) loads as one request.\n */\nexport async function resolveSlots(\n slotKeys: string[],\n opts: ResolveSlotOptions = {},\n): Promise<Record<string, SlotResolution>> {\n if (slotKeys.length === 0) return {};\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const now = Date.now();\n const missing: string[] = [];\n const out: Record<string, SlotResolution> = {};\n for (const k of slotKeys) {\n const cacheKey = `${tenantCode ?? \"_\"}:${k}`;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n out[k] = materializeResolution(hit.value, opts.preset);\n } else {\n missing.push(k);\n }\n }\n if (missing.length > 0) {\n const resolved = await fetchSlotsBulk(missing);\n for (const k of missing) {\n const dto = resolved[k] ?? null;\n cache.set(`${tenantCode ?? \"_\"}:${k}`, { fetchedAt: now, value: dto });\n out[k] = materializeResolution(dto, opts.preset);\n }\n }\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction defaultPresetFor(asset: AssetDTO | undefined): VariantPreset {\n if (!asset) return \"lg\";\n return asset.kind === \"video\" ? \"video\" : \"lg\";\n}\n\nfunction materializeResolution(\n dto: SlotDTO | null,\n overridePreset?: VariantPreset,\n): SlotResolution {\n if (!dto) return { slot: null, preset: overridePreset ?? \"lg\", url: null };\n const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);\n // Fall back to \"lg\" when the bound preset doesn't exist on the asset\n // (e.g. slot was bound to a video but caller asked for a thumb).\n const finalPreset = hasPreset(dto.asset, effective)\n ? effective\n : defaultPresetFor(dto.asset);\n return {\n slot: dto,\n preset: finalPreset,\n url: getAssetUrl(dto.asset, finalPreset),\n };\n}\n","/**\n * On-the-fly transform URL builder.\n *\n * Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated\n * here hashes to the same R2 cache key as the server's canonical form.\n *\n * Canonicalization rules (keep in sync with\n * `apps/asset-manager/src/features/assets/transform.dsl.ts`):\n * - Drop entries whose value is `undefined`\n * - Sort keys alphabetically\n * - Numbers rendered without leading zeros or trailing dots\n * - String values lowercased\n *\n * URL shape:\n * <cdnBase>/t/<dsl>/<sha>.<ext>\n *\n * The `.ext` is informational (browser content-sniff hint); the server\n * decides the actual output format from the DSL `format` param + the\n * request's Accept header.\n */\n\nimport type { AssetDTO } from \"./index\";\nimport { getCdnBase } from \"./index\";\n\n/**\n * Widths the CDN edge whitelists (DoS guard — see `apps/cdn-proxy` WHITELIST_WIDTHS).\n * Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the\n * 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import\n * this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.\n */\nexport const TRANSFORM_WIDTHS = [\n 96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280,\n 1440, 1600, 1920, 2560, 3840,\n] as const;\n/**\n * A CDN-whitelisted transform width — the only widths `TransformOptions.width`\n * accepts. Off-ladder widths are a compile error; for signed URLs that need a\n * custom width, use {@link SignedTransformOptions} (number) via\n * {@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`.\n */\nexport type TransformWidth = (typeof TRANSFORM_WIDTHS)[number];\n\nexport type TransformFit = \"cover\" | \"contain\" | \"fill\" | \"inside\" | \"outside\";\nexport type TransformGravity =\n | \"auto\"\n | \"face\"\n | \"center\"\n | \"north\"\n | \"south\"\n | \"east\"\n | \"west\";\nexport type TransformFormat =\n | \"auto\"\n | \"avif\"\n | \"webp\"\n | \"jpeg\"\n | \"png\"\n // Video-only formats (Phase 4). The image path ignores them.\n | \"mp4\"\n | \"webm\"\n // HLS adaptive ladder (Phase 5). Video-only. Output is a directory\n // of m3u8 + .ts segments fronted by master.m3u8; the SDK returns\n // the master URL via `getHlsStreamingUrl`.\n | \"hls\";\nexport type TransformEffect = \"removebg\" | \"genfill\";\n\nexport type TransformOptions = {\n /**\n * Target max-side width in CSS pixels (multiplied by `dpr` server-side).\n * MUST be a {@link TRANSFORM_WIDTHS} value — off-ladder widths are rejected\n * (HTTP 400) by the edge whitelist for unsigned URLs, so the type forbids\n * them at compile time. For SIGNED URLs with a custom width, use\n * {@link SignedTransformOptions} (which widens this to `number`).\n */\n width?: TransformWidth;\n /** Target max-side height. Multiplied by `dpr` server-side. */\n height?: number;\n /** Resize fit mode. Default `cover` server-side. */\n fit?: TransformFit;\n /** Crop gravity. `auto` uses sharp's `attention` strategy. */\n gravity?: TransformGravity;\n /** Output format. `auto` → policy decides (see asset-manager bench). */\n format?: TransformFormat;\n /** Output quality. `auto` → format-specific default. */\n quality?: \"auto\" | number;\n /** Device pixel ratio. Width/height are multiplied by this before resize. */\n dpr?: 1 | 2 | 3;\n /**\n * AI effect applied before resize/encode.\n *\n * - `removebg`: remove the background; output is a transparent PNG\n * of the foreground subject. Forces `format=png` regardless of\n * other format hints. Runs U²-Net ONNX locally (or BRIA via\n * Replicate when `BG_REMOVAL_BACKEND=replicate`). Single cache\n * miss per (sha, dsl) tuple; subsequent identical DSLs serve\n * from R2 — no inference, no per-image cost.\n *\n * - `genfill`: aspect-extension outpaint via Flux-Fill Pro on\n * Replicate. Requires BOTH `width` and `height` — the server\n * fits the source centered into the target canvas and outpaints\n * the gutters. Output is PNG (forced) at exactly target dims.\n * ~$0.05/image first time; same R2 cache as removebg after.\n * Primary use case: building OG cards (1200×630) from portrait\n * listing photos without awkward edge mirroring.\n */\n effect?: TransformEffect;\n /**\n * Video-only: clip start in seconds. Image transforms ignore.\n * Accepts decimals (e.g. 1.5 for sub-second seek).\n */\n start?: number;\n /**\n * Video-only: clip duration in seconds (1..300). Image transforms\n * ignore. With `start`, lets a single request grab a sub-clip.\n */\n duration?: number;\n};\n\n/**\n * Like {@link TransformOptions} but with `width` widened to any `number` —\n * the escape hatch for SIGNED URLs that need an off-ladder custom width.\n *\n * The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid\n * `?sig=` earns the whitelist bypass at the worker (the asset-manager still\n * does the real HMAC check). So a custom width is ONLY safe when the URL is\n * signed — hence this type is accepted exclusively by the signing helpers\n * ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),\n * never by the plain unsigned {@link getTransformUrl}.\n */\nexport type SignedTransformOptions = Omit<TransformOptions, \"width\"> & {\n /** Off-ladder width — valid ONLY on signed URLs (edge whitelist bypass). */\n width?: number;\n};\n\n/**\n * Serialize transform options into the canonical DSL path segment.\n * Empty options return an empty string (caller should fall back to a\n * variant URL instead of a transform URL in that case).\n *\n * Accepts {@link SignedTransformOptions} (the wider type) so it also covers\n * custom-width signed URLs; {@link TransformOptions} is assignable to it.\n */\n/**\n * Extract the 16-hex short sha from an aquienpz CDN URL, regardless of\n * shape:\n * - tenant-prefixed variant: `https://8ok.uk/4/v/<sha16>-<preset>.<ext>`\n * - legacy variant: `https://8ok.uk/<sha16>-<preset>.<ext>`\n * - on-the-fly transform: `https://8ok.uk/t/<dsl>/<sha16>.<ext>`\n * - streaming HLS: `https://8ok.uk/t/format=hls/<sha16>.m3u8`\n *\n * Returns `null` for non-aquienpz URLs (pexels, googleusercontent, raw\n * uploaded URLs to other CDNs) so call sites can fall back to the URL\n * with a plain `<img>` instead of generating a broken transform URL.\n *\n * Useful when a value reaches the component as a pre-built URL string\n * (legacy data, site-config JSON, third-party feeds) but you want to\n * drop in `getTransformSrcSet` for the responsive ladder if it happens\n * to be an aquienpz asset.\n */\nexport function extractAssetSha(url: string | null | undefined): string | null {\n if (!url) return null;\n // Match the canonical aquienpz sha pattern: 16 lowercase hex chars\n // appearing as a path segment, optionally followed by `-<preset>`\n // (variant URL) or `.<ext>` (transform URL).\n const m = url.match(/\\/([0-9a-f]{16})(?:[-.]|$)/);\n return m ? m[1]! : null;\n}\n\nexport function serializeTransform(opts: SignedTransformOptions): string {\n const entries: Array<[string, string]> = [];\n const keys = Object.keys(opts).sort() as Array<keyof SignedTransformOptions>;\n for (const k of keys) {\n const v = opts[k];\n if (v == null) continue;\n const serialized = typeof v === \"string\" ? v.toLowerCase() : String(v);\n entries.push([k, serialized]);\n }\n return entries.map(([k, v]) => `${k}=${v}`).join(\",\");\n}\n\nfunction extForOptions(opts: SignedTransformOptions): string {\n // effect=removebg forces PNG output server-side (needs alpha).\n if (opts.effect === \"removebg\") return \"png\";\n // effect=genfill defaults to WebP (12× lighter than the raw Flux-Fill\n // PNG output with no visible loss at q=85). Explicit `format=png`\n // opts back into lossless for print / marketing fold-outs. The server\n // re-encodes Flux's PNG → target format before R2 cache.\n if (opts.effect === \"genfill\") {\n switch (opts.format) {\n case \"png\":\n return \"png\";\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n default:\n return \"webp\";\n }\n }\n switch (opts.format) {\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n case \"png\":\n return \"png\";\n case \"mp4\":\n return \"mp4\";\n case \"webm\":\n return \"webm\";\n case \"hls\":\n // HLS uses `.m3u8` as the URL extension; the route resolves the\n // master playlist under the cache key prefix.\n return \"m3u8\";\n default:\n // \"webp\" / \"auto\" / undefined / anything new\n return \"webp\";\n }\n}\n\n/**\n * Build a transform URL for a VIDEO asset. Same DSL shape as image\n * transforms; the server branches on the asset's `kind` column. Video\n * URLs use `.mp4` (default) or `.webm` extension and on cache miss the\n * server returns 202 Accepted while a Cloud Run Job encodes the clip;\n * subsequent GETs return 302 to the cached R2 object.\n *\n * <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}\n * autoPlay muted loop playsInline />\n */\nexport function getVideoTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: TransformOptions,\n): string | null {\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = opts.format === \"webm\" ? \"webm\" : \"mp4\";\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\n/**\n * Build an HLS streaming URL for a VIDEO asset (Phase 5). Returns the\n * master.m3u8 entry point — HLS-aware players (Video.js's\n * @videojs/http-streaming, hls.js, native iOS Safari) follow it to\n * fetch the variant playlist + segments at the appropriate bitrate\n * for the connection.\n *\n * ⚠️ A master.m3u8 is NOT a video file. Assigning it to `<video src>`\n * works only where the engine has native HLS; everywhere else it needs\n * an MSE player. And the classic feature test is now WRONG: Chrome 147\n * (April 2026) added native HLS, so `canPlayType(\"application/vnd.apple.mpegurl\")`\n * answers \"maybe\" there and routes Chrome to the native branch, where it\n * opened a measured 17 s hero at 426x240 for ~8 s. Branch on the ENGINE:\n *\n * @example\n * ```ts\n * function prefersNativeHls(video: HTMLVideoElement): boolean {\n * if (video.canPlayType(\"application/vnd.apple.mpegurl\") === \"\") return false;\n * // Apple's engine, or an engine with no MSE to fall back on (iOS < 17.1).\n * return \"ManagedMediaSource\" in globalThis || !(\"MediaSource\" in globalThis);\n * }\n *\n * const src = getHlsStreamingUrl(asset);\n * if (prefersNativeHls(video)) {\n * video.src = src;\n * } else {\n * // Defaults open at a fixed low rung — measure instead of guessing.\n * const hls = new Hls({ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 });\n * hls.loadSource(src);\n * hls.attachMedia(video);\n * }\n * ```\n *\n * On first request the server returns 202 Accepted while a Cloud Run\n * Job transcodes the ladder (typically 1-3 min for a 90 s source);\n * subsequent requests get 302 to the cached master.m3u8. Keep the\n * progressive MP4 as a fallback source for that window.\n *\n * The ladder's ceiling is the source the job probes: built at ingest it\n * reads the RAW upload and a 4K master yields 1440p/2160p rungs; rebuilt\n * on demand after the raw is unavailable it reads the `-v.mp4`, which is\n * capped at 1920 wide. `getAssetUrl(sha, \"video\")` is always <= 1080p.\n */\nexport function getHlsStreamingUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: Omit<TransformOptions, \"format\"> = {},\n): string {\n // Always serialize with format=hls so the server routes correctly.\n const merged: TransformOptions = { ...opts, format: \"hls\" };\n const dsl = serializeTransform(merged);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;\n}\n\n/**\n * Build a transform URL. Returns null when the caller passed no options —\n * callers should prefer the existing variant URL builder in that case so\n * the request hits a pre-generated variant instead of an on-the-fly encode.\n */\nfunction buildTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n): string | null {\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = extForOptions(opts);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\nexport function getTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: TransformOptions,\n): string | null {\n return buildTransformUrl(asset, opts);\n}\n\n/**\n * Build AND sign a transform URL, allowing an off-ladder custom `width`.\n *\n * This is the escape hatch for {@link SignedTransformOptions}: off-ladder\n * widths only pass the edge whitelist when the URL is signed, so building one\n * and signing it must happen together. For on-ladder widths prefer the plain\n * {@link getTransformUrl} (+ {@link signTransformUrl} if you need a signature).\n *\n * Returns `null` only when `opts` serialize to an empty DSL (no transform\n * requested) — same contract as {@link getTransformUrl}.\n */\nexport function getSignedTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n signingKey: string,\n): Promise<string> | null {\n const url = buildTransformUrl(asset, opts);\n if (!url) return null;\n return signTransformUrl(url, signingKey);\n}\n\n/**\n * Sign a transform URL with the tenant's HMAC signing key. Appends\n * `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).\n *\n * Must agree byte-for-byte with the server's `verifyTransformSignature`.\n * Uses WebCrypto, so works in browsers, Node ≥ 16, Bun, and Workers.\n *\n * The canonical DSL is the one already produced by `serializeTransform`\n * (sort keys + lowercase strings), so signing a URL built by `getTransformUrl`\n * is automatic — the same canonical form is in the URL path.\n */\nexport async function signTransformUrl(\n unsignedUrl: string,\n signingKey: string,\n): Promise<string> {\n const u = new URL(unsignedUrl);\n // Path shape: /t/<dsl>/<filename>\n const parts = u.pathname.split(\"/\").filter(Boolean);\n // First segment must be \"t\"; the rest is dsl groups + filename. With\n // Phase 1 we ship a single DSL group; chained groups stay flat for\n // signing purposes (server canonicalizer flattens them too).\n if (parts[0] !== \"t\" || parts.length < 3) {\n throw new Error(`signTransformUrl: unexpected URL shape ${unsignedUrl}`);\n }\n const filename = parts[parts.length - 1]!;\n const dsl = parts.slice(1, -1).join(\"/\");\n const message = `${dsl}/${filename}`;\n const sig = await hmacSha256Hex(signingKey, message);\n u.searchParams.set(\"sig\", sig);\n return u.toString();\n}\n\nasync function hmacSha256Hex(key: string, message: string): Promise<string> {\n const enc = new TextEncoder();\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const buf = await crypto.subtle.sign(\"HMAC\", cryptoKey, enc.encode(message));\n return [...new Uint8Array(buf)]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\n/**\n * Build a responsive `srcSet` string by generating one transform URL per\n * width. All other options apply to every URL.\n *\n * <img\n * src={aq.transform(asset, { width: 800 })!}\n * srcSet={aq.transformSrcSet(asset, [320, 640, 960, 1280])}\n * sizes=\"(max-width: 768px) 100vw, 50vw\"\n * />\n */\nexport function getTransformSrcSet(\n asset: Pick<AssetDTO, \"sha\">,\n widths: number[],\n extraOpts: Omit<TransformOptions, \"width\"> = {},\n): string {\n return widths\n .map((w) => {\n // Build via the internal (number-width) builder: `widths` is an explicit\n // responsive ladder the caller chose, so it stays `number[]`. Off-ladder\n // unsigned widths 400 at the edge — the caller's responsibility, exactly\n // as before the width type was tightened.\n const url = buildTransformUrl(asset, { ...extraOpts, width: w });\n return url ? `${url} ${w}w` : null;\n })\n .filter((s): s is string => s != null)\n .join(\", \");\n}\n","/**\n * @nitida/asset-client — read helpers for asset URLs.\n *\n * Universal CDN model (May 2026): the API ships a compact AssetDTO with a\n * 16-char SHA prefix + a `presets` string of 1-char codes; client helpers\n * construct CDN URLs deterministically from `(cdnBase, sha, preset, ext)`.\n *\n * Why: catalog sync over Electric SSE shipped 4 nearly-identical full URLs\n * per asset × hundreds of thousands of assets per snapshot. Sending only\n * what differs — sha + presets bitmap — collapses ~600 bytes per asset to\n * ~70 (88% reduction).\n *\n * No runtime dependencies — pure types + pure functions. Safe everywhere.\n * @module @nitida/asset-client\n */\n\n/**\n * Image presets are size-based (Vercel `next/image` style):\n * - `thumb` is the only square crop — semantic icon use case\n * - `sm/md/lg` are max-side bounding boxes that preserve aspect ratio\n *\n * Naming over the legacy `thumbnail/cover/web/hero` because the new names\n * say what the variant IS (a size class) rather than what it might be\n * USED for, removing the implicit landscape-only assumption that bit us\n * with vertical product photos.\n */\nexport type VariantPreset =\n // image presets\n | \"thumb\" // 256x256 square smart-crop (icon)\n | \"sm\" // 640 max-side\n | \"md\" // 1280 max-side\n | \"lg\" // 1920 max-side\n | \"xl\" // 3840 max-side (4K) — OPT-IN; not generated by default\n // passthrough for non-image kinds (PDF etc.)\n | \"original\"\n // video presets — semantic (not size classes)\n | \"poster\"\n | \"video\"\n | \"aiproxy\"\n // audio preset — the cross-browser mp3 transcode of a voice note\n // (libmp3lame) emitted alongside the original so chat audio plays on\n // both Chrome/Android (webm/opus) and iOS Safari (which can't decode opus).\n | \"mp3\";\n\n/** 1-char alias used in R2 keys / wire `presets` string. */\nexport const PRESET_SHORT: Record<VariantPreset, string> = {\n thumb: \"q\",\n sm: \"s\",\n md: \"m\",\n lg: \"l\",\n xl: \"x\",\n original: \"o\",\n poster: \"p\",\n video: \"v\",\n aiproxy: \"a\",\n // 3 chars, NOT a 1-char alias: the asset-manager has no short-form for\n // audio so its `shortPreset(\"mp3\")` falls through to the literal token,\n // and the deployed server already writes the `-mp3.mp3` variant + emits\n // the bare `mp3` token in the wire `presets` string. Must stay in lockstep.\n mp3: \"mp3\",\n};\nexport const PRESET_LONG: Record<string, VariantPreset> = Object.fromEntries(\n Object.entries(PRESET_SHORT).map(([k, v]) => [v, k as VariantPreset]),\n);\n\n/** Variant extension by preset. Image variants are always WebP, video MP4. */\nexport const PRESET_EXT: Record<VariantPreset, string> = {\n thumb: \"webp\",\n sm: \"webp\",\n md: \"webp\",\n lg: \"webp\",\n xl: \"webp\",\n original: \"bin\", // overridden per-asset via mime when needed\n poster: \"webp\",\n video: \"mp4\",\n aiproxy: \"mp4\",\n mp3: \"mp3\",\n};\n\n/** Max-side dimension by preset; null for video / passthrough. */\nexport const PRESET_MAX_DIM: Record<VariantPreset, number | null> = {\n thumb: 256,\n sm: 640,\n md: 1280,\n lg: 1920,\n xl: 3840,\n original: null,\n poster: null,\n video: null,\n aiproxy: null,\n // audio has no pixel dimensions; `null` keeps mp3 out of the\n // dimension-based `getAssetSrcSet` / `computeVariantDimensions` logic.\n mp3: null,\n};\n\n/**\n * One generated variant of an asset. Returned by the admin endpoints\n * (`GET /assets/:id`, `POST /assets/:id/regenerate`).\n */\nexport type AssetVariant = {\n /** Long name (`thumb` / `sm` / … / `original`) — see {@link VariantPreset}. */\n preset: VariantPreset;\n /** Public CDN URL of this variant. */\n url: string;\n /** Pixel width. Absent for `original`-only assets where sharp was skipped, or for video presets. */\n width?: number;\n /** Pixel height. Same caveat as `width`. */\n height?: number;\n /** Byte size of the variant file on R2. */\n bytes: number;\n /**\n * Where the bytes for this variant came from. Useful for quality\n * traceability — a `thumb` with `sourceFrom: \"original\"` is the\n * canonical case, while `sourceFrom: \"lg\"` means it was derived\n * from an already-encoded WebP (slight quality compounding).\n *\n * - `\"upload\"` → first-write at `/assets/process`. The bytes came\n * straight from the client's PUT.\n * - `VariantPreset` → regenerated from that preset's variant.\n *\n * Absent on variants written before the trace field existed.\n */\n sourceFrom?: VariantPreset | \"upload\";\n /** ISO timestamp this variant was written. Absent on pre-trace variants. */\n createdAt?: string;\n};\n\n/**\n * Compact wire shape — what the server actually sends. Aliases (`w`, `h`,\n * `dur`) are intentional to shave bytes per asset on dense lists.\n */\nexport type AssetDTO = {\n id: string;\n /** First 16 hex chars of sha256 — used to derive CDN URLs. */\n sha: string;\n kind: \"image\" | \"video\" | \"document\" | \"audio\" | \"other\";\n mime: string;\n bytes: number;\n /** Source dims (for aspect-ratio calc on the client). Optional for non-images. */\n w?: number | null;\n h?: number | null;\n /** Duration ms for videos. */\n dur?: number | null;\n /** LQIP placeholder (data URL). */\n blur?: string | null;\n palette?: AssetPalette | null;\n /**\n * Compact list of generated variants as their 1-char codes\n * concatenated, e.g. \"tcwh\" (image) / \"pv\" (video without aiproxy).\n * Ordered by ascending dimension.\n */\n presets: string;\n status: \"processing\" | \"ready\" | \"failed\";\n /** Soft-delete timestamp (ISO). Hidden from catalog when set. */\n deletedAt?: string | null;\n /**\n * Full variant list with URLs + sizes. Sent by `GET /assets/:id`; absent on\n * the slim list shape used by the resolver / catalog. Use `presets` for\n * compact existence checks, and this when you need the actual URLs.\n */\n variants?: AssetVariant[];\n /**\n * The extension the `original` variant was really stored under — the server\n * keys it off the uploaded filename, so it cannot be derived from `mime`.\n * Sent by `GET /assets/:id`; `null` when the asset has no original.\n * {@link getAssetUrl} uses it automatically when you pass the whole DTO.\n */\n oext?: string | null;\n};\n\nexport type {\n AssetPalette,\n PaletteSwatch,\n} from \"./palette\";\n\nexport {\n bestTextContrast,\n contrastRatio,\n getAmbientGradient,\n getPaletteBlurBackground,\n getPaletteCssVars,\n getTextColorForBackground,\n iteratePaletteSwatches,\n relativeLuminance,\n pickAmbientBackground,\n} from \"./palette\";\n\nimport type { AssetPalette } from \"./palette\";\n\n// ---------------------------------------------------------------------------\n// CDN base\n// ---------------------------------------------------------------------------\n\nlet cdnBaseUrl = \"https://8ok.uk\";\n/**\n * Override the CDN base for the entire process (e.g. in tests, or when\n * pointing at a tenant-specific CDN). Storefront layouts call this once at\n * boot.\n */\nexport function setCdnBase(url: string): void {\n cdnBaseUrl = url.replace(/\\/$/, \"\");\n}\nexport function getCdnBase(): string {\n return cdnBaseUrl;\n}\n\n// ---------------------------------------------------------------------------\n// Tenant scope\n//\n// Post-May-2026 the CDN serves variants under a tenant-prefixed path\n// `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see asset-manager\n// `variantKey`). Variant URL builders MUST include that prefix or every\n// URL 404s. The tenant id is process-global (one tenant per client/app),\n// set once at boot — `NitidaClient` does this from its `tenantId` option;\n// standalone consumers call `setTenantId()` directly. Left unset, builders\n// fall back to the legacy pre-cutover bare path for back-compat.\n// ---------------------------------------------------------------------------\n\nlet tenantId: number | null = null;\n/** Set the process-global tenant id used to build tenant-prefixed CDN URLs. */\nexport function setTenantId(id: number | null | undefined): void {\n tenantId =\n typeof id === \"number\" && Number.isFinite(id) && id > 0 ? id : null;\n}\nexport function getTenantId(): number | null {\n return tenantId;\n}\n/** Variant path prefix `<tid b36>/v/`, or \"\" when no tenant is configured. */\nfunction variantPrefix(): string {\n return tenantId != null ? `${tenantId.toString(36)}/v/` : \"\";\n}\n\n// ---------------------------------------------------------------------------\n// URL builders\n// ---------------------------------------------------------------------------\n\n/**\n * LAST-RESORT guess at the ORIGINAL variant's extension, from the asset's mime.\n *\n * ⚠️ This is a guess. Prefer `oext` or `variants` (see {@link getAssetUrl}) — the\n * server sends both and they ARE the key.\n *\n * The server derives the extension from the MIME with the `mime-types` package\n * (`mime.extension(body.mime)`, at presign). So a table that matched that one\n * exactly would usually be right — and this table did not: it said\n * `image/jpeg` → `jpeg` while `mime-types` says `jpg`, under a comment claiming\n * to mirror it. Usually, but not always: the row's stored `mime` is not always\n * the mime the key was built from, so no client-side table can close the gap.\n *\n * Measured against the 2 001 stored originals in production, 2026-08-17: **all\n * 420 `image/jpeg` originals are stored `.jpg` and none `.jpeg`** — the old\n * entry here 404'd on every single JPEG. And 234 originals carry\n * `application/octet-stream` (`.mpga`, `.docx`, `.m4a`), where no mime table can\n * produce the right key at all — those need `oext`.\n */\nconst ORIGINAL_EXT_BY_MIME: Record<string, string> = {\n \"image/png\": \"png\",\n \"image/jpeg\": \"jpg\",\n \"image/webp\": \"webp\",\n \"image/gif\": \"gif\",\n \"image/avif\": \"avif\",\n \"image/svg+xml\": \"svg\",\n \"image/heic\": \"heic\",\n \"image/heif\": \"heif\",\n \"image/bmp\": \"bmp\",\n \"image/tiff\": \"tiff\",\n \"application/pdf\": \"pdf\",\n \"video/mp4\": \"mp4\",\n \"video/webm\": \"webm\",\n \"video/quicktime\": \"mov\",\n // Audio — ausentes hasta 2026-08-17, y su ausencia costó un rodeo entero en\n // neo (`withRealOriginalExt`), que existe SÓLO porque esta tabla devolvía el\n // centinela `bin` para toda nota de voz. Medido en producción: `-o.bin` da\n // 404 y `-o.m4a` da 200.\n //\n // ⚠️ Se keyean por el mime COMPLETO, no por el subtipo: `audio/mp4` guarda\n // `.m4a` y `video/mp4` guarda `.mp4`. Un `switch` sobre el subtipo `mp4` no\n // puede distinguirlos — es el error que un consumidor cometió y tuvo que\n // corregir por su cuenta.\n \"audio/mpeg\": \"mpga\",\n \"audio/mp4\": \"m4a\",\n \"audio/x-m4a\": \"m4a\",\n \"audio/wav\": \"wav\",\n \"audio/webm\": \"weba\",\n \"audio/ogg\": \"oga\",\n \"audio/aac\": \"adts\",\n};\nfunction originalExtForMime(mime: string | undefined): string {\n return (mime ? ORIGINAL_EXT_BY_MIME[mime] : undefined) ?? PRESET_EXT.original;\n}\n\n/** What the asset itself knows about where its `original` lives. */\ntype OriginalHints = {\n mime?: string;\n /** The extension the server actually stored it under. Authoritative. */\n oext?: string | null;\n /** Full variant list — carries the stored URL verbatim. Authoritative. */\n variants?: AssetVariant[];\n};\n\n/**\n * Build the public CDN URL for a specific variant of an asset. The variant\n * may not actually exist (regenerate may not have run, or video has no\n * `aiproxy`); call `hasPreset()` first or expect a 404.\n *\n * For the `original` preset, pass the whole `AssetDTO` — it carries `variants`\n * and `oext`, either of which gives the EXACT stored key. `mime` alone is only a\n * guess (the server keys the original off the uploaded filename), and with\n * nothing at all the original falls back to the `\"bin\"` sentinel, which 404s.\n *\n * @example Video — the tenant segment is base36, so let this build the path\n * ```ts\n * import { getAssetUrl, setTenantId } from \"@nitida/asset-client\";\n *\n * setTenantId(12); // 12 → \"c\"; a decimal \"/12/v/\" 404s\n * getAssetUrl({ sha }, \"video\"); // → https://8ok.uk/c/v/<sha16>-v.mp4\n * getAssetUrl({ sha }, \"poster\"); // → https://8ok.uk/c/v/<sha16>-p.webp\n * ```\n *\n * @example The `original` — pass the DTO, not just the sha\n * ```ts\n * const asset = await aq.assets.get(id);\n *\n * getAssetUrl({ sha }, \"original\"); // → …-o.bin ❌ 404, always\n * getAssetUrl({ sha, mime }, \"original\"); // → a guess from the mime table\n * getAssetUrl(asset, \"original\"); // ✓ the stored key, verbatim\n * ```\n *\n * @example Check before you link\n * ```ts\n * import { getAssetUrl, hasPreset } from \"@nitida/asset-client\";\n * const url = hasPreset(asset, \"thumb\") ? getAssetUrl(asset, \"thumb\") : null;\n * ```\n */\nexport function getAssetUrl(\n asset: Pick<AssetDTO, \"sha\"> & OriginalHints,\n preset: VariantPreset,\n): string {\n if (preset === \"original\") {\n // The stored URL beats every derivation, because it IS the key. Only fall\n // through to a guess when the caller gave us the sha and nothing else.\n const stored = asset.variants?.find((v) => v.preset === \"original\")?.url;\n if (stored) return stored;\n const ext = asset.oext || originalExtForMime(asset.mime);\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT.original}.${ext}`;\n }\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;\n}\n\n/**\n * Did the processor actually generate this preset?\n *\n * Reads `dto.presets` — the compact 1-char code string the server sends on EVERY shape, including\n * the slim list/resolver one that carries no `variants` at all. That is why this exists and why it\n * stays the right existence check even now that `GET /assets/:id` really does send `variants`\n * (it did not until 2026-08-17; doc 240 §4.3).\n *\n * @example\n * ```ts\n * import { hasPreset } from \"@nitida/asset-client\";\n *\n * hasPreset({ presets: \"oq\" }, \"original\"); // → true (\"o\" = original, \"q\" = thumb)\n * hasPreset({ presets: \"oq\" }, \"lg\"); // → false — do not link to it\n * hasPreset({ presets: \"pv\" }, \"aiproxy\"); // → false — video without the AI proxy\n * ```\n */\nexport function hasPreset(\n asset: Pick<AssetDTO, \"presets\">,\n preset: VariantPreset,\n): boolean {\n if (preset === \"mp3\") return asset.presets.includes(\"mp3\");\n return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);\n}\n\n/**\n * Remove every MULTI-character token from a `presets` string, leaving only the\n * 1-char codes that a `.includes` can safely be run against.\n *\n * `presets` is documented as a concatenation of 1-char codes, and membership is\n * a 1-char substring test — so any longer token is a false-positive generator.\n * Servers before the 2026-08-17 deploy emitted several (doc 240 §4.3b):\n *\n * | token in the string | letters it donates | presets it falsely answers |\n * |---|---|---|\n * | `transform-<hex>` | t r a n s f o m + a–f | `sm` `md` `original` `aiproxy` |\n * | `pr` (probe) | p r | `poster` |\n * | `mp3` (audio) | m p | `md` `poster` — stripped here since forever |\n *\n * Measured against production: **62 % of live assets** carried a polluted\n * string, and **16 299 of them were told they have an `original` they do not**\n * — which builds a `-o.<ext>` URL that 404s. (`aiproxy`: 16 479. `sm`/`md`: 487.\n * The `pr` → `poster` collision is real but has 0 instances today.)\n *\n * Current servers no longer emit these, but this stays: an older\n * `asset-manager` keeps sending them, and this is the check every guide points\n * at as the reliable one. Order matters — strip the longest tokens first.\n */\nfunction stripMultiCharTokens(presets: string): string {\n return presets\n .replace(/transform-[0-9a-f]*/g, \"\")\n .replace(/upscale_[a-z0-9_]*/g, \"\")\n .replace(/mp3/g, \"\")\n .replace(/u[2-8]|t[1248ghij]/g, \"\")\n .replace(/pr/g, \"\");\n}\n\n/**\n * Build a srcSet string for responsive `<img>`. Walks the available image\n * presets in size order and only includes the ones the asset actually has.\n *\n * <img\n * src={getAssetUrl(asset, 'web')}\n * srcSet={getAssetSrcSet(asset)}\n * sizes=\"(max-width: 768px) 100vw, 800px\"\n * />\n */\nconst IMAGE_PRESETS: VariantPreset[] = [\"thumb\", \"sm\", \"md\", \"lg\", \"xl\"];\nexport function getAssetSrcSet(\n asset: Pick<AssetDTO, \"sha\" | \"presets\">,\n): string {\n return IMAGE_PRESETS.filter(\n (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null,\n )\n .map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`)\n .join(\", \");\n}\n\n/**\n * Compute the dimensions a variant would have given the source asset's\n * width/height and the variant's bounding box. For thumbnails (square\n * smart-crop) the result is always 256×256; for the other presets, scales\n * the max side to the box dimension and the other side proportionally.\n */\nexport function computeVariantDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n preset: VariantPreset,\n): { width: number; height: number } | null {\n const cap = PRESET_MAX_DIM[preset];\n if (cap == null) return null;\n if (preset === \"thumb\") return { width: cap, height: cap };\n if (!asset.w || !asset.h) return null;\n const scale = Math.min(cap / asset.w, cap / asset.h, 1);\n return {\n width: Math.round(asset.w * scale),\n height: Math.round(asset.h * scale),\n };\n}\n\n/** Source dimensions, for aspect-ratio sizing. */\nexport function getAssetDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n): { width: number; height: number } | null {\n if (asset.w && asset.h) return { width: asset.w, height: asset.h };\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Slot system — tenant-named asset bindings.\n// See ./slots for full docs.\n// ---------------------------------------------------------------------------\n\nexport {\n configureSlotResolver,\n invalidateSlotCache,\n type ResolveSlotOptions,\n resolveSlot,\n resolveSlots,\n type SlotDTO,\n type SlotResolution,\n} from \"./slots\";\n\n// ---------------------------------------------------------------------------\n// On-the-fly transforms — see ./transform for docs.\n// ---------------------------------------------------------------------------\n\nexport {\n extractAssetSha,\n getHlsStreamingUrl,\n getSignedTransformUrl,\n getTransformSrcSet,\n getTransformUrl,\n getVideoTransformUrl,\n type SignedTransformOptions,\n serializeTransform,\n signTransformUrl,\n TRANSFORM_WIDTHS,\n type TransformEffect,\n type TransformFit,\n type TransformFormat,\n type TransformGravity,\n type TransformOptions,\n type TransformWidth,\n} from \"./transform\";\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/palette.ts","../src/slots.ts","../src/transform.ts","../src/index.ts"],"sourcesContent":["/**\n * Color palette helpers — render harmonious ambient backgrounds behind\n * product images, inspired by Spotify Now Playing / Apple Music / Pico.\n *\n * Wire format is intentionally compact: only the hex per swatch, only the\n * swatches the source actually had. The full names map to 1-2 letter aliases\n * (`d` dominant, `v` vibrant, `m` muted, `dv` darkVibrant, `lv` lightVibrant,\n * `dm` darkMuted, `lm` lightMuted) to shave bytes for catalog-sized payloads\n * (palette was 62% of asset DTO before this).\n *\n * Population + RGB array + textColor are derivable client-side; we don't\n * ship them. textColor is computed via WCAG relative luminance on demand.\n */\n\n/** Compact wire shape for an asset's palette. All swatches optional except dominant. */\nexport type AssetPalette = {\n /** dominant hex (always present when palette exists) */\n d: string;\n /** vibrant */ v?: string;\n /** muted */ m?: string;\n /** darkVibrant */ dv?: string;\n /** lightVibrant */ lv?: string;\n /** darkMuted */ dm?: string;\n /** lightMuted */ lm?: string;\n};\n\n/** Backwards-compat alias for older callers that referenced PaletteSwatch. */\nexport type PaletteSwatch = { hex: string; textColor: \"#000000\" | \"#FFFFFF\" };\n\n/**\n * Resolve a palette key to its hex value if present.\n */\nfunction resolveSwatch(\n palette: AssetPalette | null | undefined,\n ...keys: (keyof AssetPalette)[]\n): string | null {\n if (!palette) return null;\n for (const k of keys) {\n const v = palette[k];\n if (v) return v;\n }\n return null;\n}\n\n/**\n * Pick the swatch best suited for an ambient surface behind the image.\n * Prefers muted/light tones — too vibrant a background fights the image.\n *\n * Order: lightMuted → muted → lightVibrant → dominant.\n */\nexport function pickAmbientBackground(\n palette: AssetPalette | null | undefined,\n): PaletteSwatch | null {\n const hex = resolveSwatch(palette, \"lm\", \"m\", \"lv\", \"d\");\n if (!hex) return null;\n return { hex, textColor: textColorForHex(hex) };\n}\n\n/**\n * Build a CSS linear-gradient from the palette. Useful for hero / detail\n * backgrounds.\n */\nexport function getAmbientGradient(\n palette: AssetPalette | null | undefined,\n opts: {\n angle?: string;\n from?: keyof AssetPalette;\n to?: keyof AssetPalette;\n } = {},\n): string | undefined {\n if (!palette) return undefined;\n const fromHex = palette[opts.from ?? \"lm\"] ?? palette.m ?? palette.d;\n const toHex = palette[opts.to ?? \"m\"] ?? palette.dm ?? palette.d;\n if (!fromHex || !toHex) return undefined;\n return `linear-gradient(${opts.angle ?? \"135deg\"}, ${fromHex}, ${toHex})`;\n}\n\n/**\n * Recommended text color (#000 or #FFF) for any background hex,\n * computed via WCAG relative luminance.\n */\nexport function getTextColorForBackground(\n swatch: PaletteSwatch | string | null | undefined,\n): string {\n if (!swatch) return \"#000000\";\n const hex = typeof swatch === \"string\" ? swatch : swatch.hex;\n return textColorForHex(hex);\n}\n\n/** Luminancia relativa WCAG de un hex. 0 = negro, 1 = blanco. */\nexport function relativeLuminance(hex: string): number {\n const h = hex.replace(\"#\", \"\");\n const r = Number.parseInt(h.slice(0, 2), 16) / 255;\n const g = Number.parseInt(h.slice(2, 4), 16) / 255;\n const b = Number.parseInt(h.slice(4, 6), 16) / 255;\n // sRGB → linear\n const lin = (c: number) =>\n c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\n}\n\n/**\n * Razón de contraste WCAG entre dos luminancias: `(L1 + 0.05) / (L2 + 0.05)`,\n * con la más clara arriba. 1 = idénticos, 21 = negro contra blanco.\n */\nexport function contrastRatio(l1: number, l2: number): number {\n const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];\n return (hi + 0.05) / (lo + 0.05);\n}\n\n/**\n * Negro o blanco, el que **de verdad** contraste más sobre este fondo.\n *\n * ⚠️ CORREGIDO 2026-08-17. Antes decidía con `L > 0.5`, y **0,5 no es el punto\n * de empate**. Igualando las dos razones de contraste:\n *\n * (1 + 0.05) / (L + 0.05) = (L + 0.05) / 0.05\n * (L + 0.05)² = 0.0525\n * L = 0.1791…\n *\n * Entre **0,179 y 0,5** el código viejo elegía BLANCO cuando negro contrastaba\n * más — una banda ancha, y justo donde caen los colores de marca saturados.\n * Medido sobre cuatro paletas reales de producción: **8 swatches** en esa banda.\n * El peor, el dorado dominante `#CA9528`: devolvía blanco a **2,68:1** cuando\n * negro da **7,84:1**. 2,68 no pasa AA ni para texto grande.\n *\n * No se cambia el umbral por 0,179: se **calculan las dos razones y gana la\n * mayor**. Un umbral es una constante que hay que mantener correcta; la\n * comparación es correcta por construcción.\n *\n * ⚠️ Esto elige el MEJOR de dos, no garantiza que alcance. Sobre un fondo de\n * luminancia media el mejor par puede quedar por debajo de 4,5:1 igual — para\n * eso está {@link bestTextContrast}, que además devuelve el número.\n */\nfunction textColorForHex(hex: string): \"#000000\" | \"#FFFFFF\" {\n return bestTextContrast(hex).color;\n}\n\n/**\n * Igual que el color recomendado, pero devuelve también **la razón lograda** y\n * si pasa AA — para que quien lo use pueda decidir con el número a la vista en\n * vez de asumir que alcanzó.\n */\nexport function bestTextContrast(hex: string): {\n color: \"#000000\" | \"#FFFFFF\";\n ratio: number;\n passesAA: boolean;\n passesAALarge: boolean;\n} {\n const L = relativeLuminance(hex);\n const onBlack = contrastRatio(L, 0);\n const onWhite = contrastRatio(L, 1);\n const useBlack = onBlack >= onWhite;\n const ratio = useBlack ? onBlack : onWhite;\n return {\n color: useBlack ? \"#000000\" : \"#FFFFFF\",\n ratio,\n passesAA: ratio >= 4.5,\n passesAALarge: ratio >= 3,\n };\n}\n\n/**\n * CSS variables for a wrapper so a subtree can read --asset-bg / --asset-fg /\n * --asset-dominant / --asset-vibrant / etc.\n */\nexport function getPaletteCssVars(\n palette: AssetPalette | null | undefined,\n): Record<string, string> {\n if (!palette) return {};\n const bg = pickAmbientBackground(palette);\n return {\n \"--asset-bg\": bg?.hex ?? \"transparent\",\n \"--asset-fg\": bg ? bg.textColor : \"#000000\",\n \"--asset-dominant\": palette.d,\n ...(palette.v && { \"--asset-vibrant\": palette.v }),\n ...(palette.m && { \"--asset-muted\": palette.m }),\n ...(palette.lv && { \"--asset-light-vibrant\": palette.lv }),\n ...(palette.dv && { \"--asset-dark-vibrant\": palette.dv }),\n ...(palette.lm && { \"--asset-light-muted\": palette.lm }),\n ...(palette.dm && { \"--asset-dark-muted\": palette.dm }),\n };\n}\n\n/**\n * Iterate the palette in display order (dominant first, then vibrant +\n * muted families). Useful for rendering a swatch strip in admin UIs.\n */\nexport function iteratePaletteSwatches(\n palette: AssetPalette | null | undefined,\n): Array<{ key: keyof AssetPalette; label: string; hex: string }> {\n if (!palette) return [];\n const order: Array<{ key: keyof AssetPalette; label: string }> = [\n { key: \"d\", label: \"dominant\" },\n { key: \"v\", label: \"vibrant\" },\n { key: \"lv\", label: \"lightVibrant\" },\n { key: \"dv\", label: \"darkVibrant\" },\n { key: \"m\", label: \"muted\" },\n { key: \"lm\", label: \"lightMuted\" },\n { key: \"dm\", label: \"darkMuted\" },\n ];\n return order\n .map(({ key, label }) => {\n const hex = palette[key];\n return hex ? { key, label, hex } : null;\n })\n .filter(\n (s): s is { key: keyof AssetPalette; label: string; hex: string } =>\n s != null,\n );\n}\n\n/**\n * Build a multi-radial-gradient CSS `background` string from the palette\n * swatches. Acts as a zero-extra-bytes alternative to the WebP LQIP: the\n * palette is already in the DTO, so this placeholder costs nothing extra\n * to ship. Renders as a smooth abstract \"color cloud\" reminiscent of the\n * source image's vibe.\n *\n * Strategy: anchor 4 radial gradients at fixed corners using vibrant/muted\n * pairs, layered over the dominant fill. Skips missing swatches gracefully.\n */\nexport function getPaletteBlurBackground(\n palette: AssetPalette | null | undefined,\n): string | undefined {\n if (!palette) return undefined;\n const corners: Array<{ pos: string; key: keyof AssetPalette }> = [\n { pos: \"20% 20%\", key: \"lv\" },\n { pos: \"80% 25%\", key: \"v\" },\n { pos: \"25% 80%\", key: \"lm\" },\n { pos: \"80% 80%\", key: \"dv\" },\n ];\n const layers = corners\n .map(({ pos, key }) => {\n const hex = palette[key];\n if (!hex) return null;\n return `radial-gradient(circle at ${pos}, ${hex} 0%, transparent 55%)`;\n })\n .filter(Boolean) as string[];\n // Fallback fill = dominant (or muted if dominant is missing — shouldn't happen)\n const base = palette.d ?? palette.m ?? \"#888\";\n return layers.length > 0 ? `${layers.join(\", \")}, ${base}` : base;\n}\n","/**\n * @nitida/asset-client/slots — slot resolver for tenant-named assets.\n *\n * Slots give tenants a way to attach stable, human-readable names\n * (\"webapp.wizard.pool-type.icon-1\", \"storefront.cr.hero-video.landscape_hd_16x9.mp4\")\n * to assets they uploaded. Consumers resolve names → AssetDTOs at\n * build / runtime so their source never hardcodes a CDN URL; the\n * admin rebinds a slot from `asset-lab-web` and every consumer picks\n * up the swap on cache refresh.\n *\n * Two layers in this package:\n * - `resolveSlot` / `resolveSlots` — universal (server, edge,\n * workers) fetch helpers. Cache 60s by default.\n * - React hooks live in `@nitida/asset-client/react/use-slot`\n * (kept out of this module so the SSR-safe core stays\n * dependency-free of react).\n */\n\nimport type { AssetDTO, VariantPreset } from \"./index\";\nimport { getAssetUrl, hasPreset } from \"./index\";\n\n// ---------------------------------------------------------------------------\n// Wire shape — matches the server's slots route.\n// ---------------------------------------------------------------------------\n\nexport type SlotDTO = {\n slotKey: string;\n /** Preset hint set when the slot was bound (e.g. `thumb` for icon slots). */\n preset: VariantPreset | null;\n description: string | null;\n updatedAt: string;\n asset: AssetDTO;\n};\n\nexport type SlotResolution = {\n /** The resolved DTO (`null` when the slot is unbound or asset missing). */\n slot: SlotDTO | null;\n /**\n * Effective preset — what `url` below was built with. Resolution order:\n * 1. caller's `preset` override\n * 2. slot's `preset` hint\n * 3. `lg` for images, `video` for video kind\n */\n preset: VariantPreset;\n /** The CDN URL the consumer should use. */\n url: string | null;\n};\n\n// ---------------------------------------------------------------------------\n// Config\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_TTL_MS = 60_000;\nconst cache = new Map<string, { fetchedAt: number; value: SlotDTO | null }>();\n\n// The published API host — the same address the docs give out, so a caller\n// that configured nothing still talks to the documented endpoint. Override it\n// with `configureSlotResolver({ endpoint })` to point at a different\n// deployment.\nlet endpoint = \"https://api.nitida.gofuture.space\";\nlet apiKey: string | null = null;\nlet tenantCode: string | null = null;\n\n/**\n * Configure the resolver process-wide. Call once at boot from your\n * storefront layout / server entry / worker init.\n *\n * configureSlotResolver({\n * endpoint: process.env.AQUIENPZ_URL,\n * apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY,\n * tenantCode: \"realtyone-cr\",\n * });\n */\nexport function configureSlotResolver(opts: {\n endpoint?: string;\n apiKey?: string;\n tenantCode?: string;\n}): void {\n if (opts.endpoint) endpoint = opts.endpoint.replace(/\\/+$/, \"\");\n if (opts.apiKey !== undefined) apiKey = opts.apiKey;\n if (opts.tenantCode !== undefined) tenantCode = opts.tenantCode;\n}\n\n/** Wipe the in-process cache (test helper or forced refresh). */\nexport function invalidateSlotCache(slotKey?: string): void {\n if (slotKey === undefined) cache.clear();\n else\n for (const k of cache.keys())\n if (k.endsWith(`:${slotKey}`)) cache.delete(k);\n}\n\n// ---------------------------------------------------------------------------\n// Internal fetch helper\n// ---------------------------------------------------------------------------\n\nconst baseHeaders = (): Record<string, string> => {\n const h: Record<string, string> = {};\n if (apiKey) h.Authorization = `Bearer ${apiKey}`;\n if (tenantCode) h[\"X-Tenant-Code\"] = tenantCode;\n return h;\n};\n\nasync function fetchSlot(slotKey: string): Promise<SlotDTO | null> {\n const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {\n headers: baseHeaders(),\n });\n if (r.status === 404) return null;\n if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);\n return (await r.json()) as SlotDTO;\n}\n\nasync function fetchSlotsBulk(\n slotKeys: string[],\n): Promise<Record<string, SlotDTO | null>> {\n if (slotKeys.length === 0) return {};\n const r = await fetch(`${endpoint}/slots/resolve`, {\n method: \"POST\",\n headers: { ...baseHeaders(), \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ keys: slotKeys }),\n });\n if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);\n const body = (await r.json()) as { resolved: Record<string, SlotDTO | null> };\n return body.resolved;\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport type ResolveSlotOptions = {\n /** Override preset (caller knows the use case better than the slot binding). */\n preset?: VariantPreset;\n /** TTL for the in-process cache. Default 60s. Set 0 to bypass. */\n ttlMs?: number;\n};\n\n/**\n * Resolve a single slot to a CDN URL. Returns `{slot: null, url: null}`\n * when the slot is unbound — callers fall back to a placeholder.\n *\n * Cached for `ttlMs` (default 60s). Slot rebindings propagate within the\n * TTL window without an app restart.\n */\nexport async function resolveSlot(\n slotKey: string,\n opts: ResolveSlotOptions = {},\n): Promise<SlotResolution> {\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const cacheKey = `${tenantCode ?? \"_\"}:${slotKey}`;\n const now = Date.now();\n let dto: SlotDTO | null;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n dto = hit.value;\n } else {\n dto = await fetchSlot(slotKey);\n cache.set(cacheKey, { fetchedAt: now, value: dto });\n }\n return materializeResolution(dto, opts.preset);\n}\n\n/**\n * Bulk-resolve N slot keys in one round-trip. The SDK's `useSlots`\n * React hook calls this so every storefront header (logo + tagline +\n * nav cover + …) loads as one request.\n */\nexport async function resolveSlots(\n slotKeys: string[],\n opts: ResolveSlotOptions = {},\n): Promise<Record<string, SlotResolution>> {\n if (slotKeys.length === 0) return {};\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const now = Date.now();\n const missing: string[] = [];\n const out: Record<string, SlotResolution> = {};\n for (const k of slotKeys) {\n const cacheKey = `${tenantCode ?? \"_\"}:${k}`;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n out[k] = materializeResolution(hit.value, opts.preset);\n } else {\n missing.push(k);\n }\n }\n if (missing.length > 0) {\n const resolved = await fetchSlotsBulk(missing);\n for (const k of missing) {\n const dto = resolved[k] ?? null;\n cache.set(`${tenantCode ?? \"_\"}:${k}`, { fetchedAt: now, value: dto });\n out[k] = materializeResolution(dto, opts.preset);\n }\n }\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction defaultPresetFor(asset: AssetDTO | undefined): VariantPreset {\n if (!asset) return \"lg\";\n return asset.kind === \"video\" ? \"video\" : \"lg\";\n}\n\nfunction materializeResolution(\n dto: SlotDTO | null,\n overridePreset?: VariantPreset,\n): SlotResolution {\n if (!dto) return { slot: null, preset: overridePreset ?? \"lg\", url: null };\n const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);\n // Fall back to \"lg\" when the bound preset doesn't exist on the asset\n // (e.g. slot was bound to a video but caller asked for a thumb).\n const finalPreset = hasPreset(dto.asset, effective)\n ? effective\n : defaultPresetFor(dto.asset);\n return {\n slot: dto,\n preset: finalPreset,\n url: getAssetUrl(dto.asset, finalPreset),\n };\n}\n","/**\n * On-the-fly transform URL builder.\n *\n * Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated\n * here hashes to the same cache key as the server's canonical form.\n *\n * Canonicalization rules (kept in sync with the server):\n * - Drop entries whose value is `undefined`\n * - Sort keys alphabetically\n * - Numbers rendered without leading zeros or trailing dots\n * - String values lowercased\n *\n * URL shape:\n * <cdnBase>/t/<dsl>/<sha>.<ext>\n *\n * The `.ext` is informational (browser content-sniff hint); the server\n * decides the actual output format from the DSL `format` param + the\n * request's Accept header.\n */\n\nimport type { AssetDTO } from \"./index\";\nimport { getCdnBase } from \"./index\";\n\n/**\n * Widths the CDN edge whitelists (DoS guard).\n * Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the\n * 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import\n * this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.\n */\nexport const TRANSFORM_WIDTHS = [\n 96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280,\n 1440, 1600, 1920, 2560, 3840,\n] as const;\n/**\n * A CDN-whitelisted transform width — the only widths `TransformOptions.width`\n * accepts. Off-ladder widths are a compile error; for signed URLs that need a\n * custom width, use {@link SignedTransformOptions} (number) via\n * {@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`.\n */\nexport type TransformWidth = (typeof TRANSFORM_WIDTHS)[number];\n\nexport type TransformFit = \"cover\" | \"contain\" | \"fill\" | \"inside\" | \"outside\";\nexport type TransformGravity =\n | \"auto\"\n | \"face\"\n | \"center\"\n | \"north\"\n | \"south\"\n | \"east\"\n | \"west\";\nexport type TransformFormat =\n | \"auto\"\n | \"avif\"\n | \"webp\"\n | \"jpeg\"\n | \"png\"\n // Video-only formats (Phase 4). The image path ignores them.\n | \"mp4\"\n | \"webm\"\n // HLS adaptive ladder (Phase 5). Video-only. Output is a directory\n // of m3u8 + .ts segments fronted by master.m3u8; the SDK returns\n // the master URL via `getHlsStreamingUrl`.\n | \"hls\";\nexport type TransformEffect = \"removebg\" | \"genfill\";\n\nexport type TransformOptions = {\n /**\n * Target max-side width in CSS pixels (multiplied by `dpr` server-side).\n * MUST be a {@link TRANSFORM_WIDTHS} value — off-ladder widths are rejected\n * (HTTP 400) by the edge whitelist for unsigned URLs, so the type forbids\n * them at compile time. For SIGNED URLs with a custom width, use\n * {@link SignedTransformOptions} (which widens this to `number`).\n */\n width?: TransformWidth;\n /** Target max-side height. Multiplied by `dpr` server-side. */\n height?: number;\n /** Resize fit mode. Default `cover` server-side. */\n fit?: TransformFit;\n /** Crop gravity. `auto` picks the region with the most visual salience. */\n gravity?: TransformGravity;\n /** Output format. `auto` → the platform's policy decides. */\n format?: TransformFormat;\n /** Output quality. `auto` → format-specific default. */\n quality?: \"auto\" | number;\n /** Device pixel ratio. Width/height are multiplied by this before resize. */\n dpr?: 1 | 2 | 3;\n /**\n * AI effect applied before resize/encode.\n *\n * - `removebg`: remove the background; output is a transparent PNG\n * of the foreground subject. Forces `format=png` regardless of\n * other format hints. A single cache miss per (sha, dsl) tuple;\n * subsequent identical DSLs serve from cache — no inference, no\n * per-image cost.\n *\n * - `genfill`: aspect-extension outpaint. Requires BOTH `width`\n * and `height` — the server\n * fits the source centered into the target canvas and outpaints\n * the gutters. Output is PNG (forced) at exactly target dims.\n * ~$0.05/image first time; same cache as removebg after.\n * Primary use case: building OG cards (1200×630) from portrait\n * listing photos without awkward edge mirroring.\n */\n effect?: TransformEffect;\n /**\n * Video-only: clip start in seconds. Image transforms ignore.\n * Accepts decimals (e.g. 1.5 for sub-second seek).\n */\n start?: number;\n /**\n * Video-only: clip duration in seconds (1..300). Image transforms\n * ignore. With `start`, lets a single request grab a sub-clip.\n */\n duration?: number;\n};\n\n/**\n * Like {@link TransformOptions} but with `width` widened to any `number` —\n * the escape hatch for SIGNED URLs that need an off-ladder custom width.\n *\n * The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid\n * `?sig=` earns the whitelist bypass at the edge (the server still\n * does the real HMAC check). So a custom width is ONLY safe when the URL is\n * signed — hence this type is accepted exclusively by the signing helpers\n * ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),\n * never by the plain unsigned {@link getTransformUrl}.\n */\nexport type SignedTransformOptions = Omit<TransformOptions, \"width\"> & {\n /** Off-ladder width — valid ONLY on signed URLs (edge whitelist bypass). */\n width?: number;\n};\n\n/**\n * Serialize transform options into the canonical DSL path segment.\n * Empty options return an empty string (caller should fall back to a\n * variant URL instead of a transform URL in that case).\n *\n * Accepts {@link SignedTransformOptions} (the wider type) so it also covers\n * custom-width signed URLs; {@link TransformOptions} is assignable to it.\n */\n/**\n * Extract the 16-hex short sha from an aquienpz CDN URL, regardless of\n * shape:\n * - tenant-prefixed variant: `https://8ok.uk/4/v/<sha16>-<preset>.<ext>`\n * - legacy variant: `https://8ok.uk/<sha16>-<preset>.<ext>`\n * - on-the-fly transform: `https://8ok.uk/t/<dsl>/<sha16>.<ext>`\n * - streaming HLS: `https://8ok.uk/t/format=hls/<sha16>.m3u8`\n *\n * Returns `null` for non-aquienpz URLs (pexels, googleusercontent, raw\n * uploaded URLs to other CDNs) so call sites can fall back to the URL\n * with a plain `<img>` instead of generating a broken transform URL.\n *\n * Useful when a value reaches the component as a pre-built URL string\n * (legacy data, site-config JSON, third-party feeds) but you want to\n * drop in `getTransformSrcSet` for the responsive ladder if it happens\n * to be an aquienpz asset.\n */\nexport function extractAssetSha(url: string | null | undefined): string | null {\n if (!url) return null;\n // Match the canonical aquienpz sha pattern: 16 lowercase hex chars\n // appearing as a path segment, optionally followed by `-<preset>`\n // (variant URL) or `.<ext>` (transform URL).\n const m = url.match(/\\/([0-9a-f]{16})(?:[-.]|$)/);\n return m ? m[1]! : null;\n}\n\nexport function serializeTransform(opts: SignedTransformOptions): string {\n const entries: Array<[string, string]> = [];\n const keys = Object.keys(opts).sort() as Array<keyof SignedTransformOptions>;\n for (const k of keys) {\n const v = opts[k];\n if (v == null) continue;\n const serialized = typeof v === \"string\" ? v.toLowerCase() : String(v);\n entries.push([k, serialized]);\n }\n return entries.map(([k, v]) => `${k}=${v}`).join(\",\");\n}\n\nfunction extForOptions(opts: SignedTransformOptions): string {\n // effect=removebg forces PNG output server-side (needs alpha).\n if (opts.effect === \"removebg\") return \"png\";\n // effect=genfill defaults to WebP (12× lighter than the raw generated\n // PNG output with no visible loss at q=85). Explicit `format=png`\n // opts back into lossless for print / marketing fold-outs. The server\n // re-encodes the generated PNG → target format before caching.\n if (opts.effect === \"genfill\") {\n switch (opts.format) {\n case \"png\":\n return \"png\";\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n default:\n return \"webp\";\n }\n }\n switch (opts.format) {\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n case \"png\":\n return \"png\";\n case \"mp4\":\n return \"mp4\";\n case \"webm\":\n return \"webm\";\n case \"hls\":\n // HLS uses `.m3u8` as the URL extension; the route resolves the\n // master playlist under the cache key prefix.\n return \"m3u8\";\n default:\n // \"webp\" / \"auto\" / undefined / anything new\n return \"webp\";\n }\n}\n\n/**\n * Build a transform URL for a VIDEO asset. Same DSL shape as image\n * transforms; the server branches on the asset's `kind` column. Video\n * URLs use `.mp4` (default) or `.webm` extension and on cache miss the\n * server returns 202 Accepted while a background job encodes the clip;\n * subsequent GETs return 302 to the cached object.\n *\n * <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}\n * autoPlay muted loop playsInline />\n */\nexport function getVideoTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: TransformOptions,\n): string | null {\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = opts.format === \"webm\" ? \"webm\" : \"mp4\";\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\n/**\n * Build an HLS streaming URL for a VIDEO asset (Phase 5). Returns the\n * master.m3u8 entry point — HLS-aware players (Video.js's\n * @videojs/http-streaming, hls.js, native iOS Safari) follow it to\n * fetch the variant playlist + segments at the appropriate bitrate\n * for the connection.\n *\n * ⚠️ A master.m3u8 is NOT a video file. Assigning it to `<video src>`\n * works only where the engine has native HLS; everywhere else it needs\n * an MSE player. And the classic feature test is now WRONG: Chrome 147\n * (April 2026) added native HLS, so `canPlayType(\"application/vnd.apple.mpegurl\")`\n * answers \"maybe\" there and routes Chrome to the native branch, where it\n * opened a measured 17 s hero at 426x240 for ~8 s. Branch on the ENGINE:\n *\n * @example\n * ```ts\n * function prefersNativeHls(video: HTMLVideoElement): boolean {\n * if (video.canPlayType(\"application/vnd.apple.mpegurl\") === \"\") return false;\n * // Apple's engine, or an engine with no MSE to fall back on (iOS < 17.1).\n * return \"ManagedMediaSource\" in globalThis || !(\"MediaSource\" in globalThis);\n * }\n *\n * const src = getHlsStreamingUrl(asset);\n * if (prefersNativeHls(video)) {\n * video.src = src;\n * } else {\n * // Defaults open at a fixed low rung — measure instead of guessing.\n * const hls = new Hls({ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 });\n * hls.loadSource(src);\n * hls.attachMedia(video);\n * }\n * ```\n *\n * On first request the server returns 202 Accepted while a background\n * job transcodes the ladder (typically 1-3 min for a 90 s source);\n * subsequent requests get 302 to the cached master.m3u8. Keep the\n * progressive MP4 as a fallback source for that window.\n *\n * The ladder's ceiling is the source the job probes: built at ingest it\n * reads the RAW upload and a 4K master yields 1440p/2160p rungs; rebuilt\n * on demand after the raw is unavailable it reads the `-v.mp4`, which is\n * capped at 1920 wide. `getAssetUrl(sha, \"video\")` is always <= 1080p.\n */\nexport function getHlsStreamingUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: Omit<TransformOptions, \"format\"> = {},\n): string {\n // Always serialize with format=hls so the server routes correctly.\n const merged: TransformOptions = { ...opts, format: \"hls\" };\n const dsl = serializeTransform(merged);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;\n}\n\n/**\n * Build a transform URL. Returns null when the caller passed no options —\n * callers should prefer the existing variant URL builder in that case so\n * the request hits a pre-generated variant instead of an on-the-fly encode.\n */\nfunction buildTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n): string | null {\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = extForOptions(opts);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\nexport function getTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: TransformOptions,\n): string | null {\n return buildTransformUrl(asset, opts);\n}\n\n/**\n * Build AND sign a transform URL, allowing an off-ladder custom `width`.\n *\n * This is the escape hatch for {@link SignedTransformOptions}: off-ladder\n * widths only pass the edge whitelist when the URL is signed, so building one\n * and signing it must happen together. For on-ladder widths prefer the plain\n * {@link getTransformUrl} (+ {@link signTransformUrl} if you need a signature).\n *\n * Returns `null` only when `opts` serialize to an empty DSL (no transform\n * requested) — same contract as {@link getTransformUrl}.\n */\nexport function getSignedTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n signingKey: string,\n): Promise<string> | null {\n const url = buildTransformUrl(asset, opts);\n if (!url) return null;\n return signTransformUrl(url, signingKey);\n}\n\n/**\n * Sign a transform URL with the tenant's HMAC signing key. Appends\n * `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).\n *\n * Must agree byte-for-byte with the server's `verifyTransformSignature`.\n * Uses WebCrypto, so works in browsers, Node ≥ 16, Bun, and Workers.\n *\n * The canonical DSL is the one already produced by `serializeTransform`\n * (sort keys + lowercase strings), so signing a URL built by `getTransformUrl`\n * is automatic — the same canonical form is in the URL path.\n */\nexport async function signTransformUrl(\n unsignedUrl: string,\n signingKey: string,\n): Promise<string> {\n const u = new URL(unsignedUrl);\n // Path shape: /t/<dsl>/<filename>\n const parts = u.pathname.split(\"/\").filter(Boolean);\n // First segment must be \"t\"; the rest is dsl groups + filename. With\n // Phase 1 we ship a single DSL group; chained groups stay flat for\n // signing purposes (server canonicalizer flattens them too).\n if (parts[0] !== \"t\" || parts.length < 3) {\n throw new Error(`signTransformUrl: unexpected URL shape ${unsignedUrl}`);\n }\n const filename = parts[parts.length - 1]!;\n const dsl = parts.slice(1, -1).join(\"/\");\n const message = `${dsl}/${filename}`;\n const sig = await hmacSha256Hex(signingKey, message);\n u.searchParams.set(\"sig\", sig);\n return u.toString();\n}\n\nasync function hmacSha256Hex(key: string, message: string): Promise<string> {\n const enc = new TextEncoder();\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const buf = await crypto.subtle.sign(\"HMAC\", cryptoKey, enc.encode(message));\n return [...new Uint8Array(buf)]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\n/**\n * Build a responsive `srcSet` string by generating one transform URL per\n * width. All other options apply to every URL.\n *\n * <img\n * src={aq.transform(asset, { width: 800 })!}\n * srcSet={aq.transformSrcSet(asset, [320, 640, 960, 1280])}\n * sizes=\"(max-width: 768px) 100vw, 50vw\"\n * />\n */\nexport function getTransformSrcSet(\n asset: Pick<AssetDTO, \"sha\">,\n widths: number[],\n extraOpts: Omit<TransformOptions, \"width\"> = {},\n): string {\n return widths\n .map((w) => {\n // Build via the internal (number-width) builder: `widths` is an explicit\n // responsive ladder the caller chose, so it stays `number[]`. Off-ladder\n // unsigned widths 400 at the edge — the caller's responsibility, exactly\n // as before the width type was tightened.\n const url = buildTransformUrl(asset, { ...extraOpts, width: w });\n return url ? `${url} ${w}w` : null;\n })\n .filter((s): s is string => s != null)\n .join(\", \");\n}\n","/**\n * @nitida/asset-client — read helpers for asset URLs.\n *\n * Universal CDN model (May 2026): the API ships a compact AssetDTO with a\n * 16-char SHA prefix + a `presets` string of 1-char codes; client helpers\n * construct CDN URLs deterministically from `(cdnBase, sha, preset, ext)`.\n *\n * Why: catalog sync over Electric SSE shipped 4 nearly-identical full URLs\n * per asset × hundreds of thousands of assets per snapshot. Sending only\n * what differs — sha + presets bitmap — collapses ~600 bytes per asset to\n * ~70 (88% reduction).\n *\n * No runtime dependencies — pure types + pure functions. Safe everywhere.\n * @module @nitida/asset-client\n */\n\n/**\n * Image presets are size-based (Vercel `next/image` style):\n * - `thumb` is the only square crop — semantic icon use case\n * - `sm/md/lg` are max-side bounding boxes that preserve aspect ratio\n *\n * Naming over the legacy `thumbnail/cover/web/hero` because the new names\n * say what the variant IS (a size class) rather than what it might be\n * USED for, removing the implicit landscape-only assumption that bit us\n * with vertical product photos.\n */\nexport type VariantPreset =\n // image presets\n | \"thumb\" // 256x256 square smart-crop (icon)\n | \"sm\" // 640 max-side\n | \"md\" // 1280 max-side\n | \"lg\" // 1920 max-side\n | \"xl\" // 3840 max-side (4K) — OPT-IN; not generated by default\n // passthrough for non-image kinds (PDF etc.)\n | \"original\"\n // video presets — semantic (not size classes)\n | \"poster\"\n | \"video\"\n | \"aiproxy\"\n // audio preset — the cross-browser mp3 transcode of a voice note\n // (libmp3lame) emitted alongside the original so chat audio plays on\n // both Chrome/Android (webm/opus) and iOS Safari (which can't decode opus).\n | \"mp3\";\n\n/** 1-char alias used in storage keys / wire `presets` string. */\nexport const PRESET_SHORT: Record<VariantPreset, string> = {\n thumb: \"q\",\n sm: \"s\",\n md: \"m\",\n lg: \"l\",\n xl: \"x\",\n original: \"o\",\n poster: \"p\",\n video: \"v\",\n aiproxy: \"a\",\n // 3 chars, NOT a 1-char alias: the server has no short-form for\n // audio so its `shortPreset(\"mp3\")` falls through to the literal token,\n // and the deployed server already writes the `-mp3.mp3` variant + emits\n // the bare `mp3` token in the wire `presets` string. Must stay in lockstep.\n mp3: \"mp3\",\n};\nexport const PRESET_LONG: Record<string, VariantPreset> = Object.fromEntries(\n Object.entries(PRESET_SHORT).map(([k, v]) => [v, k as VariantPreset]),\n);\n\n/** Variant extension by preset. Image variants are always WebP, video MP4. */\nexport const PRESET_EXT: Record<VariantPreset, string> = {\n thumb: \"webp\",\n sm: \"webp\",\n md: \"webp\",\n lg: \"webp\",\n xl: \"webp\",\n original: \"bin\", // overridden per-asset via mime when needed\n poster: \"webp\",\n video: \"mp4\",\n aiproxy: \"mp4\",\n mp3: \"mp3\",\n};\n\n/** Max-side dimension by preset; null for video / passthrough. */\nexport const PRESET_MAX_DIM: Record<VariantPreset, number | null> = {\n thumb: 256,\n sm: 640,\n md: 1280,\n lg: 1920,\n xl: 3840,\n original: null,\n poster: null,\n video: null,\n aiproxy: null,\n // audio has no pixel dimensions; `null` keeps mp3 out of the\n // dimension-based `getAssetSrcSet` / `computeVariantDimensions` logic.\n mp3: null,\n};\n\n/**\n * One generated variant of an asset. Returned by the admin endpoints\n * (`GET /assets/:id`, `POST /assets/:id/regenerate`).\n */\nexport type AssetVariant = {\n /** Long name (`thumb` / `sm` / … / `original`) — see {@link VariantPreset}. */\n preset: VariantPreset;\n /** Public CDN URL of this variant. */\n url: string;\n /** Pixel width. Absent for `original`-only assets where image processing was skipped, or for video presets. */\n width?: number;\n /** Pixel height. Same caveat as `width`. */\n height?: number;\n /** Byte size of the stored variant file. */\n bytes: number;\n /**\n * Where the bytes for this variant came from. Useful for quality\n * traceability — a `thumb` with `sourceFrom: \"original\"` is the\n * canonical case, while `sourceFrom: \"lg\"` means it was derived\n * from an already-encoded WebP (slight quality compounding).\n *\n * - `\"upload\"` → first-write at `/assets/process`. The bytes came\n * straight from the client's PUT.\n * - `VariantPreset` → regenerated from that preset's variant.\n *\n * Absent on variants written before the trace field existed.\n */\n sourceFrom?: VariantPreset | \"upload\";\n /** ISO timestamp this variant was written. Absent on pre-trace variants. */\n createdAt?: string;\n};\n\n/**\n * Compact wire shape — what the server actually sends. Aliases (`w`, `h`,\n * `dur`) are intentional to shave bytes per asset on dense lists.\n */\nexport type AssetDTO = {\n id: string;\n /** First 16 hex chars of sha256 — used to derive CDN URLs. */\n sha: string;\n kind: \"image\" | \"video\" | \"document\" | \"audio\" | \"other\";\n mime: string;\n bytes: number;\n /** Source dims (for aspect-ratio calc on the client). Optional for non-images. */\n w?: number | null;\n h?: number | null;\n /** Duration ms for videos. */\n dur?: number | null;\n /** LQIP placeholder (data URL). */\n blur?: string | null;\n palette?: AssetPalette | null;\n /**\n * Compact list of generated variants as their 1-char codes\n * concatenated, e.g. \"tcwh\" (image) / \"pv\" (video without aiproxy).\n * Ordered by ascending dimension.\n */\n presets: string;\n status: \"processing\" | \"ready\" | \"failed\";\n /** Soft-delete timestamp (ISO). Hidden from catalog when set. */\n deletedAt?: string | null;\n /**\n * Full variant list with URLs + sizes. Sent by `GET /assets/:id`; absent on\n * the slim list shape used by the resolver / catalog. Use `presets` for\n * compact existence checks, and this when you need the actual URLs.\n */\n variants?: AssetVariant[];\n /**\n * The extension the `original` variant was really stored under — the server\n * keys it off the uploaded filename, so it cannot be derived from `mime`.\n * Sent by `GET /assets/:id`; `null` when the asset has no original.\n * {@link getAssetUrl} uses it automatically when you pass the whole DTO.\n */\n oext?: string | null;\n};\n\nexport type {\n AssetPalette,\n PaletteSwatch,\n} from \"./palette\";\n\nexport {\n bestTextContrast,\n contrastRatio,\n getAmbientGradient,\n getPaletteBlurBackground,\n getPaletteCssVars,\n getTextColorForBackground,\n iteratePaletteSwatches,\n relativeLuminance,\n pickAmbientBackground,\n} from \"./palette\";\n\nimport type { AssetPalette } from \"./palette\";\n\n// ---------------------------------------------------------------------------\n// CDN base\n// ---------------------------------------------------------------------------\n\nlet cdnBaseUrl = \"https://8ok.uk\";\n/**\n * Override the CDN base for the entire process (e.g. in tests, or when\n * pointing at a tenant-specific CDN). Storefront layouts call this once at\n * boot.\n */\nexport function setCdnBase(url: string): void {\n cdnBaseUrl = url.replace(/\\/$/, \"\");\n}\nexport function getCdnBase(): string {\n return cdnBaseUrl;\n}\n\n// ---------------------------------------------------------------------------\n// Tenant scope\n//\n// Post-May-2026 the CDN serves variants under a tenant-prefixed path\n// `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see the server\n// `variantKey`). Variant URL builders MUST include that prefix or every\n// URL 404s. The tenant id is process-global (one tenant per client/app),\n// set once at boot — `NitidaClient` does this from its `tenantId` option;\n// standalone consumers call `setTenantId()` directly. Left unset, builders\n// fall back to the legacy pre-cutover bare path for back-compat.\n// ---------------------------------------------------------------------------\n\nlet tenantId: number | null = null;\n/** Set the process-global tenant id used to build tenant-prefixed CDN URLs. */\nexport function setTenantId(id: number | null | undefined): void {\n tenantId =\n typeof id === \"number\" && Number.isFinite(id) && id > 0 ? id : null;\n}\nexport function getTenantId(): number | null {\n return tenantId;\n}\n/** Variant path prefix `<tid b36>/v/`, or \"\" when no tenant is configured. */\nfunction variantPrefix(): string {\n return tenantId != null ? `${tenantId.toString(36)}/v/` : \"\";\n}\n\n// ---------------------------------------------------------------------------\n// URL builders\n// ---------------------------------------------------------------------------\n\n/**\n * LAST-RESORT guess at the ORIGINAL variant's extension, from the asset's mime.\n *\n * ⚠️ This is a guess. Prefer `oext` or `variants` (see {@link getAssetUrl}) — the\n * server sends both and they ARE the key.\n *\n * The server derives the extension from the MIME with the `mime-types` package\n * (`mime.extension(body.mime)`, at presign). So a table that matched that one\n * exactly would usually be right — and this table did not: it said\n * `image/jpeg` → `jpeg` while `mime-types` says `jpg`, under a comment claiming\n * to mirror it. Usually, but not always: the row's stored `mime` is not always\n * the mime the key was built from, so no client-side table can close the gap.\n *\n * Measured against the 2 001 stored originals in production, 2026-08-17: **all\n * 420 `image/jpeg` originals are stored `.jpg` and none `.jpeg`** — the old\n * entry here 404'd on every single JPEG. And 234 originals carry\n * `application/octet-stream` (`.mpga`, `.docx`, `.m4a`), where no mime table can\n * produce the right key at all — those need `oext`.\n */\nconst ORIGINAL_EXT_BY_MIME: Record<string, string> = {\n \"image/png\": \"png\",\n \"image/jpeg\": \"jpg\",\n \"image/webp\": \"webp\",\n \"image/gif\": \"gif\",\n \"image/avif\": \"avif\",\n \"image/svg+xml\": \"svg\",\n \"image/heic\": \"heic\",\n \"image/heif\": \"heif\",\n \"image/bmp\": \"bmp\",\n \"image/tiff\": \"tiff\",\n \"application/pdf\": \"pdf\",\n \"video/mp4\": \"mp4\",\n \"video/webm\": \"webm\",\n \"video/quicktime\": \"mov\",\n // Audio — ausentes hasta 2026-08-17, y su ausencia costó un rodeo entero en\n // neo (`withRealOriginalExt`), que existe SÓLO porque esta tabla devolvía el\n // centinela `bin` para toda nota de voz. Medido en producción: `-o.bin` da\n // 404 y `-o.m4a` da 200.\n //\n // ⚠️ Se keyean por el mime COMPLETO, no por el subtipo: `audio/mp4` guarda\n // `.m4a` y `video/mp4` guarda `.mp4`. Un `switch` sobre el subtipo `mp4` no\n // puede distinguirlos — es el error que un consumidor cometió y tuvo que\n // corregir por su cuenta.\n \"audio/mpeg\": \"mpga\",\n \"audio/mp4\": \"m4a\",\n \"audio/x-m4a\": \"m4a\",\n \"audio/wav\": \"wav\",\n \"audio/webm\": \"weba\",\n \"audio/ogg\": \"oga\",\n \"audio/aac\": \"adts\",\n};\nfunction originalExtForMime(mime: string | undefined): string {\n return (mime ? ORIGINAL_EXT_BY_MIME[mime] : undefined) ?? PRESET_EXT.original;\n}\n\n/** What the asset itself knows about where its `original` lives. */\ntype OriginalHints = {\n mime?: string;\n /** The extension the server actually stored it under. Authoritative. */\n oext?: string | null;\n /** Full variant list — carries the stored URL verbatim. Authoritative. */\n variants?: AssetVariant[];\n};\n\n/**\n * Build the public CDN URL for a specific variant of an asset. The variant\n * may not actually exist (regenerate may not have run, or video has no\n * `aiproxy`); call `hasPreset()` first or expect a 404.\n *\n * For the `original` preset, pass the whole `AssetDTO` — it carries `variants`\n * and `oext`, either of which gives the EXACT stored key. `mime` alone is only a\n * guess (the server keys the original off the uploaded filename), and with\n * nothing at all the original falls back to the `\"bin\"` sentinel, which 404s.\n *\n * @example Video — the tenant segment is base36, so let this build the path\n * ```ts\n * import { getAssetUrl, setTenantId } from \"@nitida/asset-client\";\n *\n * setTenantId(12); // 12 → \"c\"; a decimal \"/12/v/\" 404s\n * getAssetUrl({ sha }, \"video\"); // → https://8ok.uk/c/v/<sha16>-v.mp4\n * getAssetUrl({ sha }, \"poster\"); // → https://8ok.uk/c/v/<sha16>-p.webp\n * ```\n *\n * @example The `original` — pass the DTO, not just the sha\n * ```ts\n * const asset = await aq.assets.get(id);\n *\n * getAssetUrl({ sha }, \"original\"); // → …-o.bin ❌ 404, always\n * getAssetUrl({ sha, mime }, \"original\"); // → a guess from the mime table\n * getAssetUrl(asset, \"original\"); // ✓ the stored key, verbatim\n * ```\n *\n * @example Check before you link\n * ```ts\n * import { getAssetUrl, hasPreset } from \"@nitida/asset-client\";\n * const url = hasPreset(asset, \"thumb\") ? getAssetUrl(asset, \"thumb\") : null;\n * ```\n */\nexport function getAssetUrl(\n asset: Pick<AssetDTO, \"sha\"> & OriginalHints,\n preset: VariantPreset,\n): string {\n if (preset === \"original\") {\n // The stored URL beats every derivation, because it IS the key. Only fall\n // through to a guess when the caller gave us the sha and nothing else.\n const stored = asset.variants?.find((v) => v.preset === \"original\")?.url;\n if (stored) return stored;\n const ext = asset.oext || originalExtForMime(asset.mime);\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT.original}.${ext}`;\n }\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;\n}\n\n/**\n * Did the processor actually generate this preset?\n *\n * Reads `dto.presets` — the compact 1-char code string the server sends on EVERY shape, including\n * the slim list/resolver one that carries no `variants` at all. That is why this exists and why it\n * stays the right existence check even now that `GET /assets/:id` really does send `variants`\n * (it did not until 2026-08-17; doc 240 §4.3).\n *\n * @example\n * ```ts\n * import { hasPreset } from \"@nitida/asset-client\";\n *\n * hasPreset({ presets: \"oq\" }, \"original\"); // → true (\"o\" = original, \"q\" = thumb)\n * hasPreset({ presets: \"oq\" }, \"lg\"); // → false — do not link to it\n * hasPreset({ presets: \"pv\" }, \"aiproxy\"); // → false — video without the AI proxy\n * ```\n */\nexport function hasPreset(\n asset: Pick<AssetDTO, \"presets\">,\n preset: VariantPreset,\n): boolean {\n if (preset === \"mp3\") return asset.presets.includes(\"mp3\");\n return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);\n}\n\n/**\n * Remove every MULTI-character token from a `presets` string, leaving only the\n * 1-char codes that a `.includes` can safely be run against.\n *\n * `presets` is documented as a concatenation of 1-char codes, and membership is\n * a 1-char substring test — so any longer token is a false-positive generator.\n * Servers before the 2026-08-17 deploy emitted several (doc 240 §4.3b):\n *\n * | token in the string | letters it donates | presets it falsely answers |\n * |---|---|---|\n * | `transform-<hex>` | t r a n s f o m + a–f | `sm` `md` `original` `aiproxy` |\n * | `pr` (probe) | p r | `poster` |\n * | `mp3` (audio) | m p | `md` `poster` — stripped here since forever |\n *\n * Measured against production: **62 % of live assets** carried a polluted\n * string, and **16 299 of them were told they have an `original` they do not**\n * — which builds a `-o.<ext>` URL that 404s. (`aiproxy`: 16 479. `sm`/`md`: 487.\n * The `pr` → `poster` collision is real but has 0 instances today.)\n *\n * Current servers no longer emit these, but this stays: an older\n * older server deploys keep sending them, and this is the check every guide points\n * at as the reliable one. Order matters — strip the longest tokens first.\n */\nfunction stripMultiCharTokens(presets: string): string {\n return presets\n .replace(/transform-[0-9a-f]*/g, \"\")\n .replace(/upscale_[a-z0-9_]*/g, \"\")\n .replace(/mp3/g, \"\")\n .replace(/u[2-8]|t[1248ghij]/g, \"\")\n .replace(/pr/g, \"\");\n}\n\n/**\n * Build a srcSet string for responsive `<img>`. Walks the available image\n * presets in size order and only includes the ones the asset actually has.\n *\n * <img\n * src={getAssetUrl(asset, 'web')}\n * srcSet={getAssetSrcSet(asset)}\n * sizes=\"(max-width: 768px) 100vw, 800px\"\n * />\n */\nconst IMAGE_PRESETS: VariantPreset[] = [\"thumb\", \"sm\", \"md\", \"lg\", \"xl\"];\nexport function getAssetSrcSet(\n asset: Pick<AssetDTO, \"sha\" | \"presets\">,\n): string {\n return IMAGE_PRESETS.filter(\n (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null,\n )\n .map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`)\n .join(\", \");\n}\n\n/**\n * Compute the dimensions a variant would have given the source asset's\n * width/height and the variant's bounding box. For thumbnails (square\n * smart-crop) the result is always 256×256; for the other presets, scales\n * the max side to the box dimension and the other side proportionally.\n */\nexport function computeVariantDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n preset: VariantPreset,\n): { width: number; height: number } | null {\n const cap = PRESET_MAX_DIM[preset];\n if (cap == null) return null;\n if (preset === \"thumb\") return { width: cap, height: cap };\n if (!asset.w || !asset.h) return null;\n const scale = Math.min(cap / asset.w, cap / asset.h, 1);\n return {\n width: Math.round(asset.w * scale),\n height: Math.round(asset.h * scale),\n };\n}\n\n/** Source dimensions, for aspect-ratio sizing. */\nexport function getAssetDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n): { width: number; height: number } | null {\n if (asset.w && asset.h) return { width: asset.w, height: asset.h };\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Slot system — tenant-named asset bindings.\n// See ./slots for full docs.\n// ---------------------------------------------------------------------------\n\nexport {\n configureSlotResolver,\n invalidateSlotCache,\n type ResolveSlotOptions,\n resolveSlot,\n resolveSlots,\n type SlotDTO,\n type SlotResolution,\n} from \"./slots\";\n\n// ---------------------------------------------------------------------------\n// On-the-fly transforms — see ./transform for docs.\n// ---------------------------------------------------------------------------\n\nexport {\n extractAssetSha,\n getHlsStreamingUrl,\n getSignedTransformUrl,\n getTransformSrcSet,\n getTransformUrl,\n getVideoTransformUrl,\n type SignedTransformOptions,\n serializeTransform,\n signTransformUrl,\n TRANSFORM_WIDTHS,\n type TransformEffect,\n type TransformFit,\n type TransformFormat,\n type TransformGravity,\n type TransformOptions,\n type TransformWidth,\n} from \"./transform\";\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitida/asset-client",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.2",
|
|
4
4
|
"description": "nitida URL builders — construct image, video and HLS URLs for the nitida CDN. No network, no key, no config beyond a tenant id.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
package/src/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ export type VariantPreset =
|
|
|
42
42
|
// both Chrome/Android (webm/opus) and iOS Safari (which can't decode opus).
|
|
43
43
|
| "mp3";
|
|
44
44
|
|
|
45
|
-
/** 1-char alias used in
|
|
45
|
+
/** 1-char alias used in storage keys / wire `presets` string. */
|
|
46
46
|
export const PRESET_SHORT: Record<VariantPreset, string> = {
|
|
47
47
|
thumb: "q",
|
|
48
48
|
sm: "s",
|
|
@@ -53,7 +53,7 @@ export const PRESET_SHORT: Record<VariantPreset, string> = {
|
|
|
53
53
|
poster: "p",
|
|
54
54
|
video: "v",
|
|
55
55
|
aiproxy: "a",
|
|
56
|
-
// 3 chars, NOT a 1-char alias: the
|
|
56
|
+
// 3 chars, NOT a 1-char alias: the server has no short-form for
|
|
57
57
|
// audio so its `shortPreset("mp3")` falls through to the literal token,
|
|
58
58
|
// and the deployed server already writes the `-mp3.mp3` variant + emits
|
|
59
59
|
// the bare `mp3` token in the wire `presets` string. Must stay in lockstep.
|
|
@@ -102,11 +102,11 @@ export type AssetVariant = {
|
|
|
102
102
|
preset: VariantPreset;
|
|
103
103
|
/** Public CDN URL of this variant. */
|
|
104
104
|
url: string;
|
|
105
|
-
/** Pixel width. Absent for `original`-only assets where
|
|
105
|
+
/** Pixel width. Absent for `original`-only assets where image processing was skipped, or for video presets. */
|
|
106
106
|
width?: number;
|
|
107
107
|
/** Pixel height. Same caveat as `width`. */
|
|
108
108
|
height?: number;
|
|
109
|
-
/** Byte size of the variant file
|
|
109
|
+
/** Byte size of the stored variant file. */
|
|
110
110
|
bytes: number;
|
|
111
111
|
/**
|
|
112
112
|
* Where the bytes for this variant came from. Useful for quality
|
|
@@ -208,7 +208,7 @@ export function getCdnBase(): string {
|
|
|
208
208
|
// Tenant scope
|
|
209
209
|
//
|
|
210
210
|
// Post-May-2026 the CDN serves variants under a tenant-prefixed path
|
|
211
|
-
// `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see
|
|
211
|
+
// `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see the server
|
|
212
212
|
// `variantKey`). Variant URL builders MUST include that prefix or every
|
|
213
213
|
// URL 404s. The tenant id is process-global (one tenant per client/app),
|
|
214
214
|
// set once at boot — `NitidaClient` does this from its `tenantId` option;
|
|
@@ -392,7 +392,7 @@ export function hasPreset(
|
|
|
392
392
|
* The `pr` → `poster` collision is real but has 0 instances today.)
|
|
393
393
|
*
|
|
394
394
|
* Current servers no longer emit these, but this stays: an older
|
|
395
|
-
*
|
|
395
|
+
* older server deploys keep sending them, and this is the check every guide points
|
|
396
396
|
* at as the reliable one. Order matters — strip the longest tokens first.
|
|
397
397
|
*/
|
|
398
398
|
function stripMultiCharTokens(presets: string): string {
|
package/src/slots.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { AssetDTO, VariantPreset } from "./index";
|
|
|
20
20
|
import { getAssetUrl, hasPreset } from "./index";
|
|
21
21
|
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
|
-
// Wire shape — matches
|
|
23
|
+
// Wire shape — matches the server's slots route.
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
|
|
26
26
|
export type SlotDTO = {
|
|
@@ -53,14 +53,11 @@ 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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
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.
|
|
63
|
-
let endpoint = "https://aquienpz-asset-manager-nlchzy26qa-uc.a.run.app";
|
|
56
|
+
// The published API host — the same address the docs give out, so a caller
|
|
57
|
+
// that configured nothing still talks to the documented endpoint. Override it
|
|
58
|
+
// with `configureSlotResolver({ endpoint })` to point at a different
|
|
59
|
+
// deployment.
|
|
60
|
+
let endpoint = "https://api.nitida.gofuture.space";
|
|
64
61
|
let apiKey: string | null = null;
|
|
65
62
|
let tenantCode: string | null = null;
|
|
66
63
|
|
package/src/transform.ts
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* On-the-fly transform URL builder.
|
|
3
3
|
*
|
|
4
4
|
* Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated
|
|
5
|
-
* here hashes to the same
|
|
5
|
+
* here hashes to the same cache key as the server's canonical form.
|
|
6
6
|
*
|
|
7
|
-
* Canonicalization rules (
|
|
8
|
-
* `apps/asset-manager/src/features/assets/transform.dsl.ts`):
|
|
7
|
+
* Canonicalization rules (kept in sync with the server):
|
|
9
8
|
* - Drop entries whose value is `undefined`
|
|
10
9
|
* - Sort keys alphabetically
|
|
11
10
|
* - Numbers rendered without leading zeros or trailing dots
|
|
@@ -23,7 +22,7 @@ import type { AssetDTO } from "./index";
|
|
|
23
22
|
import { getCdnBase } from "./index";
|
|
24
23
|
|
|
25
24
|
/**
|
|
26
|
-
* Widths the CDN edge whitelists (DoS guard
|
|
25
|
+
* Widths the CDN edge whitelists (DoS guard).
|
|
27
26
|
* Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the
|
|
28
27
|
* 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import
|
|
29
28
|
* this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.
|
|
@@ -77,9 +76,9 @@ export type TransformOptions = {
|
|
|
77
76
|
height?: number;
|
|
78
77
|
/** Resize fit mode. Default `cover` server-side. */
|
|
79
78
|
fit?: TransformFit;
|
|
80
|
-
/** Crop gravity. `auto`
|
|
79
|
+
/** Crop gravity. `auto` picks the region with the most visual salience. */
|
|
81
80
|
gravity?: TransformGravity;
|
|
82
|
-
/** Output format. `auto` → policy decides
|
|
81
|
+
/** Output format. `auto` → the platform's policy decides. */
|
|
83
82
|
format?: TransformFormat;
|
|
84
83
|
/** Output quality. `auto` → format-specific default. */
|
|
85
84
|
quality?: "auto" | number;
|
|
@@ -90,16 +89,15 @@ export type TransformOptions = {
|
|
|
90
89
|
*
|
|
91
90
|
* - `removebg`: remove the background; output is a transparent PNG
|
|
92
91
|
* of the foreground subject. Forces `format=png` regardless of
|
|
93
|
-
* other format hints.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* from R2 — no inference, no per-image cost.
|
|
92
|
+
* other format hints. A single cache miss per (sha, dsl) tuple;
|
|
93
|
+
* subsequent identical DSLs serve from cache — no inference, no
|
|
94
|
+
* per-image cost.
|
|
97
95
|
*
|
|
98
|
-
* - `genfill`: aspect-extension outpaint
|
|
99
|
-
*
|
|
96
|
+
* - `genfill`: aspect-extension outpaint. Requires BOTH `width`
|
|
97
|
+
* and `height` — the server
|
|
100
98
|
* fits the source centered into the target canvas and outpaints
|
|
101
99
|
* the gutters. Output is PNG (forced) at exactly target dims.
|
|
102
|
-
* ~$0.05/image first time; same
|
|
100
|
+
* ~$0.05/image first time; same cache as removebg after.
|
|
103
101
|
* Primary use case: building OG cards (1200×630) from portrait
|
|
104
102
|
* listing photos without awkward edge mirroring.
|
|
105
103
|
*/
|
|
@@ -121,7 +119,7 @@ export type TransformOptions = {
|
|
|
121
119
|
* the escape hatch for SIGNED URLs that need an off-ladder custom width.
|
|
122
120
|
*
|
|
123
121
|
* The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid
|
|
124
|
-
* `?sig=` earns the whitelist bypass at the
|
|
122
|
+
* `?sig=` earns the whitelist bypass at the edge (the server still
|
|
125
123
|
* does the real HMAC check). So a custom width is ONLY safe when the URL is
|
|
126
124
|
* signed — hence this type is accepted exclusively by the signing helpers
|
|
127
125
|
* ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),
|
|
@@ -181,10 +179,10 @@ export function serializeTransform(opts: SignedTransformOptions): string {
|
|
|
181
179
|
function extForOptions(opts: SignedTransformOptions): string {
|
|
182
180
|
// effect=removebg forces PNG output server-side (needs alpha).
|
|
183
181
|
if (opts.effect === "removebg") return "png";
|
|
184
|
-
// effect=genfill defaults to WebP (12× lighter than the raw
|
|
182
|
+
// effect=genfill defaults to WebP (12× lighter than the raw generated
|
|
185
183
|
// PNG output with no visible loss at q=85). Explicit `format=png`
|
|
186
184
|
// opts back into lossless for print / marketing fold-outs. The server
|
|
187
|
-
// re-encodes
|
|
185
|
+
// re-encodes the generated PNG → target format before caching.
|
|
188
186
|
if (opts.effect === "genfill") {
|
|
189
187
|
switch (opts.format) {
|
|
190
188
|
case "png":
|
|
@@ -222,8 +220,8 @@ function extForOptions(opts: SignedTransformOptions): string {
|
|
|
222
220
|
* Build a transform URL for a VIDEO asset. Same DSL shape as image
|
|
223
221
|
* transforms; the server branches on the asset's `kind` column. Video
|
|
224
222
|
* URLs use `.mp4` (default) or `.webm` extension and on cache miss the
|
|
225
|
-
* server returns 202 Accepted while a
|
|
226
|
-
* subsequent GETs return 302 to the cached
|
|
223
|
+
* server returns 202 Accepted while a background job encodes the clip;
|
|
224
|
+
* subsequent GETs return 302 to the cached object.
|
|
227
225
|
*
|
|
228
226
|
* <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}
|
|
229
227
|
* autoPlay muted loop playsInline />
|
|
@@ -271,8 +269,8 @@ export function getVideoTransformUrl(
|
|
|
271
269
|
* }
|
|
272
270
|
* ```
|
|
273
271
|
*
|
|
274
|
-
* On first request the server returns 202 Accepted while a
|
|
275
|
-
*
|
|
272
|
+
* On first request the server returns 202 Accepted while a background
|
|
273
|
+
* job transcodes the ladder (typically 1-3 min for a 90 s source);
|
|
276
274
|
* subsequent requests get 302 to the cached master.m3u8. Keep the
|
|
277
275
|
* progressive MP4 as a fallback source for that window.
|
|
278
276
|
*
|