@nitida/sdk 0.24.0 → 0.25.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 CHANGED
@@ -7,7 +7,7 @@ bindings** — admin-managed names that resolve to assets at runtime.
7
7
 
8
8
  ## One client, every environment
9
9
 
10
- The whole SDK ships as **one `AquienpzClient` class** behind environment-
10
+ The whole SDK ships as **one `NitidaClient` class** behind environment-
11
11
  specific subpath entries. Each subpath bundles the same client PLUS the
12
12
  helpers safe for that runtime — same pattern as **Vercel Blob** (`@vercel/blob`
13
13
  vs `@vercel/blob/client`), **Better Auth**, **Uploadthing**, and the **Vercel AI
@@ -17,11 +17,11 @@ eliminates type drift between server and client surfaces.
17
17
 
18
18
  | Where you run | Import | What it bundles |
19
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). |
20
+ | Node 20+, Bun, Cloud Run, Lambda, Edge, agents, cron | **`@nitida/sdk/server`** | `NitidaClient` + 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`** | `NitidaClient` + everything in `/server` + `createWebUploader` (multipart, parallel parts, IndexedDB resume) + `compressImage` (HEIC→WebP, compressorjs). |
22
22
  | React Native / Expo apps | `@nitida/sdk/native` | `compressImage` for RN (libjpeg-turbo via `react-native-compressor`). Pair with `/expo`. |
23
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`. |
24
+ | Next.js / React (hooks) | `@nitida/sdk/react` | `NitidaProvider` + `useSlot` / `useSlots` / `useAsset`. |
25
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
26
 
27
27
  Uploaders + compressors are **optional** peer deps (`@aquienpz/asset-uploader-{web,expo}`,
@@ -57,7 +57,7 @@ loud — same pattern as `@vercel/blob/client`, `better-auth/client`, AI SDK's
57
57
  `/edge` subpath.
58
58
 
59
59
  `@nitida/sdk/web` v0.17+ enforces this physically: the constructor type
60
- is `Omit<AquienpzClientOptions, "apiKey" | "signingKey">`. Passing `apiKey`
60
+ is `Omit<NitidaClientOptions, "apiKey" | "signingKey">`. Passing `apiKey`
61
61
  won't compile, period.
62
62
 
63
63
  ## BFF-proxy mode (browser → your route → aquienpz)
@@ -71,11 +71,11 @@ token and forwards to aquienpz. Same pattern Vercel Blob uses for
71
71
 
72
72
  ```tsx
73
73
  "use client";
74
- import { AquienpzClient } from "@nitida/sdk/web";
74
+ import { NitidaClient } from "@nitida/sdk/web";
75
75
 
76
- const aq = new AquienpzClient({
76
+ const aq = new NitidaClient({
77
77
  endpoint: "/api/am", // OK: relative → same-origin BFF
78
- tenantCode: "realtyone-cr",
78
+ tenantCode: "acme-co",
79
79
  tenantId: 1,
80
80
  // apiKey: ... ERROR: TS error: not assignable to WebClientOptions
81
81
  });
@@ -96,7 +96,7 @@ import { NextRequest } from "next/server";
96
96
 
97
97
  const UPSTREAM = process.env.AQUIENPZ_URL!; // server-only
98
98
  const API_KEY = process.env.AQUIENPZ_API_KEY!; // server-only
99
- const TENANT = process.env.AQUIENPZ_TENANT_CODE!; // e.g. "realtyone-cr"
99
+ const TENANT = process.env.AQUIENPZ_TENANT_CODE!; // e.g. "acme-co"
100
100
 
101
101
  async function proxy(req: NextRequest, { params }: { params: { path: string[] } }) {
102
102
  const search = new URL(req.url).search;
@@ -119,12 +119,12 @@ export { proxy as GET, proxy as POST, proxy as PUT, proxy as PATCH, proxy as DEL
119
119
  ### Bun / Elysia backend
120
120
 
121
121
  ```ts
122
- import { AquienpzClient } from "@nitida/sdk/server";
122
+ import { NitidaClient } from "@nitida/sdk/server";
123
123
 
124
- const aq = new AquienpzClient({
124
+ const aq = new NitidaClient({
125
125
  endpoint: process.env.AQUIENPZ_URL!,
126
126
  apiKey: process.env.AQUIENPZ_API_KEY!, // OK: required by ServerClientOptions
127
- tenantCode: "realtyone-cr",
127
+ tenantCode: "acme-co",
128
128
  tenantId: 1,
129
129
  });
130
130
  ```
@@ -132,7 +132,7 @@ const aq = new AquienpzClient({
132
132
  ### Expo
133
133
 
134
134
  ```ts
135
- import { AquienpzClient } from "@nitida/sdk/web"; // same browser-safe type
135
+ import { NitidaClient } from "@nitida/sdk/web"; // same browser-safe type
136
136
  // Construct against your /api/am proxy; no apiKey in the app bundle.
137
137
  ```
138
138
 
@@ -150,34 +150,37 @@ bun add @nitida/sdk @nitida/asset-client
150
150
  npm install @nitida/sdk @nitida/asset-client
151
151
  ```
152
152
 
153
- Optional, only if you need large uploads:
153
+ Optional, only if you compress on the client before uploading:
154
154
 
155
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
156
+ bun add @nitida/asset-compressor-web # browser: HEIC→WebP, resize
157
+ bun add @nitida/asset-compressor-native # Expo / React Native
161
158
  ```
162
159
 
163
160
  Peer deps: `@nitida/asset-client` (URL builders + types) and `react` (only if
164
- you use the `/react` subpath).
161
+ you use the `/react` subpath). Both compressors are optional and lazy — the
162
+ SDK never imports them unless you call a compress path.
163
+
164
+ > ⚠️ The multipart uploader packages (`@aquienpz/asset-uploader-web` /
165
+ > `-expo`) are **not published on npm**. `aq.upload()` covers files in a
166
+ > single PUT and is what every consumer uses today; see *Large uploads* below
167
+ > for where the ceiling actually is.
165
168
 
166
169
  ## Quick start
167
170
 
168
171
  ```ts
169
- import { AquienpzClient } from "@nitida/sdk";
172
+ import { NitidaClient } from "@nitida/sdk";
170
173
 
171
- const aq = new AquienpzClient({
174
+ const aq = new NitidaClient({
172
175
  endpoint: "https://api.nitida.gofuture.space",
173
- apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY!, // amk_rt_* Better Auth API key
176
+ apiKey: process.env.AQUIENPZ_API_KEY!, // amk_rt_* runtime API key
174
177
  tenantCode: "your-tenant",
175
178
  tenantId: 42, // numeric tenant id (used in CDN URL prefix)
176
179
  cdnBase: "https://8ok.uk", // optional, defaults to https://8ok.uk
177
180
  });
178
181
 
179
182
  // Slots — the recommended way to reference brand assets in code.
180
- // Source never hardcodes a CDN URL; admin rebinds from asset-lab-web.
183
+ // Source never hardcodes a CDN URL; an admin rebinds it from the console.
181
184
  const hero = await aq.slots.resolve("storefront.home.hero");
182
185
  // → { slot: { asset, preset, … }, preset: "lg", url: "https://8ok.uk/2a/v/<sha>-l.webp" }
183
186
 
@@ -240,8 +243,8 @@ await aq.upload(file, {
240
243
  });
241
244
  ```
242
245
 
243
- **Defaults** (matched to the realtyone-cr production webapp:
244
- `LISTING_STANDARD_*` constants):
246
+ **Defaults** (tuned against a production photo-upload workload
247
+ phone cameras, listing covers):
245
248
 
246
249
  | Option | Default |
247
250
  |---|---|
@@ -263,9 +266,8 @@ await aq.upload(file, {
263
266
  - Expo / React Native: the `/expo` subpath uses native
264
267
  `expo-image-manipulator` instead (already wired in the uploader). The
265
268
  `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
+ - The original pre-compression size is recorded server-side, so the
270
+ savings show up in the admin usage dashboards.
269
271
 
270
272
  Direct access to the compressor (without going through `aq.upload`):
271
273
 
@@ -279,23 +281,25 @@ const results = await compressImages([fileA, fileB, fileC]);
279
281
 
280
282
  ## Large uploads (web)
281
283
 
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).
284
+ `aq.upload()` is a single PUT: atomic, BFF-friendly, and the path this SDK
285
+ supports. It is what you should call.
287
286
 
288
- ```ts
289
- import { createWebUploader } from "@nitida/sdk/web";
287
+ **Multipart is deliberately NOT exposed from `@nitida/sdk/web`.** A static
288
+ import of the uploader package broke every consumer of the subpath (the
289
+ bundler resolves before the optional-peer check runs), and the `UploadTask`
290
+ API needs a raw `authToken` that BFF-proxy mode does not hand out — so there
291
+ is no `createWebUploader` export. Earlier drafts of this README described
292
+ one; it never shipped.
290
293
 
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
- ```
294
+ Where that leaves you:
296
295
 
297
- Peer dep: `@aquienpz/asset-uploader-web` (lazy apps that only need
298
- small uploads skip it).
296
+ | file size | what to call |
297
+ |---|---|
298
+ | any size the browser can hold in memory | `aq.upload(file)` |
299
+ | bigger, or you need resume across reloads | not covered by this SDK yet — talk to us |
300
+
301
+ Client-side compression is the lever that keeps most media under the
302
+ single-PUT ceiling; see *Client-side compression* below.
299
303
 
300
304
  ## Large uploads (Expo / React Native)
301
305
 
@@ -307,13 +311,13 @@ WorkManager. Same JS API as the web flavor.
307
311
  import { useEffect, useState } from "react";
308
312
  import { Image } from "react-native";
309
313
  import * as ImagePicker from "expo-image-picker";
310
- import { AquienpzClient } from "@nitida/sdk";
314
+ import { NitidaClient } from "@nitida/sdk";
311
315
  import {
312
316
  createExpoUploader,
313
317
  listResumableSessions,
314
318
  } from "@nitida/sdk/expo";
315
319
 
316
- const aq = new AquienpzClient({
320
+ const aq = new NitidaClient({
317
321
  endpoint: process.env.EXPO_PUBLIC_AQUIENPZ_URL!,
318
322
  apiKey: process.env.EXPO_PUBLIC_AQUIENPZ_API_KEY!, // amk_rt_*
319
323
  tenantCode: "your-tenant",
@@ -359,9 +363,10 @@ export function UploadHeroScreen() {
359
363
  }
360
364
  ```
361
365
 
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`.
366
+ ⚠️ The Expo background-upload path depends on `@aquienpz/asset-uploader-expo`,
367
+ which is **not on npm**. `aq.upload()` works on Expo today without it; the
368
+ background/resumable variant is not something an external consumer can install
369
+ yet.
365
370
 
366
371
  ## Next.js App Router (Server Components)
367
372
 
@@ -370,9 +375,9 @@ no provider, no hook. The slot URLs ship as plain `<img>` markup.
370
375
 
371
376
  ```tsx
372
377
  // app/page.tsx — Server Component
373
- import { AquienpzClient } from "@nitida/sdk";
378
+ import { NitidaClient } from "@nitida/sdk";
374
379
 
375
- const aq = new AquienpzClient({
380
+ const aq = new NitidaClient({
376
381
  endpoint: process.env.AQUIENPZ_URL!,
377
382
  apiKey: process.env.AQUIENPZ_API_KEY!, // amk_rt_* — server-only
378
383
  tenantCode: "your-tenant",
@@ -412,11 +417,11 @@ each hook subscribes to the in-process cache.
412
417
  ```tsx
413
418
  // app/providers.tsx — Client Component
414
419
  "use client";
415
- import { AquienpzClient } from "@nitida/sdk";
416
- import { AquienpzProvider } from "@nitida/sdk/react";
420
+ import { NitidaClient } from "@nitida/sdk";
421
+ import { NitidaProvider } from "@nitida/sdk/react";
417
422
 
418
423
  // In production, get apiKey from a /session route instead of bundling it.
419
- const client = new AquienpzClient({
424
+ const client = new NitidaClient({
420
425
  endpoint: process.env.NEXT_PUBLIC_AQUIENPZ_URL!,
421
426
  apiKey: process.env.NEXT_PUBLIC_AQUIENPZ_API_KEY!,
422
427
  tenantCode: "your-tenant",
@@ -424,7 +429,7 @@ const client = new AquienpzClient({
424
429
  });
425
430
 
426
431
  export function Providers({ children }: { children: React.ReactNode }) {
427
- return <AquienpzProvider client={client}>{children}</AquienpzProvider>;
432
+ return <NitidaProvider client={client}>{children}</NitidaProvider>;
428
433
  }
429
434
  ```
430
435
 
@@ -451,7 +456,7 @@ deploys to brand decisions. With slots:
451
456
 
452
457
  | Without slots | With slots |
453
458
  |---|---|
454
- | Edit URL in code | Drag-drop a new asset in asset-lab-web |
459
+ | Edit URL in code | Drag-drop a new asset in the admin console |
455
460
  | Commit + PR + deploy | Cache refreshes (60s default) |
456
461
  | 5-30 minute roundtrip | Instant |
457
462
 
@@ -501,8 +506,8 @@ slot defaults to and emit responsive `srcSet` for browser-side resizing.
501
506
 
502
507
  Omitting `presets` is always equivalent to `["original"]` — the platform
503
508
  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:
509
+ one-shot uploads cheap and avoids surprise storage writes (the ladder
510
+ is 4× the source bytes). Apps that want responsive sizes pass them explicitly:
506
511
 
507
512
  ```ts
508
513
  await aq.upload(file, {
@@ -516,8 +521,8 @@ Or add missing variants later without re-uploading:
516
521
  await aq.assets.regenerate(assetId, { presets: ["thumb", "sm", "md", "lg"] });
517
522
  ```
518
523
 
519
- Variants are WebP `quality: 75–80`. Total R2 cost when generating the
520
- full responsive ladder ≈ 4× source bytes.
524
+ Variants are WebP `quality: 75–80`. Total stored bytes when generating
525
+ the full responsive ladder ≈ 4× source bytes.
521
526
 
522
527
  **Variants never upscale.** The pipeline clamps each preset's target to
523
528
  `min(presetMaxSide, sourceMaxSide)`. A 1080×720 photo asked for `xl`
@@ -628,26 +633,26 @@ You can call it many times; it's idempotent per preset.
628
633
 
629
634
  ### Source-byte lifecycle (why regenerate always works)
630
635
 
631
- Two distinct R2 prefixes; most people only care about one:
636
+ Your bytes exist in two places with **very different lifetimes**:
632
637
 
633
- | R2 path | Role | Lifetime |
638
+ | Bytes | Role | Lifetime |
634
639
  |---|---|---|
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 |
640
+ | The upload you PUT to the presigned URL | Read once to verify the SHA. Never CDN-served. | **Deleted ~24h after upload** |
641
+ | The stored variants | The CDN-served files. The `original` variant is the permanent home of your source bytes. | **Permanent** — only deleted by an explicit DELETE on the asset or variant |
642
+
643
+ ⚠️ **That 24h is why you should ask for `original`.** Once the grace window
644
+ closes, the highest-fidelity bytes still on the platform are whatever variants
645
+ you asked for.
637
646
 
638
647
  `regenerate()` walks a fidelity-ordered fallback chain until it finds
639
648
  usable bytes — it **never fails on a still-present asset**:
640
649
 
641
650
  ```
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.webplg
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
+ 1. original ← permanent, your exact source (best)
652
+ 2. raw the upload bytes, only during the 24h grace
653
+ 3. xl → lg → md → sm
654
+ 4. thumblast resort: it is a smart-cropped square, so anything
655
+ derived from it inherits the thumb's crop
651
656
  ```
652
657
 
653
658
  The no-upscale clamp guarantees we never invent pixels: asking for
@@ -677,9 +682,9 @@ For everything else — exact CSS pixel widths, art-directed crops, devicePixelR
677
682
  ladders, square thumbs from rectangular sources — call `aq.transform()`:
678
683
 
679
684
  ```tsx
680
- import { AquienpzClient } from "@nitida/sdk";
685
+ import { NitidaClient } from "@nitida/sdk";
681
686
 
682
- const aq = new AquienpzClient({ /* ... */ });
687
+ const aq = new NitidaClient({ /* ... */ });
683
688
  const asset = await aq.assets.byHash(sha256);
684
689
 
685
690
  // Single URL
@@ -721,24 +726,21 @@ const asset = await aq.assets.byHash(sha256);
721
726
  > `2400`). `height` stays `number` — for the responsive path it's derived from `width`
722
727
  > by aspect ratio; only fixed-canvas crops / `genfill` set it explicitly.
723
728
 
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.
729
+ `format=auto` resolves to **WebP** by default. Our bench over 100 random
730
+ production `lg.webp` samples showed AVIF was 4.5–9.8% **larger** than WebP in every
731
+ source-size bracket with virtually identical SSIM. The policy is re-evaluated as
732
+ libavif improves, so `auto` may resolve differently in the future — that is the
733
+ point of asking for `auto` instead of naming a format.
734
+
735
+ `gravity=face` detects the highest-confidence face and crops to it with
736
+ sensible padding (50 % of face dimensions on each side, clamped to source
737
+ bounds, keeping the requested aspect ratio). When no face is detected it
738
+ falls back to saliency-based cropping and returns the response with
739
+ `X-Transform-Face: fallback`, so you can tell the two apart. Cost to you:
740
+ ~10 ms warm; ~150 ms on the first request after a cold start.
739
741
 
740
742
  `quality=auto` adapts the per-format quality to source complexity
741
- (luminance stddev via sharp `.stats()`):
743
+ (the luminance standard deviation of the source):
742
744
 
743
745
  | Bucket (stddev) | WebP | AVIF | JPEG |
744
746
  |---|---:|---:|---:|
@@ -746,16 +748,15 @@ instance boots.
746
748
  | normal (25–55) — most photos | 70 | 60 | 80 |
747
749
  | complex (≥ 55) — busy textures | 72 | 65 | 82 |
748
750
 
749
- Validated on 100 random realtyone-cr `lg.webp` samples: **+15.4 %
751
+ Validated on 100 random production `lg.webp` samples: **+15.4 %
750
752
  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`.
753
+ 0.005). The bucket boundaries and the per-format table are server-side
754
+ policy and may be re-tuned; pass an explicit `quality=` when you need a
755
+ number that does not move.
755
756
 
756
757
  ### Canonicalization & caching
757
758
 
758
- URLs with the same params in different order share the same R2 cache entry:
759
+ URLs with the same params in different order share the same cache entry:
759
760
 
760
761
  ```ts
761
762
  aq.transform(asset, { width: 480, fit: "contain" })
@@ -767,21 +768,21 @@ aq.transform(asset, { fit: "contain", width: 480 })
767
768
 
768
769
  The server canonicalizes incoming DSL the same way the SDK does (sort keys
769
770
  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
+ canonical string into the cache key — so even non-SDK URLs (e.g. typed
771
772
  by a developer in a browser bar) collapse onto the same cache entry as long
772
773
  as they specify the same params.
773
774
 
774
775
  ### Signed URLs + strict mode (Phase 3)
775
776
 
776
- Every tenant has an HMAC-SHA256 signing key (`signing_key` column in
777
- `public.tenants`, 32 random bytes generated on tenant creation).
777
+ Every tenant has an HMAC-SHA256 signing key 32 random bytes, generated
778
+ on tenant creation.
778
779
  Optionally enable `strict_transforms = true` to reject unsigned URLs
779
780
  with a 401 — useful when transform URLs leak from a private surface
780
781
  (internal admin, b2b portal) and you don't want third parties
781
782
  generating arbitrary crops.
782
783
 
783
784
  ```ts
784
- const aq = new AquienpzClient({
785
+ const aq = new NitidaClient({
785
786
  endpoint: process.env.AQUIENPZ_URL!,
786
787
  apiKey: process.env.AQUIENPZ_API_KEY!,
787
788
  tenantCode: "your-tenant",
@@ -813,16 +814,16 @@ different order accept the same signature.
813
814
  ```bash
814
815
  # Rotate the signing key — invalidates every URL signed with the old one.
815
816
  curl -X POST -H "Authorization: Bearer amk_ad_..." \
816
- https://aquienpz-asset-manager.../admin/projects/your-tenant/rotate-signing-key
817
+ https://api.nitida.gofuture.space/admin/projects/your-tenant/rotate-signing-key
817
818
  # → { ok: true, tenantId, code, signingKey: "<64-hex>" }
818
819
 
819
820
  # Flip strict mode on/off.
820
821
  curl -X PATCH -H "Authorization: Bearer amk_ad_..." -H "Content-Type: application/json" \
821
822
  -d '{"enabled":true}' \
822
- https://aquienpz-asset-manager.../admin/projects/your-tenant/strict-transforms
823
+ https://api.nitida.gofuture.space/admin/projects/your-tenant/strict-transforms
823
824
  ```
824
825
 
825
- **Rotation cost**: cached transform variants on R2 are NOT re-keyed by
826
+ **Rotation cost**: cached transform variants are NOT re-keyed by
826
827
  the signature, so they keep serving the same bytes. Only the URLs your
827
828
  consumers hold need re-signing. Coordinate the rotation with anyone who
828
829
  pre-signs at build time (e.g. SSG / next-build).
@@ -844,33 +845,26 @@ const jpegUrl = aq.transform(asset, { format: "jpeg" });
844
845
  // → https://8ok.uk/t/format=jpeg/<sha>.jpg
845
846
  ```
846
847
 
847
- `effect=removebg` is provider-pluggable via the
848
- `BG_REMOVAL_BACKEND` env on the asset-manager:
848
+ `effect=removebg` runs on one of two server-side matting backends, chosen
849
+ per deployment. What the caller sees:
849
850
 
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.
851
+ - **CPU matting** (default) — ~1-2 s warm, ~5-7 s cold. No per-image cost.
852
+ - **GPU matting** ~3-8 s, ~$0.001-0.005 per image, better edges on hair
853
+ and fine detail.
858
854
 
859
- In both cases the route caches the PNG in R2 under the standard
855
+ Either way the contract is the same, and the first request is the only one
856
+ that pays. In both cases the route caches the PNG under the standard
860
857
  `<sha>-t<dslHash>.png` key, so subsequent identical requests are 302
861
858
  redirects to the CDN — no inference, no per-image cost. **Always
862
859
  forces `format=png`** because the entire point is preserving alpha.
863
860
 
864
861
  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
862
+ to resize the cutout in a single request (cached as one entry per
866
863
  canonical DSL).
867
864
 
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).
865
+ The matting model is server-side and may be swapped for a better one
866
+ without any change on your side — the URL, the PNG-with-alpha output and
867
+ the cache semantics are the contract.
874
868
 
875
869
  ### Generative fill / aspect outpaint (`effect=genfill`)
876
870
 
@@ -880,8 +874,8 @@ Primary use case: building OG cards (1200×630) from portrait listing
880
874
  photos, or 1:1 social tiles from 16:9 originals.
881
875
 
882
876
  ```ts
883
- // 1200×630 OG card from a portrait listing cover — gutters generated
884
- // by Flux-Fill Pro, source pasted centered.
877
+ // 1200×630 OG card from a portrait listing cover — the gutters are
878
+ // generated, the source is pasted centered.
885
879
  const ogUrl = aq.transform(asset, {
886
880
  effect: "genfill",
887
881
  width: 1200,
@@ -902,7 +896,7 @@ const tileUrl = aq.transform(asset, {
902
896
  outpaint.
903
897
 
904
898
  **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
899
+ 12× lighter than the raw generated PNG). Honors `format=` for
906
900
  explicit overrides:
907
901
 
908
902
  | Format | Bytes (typical 1200×630) | Use case |
@@ -916,20 +910,18 @@ explicit overrides:
916
910
  differs heavily from the source (1:1 from horizontal photo → tiled
917
911
  artifacts because the model has to invent rooftops and floors).
918
912
  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.
913
+ landscape source ✓). For bigger crops, prefer `gravity=auto` smart-crop,
914
+ which is deterministic and free no generation, no invention.
925
915
 
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.
916
+ **What it costs you: ~$0.05 for the first request** per (sha, dsl, format)
917
+ tuple. Subsequent identical requests are 302 redirects to the cached PNG —
918
+ zero generative cost, forever. The model is server-side and may be swapped
919
+ for a better one without any change on your side; the DSL, the output and
920
+ the cache semantics are the contract.
929
921
 
930
922
  **Short-circuit**: when the source already matches the target aspect
931
923
  exactly (resized to fill the canvas with zero padding), the server
932
- returns the resized PNG without calling Replicate — you don't pay
924
+ returns the resized PNG without generating anything — you don't pay
933
925
  $0.05 for an effective no-op.
934
926
 
935
927
  ### Video transforms (`aq.transformVideo`)
@@ -952,8 +944,8 @@ const portraitUrl = aq.transformVideo(asset, {
952
944
  const webmUrl = aq.transformVideo(asset, { format: "webm", width: 1280 });
953
945
  ```
954
946
 
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
947
+ **First request is async**. Cache miss → a background transcode runs
948
+ (typically 5-30 s for short clips) → the output is stored. The route
957
949
  returns **202 Accepted** with `Retry-After: 10` and a `Location`
958
950
  header pointing at the eventual CDN URL. The response body has
959
951
  `{ status, message, retryAfterSec, outputUrl }`. Subsequent requests
@@ -963,17 +955,16 @@ Consumer pattern with Video.js v10 / `<video>`:
963
955
 
964
956
  ```tsx
965
957
  const src = aq.transformVideo(asset, { width: 1080, height: 1920 });
966
- // Pass directly to <video src={src} />. While the Job runs the
958
+ // Pass directly to <video src={src} />. While the transcode runs the
967
959
  // browser sees 202 → retry; once cached, the 302 → CDN. Most players
968
960
  // retry transparently; if yours doesn't, poll `src` every 5 s until
969
961
  // `Response.redirected` is true or the body content-type starts with
970
962
  // "video/".
971
963
  ```
972
964
 
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.
965
+ **Idempotent** requesting the same DSL again while the first job is
966
+ still running does not start a second one, and does nothing at all once
967
+ the output is cached. Fire and retry freely.
977
968
 
978
969
  ### Adaptive HLS streaming (`aq.streamingUrl`)
979
970
 
@@ -993,9 +984,9 @@ const teaser = aq.streamingUrl(asset, { start: 0, duration: 30 });
993
984
  ```
994
985
 
995
986
  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
987
+ `Retry-After: 20` while a background job builds the multi-rung ladder
997
988
  (typically 1-3 min for a 90 s source). Subsequent requests get
998
- **302** to the cached `master.m3u8`. The R2 layout:
989
+ **302** to the cached `master.m3u8`. The CDN layout:
999
990
 
1000
991
  ```
1001
992
  <tid>/v/<sha>-hls<dslHash>/master.m3u8 ← entry point
@@ -1016,7 +1007,7 @@ either loaded or timed out.
1016
1007
  ### Billing model
1017
1008
 
1018
1009
  Transforms are billed as **storage**, not as "transformations" the way
1019
- Cloudinary does — one R2 PUT per unique canonical DSL, then served from
1010
+ Cloudinary does — one stored object per unique canonical DSL, then served from
1020
1011
  the CDN cache forever (until manually invalidated). The cache key is
1021
1012
  deterministic, so identical DSLs across deploys/tenants don't re-encode;
1022
1013
  mounting an existing CDN URL costs zero compute.
@@ -1059,7 +1050,7 @@ The wire format is intentionally tight: `{d, v, m, dv, lv, dm, lm}`
1059
1050
  light-muted). 7 hex strings per asset — much smaller than a full
1060
1051
  base64 LQIP, but composes into nicer ambient UX.
1061
1052
 
1062
- When sharp can't decode the image (SVG sources, exotic formats,
1053
+ When the image can't be decoded (SVG sources, exotic formats,
1063
1054
  deliberately corrupted bytes), palette + blur silently come back
1064
1055
  `null`. The rest of the pipeline still succeeds.
1065
1056
 
@@ -1101,9 +1092,8 @@ them — and the same URL never invalidates.
1101
1092
 
1102
1093
  ## Auth
1103
1094
 
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.
1095
+ Auth is a bearer API key (`amk_rt_*`), issued per tenant when the tenant is
1096
+ created. The key itself carries the tenant id, so `X-Tenant-Code` is log-only.
1107
1097
 
1108
1098
  API key tiers:
1109
1099
 
@@ -1139,19 +1129,36 @@ own tenant (no cross-tenant data ever leaks):
1139
1129
  ```ts
1140
1130
  const usage = await aq.usage.snapshot();
1141
1131
  // {
1142
- // tenant: { id: 4, code: "realtyone-cr" },
1132
+ // tenant: { id: 4, code: "acme-co" },
1143
1133
  // 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, ... }
1134
+ // today: { reads: 3201, writes: 18, processes: 6, videoMinutes: 4.5, ... },
1135
+ // last30Days: { reads: 86_400, writes: 412, videoMinutes: 312.75, ... }
1146
1136
  // }
1147
1137
 
1148
1138
  const chart = await aq.usage.timeseries(30); // for a 30-day line chart
1149
1139
  const byKey = await aq.usage.keys(); // who's using the most quota
1150
1140
  ```
1151
1141
 
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.
1142
+ `last30Days` is served from a rollup that runs **once per day at
1143
+ ~00:30 UTC**; the `today` window is computed live off the raw counters, so
1144
+ it is current. The exception is `videoMinutes`, which is accumulated live
1145
+ and is therefore real-time on **every** window.
1146
+
1147
+ **`videoMinutes` counts minutes actually TRANSCODED, not videos stored.**
1148
+ It is *source minutes × encode passes*, so a 7-rung HLS ladder over a
1149
+ 2-minute clip books 14. Re-encoding a video you already uploaded counts
1150
+ **again** — that is the point: the asset row is upserted on `sha256`, so
1151
+ nothing else in the system can see a re-encode. A passthrough stream copy
1152
+ or a cache hit runs no encoder and books nothing, so `0` means zero.
1153
+
1154
+ > ⚠️ **Removed 2026-08-17:** `bytesIn` (on the usage windows and daily
1155
+ > points) and `bytesTotal` (on the per-key rows). They were fed by
1156
+ > a counter that is structurally zero — measured across 440,820 usage
1157
+ > rows, none above 0 — and could not be instrumented, because uploads go
1158
+ > to object storage through presigned URLs and **never traverse the API**.
1159
+ > A field that always
1160
+ > reads `0` is worse than an absent one. The byte figure that is true,
1161
+ > `storage.totalBytes`, is unchanged.
1155
1162
 
1156
1163
  ## License
1157
1164