@nitida/sdk 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1158 @@
1
+ # `@nitida/sdk`
2
+
3
+ Universal SDK for the [aquienpz](https://aquienpz.com) multi-tenant asset platform.
4
+ A Cloudinary-style asset manager with deterministic CDN URLs, content-addressed
5
+ dedup, server-side variants (thumb / sm / md / lg / poster / video), and **slot
6
+ bindings** — admin-managed names that resolve to assets at runtime.
7
+
8
+ ## One client, every environment
9
+
10
+ The whole SDK ships as **one `AquienpzClient` class** behind environment-
11
+ specific subpath entries. Each subpath bundles the same client PLUS the
12
+ helpers safe for that runtime — same pattern as **Vercel Blob** (`@vercel/blob`
13
+ vs `@vercel/blob/client`), **Better Auth**, **Uploadthing**, and the **Vercel AI
14
+ SDK**. Stripe/Cloudinary's split into two separate npm packages is the legacy
15
+ shape — modern bundlers tree-shake subpaths perfectly and a single version
16
+ eliminates type drift between server and client surfaces.
17
+
18
+ | Where you run | Import | What it bundles |
19
+ |---|---|---|
20
+ | Node 20+, Bun, Cloud Run, Lambda, Edge, agents, cron | **`@nitida/sdk/server`** | `AquienpzClient` + URL builders + `signTransformUrl`. **No browser-only code.** Hard guarantee against shipping `Blob`-workflow helpers to the wrong runtime. |
21
+ | Browser (Next.js client, Vite, CRA, web workers) | **`@nitida/sdk/web`** | `AquienpzClient` + everything in `/server` + `createWebUploader` (multipart, parallel parts, IndexedDB resume) + `compressImage` (HEIC→WebP, compressorjs). |
22
+ | React Native / Expo apps | `@nitida/sdk/native` | `compressImage` for RN (libjpeg-turbo via `react-native-compressor`). Pair with `/expo`. |
23
+ | Expo background uploads | `@nitida/sdk/expo` | `createExpoUploader` — native `URLSession` (iOS) / `WorkManager` (Android) sessions that survive app backgrounding. |
24
+ | Next.js / React (hooks) | `@nitida/sdk/react` | `AquienpzProvider` + `useSlot` / `useSlots` / `useAsset`. |
25
+ | Backwards-compat (root) | `@nitida/sdk` | Same shape as today; equivalent to `/web` for the public symbols. **Prefer the explicit subpath in new code.** |
26
+
27
+ Uploaders + compressors are **optional** peer deps (`@aquienpz/asset-uploader-{web,expo}`,
28
+ `@aquienpz/asset-compressor-{web,native}`). Skip them if your app only resolves
29
+ slots and reads assets — your bundle stays a few KB.
30
+
31
+ ### Quick decision tree
32
+
33
+ - Server-side rendering, API routes, BFF, cron, agents → `@nitida/sdk/server`.
34
+ - Browser components, build-time pre-resolution → `@nitida/sdk/web`.
35
+ - Expo / React Native app → `@nitida/sdk/native` + `@nitida/sdk/expo`.
36
+ - React hooks (any env) → `@nitida/sdk/react`.
37
+
38
+ **Never ship the API key to the browser.** Whichever subpath you import,
39
+ the long-lived `amk_rt_*` key lives on the server. Browser flows hit a BFF
40
+ route that proxies to aquienpz with the real key.
41
+
42
+ ## Which subpath do I import from?
43
+
44
+ | Context | Import | Notes |
45
+ |---|---|---|
46
+ | 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. |
47
+ | Node/Bun server (API route, Server Action, Cloud Run, Fly, BFF) | `@nitida/sdk/server` | Requires `apiKey` at construction. |
48
+ | React Native / Expo | `@nitida/sdk/native` or `@nitida/sdk/expo` | Same BFF model as `/web` — keep keys server-side. |
49
+ | Universal React hook | `@nitida/sdk/react` | Wraps the right subpath for the runtime. |
50
+
51
+ ### Why subpaths, not a runtime flag
52
+
53
+ If you import `/server` in browser code, the bundler throws a build error.
54
+ If we used a runtime `{ mode: "browser" }` flag and you forgot it, your API
55
+ key would silently bundle into the client. Subpaths make security mistakes
56
+ loud — same pattern as `@vercel/blob/client`, `better-auth/client`, AI SDK's
57
+ `/edge` subpath.
58
+
59
+ `@nitida/sdk/web` v0.17+ enforces this physically: the constructor type
60
+ is `Omit<AquienpzClientOptions, "apiKey" | "signingKey">`. Passing `apiKey`
61
+ won't compile, period.
62
+
63
+ ## BFF-proxy mode (browser → your route → aquienpz)
64
+
65
+ Browser code constructs the client against a **relative endpoint** that
66
+ points at your own route handler. The handler attaches the real bearer
67
+ token and forwards to aquienpz. Same pattern Vercel Blob uses for
68
+ `@vercel/blob/client.upload`.
69
+
70
+ ### Next.js — client component
71
+
72
+ ```tsx
73
+ "use client";
74
+ import { AquienpzClient } from "@nitida/sdk/web";
75
+
76
+ const aq = new AquienpzClient({
77
+ endpoint: "/api/am", // OK: relative → same-origin BFF
78
+ tenantCode: "realtyone-cr",
79
+ tenantId: 1,
80
+ // apiKey: ... ERROR: TS error: not assignable to WebClientOptions
81
+ });
82
+
83
+ export function HeroPicker() {
84
+ return <input type="file" onChange={async (e) => {
85
+ const file = e.target.files?.[0];
86
+ if (file) await aq.upload(file, { compress: true });
87
+ }} />;
88
+ }
89
+ ```
90
+
91
+ ### Next.js — BFF route handler
92
+
93
+ ```ts
94
+ // app/api/am/[...path]/route.ts
95
+ import { NextRequest } from "next/server";
96
+
97
+ const UPSTREAM = process.env.AQUIENPZ_URL!; // server-only
98
+ const API_KEY = process.env.AQUIENPZ_API_KEY!; // server-only
99
+ const TENANT = process.env.AQUIENPZ_TENANT_CODE!; // e.g. "realtyone-cr"
100
+
101
+ async function proxy(req: NextRequest, { params }: { params: { path: string[] } }) {
102
+ const search = new URL(req.url).search;
103
+ const url = `${UPSTREAM}/${params.path.join("/")}${search}`;
104
+ const body = ["GET", "HEAD"].includes(req.method) ? undefined : await req.arrayBuffer();
105
+ return fetch(url, {
106
+ method: req.method,
107
+ body,
108
+ headers: {
109
+ Authorization: `Bearer ${API_KEY}`,
110
+ "X-Tenant-Code": TENANT,
111
+ "Content-Type": req.headers.get("content-type") ?? "application/json",
112
+ },
113
+ });
114
+ }
115
+
116
+ export { proxy as GET, proxy as POST, proxy as PUT, proxy as PATCH, proxy as DELETE };
117
+ ```
118
+
119
+ ### Bun / Elysia backend
120
+
121
+ ```ts
122
+ import { AquienpzClient } from "@nitida/sdk/server";
123
+
124
+ const aq = new AquienpzClient({
125
+ endpoint: process.env.AQUIENPZ_URL!,
126
+ apiKey: process.env.AQUIENPZ_API_KEY!, // OK: required by ServerClientOptions
127
+ tenantCode: "realtyone-cr",
128
+ tenantId: 1,
129
+ });
130
+ ```
131
+
132
+ ### Expo
133
+
134
+ ```ts
135
+ import { AquienpzClient } from "@nitida/sdk/web"; // same browser-safe type
136
+ // Construct against your /api/am proxy; no apiKey in the app bundle.
137
+ ```
138
+
139
+ ### SSG storefront (build-time)
140
+
141
+ Use `/server` at build time (Node/Bun) with the absolute aquienpz URL —
142
+ no proxy needed because keys never reach the browser bundle. Static
143
+ HTML output references the CDN directly.
144
+
145
+ ## Install
146
+
147
+ ```bash
148
+ bun add @nitida/sdk @nitida/asset-client
149
+ # or
150
+ npm install @nitida/sdk @nitida/asset-client
151
+ ```
152
+
153
+ Optional, only if you need large uploads:
154
+
155
+ ```bash
156
+ # Web / Vite / Next.js client uploads
157
+ bun add @aquienpz/asset-uploader-web
158
+
159
+ # Expo / React Native background uploads
160
+ bun add @aquienpz/asset-uploader-expo
161
+ ```
162
+
163
+ Peer deps: `@nitida/asset-client` (URL builders + types) and `react` (only if
164
+ you use the `/react` subpath).
165
+
166
+ ## Quick start
167
+
168
+ ```ts
169
+ import { AquienpzClient } from "@nitida/sdk";
170
+
171
+ const aq = new AquienpzClient({
172
+ endpoint: "https://aquienpz-asset-manager-xxxx.run.app",
173
+ apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY!, // amk_rt_* Better Auth API key
174
+ tenantCode: "your-tenant",
175
+ tenantId: 42, // numeric tenant id (used in CDN URL prefix)
176
+ cdnBase: "https://8ok.uk", // optional, defaults to https://8ok.uk
177
+ });
178
+
179
+ // Slots — the recommended way to reference brand assets in code.
180
+ // Source never hardcodes a CDN URL; admin rebinds from asset-lab-web.
181
+ const hero = await aq.slots.resolve("storefront.home.hero");
182
+ // → { slot: { asset, preset, … }, preset: "lg", url: "https://8ok.uk/2a/v/<sha>-l.webp" }
183
+
184
+ // Bulk resolution in one round-trip.
185
+ const heroes = await aq.slots.resolveMany([
186
+ "storefront.home.hero",
187
+ "storefront.home.tile-1",
188
+ "storefront.home.tile-2",
189
+ ]);
190
+
191
+ // Lower-level operations.
192
+ const asset = await aq.assets.byHash("3c…<64 hex>…");
193
+ const { assets, nextCursor } = await aq.assets.list({ limit: 50 });
194
+
195
+ // Uploads — hash-deduped; returns the canonical v2 URL immediately.
196
+ const result = await aq.upload(file, { fileName: "cover.jpg" });
197
+ // → { assetId, sha256, cdnUrl }
198
+
199
+ // Uploading raw bytes (Node/Bun, e.g. re-hosting a remote image)? A Uint8Array has no
200
+ // inherent MIME, so give it one — otherwise it stores as kind:"other" (NO image variants):
201
+ await aq.upload(bytes, { fileName: "cover.webp" }); // MIME inferred from .webp ✓
202
+ await aq.upload(bytes, { contentType: "image/webp" }); // or be explicit ✓
203
+ // And request the variants you'll render — `presets` DEFAULTS TO ["original"] (just the raw
204
+ // bytes), so getAssetUrl(sha, "md") style preset URLs 404 unless you ask for the ladder:
205
+ await aq.upload(bytes, { contentType: "image/webp", presets: ["thumb", "sm", "md", "lg", "xl"] });
206
+
207
+ // Bind a slot (admin operation).
208
+ await aq.slots.bind("storefront.home.hero", {
209
+ assetId: result.assetId,
210
+ preset: "lg",
211
+ description: "Homepage hero — uploaded by admin on 2026-05-16",
212
+ });
213
+ ```
214
+
215
+ ## Client-side compression (browsers)
216
+
217
+ `aq.upload(file, { compress: true })` runs the file through
218
+ compressorjs + heic2any before the PUT, saving the user's bandwidth.
219
+ Typical result for an 8 MB iPhone HEIC photo: ~800 KB uploaded after
220
+ HEIC → JPEG → WebP @ q=0.80, max-edge 3840 px.
221
+
222
+ ```ts
223
+ // Default — uses DEFAULT_COMPRESSION_OPTIONS (webapp-tuned values)
224
+ const result = await aq.upload(file, {
225
+ compress: true,
226
+ presets: ["thumb", "sm", "md", "lg"],
227
+ });
228
+
229
+ // Custom tuning per call
230
+ await aq.upload(file, {
231
+ compress: { quality: 0.7, maxWidth: 2048 },
232
+ });
233
+
234
+ // Track progress for UI
235
+ await aq.upload(file, {
236
+ compress: {
237
+ onProgress: (stage) => console.log(stage),
238
+ // stage ∈ "convertingHeic" | "compressing" | "compressingKeepingDimensions"
239
+ },
240
+ });
241
+ ```
242
+
243
+ **Defaults** (matched to the realtyone-cr production webapp:
244
+ `LISTING_STANDARD_*` constants):
245
+
246
+ | Option | Default |
247
+ |---|---|
248
+ | `quality` | `0.80` |
249
+ | `mimeType` | `"image/webp"` |
250
+ | `maxWidth` / `maxHeight` | `3840` |
251
+ | `convertSize` (PNG → JPEG threshold) | `5 MB` |
252
+ | `strict` | `true` |
253
+
254
+ **Caveats**:
255
+
256
+ - Browser-only. In Node / Bun the call is a silent no-op (warns to
257
+ console) and the raw bytes upload as-is.
258
+ - Non-image MIMEs (video, PDF) are passed through regardless of
259
+ `compress: true`. Safe to set blanket-fashion on mixed media batches.
260
+ - Adds ~50 KB to the runtime bundle **only when used** — lazy-imported
261
+ from `@nitida/sdk/web`.
262
+ - HEIC inputs go through heic2any first (additional ~200 KB lazy bundle).
263
+ - Expo / React Native: the `/expo` subpath uses native
264
+ `expo-image-manipulator` instead (already wired in the uploader). The
265
+ `compress` option on `aq.upload` is web-only.
266
+ - The server-side `assets.client_original_bytes` column is populated
267
+ from the original pre-compression size, surfacing the savings story
268
+ in admin dashboards.
269
+
270
+ Direct access to the compressor (without going through `aq.upload`):
271
+
272
+ ```ts
273
+ import { compressImage, compressImages, DEFAULT_COMPRESSION_OPTIONS }
274
+ from "@nitida/sdk/web";
275
+
276
+ const { blob, originalBytes } = await compressImage(file, { quality: 0.85 });
277
+ const results = await compressImages([fileA, fileB, fileC]);
278
+ ```
279
+
280
+ ## Large uploads (web)
281
+
282
+ The core `aq.upload()` handles files under 50 MB in a single PUT. For
283
+ bigger files use the `/web` subpath — it delegates to
284
+ `@aquienpz/asset-uploader-web`'s multipart `UploadTask` (chunks, parallel
285
+ parts, retries with backoff, IndexedDB-persisted progress, Web Worker
286
+ SHA-256).
287
+
288
+ ```ts
289
+ import { createWebUploader } from "@nitida/sdk/web";
290
+
291
+ const task = createWebUploader(aq, { file });
292
+ task.on("progress", ({ ratio }) => setProgress(ratio));
293
+ task.on("ready", ({ assetId }) => bindSlot(assetId));
294
+ const { assetId, deduped } = await task.start();
295
+ ```
296
+
297
+ Peer dep: `@aquienpz/asset-uploader-web` (lazy — apps that only need
298
+ small uploads skip it).
299
+
300
+ ## Large uploads (Expo / React Native)
301
+
302
+ Native background uploads survive app suspend, low-memory kills, and
303
+ network blips. iOS uses URLSession's background config; Android uses
304
+ WorkManager. Same JS API as the web flavor.
305
+
306
+ ```tsx
307
+ import { useEffect, useState } from "react";
308
+ import { Image } from "react-native";
309
+ import * as ImagePicker from "expo-image-picker";
310
+ import { AquienpzClient } from "@nitida/sdk";
311
+ import {
312
+ createExpoUploader,
313
+ listResumableSessions,
314
+ } from "@nitida/sdk/expo";
315
+
316
+ const aq = new AquienpzClient({
317
+ endpoint: process.env.EXPO_PUBLIC_AQUIENPZ_URL!,
318
+ apiKey: process.env.EXPO_PUBLIC_AQUIENPZ_API_KEY!, // amk_rt_*
319
+ tenantCode: "your-tenant",
320
+ tenantId: 42,
321
+ });
322
+
323
+ export function UploadHeroScreen() {
324
+ const [progress, setProgress] = useState(0);
325
+ const [url, setUrl] = useState<string | null>(null);
326
+
327
+ // Offer to resume anything from a prior app launch on boot.
328
+ useEffect(() => {
329
+ listResumableSessions().then((sessions) => {
330
+ // …show a banner if sessions.length > 0
331
+ });
332
+ }, []);
333
+
334
+ async function pickAndUpload() {
335
+ const picked = await ImagePicker.launchImageLibraryAsync({
336
+ mediaTypes: ImagePicker.MediaTypeOptions.Videos,
337
+ allowsMultipleSelection: false,
338
+ });
339
+ if (picked.canceled || !picked.assets[0]) return;
340
+ const { uri, mimeType, fileName } = picked.assets[0];
341
+
342
+ const task = createExpoUploader(aq, {
343
+ file: {
344
+ uri,
345
+ mime: mimeType ?? "video/mp4",
346
+ name: fileName ?? "tour.mp4",
347
+ },
348
+ });
349
+ task.on("progress", ({ ratio }) => setProgress(ratio));
350
+
351
+ const { assetId } = await task.start();
352
+ // Bind to a slot so the storefront picks it up without a redeploy.
353
+ await aq.slots.bind("storefront.tour.video", { assetId, preset: "video" });
354
+ const resolved = await aq.slots.resolve("storefront.tour.video");
355
+ setUrl(resolved.url);
356
+ }
357
+
358
+ return /* …UI with pickAndUpload + progress bar + <Image source={{ uri: url }}/> */;
359
+ }
360
+ ```
361
+
362
+ Peer dep: `@aquienpz/asset-uploader-expo`. Ships a config plugin for the Expo
363
+ prebuild step (background mode + native module registration); add it to
364
+ `app.json` under `plugins` before running `expo prebuild`.
365
+
366
+ ## Next.js App Router (Server Components)
367
+
368
+ The cheapest path: resolve slots at render time on the server. No client JS,
369
+ no provider, no hook. The slot URLs ship as plain `<img>` markup.
370
+
371
+ ```tsx
372
+ // app/page.tsx — Server Component
373
+ import { AquienpzClient } from "@nitida/sdk";
374
+
375
+ const aq = new AquienpzClient({
376
+ endpoint: process.env.AQUIENPZ_URL!,
377
+ apiKey: process.env.AQUIENPZ_API_KEY!, // amk_rt_* — server-only
378
+ tenantCode: "your-tenant",
379
+ tenantId: 42,
380
+ });
381
+
382
+ export default async function Page() {
383
+ const heroes = await aq.slots.resolveMany([
384
+ "storefront.home.hero",
385
+ "storefront.home.tile-1",
386
+ "storefront.home.tile-2",
387
+ ]);
388
+
389
+ return (
390
+ <>
391
+ {heroes["storefront.home.hero"].url && (
392
+ <img src={heroes["storefront.home.hero"].url} alt="" />
393
+ )}
394
+ {/* … */}
395
+ </>
396
+ );
397
+ }
398
+ ```
399
+
400
+ Keep the API key on the server — never expose `amk_rt_*` to `NEXT_PUBLIC_*`.
401
+ Uploads from Client Components should go through a thin BFF route handler that
402
+ proxies `aq.upload()` server-side.
403
+
404
+ For images that benefit from `next/image`, use `aq.urlFor()` + `aq.srcSetFor()`
405
+ to emit a static URL set; Next then handles its own optimization pipeline.
406
+
407
+ ## React hooks (Client Components / SPA)
408
+
409
+ Reactive resolution on the client. The provider holds the configured client;
410
+ each hook subscribes to the in-process cache.
411
+
412
+ ```tsx
413
+ // app/providers.tsx — Client Component
414
+ "use client";
415
+ import { AquienpzClient } from "@nitida/sdk";
416
+ import { AquienpzProvider } from "@nitida/sdk/react";
417
+
418
+ // In production, get apiKey from a /session route instead of bundling it.
419
+ const client = new AquienpzClient({
420
+ endpoint: process.env.NEXT_PUBLIC_AQUIENPZ_URL!,
421
+ apiKey: process.env.NEXT_PUBLIC_AQUIENPZ_API_KEY!,
422
+ tenantCode: "your-tenant",
423
+ tenantId: 42,
424
+ });
425
+
426
+ export function Providers({ children }: { children: React.ReactNode }) {
427
+ return <AquienpzProvider client={client}>{children}</AquienpzProvider>;
428
+ }
429
+ ```
430
+
431
+ ```tsx
432
+ // app/hero.tsx
433
+ "use client";
434
+ import { useSlot } from "@nitida/sdk/react";
435
+
436
+ export function Hero() {
437
+ const { url, isLoading } = useSlot("storefront.home.hero");
438
+ if (isLoading) return <Skeleton />;
439
+ if (!url) return <PlaceholderHero />;
440
+ return <img src={url} alt="" />;
441
+ }
442
+ ```
443
+
444
+ Works in any React 18+ host: Vite, CRA, Remix, Astro islands, Expo Router,
445
+ React Native — wherever `react-dom` (or `react-native`) runs.
446
+
447
+ ## Why slots?
448
+
449
+ Hardcoding `https://cdn.your-tenant.com/abc123.webp` in source code couples
450
+ deploys to brand decisions. With slots:
451
+
452
+ | Without slots | With slots |
453
+ |---|---|
454
+ | Edit URL in code | Drag-drop a new asset in asset-lab-web |
455
+ | Commit + PR + deploy | Cache refreshes (60s default) |
456
+ | 5-30 minute roundtrip | Instant |
457
+
458
+ The slot key (`storefront.home.hero`, `webapp.wizard.pool-type.icon-1`) is the
459
+ **stable contract** between code and brand operations. Tenants own the bindings;
460
+ code is a passive consumer.
461
+
462
+ ## URL conventions — variants are tenant-scoped, transforms are not
463
+
464
+ Two delivery paths, **by design**:
465
+
466
+ | Kind | Builder | URL shape | Tenant in path? |
467
+ |---|---|---|---|
468
+ | Variant / preset | `urlFor`, `srcSetFor`, upload `cdnUrl` | `<cdn>/<tenantId b36>/v/<sha>-<preset>.<ext>` | **Yes** — `/4/v/<sha>-lg.webp` |
469
+ | On-the-fly transform | `transform`, `transformSrcSet` | `<cdn>/t/<dsl>/<sha>.<ext>` | **No** — `/t/width=1280,.../<sha>.webp` |
470
+
471
+ The transform service is content-addressed by `sha` and resizes from the source on demand,
472
+ so it carries **no tenant segment**. Hand-building a transform URL as `/<tenant>/t/...` **404s**.
473
+ (Variants are stored per-tenant, so those DO carry the tenant prefix.) Always use the SDK
474
+ builders rather than concatenating paths — and never prepend the tenant to a `/t/` URL.
475
+
476
+ ## Presets
477
+
478
+ Presets are **platform-wide and fixed** — every tenant gets the same set,
479
+ generated server-side by the asset-manager's variant pipeline. A tenant
480
+ cannot define custom dimensions through the SDK; they pick which preset a
481
+ slot defaults to and emit responsive `srcSet` for browser-side resizing.
482
+
483
+ ### Image presets
484
+
485
+ | Preset | Code | Max-side | Typical use |
486
+ |-----------|------|--------------------|---|
487
+ | `thumb` | `q` | 256×256 smart-crop | avatars, micro-tiles |
488
+ | `sm` | `s` | 640 | mobile thumbs, list cards |
489
+ | `md` | `m` | 1280 | desktop cards, modal previews |
490
+ | `lg` | `l` | 1920 (≈2K) | hero, full-bleed |
491
+ | `xl` | `x` | 3840 (4K) | print, 4K screens |
492
+
493
+ **Defaults — what actually runs:**
494
+
495
+ | Caller path | Variants generated on upload |
496
+ |-------------------------------------------------------|------------------------------|
497
+ | `aq.upload(file)` (SDK, no `presets`) | `original` only |
498
+ | `POST /assets/upload-url` (HTTP direct, no `presets`) | `original` only (same as SDK) |
499
+ | `presets: ["original"]` (either path) | `original` only |
500
+ | `presets: ["thumb","sm","md","lg"]` | exactly the four listed |
501
+
502
+ Omitting `presets` is always equivalent to `["original"]` — the platform
503
+ never auto-generates the responsive ladder. This keeps logo / SVG /
504
+ one-shot uploads cheap and avoids surprise R2 writes (the ladder is 4×
505
+ the source bytes). Apps that want responsive sizes pass them explicitly:
506
+
507
+ ```ts
508
+ await aq.upload(file, {
509
+ presets: ["thumb", "sm", "md", "lg"], // four WebP variants
510
+ });
511
+ ```
512
+
513
+ Or add missing variants later without re-uploading:
514
+
515
+ ```ts
516
+ await aq.assets.regenerate(assetId, { presets: ["thumb", "sm", "md", "lg"] });
517
+ ```
518
+
519
+ Variants are WebP `quality: 75–80`. Total R2 cost when generating the
520
+ full responsive ladder ≈ 4× source bytes.
521
+
522
+ **Variants never upscale.** The pipeline clamps each preset's target to
523
+ `min(presetMaxSide, sourceMaxSide)`. A 1080×720 photo asked for `xl`
524
+ (3840) yields a 1080×720 `xl` variant, not a blurry 3840-wide stretch.
525
+ The preset is a **ceiling**, not a target.
526
+
527
+ ### Non-image / passthrough
528
+
529
+ | Preset | Code | Purpose |
530
+ |-----------|------|---|
531
+ | `original`| `o` | Raw uploaded bytes, no transformation. Used today for non-image MIMEs (PDFs, audio, etc.). |
532
+
533
+ ### Video presets
534
+
535
+ | Preset | Code | Purpose |
536
+ |-----------|------|------------------------|
537
+ | `poster` | `p` | extracted poster WebP |
538
+ | `video` | `v` | original MP4 |
539
+ | `aiproxy` | `a` | low-res proxy for AI captioning / search (opt-in) |
540
+
541
+ ### Per-upload preset selection
542
+
543
+ **The SDK's default is `["original"]`** — calling `aq.upload(file)`
544
+ with no `presets` option stores only the raw bytes. Add the
545
+ responsive ladder when you actually need it, or generate it later
546
+ with `aq.assets.regenerate()` (no re-upload required).
547
+
548
+ ```ts
549
+ // Default: only the original variant lands on the CDN.
550
+ const { assetId, cdnUrl } = await aq.upload(logoFile);
551
+ // asset.presets === "o"
552
+ // cdnUrl = https://8ok.uk/<sha>-o.svg
553
+ ```
554
+
555
+ **Responsive ladder (the old default — now explicit):**
556
+
557
+ ```ts
558
+ await aq.upload(heroFile, {
559
+ fileName: "homepage-hero.jpg",
560
+ presets: ["thumb", "sm", "md", "lg"], // classic 4-step
561
+ });
562
+ ```
563
+
564
+ **4K hero with the full size ladder:**
565
+
566
+ ```ts
567
+ await aq.upload(heroFile, {
568
+ presets: ["thumb", "sm", "md", "lg", "xl"],
569
+ });
570
+ // asset.presets === "qsmlx"; aq.srcSetFor(asset) now emits an xl entry.
571
+ ```
572
+
573
+ **Video without the AI proxy transcode:**
574
+
575
+ ```ts
576
+ await aq.upload(videoFile, {
577
+ presets: ["poster", "video"], // skip aiproxy
578
+ });
579
+ ```
580
+
581
+ **Large video — bump the readiness timeout:**
582
+
583
+ `aq.upload()` waits up to **5 minutes** by default for the asset to
584
+ transition to `ready`. Transcode time scales with input size and CPU,
585
+ so videos ≥30 MB (especially HLS ladders) can blow past that. Opt in
586
+ to a longer deadline via `timeoutMs`:
587
+
588
+ ```ts
589
+ // Large video (>30MB): bump timeout to 15min
590
+ await aq.upload(file, { timeoutMs: 15 * 60_000 });
591
+ ```
592
+
593
+ The default is unchanged — existing call sites need no migration.
594
+
595
+ Variants never upscale — each size preset is a **ceiling**, not a
596
+ target. A 1080×720 source asked for `xl` (3840) yields a 1080×720 xl
597
+ variant.
598
+
599
+ **Caveat (content-addressed dedup):** if someone already uploaded the
600
+ same bytes with different presets, `aq.upload()` returns the existing
601
+ asset without re-processing. The result's `cdnUrl` will use whichever
602
+ preset is actually available on that asset (the SDK picks `lg → md → sm
603
+ → thumb → xl → original` for images, `video → poster` for videos). To
604
+ add variants to an existing asset, use `aq.assets.regenerate()`.
605
+
606
+ ## Adding variants later (no re-upload)
607
+
608
+ The most common flow with the new default:
609
+
610
+ ```ts
611
+ // Day 0 — upload original only.
612
+ const { assetId } = await aq.upload(logoFile);
613
+
614
+ // Day 7 — peek at what's there.
615
+ const variants = await aq.assets.variants(assetId);
616
+ console.log(variants.map((v) => v.preset)); // → ["original"]
617
+
618
+ // Day 7 — need a thumb for an avatar slot. Generate it server-side,
619
+ // merged with the existing variant set.
620
+ await aq.assets.regenerate(assetId, { presets: ["thumb"] });
621
+
622
+ const after = await aq.assets.variants(assetId);
623
+ console.log(after.map((v) => v.preset)); // → ["original", "thumb"]
624
+ ```
625
+
626
+ `regenerate()` merges — existing variants you didn't ask for stay put.
627
+ You can call it many times; it's idempotent per preset.
628
+
629
+ ### Source-byte lifecycle (why regenerate always works)
630
+
631
+ Two distinct R2 prefixes; most people only care about one:
632
+
633
+ | R2 path | Role | Lifetime |
634
+ |---|---|---|
635
+ | `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 |
636
+ | `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 |
637
+
638
+ `regenerate()` walks a fidelity-ordered fallback chain until it finds
639
+ usable bytes — it **never fails on a still-present asset**:
640
+
641
+ ```
642
+ 1. variants/<sha>-o.<ext> ← permanent original (best)
643
+ 2. raw/<sha>.<ext> ← exact upload bytes, only during the 24h grace
644
+ 3. variants/<sha>-x.webp ← xl
645
+ 4. variants/<sha>-l.webp ← lg
646
+ 5. variants/<sha>-m.webp ← md
647
+ 6. variants/<sha>-s.webp ← sm
648
+ 7. variants/<sha>-q.webp ← thumb (last resort — smart-cropped square,
649
+ so aspect ratio of derived variants
650
+ will inherit the thumb's crop)
651
+ ```
652
+
653
+ The no-upscale clamp guarantees we never invent pixels: asking for
654
+ `lg` (1920) from a 640 `sm` source yields a 640-side `lg` variant.
655
+ The result type's `sourceUsed` field tells you which fallback was
656
+ picked so you can decide whether the quality is good enough:
657
+
658
+ ```ts
659
+ const result = await aq.assets.regenerate(assetId, { presets: ["xl"] });
660
+ if (result.kind === "image" && result.sourceUsed !== "original" && result.sourceUsed !== "raw") {
661
+ console.warn(`xl was derived from ${result.sourceUsed} — quality degraded`);
662
+ }
663
+ ```
664
+
665
+ **Recommendation:** include `"original"` in the upload preset list
666
+ when you want guaranteed lossless future re-derivation. The SDK's
667
+ default (`["original"]`) already does this for you.
668
+
669
+ `aq.assets.variants(id)` and `aq.assets.regenerate(id, opts)` are
670
+ fully typed — your editor autocompletes the preset names and the
671
+ returned shape gives you `{ preset, url, width?, height?, bytes }[]`.
672
+
673
+ ## On-the-fly transformations
674
+
675
+ Pre-generated presets (`thumb`/`sm`/`md`/`lg`/`xl`) cover the common cases.
676
+ For everything else — exact CSS pixel widths, art-directed crops, devicePixelRatio
677
+ ladders, square thumbs from rectangular sources — call `aq.transform()`:
678
+
679
+ ```tsx
680
+ import { AquienpzClient } from "@nitida/sdk";
681
+
682
+ const aq = new AquienpzClient({ /* ... */ });
683
+ const asset = await aq.assets.byHash(sha256);
684
+
685
+ // Single URL
686
+ <Image
687
+ src={aq.transform(asset!, { width: 1280, format: "auto" })}
688
+ alt="..."
689
+ />
690
+
691
+ // Responsive — one transform URL per width, all other params shared
692
+ <Image
693
+ src={aq.transform(asset!, { width: 1280 })}
694
+ srcSet={aq.transformSrcSet(asset!, [640, 960, 1280, 1920])}
695
+ sizes="(max-width: 768px) 100vw, 50vw"
696
+ alt="..."
697
+ />
698
+ ```
699
+
700
+ ### DSL params
701
+
702
+ | Param | Values | Default |
703
+ |---|---|---|
704
+ | `width` | a **`TransformWidth`** — predefined ladder; off-ladder → compile error (see note) | — |
705
+ | `height` | `1`–`7680` (`number`; aspect ratio derived from `width`) | — |
706
+ | `fit` | `cover` / `contain` / `fill` / `inside` / `outside` | `cover` |
707
+ | `gravity` | `auto` / `face` / `center` / `north` / `south` / `east` / `west` | `center` |
708
+ | `format` | `auto` / `avif` / `webp` / `jpeg` / `png` | `auto` (→ `webp`) |
709
+ | `quality` | `auto` / `1`–`100` | `auto` (source-complexity-adaptive) |
710
+ | `dpr` | `1` / `2` / `3` | `1` |
711
+
712
+ > **`width` is strongly typed.** `TransformOptions.width` is a **`TransformWidth`** — the
713
+ > predefined CDN ladder (`160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200,
714
+ > 1280, 1440, 1600, 1920, 2560, 3840`, exported as `TRANSFORM_WIDTHS`). An off-ladder
715
+ > width is a **compile error**: the edge whitelists exactly these as a DoS guard and
716
+ > HTTP 400s anything else on unsigned URLs. Need a custom off-ladder width? **Sign it** —
717
+ > `aq.transform(asset, { width: 1490 }, { sign: true })` and `getSignedTransformUrl` take
718
+ > `SignedTransformOptions` (where `width` widens to `number`); a valid `?sig=` earns the
719
+ > edge-whitelist bypass. `transformSrcSet` / `getTransformSrcSet` deliberately keep
720
+ > `number[]` because a responsive ladder may legitimately include DPR-row widths (e.g.
721
+ > `2400`). `height` stays `number` — for the responsive path it's derived from `width`
722
+ > by aspect ratio; only fixed-canvas crops / `genfill` set it explicitly.
723
+
724
+ `format=auto` resolves to **WebP** by default. The bench in `apps/asset-manager/bench/avif-vs-webp-summary.md`
725
+ (100 random realtyone-cr `lg.webp` samples, sharp 0.34.5) showed AVIF was 4.5–9.8% **larger**
726
+ than WebP in every source-size bracket with virtually identical SSIM. The policy lives
727
+ in a single function (`pickAutoFormat` in `transform.format-policy.ts`) so re-evaluation
728
+ when libavif improves is a one-file swap.
729
+
730
+ `gravity=face` runs the source image through a ~1 MB Ultra-Light face
731
+ detector (ultraface-RFB-320 via ONNX Runtime) and crops to the
732
+ highest-confidence face with sensible padding (50 % of face dimensions
733
+ on each side, clamped to source bounds, keeping the requested aspect
734
+ ratio). When no face is detected, it falls back to sharp's `attention`
735
+ strategy and returns the response with `X-Transform-Face: fallback` so
736
+ you can detect the cache state. Cost: ~10 ms inference per image
737
+ (warm); ~150 ms cold-start on the first request after a new Cloud Run
738
+ instance boots.
739
+
740
+ `quality=auto` adapts the per-format quality to source complexity
741
+ (luminance stddev via sharp `.stats()`):
742
+
743
+ | Bucket (stddev) | WebP | AVIF | JPEG |
744
+ |---|---:|---:|---:|
745
+ | simple (< 25) — logos, solids | 55 | 45 | 70 |
746
+ | normal (25–55) — most photos | 70 | 60 | 80 |
747
+ | complex (≥ 55) — busy textures | 72 | 65 | 82 |
748
+
749
+ Validated on 100 random realtyone-cr `lg.webp` samples: **+15.4 %
750
+ bytes saved vs fixed `quality=80` baseline, |ΔSSIM| 0.0011** (budget
751
+ 0.005). See `bench/auto-quality-summary.md`. Both the bucket
752
+ boundaries and per-format table live in
753
+ `transform.quality-policy.ts` — re-tune by editing the constant and
754
+ re-running `bun run apps/asset-manager/scripts/bench-auto-quality.ts`.
755
+
756
+ ### Canonicalization & caching
757
+
758
+ URLs with the same params in different order share the same R2 cache entry:
759
+
760
+ ```ts
761
+ aq.transform(asset, { width: 480, fit: "contain" })
762
+ // → https://8ok.uk/t/fit=contain,width=480/<sha>.webp
763
+
764
+ aq.transform(asset, { fit: "contain", width: 480 })
765
+ // → https://8ok.uk/t/fit=contain,width=480/<sha>.webp (same URL)
766
+ ```
767
+
768
+ The server canonicalizes incoming DSL the same way the SDK does (sort keys
769
+ alphabetically, lowercase string values, drop `undefined`) and hashes the
770
+ canonical string into the R2 cache key — so even non-SDK URLs (e.g. typed
771
+ by a developer in a browser bar) collapse onto the same cache entry as long
772
+ as they specify the same params.
773
+
774
+ ### Signed URLs + strict mode (Phase 3)
775
+
776
+ Every tenant has an HMAC-SHA256 signing key (`signing_key` column in
777
+ `public.tenants`, 32 random bytes generated on tenant creation).
778
+ Optionally enable `strict_transforms = true` to reject unsigned URLs
779
+ with a 401 — useful when transform URLs leak from a private surface
780
+ (internal admin, b2b portal) and you don't want third parties
781
+ generating arbitrary crops.
782
+
783
+ ```ts
784
+ const aq = new AquienpzClient({
785
+ endpoint: process.env.AQUIENPZ_URL!,
786
+ apiKey: process.env.AQUIENPZ_API_KEY!,
787
+ tenantCode: "your-tenant",
788
+ tenantId: 42,
789
+ // Fetch via GET /admin/projects/your-tenant; do NOT ship to the browser.
790
+ signingKey: process.env.AQUIENPZ_SIGNING_KEY!,
791
+ });
792
+
793
+ // Async when { sign: true } is set — overload returns Promise<string>.
794
+ const signed = await aq.transform(asset, { width: 1280 }, { sign: true });
795
+ // → https://8ok.uk/t/width=1280/<sha>.webp?sig=<64-hex>
796
+
797
+ // Responsive
798
+ const srcset = await aq.transformSrcSet(
799
+ asset,
800
+ [640, 960, 1280, 1920],
801
+ {},
802
+ { sign: true },
803
+ );
804
+ ```
805
+
806
+ Signature shape: `HMAC-SHA256(signingKey, "<canonical-DSL>/<filename>")`,
807
+ hex-encoded. The server canonicalizes the URL the same way the SDK does
808
+ (sort keys, lowercase strings), so two URLs with the same params in
809
+ different order accept the same signature.
810
+
811
+ **Admin operations** (system-scope admin key):
812
+
813
+ ```bash
814
+ # Rotate the signing key — invalidates every URL signed with the old one.
815
+ curl -X POST -H "Authorization: Bearer amk_ad_..." \
816
+ https://aquienpz-asset-manager.../admin/projects/your-tenant/rotate-signing-key
817
+ # → { ok: true, tenantId, code, signingKey: "<64-hex>" }
818
+
819
+ # Flip strict mode on/off.
820
+ curl -X PATCH -H "Authorization: Bearer amk_ad_..." -H "Content-Type: application/json" \
821
+ -d '{"enabled":true}' \
822
+ https://aquienpz-asset-manager.../admin/projects/your-tenant/strict-transforms
823
+ ```
824
+
825
+ **Rotation cost**: cached transform variants on R2 are NOT re-keyed by
826
+ the signature, so they keep serving the same bytes. Only the URLs your
827
+ consumers hold need re-signing. Coordinate the rotation with anyone who
828
+ pre-signs at build time (e.g. SSG / next-build).
829
+
830
+ ### Background removal (`effect=removebg`)
831
+
832
+ ```ts
833
+ // Full-resolution transparent PNG cutout
834
+ const url = aq.transform(asset, { effect: "removebg" });
835
+ // → https://8ok.uk/t/effect=removebg/<sha>.png
836
+
837
+ // Cut out + resize in one call
838
+ const thumbUrl = aq.transform(asset, { effect: "removebg", width: 400 });
839
+ // → https://8ok.uk/t/effect=removebg,width=400/<sha>.png
840
+
841
+ // Or convert format alone (no resize / no effect) — useful e.g. to
842
+ // force a JPEG copy of a WebP source for legacy email clients
843
+ const jpegUrl = aq.transform(asset, { format: "jpeg" });
844
+ // → https://8ok.uk/t/format=jpeg/<sha>.jpg
845
+ ```
846
+
847
+ `effect=removebg` is provider-pluggable via the
848
+ `BG_REMOVAL_BACKEND` env on the asset-manager:
849
+
850
+ - **`local`** (default) — U²-Net ONNX inference, Apache 2.0 weights
851
+ shipped under `/app/models/u2net.onnx` (~176 MB, fetched at
852
+ Docker build time, sha256-verified). ~1-2 s warm CPU inference,
853
+ ~5-7 s cold-start. Free runtime.
854
+ - **`replicate`** — proxies to Replicate's `851-labs/background-remover`
855
+ (BRIA-quality, commercially licensed via Replicate). ~3-8 s GPU,
856
+ ~$0.001-0.005 per image. Requires `REPLICATE_API_TOKEN` in Secret
857
+ Manager.
858
+
859
+ In both cases the route caches the PNG in R2 under the standard
860
+ `<sha>-t<dslHash>.png` key, so subsequent identical requests are 302
861
+ redirects to the CDN — no inference, no per-image cost. **Always
862
+ forces `format=png`** because the entire point is preserving alpha.
863
+
864
+ The output is the same dimensions as the source. Chain with `width`
865
+ to resize the cutout in a single request (cached as one R2 entry per
866
+ canonical DSL).
867
+
868
+ To re-tune or swap the model:
869
+ 1. Drop a new `.onnx` into `apps/asset-manager/src/features/assets/bg/`
870
+ 2. Update `MODEL_PATH` + preprocessing constants in `bg/remove.ts`
871
+ 3. Update the Dockerfile's `COPY src/features/assets/bg/*.onnx /app/models/`
872
+ 4. Re-bench against a sample set (a CSV in `apps/asset-manager/bench/`
873
+ makes sense once the comparison is non-trivial).
874
+
875
+ ### Generative fill / aspect outpaint (`effect=genfill`)
876
+
877
+ Extend a source image into a different aspect ratio without the
878
+ awkward edge mirroring that classic content-aware fill produces.
879
+ Primary use case: building OG cards (1200×630) from portrait listing
880
+ photos, or 1:1 social tiles from 16:9 originals.
881
+
882
+ ```ts
883
+ // 1200×630 OG card from a portrait listing cover — gutters generated
884
+ // by Flux-Fill Pro, source pasted centered.
885
+ const ogUrl = aq.transform(asset, {
886
+ effect: "genfill",
887
+ width: 1200,
888
+ height: 630,
889
+ });
890
+ // → https://8ok.uk/t/effect=genfill,height=630,width=1200/<sha>.png
891
+
892
+ // 1:1 social tile from a landscape original
893
+ const tileUrl = aq.transform(asset, {
894
+ effect: "genfill",
895
+ width: 1080,
896
+ height: 1080,
897
+ });
898
+ ```
899
+
900
+ **Requires both `width` and `height`.** Without them the route returns
901
+ 422 — the effect needs an explicit target canvas to know what to
902
+ outpaint.
903
+
904
+ **Output defaults to WebP** at q=85 (~150KB for a 1200×630 OG card —
905
+ 12× lighter than raw Flux-Fill PNG output). Honors `format=` for
906
+ explicit overrides:
907
+
908
+ | Format | Bytes (typical 1200×630) | Use case |
909
+ |---|---|---|
910
+ | `format=webp` (default) | ~150KB | OG cards, social tiles, storefront cards |
911
+ | `format=png` | ~1.7MB | Lossless — print, marketing fold-outs |
912
+ | `format=avif` | ~120KB | Modern browsers, even better compression |
913
+ | `format=jpeg` | ~180KB | Legacy email clients |
914
+
915
+ **Real-estate caveat**: outpainting is mediocre when the target aspect
916
+ differs heavily from the source (1:1 from horizontal photo → tiled
917
+ artifacts because the model has to invent rooftops and floors).
918
+ Reserve `genfill` for SMALL aspect deltas (OG card 1200×630 from
919
+ landscape source ✓). For bigger crops, prefer `gravity=auto` smart-crop
920
+ which is deterministic and free (no Replicate cost, no AI invention).
921
+
922
+ Powered by Replicate's `black-forest-labs/flux-fill-pro`. ~$0.05 per
923
+ first request per (sha, dsl, format) tuple; subsequent identical
924
+ requests are 302 redirects to the R2 cache — zero Replicate cost.
925
+
926
+ Requires `REPLICATE_API_TOKEN` in the asset-manager's Secret Manager
927
+ secrets. Same token as `BG_REMOVAL_BACKEND=replicate`; no separate
928
+ provisioning needed.
929
+
930
+ **Short-circuit**: when the source already matches the target aspect
931
+ exactly (resized to fill the canvas with zero padding), the server
932
+ returns the resized PNG without calling Replicate — you don't pay
933
+ $0.05 for an effective no-op.
934
+
935
+ ### Video transforms (`aq.transformVideo`)
936
+
937
+ The same DSL works for videos too — the server branches on the asset's
938
+ `kind` column. Image params (`width`, `height`, `fit`) carry over;
939
+ video adds `start` (seconds, decimal OK) + `duration` (seconds, 1..300).
940
+
941
+ ```ts
942
+ // 16:9 source → 9:16 mobile clip, first 15 s, h.264 mp4
943
+ const portraitUrl = aq.transformVideo(asset, {
944
+ width: 1080,
945
+ height: 1920,
946
+ fit: "cover",
947
+ start: 0,
948
+ duration: 15,
949
+ });
950
+
951
+ // WebM output for bandwidth-conscious storefronts
952
+ const webmUrl = aq.transformVideo(asset, { format: "webm", width: 1280 });
953
+ ```
954
+
955
+ **First request is async**. Cache miss → Cloud Run Job runs ffmpeg
956
+ (typically 5-30 s for short clips) → output written to R2. The route
957
+ returns **202 Accepted** with `Retry-After: 10` and a `Location`
958
+ header pointing at the eventual CDN URL. The response body has
959
+ `{ status, message, retryAfterSec, outputUrl }`. Subsequent requests
960
+ hit the cache → **302** to the CDN.
961
+
962
+ Consumer pattern with Video.js v10 / `<video>`:
963
+
964
+ ```tsx
965
+ const src = aq.transformVideo(asset, { width: 1080, height: 1920 });
966
+ // Pass directly to <video src={src} />. While the Job runs the
967
+ // browser sees 202 → retry; once cached, the 302 → CDN. Most players
968
+ // retry transparently; if yours doesn't, poll `src` every 5 s until
969
+ // `Response.redirected` is true or the body content-type starts with
970
+ // "video/".
971
+ ```
972
+
973
+ Implementation: extends the EXISTING `asset-processing` Cloud Run Job
974
+ with a `transform-video` op. Reuses the same Cloud Tasks dispatch
975
+ pattern as `process-video` and `compose-slideshow`. Idempotent —
976
+ re-dispatching the same DSL noops if R2 already has the output.
977
+
978
+ ### Adaptive HLS streaming (`aq.streamingUrl`)
979
+
980
+ For long-form video — property tours, walkthroughs — point an
981
+ HLS-aware player at the master playlist:
982
+
983
+ ```tsx
984
+ <video
985
+ src={aq.streamingUrl(asset)}
986
+ controls playsInline
987
+ // Video.js v10 ships native HLS via @videojs/http-streaming —
988
+ // no plugin needed. Same with hls.js or iOS Safari.
989
+ />
990
+
991
+ // Sub-clip (HLS ladder built only for the clipped range)
992
+ const teaser = aq.streamingUrl(asset, { start: 0, duration: 30 });
993
+ ```
994
+
995
+ First request to a new HLS URL returns **202 Accepted** with
996
+ `Retry-After: 20` while a Cloud Run Job builds the multi-rung ladder
997
+ (typically 1-3 min for a 90 s source). Subsequent requests get
998
+ **302** to the cached `master.m3u8`. The R2 layout:
999
+
1000
+ ```
1001
+ <tid>/v/<sha>-hls<dslHash>/master.m3u8 ← entry point
1002
+ <tid>/v/<sha>-hls<dslHash>/240p/playlist.m3u8
1003
+ <tid>/v/<sha>-hls<dslHash>/240p/seg-000.ts
1004
+ <tid>/v/<sha>-hls<dslHash>/360p/...
1005
+
1006
+ <tid>/v/<sha>-hls<dslHash>/1080p/...
1007
+ ```
1008
+
1009
+ The ladder shrinks to fit the source: a 480p source produces three
1010
+ rungs (240p / 360p / 480p), a 1080p source produces five, and a 4K
1011
+ source goes up to 2160p. The player picks the right rung on the fly
1012
+ based on the current connection — a user on 3G starts at 240p and
1013
+ climbs to 1080p as bandwidth improves, vs the monolithic MP4 that
1014
+ either loaded or timed out.
1015
+
1016
+ ### Billing model
1017
+
1018
+ Transforms are billed as **storage**, not as "transformations" the way
1019
+ Cloudinary does — one R2 PUT per unique canonical DSL, then served from
1020
+ the CDN cache forever (until manually invalidated). The cache key is
1021
+ deterministic, so identical DSLs across deploys/tenants don't re-encode;
1022
+ mounting an existing CDN URL costs zero compute.
1023
+
1024
+ ## Palette + LQIP (compact placeholder UX)
1025
+
1026
+ Every successfully decoded image gets a palette extracted alongside
1027
+ the upload — a tiny 7-color set you can use for ambient gradients,
1028
+ fallback backgrounds, or themed UI accents. It survives across all
1029
+ preset selections (yes, even `presets: ["original"]`), because
1030
+ palette is metadata derived from the source bytes, not from a
1031
+ specific resized variant.
1032
+
1033
+ ```ts
1034
+ import {
1035
+ getPaletteBlurBackground,
1036
+ pickAmbientBackground,
1037
+ getAmbientGradient,
1038
+ getTextColorForBackground,
1039
+ } from "@nitida/sdk";
1040
+
1041
+ const asset = await aq.assets.get(assetId);
1042
+ // asset.palette = { d: "#1a1a1a", v: "#c5a95e", m: "#8b7d4f", ... }
1043
+ // asset.blur = "data:image/webp;base64,UklGRhAA..." // tiny LQIP
1044
+
1045
+ // Compact 4-stop gradient for hero / card backgrounds:
1046
+ const bg = getAmbientGradient(asset.palette);
1047
+ // bg = "linear-gradient(135deg, oklch(...), oklch(...))"
1048
+
1049
+ // Auto-pick text color that contrasts with the chosen ambient:
1050
+ const fg = getTextColorForBackground(asset.palette);
1051
+ // fg = "#fff" | "#000" | similar
1052
+
1053
+ // Or just the blurry LQIP for a CSS background placeholder:
1054
+ const placeholder = getPaletteBlurBackground(asset.palette);
1055
+ ```
1056
+
1057
+ The wire format is intentionally tight: `{d, v, m, dv, lv, dm, lm}`
1058
+ (dominant, vibrant, muted, dark-vibrant, light-vibrant, dark-muted,
1059
+ light-muted). 7 hex strings per asset — much smaller than a full
1060
+ base64 LQIP, but composes into nicer ambient UX.
1061
+
1062
+ When sharp can't decode the image (SVG sources, exotic formats,
1063
+ deliberately corrupted bytes), palette + blur silently come back
1064
+ `null`. The rest of the pipeline still succeeds.
1065
+
1066
+ Per-slot default:
1067
+
1068
+ ```ts
1069
+ await aq.slots.bind("storefront.hero", { assetId, preset: "lg" });
1070
+ ```
1071
+
1072
+ Per-call override (the resolver respects this over the slot's default):
1073
+
1074
+ ```ts
1075
+ const { url } = await aq.slots.resolve("storefront.hero", { preset: "md" });
1076
+ ```
1077
+
1078
+ Responsive `<img srcSet>` across all available presets:
1079
+
1080
+ ```tsx
1081
+ <img
1082
+ src={aq.urlFor(asset, "lg")}
1083
+ srcSet={aq.srcSetFor(asset)}
1084
+ sizes="(max-width: 768px) 100vw, 1280px"
1085
+ alt=""
1086
+ />
1087
+ ```
1088
+
1089
+ If you need a dimension that doesn't exist, two options: use the closest
1090
+ preset and let the browser scale, or file an issue to add it to the
1091
+ server-side pipeline (a platform-wide addition, not a per-tenant one).
1092
+
1093
+ ### CDN URL format
1094
+
1095
+ `<cdnBase>/<sha16>-<presetCode>.<ext>`
1096
+
1097
+ Example: `https://8ok.uk/c482458e824c730e-q.webp` (the `thumb` preset of
1098
+ sha `c482458e…` rendered as WebP). The path is content-addressed, so the
1099
+ same source bytes produce the same URL regardless of which tenant uploaded
1100
+ them — and the same URL never invalidates.
1101
+
1102
+ ## Auth
1103
+
1104
+ Auth is a Better Auth API key (`amk_rt_*`) emitted by
1105
+ `aquienpz/bootstrap-project.ts` per tenant. The key's metadata holds the tenant
1106
+ id, so `X-Tenant-Code` is log-only.
1107
+
1108
+ API key tiers:
1109
+
1110
+ - `amk_rt_*` — runtime / read-and-write
1111
+ - `amk_ad_*` — admin (slot bind, asset delete)
1112
+ - `amk_ci_*` — CI / batch jobs
1113
+
1114
+ ## API surface
1115
+
1116
+ | Namespace | Method | Description |
1117
+ |---|---|---|
1118
+ | `aq.slots` | `resolve(key)` / `resolveMany(keys)` | Read-side, cached 60s |
1119
+ | `aq.slots` | `list({prefix})` | Admin tree view |
1120
+ | `aq.slots` | `bind(key, {assetId, preset})` | Admin rebind |
1121
+ | `aq.slots` | `unbind(key)` | Remove binding |
1122
+ | `aq.slots` | `invalidateCache(key?)` | After admin rebind |
1123
+ | `aq.assets` | `byHash(sha)` / `byHashes([])` | Lookup |
1124
+ | `aq.assets` | `list({limit, cursor})` | Paginated |
1125
+ | `aq.assets` | `get(id)` | Full DTO |
1126
+ | `aq.assets` | `patchMetadata(id, {…})` | Merge JSON |
1127
+ | `aq.upload(file, {fileName})` | | Hash-dedup, returns canonical URL |
1128
+ | `aq.urlFor(asset, preset)` | | Build URL from DTO |
1129
+ | `aq.srcSetFor(asset)` | | Responsive `<img srcSet>` |
1130
+ | `aq.usage` | `snapshot()` | Storage + today + last 30 days totals (current tenant) |
1131
+ | `aq.usage` | `timeseries(days)` | Daily rollup for charts (1..365 days) |
1132
+ | `aq.usage` | `keys()` | Per-API-key breakdown month-to-date |
1133
+
1134
+ ## Usage / consumption
1135
+
1136
+ Cloudinary-style "where am I in my plan" view, scoped to the API key's
1137
+ own tenant (no cross-tenant data ever leaks):
1138
+
1139
+ ```ts
1140
+ const usage = await aq.usage.snapshot();
1141
+ // {
1142
+ // tenant: { id: 4, code: "realtyone-cr" },
1143
+ // storage: { totalBytes: 4_810_000_000, assetCount: 15_760 },
1144
+ // today: { reads: 3201, writes: 18, processes: 6, bytesIn: 12_400_000, ... },
1145
+ // last30Days: { reads: 86_400, writes: 412, ... }
1146
+ // }
1147
+
1148
+ const chart = await aq.usage.timeseries(30); // for a 30-day line chart
1149
+ const byKey = await aq.usage.keys(); // who's using the most quota
1150
+ ```
1151
+
1152
+ Backed by the daily rollup of `assets.api_key_usage` →
1153
+ `assets.tenant_usage_daily`. The "today" window queries the raw
1154
+ `api_key_usage` table since the rollup runs once per day at ~00:30 UTC.
1155
+
1156
+ ## License
1157
+
1158
+ UNLICENSED — internal use only.