npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nitida/sdk

v0.36.2

Published

nitida — the media SDK: browser and mobile upload with resume, client-side compression, on-the-fly transforms behind a CDN, video transcode, HLS ladders and AI proxies. Multi-tenant.

Readme

@nitida/sdk

Universal SDK for the nitida multi-tenant media platform. A Cloudinary-style asset manager with deterministic CDN URLs, content-addressed dedup, server-side variants (thumb / sm / md / lg / poster / video), and slot bindings — admin-managed names that resolve to assets at runtime.

One client, every environment

The whole SDK ships as one NitidaClient class behind environment- specific subpath entries. Each subpath bundles the same client PLUS the helpers safe for that runtime — same pattern as Vercel Blob (@vercel/blob vs @vercel/blob/client), Better Auth, Uploadthing, and the Vercel AI SDK. Stripe/Cloudinary's split into two separate npm packages is the legacy shape — modern bundlers tree-shake subpaths perfectly and a single version eliminates type drift between server and client surfaces.

Two kinds of subpath, and the difference matters

COMPLETE entry points carry the whole surface. Import one and you are done.

| Where you run | Import | | |---|---|---| | Node 20+, Bun, Cloud Run, Lambda, Edge, agents, cron | @nitida/sdk/server | NitidaClient + every export of the root. No browser-only code. | | Browser, and React Native | @nitida/sdk/web | everything in /server, plus compressImage (HEIC→WebP). The one deliberate omission is NitidaClientOptions, the options type that carries apiKey — use WebClientOptions. | | Anywhere (the root) | @nitida/sdk | the full surface, runtime-agnostic. Prefer an explicit subpath in new code. |

ADDITIVE modules are layered on top of one of the above. They carry only their own runtime-specific symbols — you import a complete entry point as well:

| For | Import | Carries | |---|---|---| | React hooks | @nitida/sdk/react | NitidaProvider, useSlot, useSlots, useNitidaClient — and nothing else | | RN image compression | @nitida/sdk/native | compressImage / compressImages for RN (react-native-compressor) | | Expo background uploads | @nitida/sdk/expo | createExpoUploaderURLSession / WorkManager sessions that survive backgrounding. ⚠️ Needs a runtime key on the device — see below |

⚠️ /expo is the one place a runtime key lives on the device. createExpoUploader takes apiKey off the client and gives it to the native session as its authToken, because the OS replays that upload from a background task hours later and there is no BFF in that loop. It is the opposite call from @nitida/sdk/web, which hit the same constraint and chose not to expose its multipart uploader at all. An amk_rt_* in an IPA/APK is readable by anyone who unzips it, and it is a write key to a paid platform. If background survival is not worth that, build the client from /web against your own route and use aq.upload(file) — no key on the device, and the upload dies with the JS thread.

⚠️ React Native needs two imports, and this table used to imply otherwise: @nitida/sdk/web for the client and every URL builder — it has no top-level browser imports, the compressor behind it is a dynamic import()plus /native and/or /expo for the native pieces. /native alone gives you a compressor and no way to build a URL.

That /server and /web really do mirror the root is asserted in CI, not maintained by hand. It was not always true: before 2026-08-21 /server was short 28 of the root's 72 exports and /web short 37.

Uploaders + compressors are optional peer deps (@nitida/asset-uploader-{web,expo}, @nitida/asset-compressor-{web,native}). Skip them if your app only resolves slots and reads assets — your bundle stays a few KB.

Quick decision tree

  • Server-side rendering, API routes, BFF, cron, agents → @nitida/sdk/server.
  • Browser components, build-time pre-resolution → @nitida/sdk/web.
  • Expo / React Native app → @nitida/sdk/web (the client and the URL builders) plus @nitida/sdk/native and/or @nitida/sdk/expo for the native pieces.
  • React hooks (any env) → @nitida/sdk/react plus /web or /server.

Never ship the API key to the browser. Whichever subpath you import, the long-lived amk_rt_* key lives on the server. Browser flows hit a BFF route that proxies to the nitida API with the real key.

Which subpath do I import from?

| Context | Import | Notes | |---|---|---| | Browser (Next.js client component, SPA, Worker, web extension) | @nitida/sdk/web | No apiKey — your BFF injects it. Type omits the field; build error if you try. | | Node/Bun server (API route, Server Action, Cloud Run, Fly, BFF) | @nitida/sdk/server | Requires apiKey at construction. | | React Native / Expo | @nitida/sdk/web plus /native and/or /expo | Same BFF model as /web — keep keys server-side. /native and /expo are additive; on their own they carry no client. | | Universal React hook | @nitida/sdk/react plus /web or /server | The hooks read a client from context; something has to construct it. |

…and where does each HELPER live?

The table above answers "where do I get the client". It did not used to answer "where do I get getHlsLadder", and that is the question that cost the most time — an agent evaluating this SDK followed AGENTS.md to /server, imported getHlsLadder from there, and got a SyntaxError at runtime.

Now there is a one-line answer, and CI keeps it true:

@nitida/sdk, /server and /web all carry the same surface. Import any helper from whichever of the three you already use.

| what you want | @nitida/asset-client | @nitida/sdk · /server · /web | elsewhere | |---|---|---|---| | URL builders — getAssetUrl, getTransformUrl, getVideoTransformUrl, getAssetSrcSet, getTransformSrcSet, getHlsStreamingUrl, serializeTransform | ✅ | ✅ | | | Signing — signTransformUrl, getSignedTransformUrl | ✅ | ✅ | | | Asset facts — hasPreset, extractAssetSha, getAssetDimensions, computeVariantDimensions | ✅ | ✅ | | | HLS — getHlsLadder, hlsLadderAlignment, HlsRung | ✅ | ✅ | | | Palette — pickAmbientBackground, iteratePaletteSwatches, bestTextContrast, getAmbientGradient, getPaletteCssVars, getPaletteBlurBackground, getTextColorForBackground, contrastRatio, relativeLuminance | ✅ | ✅ | | | Slots — resolveSlot, resolveSlots, configureSlotResolver, invalidateSlotCache | ✅ | ✅ | | | Constants — TRANSFORM_WIDTHS, TransformWidth, PRESET_EXT, PRESET_LONG, PRESET_SHORT, PRESET_MAX_DIM | ✅ | ✅ | | | Config — setCdnBase/getCdnBase, setTenantId/getTenantId | ✅ | ✅ | | | NitidaClient and its option/result types | | ✅ | | | Client-side compression — compressImage, compressImages | | /web only | /native (React Native) | | React hooks — NitidaProvider, useNitidaClient, useSlot, useSlots | | | /react only | | Expo resumable upload — createExpoUploader, listResumableSessions, cancelResumableSession | | | /expo only |

The single deliberate exception: NitidaClientOptions is not on /web. That is the options type that carries apiKey, and this subpath exists so the apiKey-bearing shape is unreachable from browser code — use WebClientOptions.

This is not maintained by hand. scripts/check-published-doc-symbols.ts asserts that /server and /web mirror the root, and that the one denial above still has a written reason. Before 2026-08-21 it was not true: /server was short 28 of the root's 72 and /web short 37, getHlsLadder among them.

Why subpaths, not a runtime flag

If you import /server in browser code, the bundler throws a build error. If we used a runtime { mode: "browser" } flag and you forgot it, your API key would silently bundle into the client. Subpaths make security mistakes loud — same pattern as @vercel/blob/client, better-auth/client, AI SDK's /edge subpath.

@nitida/sdk/web v0.17+ enforces this physically: the constructor type is Omit<NitidaClientOptions, "apiKey" | "signingKey">. Passing apiKey won't compile, period.

BFF-proxy mode (browser → your route → nitida)

Browser code constructs the client against a relative endpoint that points at your own route handler. The handler attaches the real bearer token and forwards to the nitida API. Same pattern Vercel Blob uses for @vercel/blob/client.upload.

Next.js — client component

"use client";
import { NitidaClient } from "@nitida/sdk/web";

const aq = new NitidaClient({
  endpoint:   "/api/am",              // OK: relative → same-origin BFF
  tenantCode: "acme-co",
  tenantId:   1,
  // apiKey: ... ERROR: TS error: not assignable to WebClientOptions
});

export function HeroPicker() {
  return <input type="file" onChange={async (e) => {
    const file = e.target.files?.[0];
    if (file) await aq.upload(file, { compress: true });
  }} />;
}

Next.js — BFF route handler

// app/api/am/[...path]/route.ts
import { NextRequest } from "next/server";

const UPSTREAM = process.env.AQUIENPZ_URL!;        // server-only
const API_KEY = process.env.AQUIENPZ_API_KEY!;     // server-only
const TENANT  = process.env.AQUIENPZ_TENANT_CODE!; // e.g. "acme-co"

async function proxy(req: NextRequest, { params }: { params: { path: string[] } }) {
  const search = new URL(req.url).search;
  const url = `${UPSTREAM}/${params.path.join("/")}${search}`;
  const body = ["GET", "HEAD"].includes(req.method) ? undefined : await req.arrayBuffer();
  return fetch(url, {
    method:  req.method,
    body,
    headers: {
      Authorization:    `Bearer ${API_KEY}`,
      "X-Tenant-Code":  TENANT,
      "Content-Type":   req.headers.get("content-type") ?? "application/json",
    },
  });
}

export { proxy as GET, proxy as POST, proxy as PUT, proxy as PATCH, proxy as DELETE };

Bun / Elysia backend

import { NitidaClient } from "@nitida/sdk/server";

const aq = new NitidaClient({
  endpoint:   process.env.AQUIENPZ_URL!,
  apiKey:     process.env.AQUIENPZ_API_KEY!,   // OK: required by ServerClientOptions
  tenantCode: "acme-co",
  tenantId:   1,
});

Expo

import { NitidaClient } from "@nitida/sdk/web"; // same browser-safe type
// Construct against your /api/am proxy; no apiKey in the app bundle.

SSG storefront (build-time)

Use /server at build time (Node/Bun) with the absolute nitida API URL — no proxy needed because keys never reach the browser bundle. Static HTML output references the CDN directly.

Install

bun add @nitida/sdk @nitida/asset-client
# or
npm install @nitida/sdk @nitida/asset-client

Optional, only if you compress on the client before uploading:

bun add @nitida/asset-compressor-web      # browser: HEIC→WebP, resize
bun add @nitida/asset-compressor-native   # Expo / React Native

Peer deps: @nitida/asset-client (URL builders + types) and react (only if you use the /react subpath). Both compressors are optional and lazy — the SDK never imports them unless you call a compress path.

The multipart uploader packages (@nitida/asset-uploader-web / -expo) are on npm since 2026-08-23. You do not need them for ordinary uploads: aq.upload() covers a file in a single PUT and is what every consumer uses today — see Large uploads below for where that ceiling actually is. Reach for an uploader when you need resume: a 2 GB upload that must survive a reload (web, IndexedDB) or the app being backgrounded (Expo, iOS URLSession / Android WorkManager).

Quick start

import { NitidaClient } from "@nitida/sdk";

const aq = new NitidaClient({
  endpoint: "https://api.nitida.gofuture.space",
  apiKey:   process.env.AQUIENPZ_API_KEY!,          // amk_rt_* runtime API key
  tenantCode: "your-tenant",
  tenantId:   42,                  // ⚠️ va en la URL en BASE36: 42 → "16". Ni decimal, ni hex.
  cdnBase:    "https://8ok.uk",    // optional, defaults to https://8ok.uk
});

// Slots — the recommended way to reference brand assets in code.
// Source never hardcodes a CDN URL; an admin rebinds it from the console.
const hero = await aq.slots.resolve("storefront.home.hero");
//   → { slot: { asset, preset, … }, preset: "lg", url: "https://8ok.uk/16/v/<sha>-l.webp" }

// Bulk resolution in one round-trip.
const heroes = await aq.slots.resolveMany([
  "storefront.home.hero",
  "storefront.home.tile-1",
  "storefront.home.tile-2",
]);

// Lower-level operations. `byHash` takes the full 64-hex sha256 that
// `upload()` returns as `.sha256`, OR its 16-char prefix — the short form
// that appears inside every CDN URL. Both resolve to the same asset.
const asset = await aq.assets.byHash("3c…<64 hex>…");
const same = await aq.assets.byHash("3c8f1a20b7d94e05"); // 16-char prefix ✓
const { assets, nextCursor } = await aq.assets.list({ limit: 50 });

// Uploads — hash-deduped; returns the canonical v2 URL immediately.
const result = await aq.upload(file, { fileName: "cover.jpg" });
// → { assetId, sha256, sha, mime, oext, cdnUrl }
//   `sha` (16 hex), `mime` y `oext` existen desde 0.30.0 para que el resultado
//   se pueda pasar DIRECTO a cualquier builder, sin volver a buscar el DTO:
//     getAssetUrl(result, "original")   // → -o.jpg, no -o.bin
//   Un objeto armado a mano con sólo { assetId, sha256, cdnUrl } NO sirve:
//   los builders leen `sha`, no `sha256`, y `assertSha` lo rechaza.

// Uploading raw bytes (Node/Bun, e.g. re-hosting a remote image)? A Uint8Array has no
// inherent MIME, so give it one — otherwise it stores as kind:"other" (NO image variants):
await aq.upload(bytes, { fileName: "cover.webp" });              // MIME inferred from .webp ✓
await aq.upload(bytes, { contentType: "image/webp" });          // or be explicit ✓
// And request the variants you'll render — `presets` DEFAULTS TO ["original"] (just the raw
// bytes). ⚠️ Corrected 2026-08-25: a BARE sha does not 404 — it THROWS
// (`assertSha`); and with the full object, a missing IMAGE preset does not
// 404 either — it falls back to `/t/` and serves 200. What 404s is a
// hand-built variant key. Ask for the ladder anyway when you want
// materialised bytes instead of an on-the-fly transform:
await aq.upload(bytes, { contentType: "image/webp", presets: ["thumb", "sm", "md", "lg", "xl"] });

// Bind a slot (admin operation).
await aq.slots.bind("storefront.home.hero", {
  assetId: result.assetId,
  preset:  "lg",
  description: "Homepage hero — uploaded by admin on 2026-05-16",
});

Client-side compression (browsers)

aq.upload(file, { compress: true }) runs the file through compressorjs + heic2any before the PUT, saving the user's bandwidth. Typical result for an 8 MB iPhone HEIC photo: ~800 KB uploaded after HEIC → JPEG → WebP @ q=0.80, max-edge 3840 px.

// Default — uses DEFAULT_COMPRESSION_OPTIONS (webapp-tuned values)
const result = await aq.upload(file, {
  compress: true,
  presets: ["thumb", "sm", "md", "lg"],
});

// Custom tuning per call
await aq.upload(file, {
  compress: { quality: 0.7, maxWidth: 2048 },
});

// Track progress for UI
await aq.upload(file, {
  compress: {
    onProgress: (stage) => console.log(stage),
    // stage ∈ "convertingHeic" | "compressing" | "compressingKeepingDimensions"
  },
});

Defaults (tuned against a production photo-upload workload — phone cameras, listing covers):

| Option | Default | |---|---| | quality | 0.80 | | mimeType | "image/webp" | | maxWidth / maxHeight | 3840 | | convertSize (PNG → JPEG threshold) | 5 MB | | strict | true |

Caveats:

  • Browser-only. In Node / Bun the call is a silent no-op (warns to console) and the raw bytes upload as-is.
  • Non-image MIMEs (video, PDF) are passed through regardless of compress: true. Safe to set blanket-fashion on mixed media batches.
  • Adds ~50 KB to the runtime bundle only when used — lazy-imported from @nitida/sdk/web.
  • HEIC inputs go through heic2any first (additional ~200 KB lazy bundle).
  • Expo / React Native: the /expo subpath uses native expo-image-manipulator instead (already wired in the uploader). The compress option on aq.upload is web-only.
  • The original pre-compression size is recorded server-side, so the savings show up in the admin usage dashboards.

Direct access to the compressor (without going through aq.upload):

import { compressImage, compressImages, DEFAULT_COMPRESSION_OPTIONS }
  from "@nitida/sdk/web";

const { blob, originalBytes } = await compressImage(file, { quality: 0.85 });
const results = await compressImages([fileA, fileB, fileC]);

Large uploads (web)

aq.upload() is a single PUT: atomic, BFF-friendly, and the path this SDK supports. It is what you should call.

Multipart is deliberately NOT exposed from @nitida/sdk/web. A static import of the uploader package broke every consumer of the subpath (the bundler resolves before the optional-peer check runs), and the UploadTask API needs a raw authToken that BFF-proxy mode does not hand out — so there is no createWebUploader export. Earlier drafts of this README described one; it never shipped.

Where that leaves you:

| file size | what to call | |---|---| | any size the browser can hold in memory | aq.upload(file) | | bigger, or you need resume across reloads | not covered by this SDK yet — talk to us |

Client-side compression is the lever that keeps most media under the single-PUT ceiling; see Client-side compression below.

Large uploads (Expo / React Native)

Native background uploads survive app suspend, low-memory kills, and network blips. iOS uses URLSession's background config; Android uses WorkManager. Same JS API as the web flavor.

import { useEffect, useState } from "react";
import { Image } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { NitidaClient } from "@nitida/sdk";
import {
  createExpoUploader,
  listResumableSessions,
} from "@nitida/sdk/expo";

// Background upload survival means a token DOES reach the native session — but
// it must be a short-lived, per-user token your backend mints, NEVER a shared
// platform amk_rt_* baked into the binary via EXPO_PUBLIC_* (that ships one write
// key to the whole platform, readable by anyone who unzips the IPA/APK). Fetch it
// at runtime from your own /session route and hand it to the client.
const { uploadToken } = await fetch(`${API}/session/upload-token`).then((r) => r.json());

const aq = new NitidaClient({
  endpoint:   process.env.EXPO_PUBLIC_AQUIENPZ_URL!,
  apiKey:     uploadToken, // short-lived, scoped, from YOUR backend — not EXPO_PUBLIC_*
  tenantCode: "your-tenant",
  tenantId:   42,
});

export function UploadHeroScreen() {
  const [progress, setProgress] = useState(0);
  const [url, setUrl] = useState<string | null>(null);

  // Offer to resume anything from a prior app launch on boot.
  useEffect(() => {
    listResumableSessions().then((sessions) => {
      // …show a banner if sessions.length > 0
    });
  }, []);

  async function pickAndUpload() {
    const picked = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Videos,
      allowsMultipleSelection: false,
    });
    if (picked.canceled || !picked.assets[0]) return;
    const { uri, mimeType, fileName } = picked.assets[0];

    const task = createExpoUploader(aq, {
      file: {
        uri,
        mime: mimeType ?? "video/mp4",
        name:  fileName ?? "tour.mp4",
      },
    });
    task.on("progress", ({ ratio }) => setProgress(ratio));

    const { assetId } = await task.start();
    // Bind to a slot so the storefront picks it up without a redeploy.
    await aq.slots.bind("storefront.tour.video", { assetId, preset: "video" });
    const resolved = await aq.slots.resolve("storefront.tour.video");
    setUrl(resolved.url);
  }

  return /* …UI with pickAndUpload + progress bar + <Image source={{ uri: url }}/> */;
}

ℹ️ @nitida/asset-uploader-expo is on npm (0.2.0, MIT) and installs normally. aq.upload() works on Expo without it; you want the uploader when you need a resumable, background upload — the only way a transfer survives iOS suspending the app. See the note above on when an uploader is worth adding.

Next.js App Router (Server Components)

The cheapest path: resolve slots at render time on the server. No client JS, no provider, no hook. The slot URLs ship as plain <img> markup.

// app/page.tsx — Server Component
import { NitidaClient } from "@nitida/sdk";

const aq = new NitidaClient({
  endpoint:   process.env.AQUIENPZ_URL!,
  apiKey:     process.env.AQUIENPZ_API_KEY!, // amk_rt_* — server-only
  tenantCode: "your-tenant",
  tenantId:   42,
});

export default async function Page() {
  const heroes = await aq.slots.resolveMany([
    "storefront.home.hero",
    "storefront.home.tile-1",
    "storefront.home.tile-2",
  ]);

  return (
    <>
      {heroes["storefront.home.hero"].url && (
        <img src={heroes["storefront.home.hero"].url} alt="" />
      )}
      {/* … */}
    </>
  );
}

Keep the API key on the server — never expose amk_rt_* to NEXT_PUBLIC_*. Uploads from Client Components should go through a thin BFF route handler that proxies aq.upload() server-side.

For images that benefit from next/image, use aq.urlFor() + aq.srcSetFor() to emit a static URL set; Next then handles its own optimization pipeline.

React hooks (Client Components / SPA)

Reactive resolution on the client. The provider holds the configured client; each hook subscribes to the in-process cache.

// app/providers.tsx — Client Component
"use client";
import { NitidaClient } from "@nitida/sdk";
import { NitidaProvider } from "@nitida/sdk/react";

// NO apiKey in the browser. Reads and URL building (useSlot, urlFor, srcSetFor)
// need no credential; writes go through a BFF route that calls aq.upload()
// server-side with the real amk_rt_* key (see the warning above). Shipping
// NEXT_PUBLIC_AQUIENPZ_API_KEY inlines a shared platform write key into every
// bundle — readable by any visitor.
const client = new NitidaClient({
  endpoint:   process.env.NEXT_PUBLIC_AQUIENPZ_URL!,
  tenantCode: "your-tenant",
  tenantId:   42,
});

export function Providers({ children }: { children: React.ReactNode }) {
  return <NitidaProvider client={client}>{children}</NitidaProvider>;
}
// app/hero.tsx
"use client";
import { useSlot } from "@nitida/sdk/react";

export function Hero() {
  const { url, isLoading } = useSlot("storefront.home.hero");
  if (isLoading) return <Skeleton />;
  if (!url) return <PlaceholderHero />;
  return <img src={url} alt="" />;
}

Works in any React 18+ host: Vite, CRA, Remix, Astro islands, Expo Router, React Native — wherever react-dom (or react-native) runs.

Why slots?

Hardcoding https://cdn.your-tenant.com/abc123.webp in source code couples deploys to brand decisions. With slots:

| Without slots | With slots | |---|---| | Edit URL in code | Drag-drop a new asset in the admin console | | Commit + PR + deploy | Cache refreshes (60s default) | | 5-30 minute roundtrip | Instant |

The slot key (storefront.home.hero, webapp.wizard.pool-type.icon-1) is the stable contract between code and brand operations. Tenants own the bindings; code is a passive consumer.

URL conventions — variants are tenant-scoped, transforms are not

Two delivery paths, by design:

| Kind | Builder | URL shape | Tenant in path? | |---|---|---|---| | Variant / preset | urlFor, srcSetFor, upload cdnUrl | <cdn>/<tenantId b36>/v/<sha>-<preset>.<ext> | Yes/4/v/<sha>-l.webp | | On-the-fly transform | transform, transformSrcSet | <cdn>/t/<dsl>/<sha>.<ext> | No/t/width=1280,.../<sha>.webp |

The transform service is content-addressed by sha and resizes from the source on demand, so it carries no tenant segment. Hand-building a transform URL as /<tenant>/t/... 404s. (Variants are stored per-tenant, so those DO carry the tenant prefix.) Always use the SDK builders rather than concatenating paths — and never prepend the tenant to a /t/ URL.

Presets

Presets are platform-wide and fixed — every tenant gets the same set, generated server-side by the asset-manager's variant pipeline. A tenant cannot define custom dimensions through the SDK; they pick which preset a slot defaults to and emit responsive srcSet for browser-side resizing.

Preset short codes — generated, do not edit by hand

A variant URL carries the code, not the preset name (/v/<sha>-l.webp, not -lg.webp). Copy the letter from here — bun run gen:docs regenerates this table from the source, so it can't have the wrong one.

| Preset | Code | |---|---| | thumb | q | | sm | s | | md | m | | lg | l | | xl | x | | original | o | | poster | p | | video | v | | aiproxy | a | | hls | h | | mp3 | mp3 |

Image presets

| Preset | Code | Max-side | Typical use | |-----------|------|--------------------|---| | thumb | q | 256×256 smart-crop | avatars, micro-tiles | | sm | s | 640 | mobile thumbs, list cards | | md | m | 1280 | desktop cards, modal previews | | lg | l | 1920 (≈2K) | hero, full-bleed | | xl | x | 3840 (4K) | print, 4K screens |

Defaults — what actually runs:

| Caller path | Variants generated on upload | |-------------------------------------------------------|------------------------------| | aq.upload(file) (SDK, no presets) | original only | | POST /assets/upload-url (HTTP direct, no presets) | original only (same as SDK) | | presets: ["original"] (either path) | original only | | presets: ["thumb","sm","md","lg"] | exactly the four listed |

Omitting presets is always equivalent to ["original"] — the platform never auto-generates the responsive ladder. This keeps logo / SVG / one-shot uploads cheap and avoids surprise storage writes (the ladder is 4× the source bytes). Apps that want responsive sizes pass them explicitly:

await aq.upload(file, {
  presets: ["thumb", "sm", "md", "lg"],   // four WebP variants
});

Or add missing variants later without re-uploading:

await aq.assets.regenerate(assetId, { presets: ["thumb", "sm", "md", "lg"] });

Variants are WebP quality: 75–80. Total stored bytes when generating the full responsive ladder ≈ 4× source bytes.

Variants never upscale. The pipeline clamps each preset's target to min(presetMaxSide, sourceMaxSide). A 1080×720 photo asked for xl (3840) yields a 1080×720 xl variant, not a blurry 3840-wide stretch. The preset is a ceiling, not a target.

Non-image / passthrough

| Preset | Code | Purpose | |-----------|------|---| | original| o | Raw uploaded bytes, no transformation. Used today for non-image MIMEs (PDFs, audio, etc.). |

Video presets

| Preset | Code | Purpose | |-----------|------|------------------------| | poster | p | extracted poster WebP | | video | v | original MP4 | | aiproxy | a | low-res proxy for AI captioning / search (opt-in) |

⚠️ What you can ASK FOR is not what a variant can BE

Two different sets, two different types, and confusing them is the most expensive type error this package has shipped — three unknown agents evaluating the SDK hit it independently in one afternoon.

aq.assets.regenerate(id, { presets: ["hls"] });   // ← used to compile. HTTP 400.
aq.upload(file,          { presets: ["mp3"] });   // ← used to compile. HTTP 400.

Both symbols are real. Neither is orderable.

| | RequestablePreset — you may ask | VariantPreset — a variant may be | |---|---|---| | thumb sm md lg xl original poster video aiproxy | ✅ | ✅ | | hls — the adaptive ladder, built when a video transcodes | ❌ | ✅ | | mp3 — emitted alongside an audio original only when the original is not already universally playable (i.e. NOT audio/mpeg, audio/mp4, audio/aac) | ❌ | ✅ | | probe — indexed stills (-pr0.jpg), never on the compact presets string | ✅ | ❌ |

The write methods (upload, presignUploadUrl, regenerate) take RequestablePreset[], so all three lines above are now compile errors. The read helpers (hasPreset, variants[].preset, PRESET_SHORT) keep VariantPreset, because hls and mp3 genuinely do exist on assets.

probe was the same bug mirrored: the server has always accepted it and the type forbade it. It is requestable now.

This is checked in CI against the server itself — scripts/check-preset-contract.ts reads the Elysia schemas of all four write routes and asserts RequestablePreset still equals what they accept, so it cannot go stale the way a hand-written list would.

Per-upload preset selection

The SDK's default is ["original"] — calling aq.upload(file) with no presets option stores only the raw bytes. Add the responsive ladder when you actually need it, or generate it later with aq.assets.regenerate() (no re-upload required).

// Default: only the original variant lands on the CDN.
const { assetId, cdnUrl } = await aq.upload(logoFile);
// asset.presets === "o"
// cdnUrl = https://8ok.uk/<tid b36>/v/<sha>-o.svg   ← the tenant segment is not optional

// Processing is asynchronous. `upload()` returns as soon as the bytes are
// accepted; the variants are not on the CDN yet. Wait for them:
const ready = await aq.assets.waitReady(assetId, 90_000); // MILLISECONDS
// ready.presets is the compact string ("o", "lmoqs", …), ALPHABETICALLY
// sorted — not in the order you asked for.

Responsive ladder (the old default — now explicit):

await aq.upload(heroFile, {
  fileName: "homepage-hero.jpg",
  presets: ["thumb", "sm", "md", "lg"],  // classic 4-step
});

4K hero with the full size ladder:

await aq.upload(heroFile, {
  presets: ["thumb", "sm", "md", "lg", "xl"],
});
// asset.presets === "qsmlx"; aq.srcSetFor(asset) now emits an xl entry.

Video without the AI proxy transcode:

await aq.upload(videoFile, {
  presets: ["poster", "video"],  // skip aiproxy
});

Large video — bump the readiness timeout:

aq.upload() waits up to 5 minutes by default for the asset to transition to ready. Transcode time scales with input size and CPU, so videos ≥30 MB (especially HLS ladders) can blow past that. Opt in to a longer deadline via timeoutMs:

// Large video (>30MB): bump timeout to 15min
await aq.upload(file, { timeoutMs: 15 * 60_000 });

The default is unchanged — existing call sites need no migration.

Variants never upscale — each size preset is a ceiling, not a target. A 1080×720 source asked for xl (3840) yields a 1080×720 xl variant.

Caveat (content-addressed dedup): if someone already uploaded the same bytes with different presets, aq.upload() returns the existing asset without re-processing. The result's cdnUrl will use whichever preset is actually available on that asset (the SDK picks lg → md → sm → thumb → xl → original for images, video → poster for videos). To add variants to an existing asset, use aq.assets.regenerate().

Adding variants later (no re-upload)

The most common flow with the new default:

// Day 0 — upload original only.
const { assetId } = await aq.upload(logoFile);

// Day 7 — peek at what's there.
const variants = await aq.assets.variants(assetId);
console.log(variants.map((v) => v.preset));  // → ["original"]

// Day 7 — need a thumb for an avatar slot. Generate it server-side,
// merged with the existing variant set.
await aq.assets.regenerate(assetId, { presets: ["thumb"] });

const after = await aq.assets.variants(assetId);
console.log(after.map((v) => v.preset));     // → ["original", "thumb"]

regenerate() merges — existing variants you didn't ask for stay put. You can call it many times; it's idempotent per preset.

Source-byte lifecycle (why regenerate always works)

Your bytes exist in two places with very different lifetimes:

| Bytes | Role | Lifetime | |---|---|---| | The upload you PUT to the presigned URL | Read once to verify the SHA. Never CDN-served. | Deleted ~24h after upload | | The stored variants | The CDN-served files. The original variant is the permanent home of your source bytes. | Permanent — only deleted by an explicit DELETE on the asset or variant |

⚠️ That 24h is why you should ask for original. Once the grace window closes, the highest-fidelity bytes still on the platform are whatever variants you asked for.

regenerate() walks a fidelity-ordered fallback chain until it finds usable bytes — it never fails on a still-present asset:

1. original   ← permanent, your exact source (best)
2. raw        ← the upload bytes, only during the 24h grace
3. xl → lg → md → sm
4. thumb      ← last resort: it is a smart-cropped square, so anything
                derived from it inherits the thumb's crop

The no-upscale clamp guarantees we never invent pixels: asking for lg (1920) from a 640 sm source yields a 640-side lg variant. The result type's sourceUsed field tells you which fallback was picked so you can decide whether the quality is good enough:

const result = await aq.assets.regenerate(assetId, { presets: ["xl"] });
if (result.kind === "image" && result.sourceUsed !== "original" && result.sourceUsed !== "raw") {
  console.warn(`xl was derived from ${result.sourceUsed} — quality degraded`);
}

Recommendation: include "original" in the upload preset list when you want guaranteed lossless future re-derivation. The SDK's default (["original"]) already does this for you.

aq.assets.variants(id) and aq.assets.regenerate(id, opts) are fully typed — your editor autocompletes the preset names and the returned shape gives you { preset, url, width?, height?, bytes }[].

On-the-fly transformations

Pre-generated presets (thumb/sm/md/lg/xl) cover the common cases. For everything else — exact CSS pixel widths, art-directed crops, devicePixelRatio ladders, square thumbs from rectangular sources — build a /t/ URL.

aq.transform() or getTransformUrl()? Both, and the rule is which you have

Two agents evaluating this SDK picked different ones for the same task and both were right, which is a documentation failure rather than a design one. They are not duplicates: aq.transform() is getTransformUrl() plus the client's context.

| you have | use | why | |---|---|---| | a client (aq) | aq.transform(asset, opts) | it already knows your CDN base and tenant (⚠️ see the multi-tenant warning below), and it is the only one that can signaq.transform(asset, opts, { sign: true }) returns a ?sig= URL, the only way to an arbitrary width |

⚠️ THE TENANT IS ONE MODULE-WIDE GLOBAL, NOT PER-CLIENT STATE.

Constructing a NitidaClient calls setTenantId() — the same global the standalone setter writes. The last client constructed wins, and every client made before it silently starts emitting the other tenant's URLs. Measured:

const a = new NitidaClient({ …, tenantId: 15 });
a.urlFor(asset, "md");   // → https://8ok.uk/f/v/<sha>-m.webp   ✅

const b = new NitidaClient({ …, tenantId: 4 });
a.urlFor(asset, "md");   // → https://8ok.uk/4/v/<sha>-m.webp   ❌ 404, no error

It affects everything that carries a tenant segment — urlFor, srcSetFor, getAssetUrl, and the private /a/{tenant}/… tree. Public transform() has no tenant segment, so it is unaffected.

In a multi-tenant process (a BFF, a cron, a migration) use one process per tenant, or call setTenantId(n) immediately before each block of URL building. Holding two clients and trusting each to remember its own tenant does not work. | only a DTO — a component, a Server Component, a worker | getTransformUrl(asset, opts) | no client needed. Call setCdnBase() / setTenantId() once at module load first |

Same builder underneath, same URL out. If you are holding a client, use its method — reaching for the standalone one there means passing the tenant twice and losing signing.

import { NitidaClient } from "@nitida/sdk";

const aq = new NitidaClient({ /* ... */ });
const asset = await aq.assets.byHash(sha256);

// Single URL
<Image
  src={aq.transform(asset!, { width: 1280, format: "auto" })}
  alt="..."
/>

// Responsive — one transform URL per width, all other params shared
<Image
  src={aq.transform(asset!, { width: 1280 })}
  srcSet={aq.transformSrcSet(asset!, [640, 960, 1280, 1920])}
  sizes="(max-width: 768px) 100vw, 50vw"
  alt="..."
/>

DSL params

| Param | Values | Default | |---|---|---| | width | a TransformWidth — predefined ladder; off-ladder → compile error (see note) | — | | height | 17680 (number; aspect ratio derived from width) | — | | fit | cover / contain / fill / inside / outside | cover | | gravity | auto / face / center / north / south / east / west | center | | format | auto / avif / webp / jpeg / png | auto (→ webp) | | quality | auto / 1100 | auto (source-complexity-adaptive) | | dpr | 1 / 2 / 3 | 1 |

width is strongly typed. TransformOptions.width is a TransformWidth — the predefined CDN ladder below, exported as TRANSFORM_WIDTHS. An off-ladder width is a compile error.

96, 128, 160, 180, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840 — 21 widths.

⚠️ The type is narrower than the edge, on purpose. The edge whitelists a longer list (28 today — its own 400 response enumerates them, and 512, 768, 1800, 2160, 2400, 2700, 2880 and 3600 all serve 200 unsigned). So a width outside the TYPE is not automatically a 400; a width outside the EDGE's list is. Signing is the only way to an arbitrary width — not to every width outside TransformWidth. Need an arbitrary one? Sign itaq.transform(asset, { width: 1490 }, { sign: true }) and getSignedTransformUrl take SignedTransformOptions (where width widens to number); a valid ?sig= earns the edge-whitelist bypass.

⚠️ Signed widths have two ceilings the edge does not announce (measured 2026-08-24):

  1. 7680 is the hard cap. 7680 → 200, 7681 → 400. Unsigned, the real cap is 3840 — so 3841…4320 is a dead band the old 400 message called "in range", and 4321…7680 works signed while that message called it impossible.
  2. Above the source width it CLAMPS and still answers 200. /t/ never upscales. Ask for 5000 on an 800 px master and you get 800 px of bytes with HTTP 200 — no error, no warning. Read x-transform-dsl on the response (it names the width actually used) or size against the source, if the exact width matters. transformSrcSet / getTransformSrcSet deliberately keep number[] because a responsive ladder may legitimately include DPR-row widths (e.g. 2400). height stays number — for the responsive path it's derived from width by aspect ratio; only fixed-canvas crops / genfill set it explicitly.

format=auto resolves to WebP by default. Our bench over 100 random production lg.webp samples showed AVIF was 4.5–9.8% larger than WebP in every source-size bracket with virtually identical SSIM. The policy is re-evaluated as libavif improves, so auto may resolve differently in the future — that is the point of asking for auto instead of naming a format.

gravity=face detects the highest-confidence face and crops to it with sensible padding (50 % of face dimensions on each side, clamped to source bounds, keeping the requested aspect ratio). When no face is detected it falls back to saliency-based cropping. The response always carries X-Transform-Face, with one of two values: matched when a face was found, fallback when it saliency-cropped.

⚠️ Check the VALUE, not the presence. The header is emitted only for gravity=face — with center or auto it is absent, and that absence does not mean "no face". Code that tests for presence is right 0 % of the time on photos that do contain a face. Cost to you: ~10 ms warm; ~150 ms on the first request after a cold start.

quality=auto adapts the per-format quality to source complexity (the luminance standard deviation of the source):

| Bucket (stddev) | WebP | AVIF | JPEG | |---|---:|---:|---:| | simple (< 25) — logos, solids | 55 | 45 | 70 | | normal (25–55) — most photos | 70 | 60 | 80 | | complex (≥ 55) — busy textures | 72 | 65 | 82 |

Validated on 100 random production lg.webp samples: +15.4 % bytes saved vs fixed quality=80 baseline, |ΔSSIM| 0.0011 (budget 0.005). The bucket boundaries and the per-format table are server-side policy and may be re-tuned; pass an explicit quality= when you need a number that does not move.

Canonicalization & caching

URLs with the same params in different order share the same cache entry:

aq.transform(asset, { width: 480, fit: "contain" })
// → https://8ok.uk/t/fit=contain,width=480/<sha>.webp

aq.transform(asset, { fit: "contain", width: 480 })
// → https://8ok.uk/t/fit=contain,width=480/<sha>.webp   (same URL)

The server canonicalizes incoming DSL the same way the SDK does (sort keys alphabetically, lowercase string values, drop undefined) and hashes the canonical string into the cache key — so even non-SDK URLs (e.g. typed by a developer in a browser bar) collapse onto the same cache entry as long as they specify the same params.

Signed URLs + strict mode (Phase 3)

Every tenant has an HMAC-SHA256 signing key — 32 random bytes, generated on tenant creation.

Where you get it: the response that created your project, once. POST /admin/projects returns signingKey next to the three API keys, and the console shows it in the same panel. Save it with the keys — nothing else hands it out. In particular GET /admin/projects/:code does not return it, and neither does any tenant-scoped endpoint.

Your project already exists and you never saw a signing key? Then you never got one: projects created before 2026-08-23 were not handed it, and there is no endpoint that shows you the current one. This is the exact wall to hit, so here is the way through it:

  • If you have not signed any URLs yet — which is true of every project that has not shipped private assets — ask us to rotate. Rotation returns a key, and with nothing in flight it invalidates nothing. It is free.
  • If you already have signed URLs in circulation, rotation kills them. Ask us for the current key instead; we can read it.

Either way it is one request to us, because POST /admin/projects/:code/rotate-signing-key needs the system-scope key the platform operator holds — your own admin key answers 403 SYSTEM_KEY_REQUIRED.

Optionally enable strict_transforms = true to reject unsigned URLs with a 401 — useful when transform URLs leak from a private surface (internal admin, b2b portal) and you don't want third parties generating arbitrary crops.

const aq = new NitidaClient({
  endpoint:   process.env.AQUIENPZ_URL!,
  apiKey:     process.env.AQUIENPZ_API_KEY!,
  tenantCode: "your-tenant",
  tenantId:   42,
  // Handed to you once, by the response that created the project.
  // do NOT ship to the browser.
  signingKey: process.env.AQUIENPZ_SIGNING_KEY!,
});

// Async when { sign: true } is set — overload returns Promise<string>.
// `expiresInSeconds` is REQUIRED: a signature with no expiry is a bearer
// token with no expiry. 120 s minimum, 7 days maximum.
const signed = await aq.transform(
  asset,
  { width: 1280 },
  { sign: true, expiresInSeconds: 3600 },
);
// → https://8ok.uk/t/width=1280/<sha>.webp?kid=<8-hex>&exp=<unix>&sig=<64-hex>

// Responsive
const srcset = await aq.transformSrcSet(
  asset,
  [640, 960, 1280, 1920],
  {},
  { sign: true, expiresInSeconds: 3600 },
);

Signature shape:

message = "nitida/transform/v2\n<tenantId base36>\n<exp>\n<canonical-DSL>/<filename>"
sig     = HMAC-SHA256(signingKey, message)      // hex
kid     = HMAC-SHA256(signingKey, "nitida/kid/v1").slice(0, 8)

The server canonicalizes the URL the same way the SDK does (sort keys, lowercase strings), so two URLs with the same params in different order accept the same signature.

kid names the key so a rotation does not kill URLs already in flight: the outgoing key keeps verifying for 14 days (POST /admin/projects/:code/rotate-signing-key returns the new key, its kid, and when the previous one retires). The rotation runbook lives in the private source repository — ask the platform owner if you need it.

⚠️ A signature does NOT widen the ladder. Off-ladder widths are 400 at the edge whether or not the URL is signed. Until @nitida/asset-client 0.22.0 a valid-looking ?sig= skipped the edge whitelist entirely, so a signature bought an arbitrary width up to 7680 px — one request measured at 1 850 MB. What a signature buys now is IDENTITY: which tenant asked, proven with its key, until exp. That is what strict_transforms and the effect=genfill cost guard require.

Private assets — visibility

Every asset carries visibility, and the default is "public".

| | "public" | "private" | |---|---|---| | stored variants, raw, HLS ladder, transforms | served to anyone with the URL | 404, to everyone | | how the bytes come back | the URL | a signed URL under /a/{tenant}/…?exp&sig | | expiry | none | mandatory | | edge caching | shared, effectively free | private, per-viewer |

// Flip it (write scope):
await fetch(`${endpoint}/assets/${assetId}/visibility`, {
  method: "PATCH",
  headers: { Authorization: `Bearer ${apiKey}`, "X-Tenant-Code": code,
             "Content-Type": "application/json" },
  body: JSON.stringify({ visibility: "private" }),
});

// Hand a viewer the bytes — ON YOUR BACKEND:
import { getPrivateAssetUrl, getPrivateTransformUrl } from "@nitida/sdk";

const url = await getPrivateAssetUrl(asset, "lg", signingKey, {
  expiresInSeconds: 300,
});
// → https://8ok.uk/a/5/v/<sha>-l.webp?exp=…&sig=…

// …or any width/crop/format, not just the materialised ones:
const resized = await getPrivateTransformUrl(
  asset,
  { width: 1280, format: "webp" },
  signingKey,
  { expiresInSeconds: 300 },
);
// → https://8ok.uk/a/5/t/format=webp,width=1280/<sha>.webp?exp=…&sig=…

Nothing moves when you flip it: one row changes and the edge cache for that asset is purged, so a 1 GB video flips as fast as a thumbnail. It is the same single copy behind both doors.

Four things worth knowing before you rely on it:

  1. A 404 on a private asset is the feature, not a missing file. Everywhere else here a 404 means the object was never written. Check visibility on the DTO before you check storage.
  2. The SDK refuses instead of handing you a URL that 404s. getAssetUrl, getAssetSrcSet, getTransformUrl, getTransformSrcSet, getVideoTransformUrl and getHlsStreamingUrl all throw when the value you pass says visibility: "private" — with a message naming getPrivateAssetUrl. Pass only { sha } and there is nothing to check.
  3. Revocation is "within a minute". Flipping back to private really does kill URLs already handed out — the edge is purged — but the CDN refreshes its list of private assets on a 60-second TTL.
  4. The signing key is a backend secret. It mints URLs for every private asset the tenant owns. It is a different claim from the transform ?sig= above, computed under a separately derived key, so a signature minted to resize can never be replayed as one to enter.

⚠️ Admin operations need the SYSTEM key, not the admin key in your triplet

Both are amk_ad_*, and that is the whole trap. The admin key issued with your tenant is tenant-scoped: it manages your assets and cannot touch /admin/projects/*. Verified 2026-08-21:

tenant admin key  → GET /admin/projects → 403 SYSTEM_KEY_REQUIRED
                    "API key … is tenant-scoped; this endpoint requires a
                     system-scope key"
system admin key  → GET /admin/projects → 200

There is exactly one system-scope key and we hold it. These calls are ours to run, not yours — ask, and we run them. They are documented here so you know what exists and can name what you want done, not so you can copy the amk_ad_ out of your own triplet and get a 403 that reads like a broken credential.

# Rotate the signing key — invalidates every URL signed with the old one.
curl -X POST -H "Authorization: Bearer <SYSTEM amk_ad_...>" \
  https://api.nitida.gofuture.space/admin/projects/your-tenant/rotate-signing-key
# → { ok: true, tenantId, code, signingKey: "<64-hex>" }

# Flip strict mode on/off.
curl -X PATCH -H "Authorization: Bearer <SYSTEM amk_ad_...>" -H "Content-Type: application/json" \
  -d '{"enabled":true}' \
  https://api.nitida.gofuture.space/admin/projects/your-tenant/strict-transforms

Rotation cost: cached transform variants are NOT re-keyed by the signature, so they keep serving the same bytes. Only the URLs your consumers hold need re-signing. Coordinate the rotation with anyone who pre-signs at build time (e.g. SSG / next-build).

Background removal (effect=removebg)

// Full-resolution transparent PNG cutout
const url = aq.transform(asset, { effect: "removebg" });
// → https://8ok.uk/t/effect=removebg/<sha>.png

// Cut out + resize in one call
const thumbUrl = aq.transform(asset, { effect: "removebg", width: 400 });
// → https://8ok.uk/t/effect=removebg,width=400/<sha>.png

// Or convert format alone (no resize / no effect) — useful e.g. to
// force a JPEG copy of a WebP source for legacy email clients
const jpegUrl = aq.transform(asset, { format: "jpeg" });
// → https://8ok.uk/t/format=jpeg/<sha>.jpg

effect=removebg runs on one of two server-side matting backends, chosen per deployment. What the caller sees:

  • CPU matting (default) — ~1-2 s warm, ~5-7 s cold. No per-image cost.
  • GPU matting — ~3-8 s, ~$0.001-0.005 per image, better edges on hair and fine detail.

Either way the contract is the same, and the first request is the only one that pays. In both cases the route caches the PNG under the standard <sha>-t<dslHash>.png key, so subsequent identical requests are 302 redirects to the CDN — no inference, no per-image cost. Always forces format=png because the entire point is preserving alpha.

The output is the same dimensions as the source. Chain with width to resize the cutout in a single request (cached as one entry per canonical DSL).

The matting model is server-side and may be swapped for a better one without any change on your side — the URL, the PNG-with-alpha output and the cache semantics are the contract.

Generative fill / aspect outpaint (effect=genfill)

⚠️ genfill only works on SIGNED URLs. It is a cost guard: the effect runs a generative model per unique tuple. Without { sign: true } the edge answers 401 {"error":"signature_required"} — whether or not your tenant has strict_transforms, and unlike width= or effect=removebg, which serve 200 unsigned. You need the project's signing key (see Signed URLs); for a project created before 2026-08-23, ask your operator — it cannot be recovered afterwards. Every example below therefore passes { sign: true }.

Extend a source image into a different aspect ratio without the awkward edge mirroring that classic content-aware fill produces. Primary use case: building OG cards (1200×630) from portrait listing photos, or 1:1 social tiles from 16:9 originals.

// 1200×630 OG card from a portrait listing cover — the gutters are
// generated, the source is pasted centered.
const ogUrl = await aq.transform(
  asset,
  { effect: "genfill", width: 1200, height: 630 },
  { sign: true },                       // ⚠️ sin esto: 401 signature_required
);
// → https://8ok.uk/t/effect=genfill,height=630,width=1200/<sha>.webp

// 1:1 social tile from a landscape original
const tileUrl = await aq.transform(
  asset,
  { effect: "genfill", width: 1080, height: 1080 },
  { sign: true },
);

Requires both width and height. Without them the route returns 422 — the effect needs an explicit target canvas to know what to outpaint. ⚠️ You only ever see that 422 after signing: unsigned, the 401 comes first, so a missing { sign: true } looks like a different bug than it is.

Output defaults to WebP at q=85 (~150KB for a 1200×630 OG card — 12× lighter than the raw generated PNG). Honors format= for explicit overrides:

| Format | Bytes (typical 1200×630) | Use case | |---|---|---| | format=webp (default) | ~150KB | OG cards, social tiles, storefront cards | | format=png | ~1.7MB | Lossless — print, marketing fold-outs | | format=avif | ~120KB | Modern browsers, even better compression | | format=jpeg | ~180KB | Legacy email clients |

Real-estate caveat: outpainting is mediocre when the target aspect differs heavily from the source (1:1 from horizontal photo → tiled artifacts because the model has to invent rooftops and floors). Reserve genfill for SMALL aspect deltas (OG card 1200×630 from landscape source ✓). For bigger crops, prefer gravity=auto smart-crop, which is deterministic and free — no generation, no invention.

What it costs you: ~$0.05 for the first request per (sha, dsl, format) ⚠️ The signature guards GENERATION, not DELIVERY. Once a signed transform runs, its result is written to /{tenant}/v/<sha>-t<dslHash>.<ext> and served there with HTTP 200 and no ?sig=. <dslHash> is sha256(canonical DSL) truncated to 16 — no secret in it, so anyone who guesses the DSL derives the URL. Measured: a signed width=641 (unreachable unsigned) went from 404 to 200 at the derived path after a single signed GET.

Two consequences worth planning around: turning on strict_transforms does not un-publish anything already generated, and for genfill — whose entire cost guard is the signature — the result you paid for stays publicly readable. Treat a signed transform as "pay once, publish forever", not as an access control.

tuple. Subsequent identical requests are 302 redirects to the cached WebP — zero generative cost, forever. The model is server-side and may be swapped for a better one without any change on your side; the DSL, the output and the cache semantics are the contract.

Short-circuit: when the source already matches the target aspect exactly (resized to fill the canvas with zero padding), the server returns the resized PNG without generating anything — you don't pay $0.05 for an effective no-op.

Video transforms (aq.transformVideo)

The same DSL works for videos too — the server branches on the asset's kind column. Image params (width, height, fit) carry over; video adds start (seconds, decimal OK) + duration (seconds, 1..300).

// 16:9 source → 9:16 mobile clip, first 15 s, h.264 mp4
const portraitUrl = aq.transformVideo(asset, {
  width: 1080,
  height: 1920,
  fit: "cover",
  start: 0,
  duration: 15,
});

// WebM output for bandwidth-conscious storefronts
const webmUrl = aq.transformVideo(asset, { format: "webm", width: 1280 });

First request is async. Cache miss → a background transcode runs (typically 5-30 s for short clips) → the output is stored. The route returns 202 Accepted with Retry-After: 10 and a Location header pointing at the eventual CDN URL. The response body has { status, message, retryAfterSec, outputUrl }. Subsequent requests hit the cache → 302 to the CDN.

Consumer pattern with Video.js v10 / <video>:

const src = aq.transformVideo(asset, { width: 1080, height: 1920 });
// Pass directly to <video src={src} />. While the transcode runs the
// browser sees 202 → retry; once cached, the 302 → CDN. Most players
// retry transparently; if yours doesn't, poll `src` every 5 s until
// `Response.redirected` is true or the body content-type starts with
// "video/".

Idempotent — requesting the same DSL again while the first job is still running does not start a second one, and does nothing at all once the output is cached. Fire and retry freely.

Adaptive HLS streaming (aq.streamingUrl)

For long-form video — property tours, walkthroughs — point an HLS-aware player at the master playlist:

<video
  src={aq.streamingUrl(asset)}
  controls playsInline
  // Video.js v10 ships native HLS via @videojs/http-streaming —
  // no plugin needed. Same with hls.js or iOS Safari.
/>

// Sub-clip (HLS ladder built only for the clipped range)
const teaser = aq.streamingUrl(asset, { start: 0, duration: 30 });

First request to a new HLS URL returns 202 Accepted with Retry-After: 20 while a background job builds the multi-rung ladder (typically 1-3 min for a 90 s source). Subsequent requests get 302 to the cached master.m3u8. The CDN layout:

<tid>/v/<sha>-hls<dslHash>/master.m3u8        ← entry point
<tid>/v/<sha>-hls<dslHash>/240p/playlist.m3u8
<tid>/v/<sha>-hls<dslHash>/240p/seg-000.ts
<tid>/v/<sha>-hls<dslHash>/360p/...
…
<tid>/v/<sha>-hls<dslHash>/1080p/...

The ladder shrinks to fit the source: a 480p source produces three rungs (240p / 360p / 480p), a 1080p source produces five, and a 4K source goes up to 2160p.

⚠️ Clips under 18 seconds are the exception, and they are common. A short source is restricted to the 720p–1080p band on purpose, so an 8 s 720p clip gets one rung, not four — the rung-switching a ladder exists for cannot happen inside a clip that short, and building five of them just burns transcode budget. Read switchable on the ladder rather than rungs.length: it is false exactly when there is nothing to switch between. A test asserting rungs.length > 1 will fail on every short clip. The player picks the right rung on the fly based on the current connection — a user on 3G starts at 240p and climbs to 1080p as bandwidth improves, vs the monolithic MP4 that either loaded or timed out.

Billing model

Transforms are billed as storage, not as "transformations" the way Cloudinary does — one stored object per unique canonical DSL, then served from the CDN cache forever (until manually invalidated). The cache key is deterministic, so identical DSLs across deploys/tenants don't re-encode; mounting an existing CDN URL costs zero compute.

Palette + LQIP (compact placeholder UX)

Every successfully decoded image gets a palette extracted alongside the upload — a tiny 7-color set you can use for ambient gradients, fallback backgrounds, or themed UI accents. It survives across all preset selections (yes, even presets: ["original"]), because palette is metadata derived from the source bytes, not from a specific resized variant.

import {
  getPaletteBlurBackground,
  pickAmbientBackground,
  getAmbientGradient,
  getTextColorForBackground,
} from "@nitida/sdk";

const asset = await aq.assets.get(assetId);
//   asset.palette = { d: "#1a1a1a", v: "#c5a95e", m: "#8b7d4f", ... }
//   asset.blur    = "data:image/webp;base64,UklGRhAA..."  // tiny LQIP

// Compact 4-stop gradient for hero / card backgrounds:
const bg = getAmbientGradient(asset.palette);
//   bg = "linear-gradient(135deg, oklch(...), oklch(...))"

// Auto-pick text color that contrasts with the chosen ambient:
// ⚠️ `getTextColorForBackground` toma UN SWATCH, no la paleta entera.
// Pasarle `asset.palette` tira `TypeError: … evaluating 'hex.replace'`.
const bg = pickAmbientBackground(asset.palette);   // PaletteSwatch | null
const fg = getTextColorForBackground(bg);
//   fg = "#fff" | "#000" | similar

// Or just the blurry LQIP for a CSS background placeholder:
const placeholder = getPaletteBlurBackground(asset.palette);

The wire format is intentionally tight: {d, v, m, dv, lv, dm, lm} (dominant, vibrant, muted, dark-vibrant, light-vibrant, dark-muted, light-muted) — up to 7 hex strings per asset, much smaller than a full base64 LQIP but composing into nicer ambient UX.

⚠️ Only d (dominant) is guaranteed. Every other key is optional, because only the swatches the source actually had get extracted — measured across 30 assets, palettes carry anywhere from 2 to 7 keys. Never index one directly: palette.dv on a 2-swatch palette emits linear-gradient(135deg, #1a1a1a, undefined). Use the helpers (pickAmbientBackground, getAmbientGradient, getPaletteCssVars), which skip the missing ones.

When the image can't be decoded (SVG sources, exotic formats, deliberately corrupted bytes), palette + blur silently come back null. The rest of the pipeline still succeeds.

Per-slot default:

await aq.slots.bind("storefront.hero", { assetId, preset: "lg" });

Per-call override (the resolver respects this over the slot's default):

const { url } = await aq.slots.resolve("storefront.hero", { preset: "md" });

Responsive <img srcSet> across all available presets:

<img
  src={aq.urlFor(asset, "lg")}
  srcSet={aq.srcSetFor(asset)}
  sizes="(max-width: 768px) 100vw, 1280px"
  alt=""
/>

If you need a dimension that doesn't exist, two options: use the closest preset and let the browser scale, or file an issue to add it to the server-side pipeline (a platform-wide addition, not a per-tenant one).

⭐ What happens when two tenants upload the SAME image

Dedup is content-addressed and cross-tenant: the sha is a hash of the bytes, so if you and another customer upload the same logo, the same stock photo or the same placeholder, you share a candidate row. This is normal, not exotic.

A /t/… transform URL names content, not a tenant — there is no tenant segment in it. Three consequences follow, and all three are measured behaviour:

1 · A signed transform is resolved by the SIGNATURE, not by tenant id. The signature proves possession of one specific tenant's key, which is exactly the identity the URL lacks. Before 2026-08-25 the lowest tenant id won, so a correctly signed URL could be verified against someone else's key and answer invalid_signature. Nothing to do on your side — just know the signature is what disambiguates.

2 · strict_transforms fails CLOSED across every tenant that shares the sha. If any tenant holding those bytes requires signed URLs, the unsigned request is refused — because an unsigned request cannot say which tenant it belongs to, and picking the most permissive one would let a third party defeat your policy.

⚠️ The cost, stated plainly: if you do not use strict_transforms but you share content with someone who does, that sha needs a signed URL from you too. The alternative — failing open — means your own strict setting is silently cancelled by a stranger. A 401 you fix by signing is the cheaper mistake.

3 · Making an asset private does NOT unpublish another tenant's public copy. visibility: "private" retracts your row. If another tenant uploaded the same bytes and left them public, that copy keeps serving, and a /t/ request resolves to it. No new information leaks — those exact bytes were already public — but do not read "private" as "these bytes are now unreachable". It means "reachable through me only by signature".

If that distinction matters for your content, the answer is not a flag: it is not to share the bytes. Anything unique to you (a customer photo, a document, a render) has a unique sha and never collides.

CDN URL format

<cdnBase>/<tenantId base36>/v/<sha16>-<presetCode>.<ext>

Example: https://8ok.uk/f/v/<sha16>-q.webp — the thumb preset of a 16-hex sha as WebP, for tenant 15 (15 in base36 is f).

<sha16> is a placeholder on purpose. A concrete sha pinned here rots: the previous example pasted a real tenant-4 URL with the prefix swapped to f, so it 404'd on every preset while the identical path under /4/ served 200. Substitute a sha from your own tenant — upload() returns it as sha.

⚠️ The tenant segment is not optional, and it is base36. /15/… 404s; so does a bare /<sha16>-q.webp with no tenant at all. Nothing serves that shape — it is not a legacy path, it is a 404. Call setTenantId(id) once at boot (or construct a NitidaClient with tenantId, which does it for you); without it the builders throw rather than hand you a URL that cannot work.

Within one tenant the path is content-addressed: the same source bytes always produce the same URL, and that URL never invalidates.

Before @nitida/[email protected] this section documented the bare <cdnBase>/<sha16>-<preset>.<ext> form, with an example that 404s, and said the URL was the same "regardless of which tenant uploaded them". That stopped being true at the tenant-prefix cutover. If you copied a U