@aquienpz/sdk
v0.18.0
Published
Universal SDK for the aquienpz multi-tenant asset platform. Cloudinary-style asset management with slot bindings, presets, and Better Auth API keys.
Downloads
4,524
Maintainers
Readme
@aquienpz/sdk
Universal SDK for the aquienpz multi-tenant asset 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 AquienpzClient 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.
| Where you run | Import | What it bundles |
|---|---|---|
| Node 20+, Bun, Cloud Run, Lambda, Edge, agents, cron | @aquienpz/sdk/server | AquienpzClient + URL builders + signTransformUrl. No browser-only code. Hard guarantee against shipping Blob-workflow helpers to the wrong runtime. |
| Browser (Next.js client, Vite, CRA, web workers) | @aquienpz/sdk/web | AquienpzClient + everything in /server + createWebUploader (multipart, parallel parts, IndexedDB resume) + compressImage (HEIC→WebP, compressorjs). |
| React Native / Expo apps | @aquienpz/sdk/native | compressImage for RN (libjpeg-turbo via react-native-compressor). Pair with /expo. |
| Expo background uploads | @aquienpz/sdk/expo | createExpoUploader — native URLSession (iOS) / WorkManager (Android) sessions that survive app backgrounding. |
| Next.js / React (hooks) | @aquienpz/sdk/react | AquienpzProvider + useSlot / useSlots / useAsset. |
| Backwards-compat (root) | @aquienpz/sdk | Same shape as today; equivalent to /web for the public symbols. Prefer the explicit subpath in new code. |
Uploaders + compressors are optional peer deps (@aquienpz/asset-uploader-{web,expo},
@aquienpz/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 →
@aquienpz/sdk/server. - Browser components, build-time pre-resolution →
@aquienpz/sdk/web. - Expo / React Native app →
@aquienpz/sdk/native+@aquienpz/sdk/expo. - React hooks (any env) →
@aquienpz/sdk/react.
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 aquienpz with the real key.
Which subpath do I import from?
| Context | Import | Notes |
|---|---|---|
| Browser (Next.js client component, SPA, Worker, web extension) | @aquienpz/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) | @aquienpz/sdk/server | Requires apiKey at construction. |
| React Native / Expo | @aquienpz/sdk/native or @aquienpz/sdk/expo | Same BFF model as /web — keep keys server-side. |
| Universal React hook | @aquienpz/sdk/react | Wraps the right subpath for the runtime. |
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.
@aquienpz/sdk/web v0.17+ enforces this physically: the constructor type
is Omit<AquienpzClientOptions, "apiKey" | "signingKey">. Passing apiKey
won't compile, period.
BFF-proxy mode (browser → your route → aquienpz)
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 aquienpz. Same pattern Vercel Blob uses for
@vercel/blob/client.upload.
Next.js — client component
"use client";
import { AquienpzClient } from "@aquienpz/sdk/web";
const aq = new AquienpzClient({
endpoint: "/api/am", // OK: relative → same-origin BFF
tenantCode: "realtyone-cr",
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. "realtyone-cr"
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 { AquienpzClient } from "@aquienpz/sdk/server";
const aq = new AquienpzClient({
endpoint: process.env.AQUIENPZ_URL!,
apiKey: process.env.AQUIENPZ_API_KEY!, // OK: required by ServerClientOptions
tenantCode: "realtyone-cr",
tenantId: 1,
});Expo
import { AquienpzClient } from "@aquienpz/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 aquienpz URL —
no proxy needed because keys never reach the browser bundle. Static
HTML output references the CDN directly.
Install
bun add @aquienpz/sdk @aquienpz/asset-client
# or
npm install @aquienpz/sdk @aquienpz/asset-clientOptional, only if you need large uploads:
# Web / Vite / Next.js client uploads
bun add @aquienpz/asset-uploader-web
# Expo / React Native background uploads
bun add @aquienpz/asset-uploader-expoPeer deps: @aquienpz/asset-client (URL builders + types) and react (only if
you use the /react subpath).
Quick start
import { AquienpzClient } from "@aquienpz/sdk";
const aq = new AquienpzClient({
endpoint: "https://aquienpz-asset-manager-xxxx.run.app",
apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY!, // amk_rt_* Better Auth API key
tenantCode: "your-tenant",
tenantId: 42, // numeric tenant id (used in CDN URL prefix)
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; admin rebinds from asset-lab-web.
const hero = await aq.slots.resolve("storefront.home.hero");
// → { slot: { asset, preset, … }, preset: "lg", url: "https://8ok.uk/2a/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.
const asset = await aq.assets.byHash("3c…<64 hex>…");
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, cdnUrl }
// 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 (matched to the realtyone-cr production webapp:
LISTING_STANDARD_* constants):
| 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
@aquienpz/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 server-side
assets.client_original_bytescolumn is populated from the original pre-compression size, surfacing the savings story in admin dashboards.
Direct access to the compressor (without going through aq.upload):
import { compressImage, compressImages, DEFAULT_COMPRESSION_OPTIONS }
from "@aquienpz/sdk/web";
const { blob, originalBytes } = await compressImage(file, { quality: 0.85 });
const results = await compressImages([fileA, fileB, fileC]);Large uploads (web)
The core aq.upload() handles files under 50 MB in a single PUT. For
bigger files use the /web subpath — it delegates to
@aquienpz/asset-uploader-web's multipart UploadTask (chunks, parallel
parts, retries with backoff, IndexedDB-persisted progress, Web Worker
SHA-256).
import { createWebUploader } from "@aquienpz/sdk/web";
const task = createWebUploader(aq, { file });
task.on("progress", ({ ratio }) => setProgress(ratio));
task.on("ready", ({ assetId }) => bindSlot(assetId));
const { assetId, deduped } = await task.start();Peer dep: @aquienpz/asset-uploader-web (lazy — apps that only need
small uploads skip it).
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 { AquienpzClient } from "@aquienpz/sdk";
import {
createExpoUploader,
listResumableSessions,
} from "@aquienpz/sdk/expo";
const aq = new AquienpzClient({
endpoint: process.env.EXPO_PUBLIC_AQUIENPZ_URL!,
apiKey: process.env.EXPO_PUBLIC_AQUIENPZ_API_KEY!, // amk_rt_*
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 }}/> */;
}Peer dep: @aquienpz/asset-uploader-expo. Ships a config plugin for the Expo
prebuild step (background mode + native module registration); add it to
app.json under plugins before running expo prebuild.
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 { AquienpzClient } from "@aquienpz/sdk";
const aq = new AquienpzClient({
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 { AquienpzClient } from "@aquienpz/sdk";
import { AquienpzProvider } from "@aquienpz/sdk/react";
// In production, get apiKey from a /session route instead of bundling it.
const client = new AquienpzClient({
endpoint: process.env.NEXT_PUBLIC_AQUIENPZ_URL!,
apiKey: process.env.NEXT_PUBLIC_AQUIENPZ_API_KEY!,
tenantCode: "your-tenant",
tenantId: 42,
});
export function Providers({ children }: { children: React.ReactNode }) {
return <AquienpzProvider client={client}>{children}</AquienpzProvider>;
}// app/hero.tsx
"use client";
import { useSlot } from "@aquienpz/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 asset-lab-web | | 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.
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.
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 R2 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 R2 cost 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) |
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/<sha>-o.svgResponsive 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)
Two distinct R2 prefixes; most people only care about one:
| R2 path | Role | Lifetime |
|---|---|---|
| raw/<sha>.<ext> | Landing zone for the presigned PUT. /process reads it once to verify the SHA. Never CDN-served. | Deleted ~24h after upload by the cleanup job |
| variants/<sha>-<preset>.<ext> | The CDN-served files. <sha>-o.<ext> is the permanent home of original. | Permanent — only deleted by explicit DELETE on the asset or variant |
regenerate() walks a fidelity-ordered fallback chain until it finds
usable bytes — it never fails on a still-present asset:
1. variants/<sha>-o.<ext> ← permanent original (best)
2. raw/<sha>.<ext> ← exact upload bytes, only during the 24h grace
3. variants/<sha>-x.webp ← xl
4. variants/<sha>-l.webp ← lg
5. variants/<sha>-m.webp ← md
6. variants/<sha>-s.webp ← sm
7. variants/<sha>-q.webp ← thumb (last resort — smart-cropped square,
so aspect ratio of derived variants
will inherit 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 — call aq.transform():
import { AquienpzClient } from "@aquienpz/sdk";
const aq = new AquienpzClient({ /* ... */ });
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 | 1–7680 | — |
| height | 1–7680 | — |
| 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 |
format=auto resolves to WebP by default. The bench in apps/asset-manager/bench/avif-vs-webp-summary.md
(100 random realtyone-cr lg.webp samples, sharp 0.34.5) showed AVIF was 4.5–9.8% larger
than WebP in every source-size bracket with virtually identical SSIM. The policy lives
in a single function (pickAutoFormat in transform.format-policy.ts) so re-evaluation
when libavif improves is a one-file swap.
gravity=face runs the source image through a ~1 MB Ultra-Light face
detector (ultraface-RFB-320 via ONNX Runtime) and crops to the
highest-confidence face 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 sharp's attention
strategy and returns the response with X-Transform-Face: fallback so
you can detect the cache state. Cost: ~10 ms inference per image
(warm); ~150 ms cold-start on the first request after a new Cloud Run
instance boots.
quality=auto adapts the per-format quality to source complexity
(luminance stddev via sharp .stats()):
| 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 realtyone-cr lg.webp samples: +15.4 %
bytes saved vs fixed quality=80 baseline, |ΔSSIM| 0.0011 (budget
0.005). See bench/auto-quality-summary.md. Both the bucket
boundaries and per-format table live in
transform.quality-policy.ts — re-tune by editing the constant and
re-running bun run apps/asset-manager/scripts/bench-auto-quality.ts.
Canonicalization & caching
URLs with the same params in different order share the same R2 cache entry:
aq.transform(asset, { width: 500, fit: "contain" })
// → https://8ok.uk/t/fit=contain,width=500/<sha>.webp
aq.transform(asset, { fit: "contain", width: 500 })
// → https://8ok.uk/t/fit=contain,width=500/<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 R2 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 (signing_key column in
public.tenants, 32 random bytes generated on tenant creation).
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 AquienpzClient({
endpoint: process.env.AQUIENPZ_URL!,
apiKey: process.env.AQUIENPZ_API_KEY!,
tenantCode: "your-tenant",
tenantId: 42,
// Fetch via GET /admin/projects/your-tenant; do NOT ship to the browser.
signingKey: process.env.AQUIENPZ_SIGNING_KEY!,
});
// Async when { sign: true } is set — overload returns Promise<string>.
const signed = await aq.transform(asset, { width: 1280 }, { sign: true });
// → https://8ok.uk/t/width=1280/<sha>.webp?sig=<64-hex>
// Responsive
const srcset = await aq.transformSrcSet(
asset,
[640, 960, 1280, 1920],
{},
{ sign: true },
);Signature shape: HMAC-SHA256(signingKey, "<canonical-DSL>/<filename>"),
hex-encoded. 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.
Admin operations (system-scope admin key):
# Rotate the signing key — invalidates every URL signed with the old one.
curl -X POST -H "Authorization: Bearer amk_ad_..." \
https://aquienpz-asset-manager.../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 amk_ad_..." -H "Content-Type: application/json" \
-d '{"enabled":true}' \
https://aquienpz-asset-manager.../admin/projects/your-tenant/strict-transformsRotation cost: cached transform variants on R2 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 is provider-pluggable via the
BG_REMOVAL_BACKEND env on the asset-manager:
local(default) — U²-Net ONNX inference, Apache 2.0 weights shipped under/app/models/u2net.onnx(~176 MB, fetched at Docker build time, sha256-verified). ~1-2 s warm CPU inference, ~5-7 s cold-start. Free runtime.replicate— proxies to Replicate's851-labs/background-remover(BRIA-quality, commercially licensed via Replicate). ~3-8 s GPU, ~$0.001-0.005 per image. RequiresREPLICATE_API_TOKENin Secret Manager.
In both cases the route caches the PNG in R2 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 R2 entry per
canonical DSL).
To re-tune or swap the model:
- Drop a new
.onnxintoapps/asset-manager/src/features/assets/bg/ - Update
MODEL_PATH+ preprocessing constants inbg/remove.ts - Update the Dockerfile's
COPY src/features/assets/bg/*.onnx /app/models/ - Re-bench against a sample set (a CSV in
apps/asset-manager/bench/makes sense once the comparison is non-trivial).
Generative fill / aspect outpaint (effect=genfill)
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 — gutters generated
// by Flux-Fill Pro, source pasted centered.
const ogUrl = aq.transform(asset, {
effect: "genfill",
width: 1200,
height: 630,
});
// → https://8ok.uk/t/effect=genfill,height=630,width=1200/<sha>.png
// 1:1 social tile from a landscape original
const tileUrl = aq.transform(asset, {
effect: "genfill",
width: 1080,
height: 1080,
});Requires both width and height. Without them the route returns
422 — the effect needs an explicit target canvas to know what to
outpaint.
Output defaults to WebP at q=85 (~150KB for a 1200×630 OG card —
12× lighter than raw Flux-Fill PNG output). 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 Replicate cost, no AI invention).
Powered by Replicate's black-forest-labs/flux-fill-pro. ~$0.05 per
first request per (sha, dsl, format) tuple; subsequent identical
requests are 302 redirects to the R2 cache — zero Replicate cost.
Requires REPLICATE_API_TOKEN in the asset-manager's Secret Manager
secrets. Same token as BG_REMOVAL_BACKEND=replicate; no separate
provisioning needed.
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 calling Replicate — 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 → Cloud Run Job runs ffmpeg
(typically 5-30 s for short clips) → output written to R2. 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 Job 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/".Implementation: extends the EXISTING asset-processing Cloud Run Job
with a transform-video op. Reuses the same Cloud Tasks dispatch
pattern as process-video and compose-slideshow. Idempotent —
re-dispatching the same DSL noops if R2 already has the output.
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 Cloud Run 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 R2 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. 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 R2 PUT 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 "@aquienpz/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:
const fg = getTextColorForBackground(asset.palette);
// 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). 7 hex strings per asset — much smaller than a full
base64 LQIP, but composes into nicer ambient UX.
When sharp can't decode the image (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).
CDN URL format
<cdnBase>/<sha16>-<presetCode>.<ext>
Example: https://8ok.uk/c482458e824c730e-q.webp (the thumb preset of
sha c482458e… rendered as WebP). The path is content-addressed, so the
same source bytes produce the same URL regardless of which tenant uploaded
them — and the same URL never invalidates.
Auth
Auth is a Better Auth API key (amk_rt_*) emitted by
aquienpz/bootstrap-project.ts per tenant. The key's metadata holds the tenant
id, so X-Tenant-Code is log-only.
API key tiers:
amk_rt_*— runtime / read-and-writeamk_ad_*— admin (slot bind, asset delete)amk_ci_*— CI / batch jobs
API surface
| Namespace | Method | Description |
|---|---|---|
| aq.slots | resolve(key) / resolveMany(keys) | Read-side, cached 60s |
| aq.slots | list({prefix}) | Admin tree view |
| aq.slots | bind(key, {assetId, preset}) | Admin rebind |
| aq.slots | unbind(key) | Remove binding |
| aq.slots | invalidateCache(key?) | After admin rebind |
| aq.assets | byHash(sha) / byHashes([]) | Lookup |
| aq.assets | list({limit, cursor}) | Paginated |
| aq.assets | get(id) | Full DTO |
| aq.assets | patchMetadata(id, {…}) | Merge JSON |
| aq.upload(file, {fileName}) | | Hash-dedup, returns canonical URL |
| aq.urlFor(asset, preset) | | Build URL from DTO |
| aq.srcSetFor(asset) | | Responsive <img srcSet> |
| aq.usage | snapshot() | Storage + today + last 30 days totals (current tenant) |
| aq.usage | timeseries(days) | Daily rollup for charts (1..365 days) |
| aq.usage | keys() | Per-API-key breakdown month-to-date |
Usage / consumption
Cloudinary-style "where am I in my plan" view, scoped to the API key's own tenant (no cross-tenant data ever leaks):
const usage = await aq.usage.snapshot();
// {
// tenant: { id: 4, code: "realtyone-cr" },
// storage: { totalBytes: 4_810_000_000, assetCount: 15_760 },
// today: { reads: 3201, writes: 18, processes: 6, bytesIn: 12_400_000, ... },
// last30Days: { reads: 86_400, writes: 412, ... }
// }
const chart = await aq.usage.timeseries(30); // for a 30-day line chart
const byKey = await aq.usage.keys(); // who's using the most quotaBacked by the daily rollup of assets.api_key_usage →
assets.tenant_usage_daily. The "today" window queries the raw
api_key_usage table since the rollup runs once per day at ~00:30 UTC.
License
UNLICENSED — internal use only.
