npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@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

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-client

Optional, 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-expo

Peer 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 /expo subpath uses native expo-image-manipulator instead (already wired in the uploader). The compress option on aq.upload is web-only.
  • The server-side assets.client_original_bytes column 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.svg

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)

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 | 17680 | — | | height | 17680 | — | | fit | cover / contain / fill / inside / outside | cover | | gravity | auto / face / center / north / south / east / west | center | | format | auto / avif / webp / jpeg / png | auto (→ webp) | | quality | auto / 1100 | auto (source-complexity-adaptive) | | dpr | 1 / 2 / 3 | 1 |

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-transforms

Rotation 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>.jpg

effect=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's 851-labs/background-remover (BRIA-quality, commercially licensed via Replicate). ~3-8 s GPU, ~$0.001-0.005 per image. Requires REPLICATE_API_TOKEN in 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:

  1. Drop a new .onnx into apps/asset-manager/src/features/assets/bg/
  2. Update MODEL_PATH + preprocessing constants in bg/remove.ts
  3. Update the Dockerfile's COPY src/features/assets/bg/*.onnx /app/models/
  4. 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-write
  • amk_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 quota

Backed by the daily rollup of assets.api_key_usageassets.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.