@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.
Maintainers
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 | createExpoUploader — URLSession / 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/nativeand/or@nitida/sdk/expofor the native pieces. - React hooks (any env) →
@nitida/sdk/reactplus/webor/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,/serverand/weball 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-clientOptional, 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 NativePeer 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
/exposubpath uses nativeexpo-image-manipulatorinstead (already wired in the uploader). Thecompressoption onaq.uploadis 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 cropThe 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 sign — aq.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
NitidaClientcallssetTenantId()— 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 errorIt affects everything that carries a tenant segment —
urlFor,srcSetFor,getAssetUrl, and the private/a/{tenant}/…tree. Publictransform()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. CallsetCdnBase()/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 | 1–7680 (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 / 1–100 | auto (source-complexity-adaptive) |
| dpr | 1 / 2 / 3 | 1 |
widthis strongly typed.TransformOptions.widthis aTransformWidth— the predefined CDN ladder below, exported asTRANSFORM_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,2880and3600all 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 outsideTransformWidth. Need an arbitrary one? Sign it —aq.transform(asset, { width: 1490 }, { sign: true })andgetSignedTransformUrltakeSignedTransformOptions(wherewidthwidens tonumber); a valid?sig=earns the edge-whitelist bypass.⚠️ Signed widths have two ceilings the edge does not announce (measured 2026-08-24):
- 7680 is the hard cap.
7680→ 200,7681→ 400. Unsigned, the real cap is 3840 — so3841…4320is a dead band the old 400 message called "in range", and4321…7680works signed while that message called it impossible.- 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. Readx-transform-dslon the response (it names the width actually used) or size against the source, if the exact width matters.transformSrcSet/getTransformSrcSetdeliberately keepnumber[]because a responsive ladder may legitimately include DPR-row widths (e.g.2400).heightstaysnumber— for the responsive path it's derived fromwidthby aspect ratio; only fixed-canvas crops /genfillset 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/projectsreturnssigningKeynext 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 particularGET /admin/projects/:codedoes 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-keyneeds the system-scope key the platform operator holds — your own admin key answers403 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-client0.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, untilexp. That is whatstrict_transformsand theeffect=genfillcost 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:
- 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
visibilityon the DTO before you check storage. - The SDK refuses instead of handing you a URL that 404s.
getAssetUrl,getAssetSrcSet,getTransformUrl,getTransformSrcSet,getVideoTransformUrlandgetHlsStreamingUrlall throw when the value you pass saysvisibility: "private"— with a message naminggetPrivateAssetUrl. Pass only{ sha }and there is nothing to check. - Revocation is "within a minute". Flipping back to
privatereally does kill URLs already handed out — the edge is purged — but the CDN refreshes its list of private assets on a 60-second TTL. - 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 → 200There 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-transformsRotation 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>.jpgeffect=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
switchableon the ladder rather thanrungs.length: it isfalseexactly when there is nothing to switch between. A test assertingrungs.length > 1will 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_transformsbut 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 tof, 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 assha.
⚠️ 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
