@nitida/sdk 0.24.1 → 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
@@ -727,18 +732,15 @@ source-size bracket with virtually identical SSIM. The policy is re-evaluated as
727
732
  libavif improves, so `auto` may resolve differently in the future — that is the
728
733
  point of asking for `auto` instead of naming a format.
729
734
 
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.
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
  |---|---:|---:|---:|
@@ -754,7 +756,7 @@ number that does not move.
754
756
 
755
757
  ### Canonicalization & caching
756
758
 
757
- 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:
758
760
 
759
761
  ```ts
760
762
  aq.transform(asset, { width: 480, fit: "contain" })
@@ -766,21 +768,21 @@ aq.transform(asset, { fit: "contain", width: 480 })
766
768
 
767
769
  The server canonicalizes incoming DSL the same way the SDK does (sort keys
768
770
  alphabetically, lowercase string values, drop `undefined`) and hashes the
769
- 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
770
772
  by a developer in a browser bar) collapse onto the same cache entry as long
771
773
  as they specify the same params.
772
774
 
773
775
  ### Signed URLs + strict mode (Phase 3)
774
776
 
775
- Every tenant has an HMAC-SHA256 signing key (`signing_key` column in
776
- `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.
777
779
  Optionally enable `strict_transforms = true` to reject unsigned URLs
778
780
  with a 401 — useful when transform URLs leak from a private surface
779
781
  (internal admin, b2b portal) and you don't want third parties
780
782
  generating arbitrary crops.
781
783
 
782
784
  ```ts
783
- const aq = new AquienpzClient({
785
+ const aq = new NitidaClient({
784
786
  endpoint: process.env.AQUIENPZ_URL!,
785
787
  apiKey: process.env.AQUIENPZ_API_KEY!,
786
788
  tenantCode: "your-tenant",
@@ -812,16 +814,16 @@ different order accept the same signature.
812
814
  ```bash
813
815
  # Rotate the signing key — invalidates every URL signed with the old one.
814
816
  curl -X POST -H "Authorization: Bearer amk_ad_..." \
815
- https://aquienpz-asset-manager.../admin/projects/your-tenant/rotate-signing-key
817
+ https://api.nitida.gofuture.space/admin/projects/your-tenant/rotate-signing-key
816
818
  # → { ok: true, tenantId, code, signingKey: "<64-hex>" }
817
819
 
818
820
  # Flip strict mode on/off.
819
821
  curl -X PATCH -H "Authorization: Bearer amk_ad_..." -H "Content-Type: application/json" \
820
822
  -d '{"enabled":true}' \
821
- https://aquienpz-asset-manager.../admin/projects/your-tenant/strict-transforms
823
+ https://api.nitida.gofuture.space/admin/projects/your-tenant/strict-transforms
822
824
  ```
823
825
 
824
- **Rotation cost**: cached transform variants on R2 are NOT re-keyed by
826
+ **Rotation cost**: cached transform variants are NOT re-keyed by
825
827
  the signature, so they keep serving the same bytes. Only the URLs your
826
828
  consumers hold need re-signing. Coordinate the rotation with anyone who
827
829
  pre-signs at build time (e.g. SSG / next-build).
@@ -851,13 +853,13 @@ per deployment. What the caller sees:
851
853
  and fine detail.
852
854
 
853
855
  Either way the contract is the same, and the first request is the only one
854
- that pays. In both cases the route caches the PNG in R2 under the standard
856
+ that pays. In both cases the route caches the PNG under the standard
855
857
  `<sha>-t<dslHash>.png` key, so subsequent identical requests are 302
856
858
  redirects to the CDN — no inference, no per-image cost. **Always
857
859
  forces `format=png`** because the entire point is preserving alpha.
858
860
 
859
861
  The output is the same dimensions as the source. Chain with `width`
860
- 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
861
863
  canonical DSL).
862
864
 
863
865
  The matting model is server-side and may be swapped for a better one
@@ -872,8 +874,8 @@ Primary use case: building OG cards (1200×630) from portrait listing
872
874
  photos, or 1:1 social tiles from 16:9 originals.
873
875
 
874
876
  ```ts
875
- // 1200×630 OG card from a portrait listing cover — gutters generated
876
- // 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.
877
879
  const ogUrl = aq.transform(asset, {
878
880
  effect: "genfill",
879
881
  width: 1200,
@@ -894,7 +896,7 @@ const tileUrl = aq.transform(asset, {
894
896
  outpaint.
895
897
 
896
898
  **Output defaults to WebP** at q=85 (~150KB for a 1200×630 OG card —
897
- 12× lighter than raw Flux-Fill PNG output). Honors `format=` for
899
+ 12× lighter than the raw generated PNG). Honors `format=` for
898
900
  explicit overrides:
899
901
 
900
902
  | Format | Bytes (typical 1200×630) | Use case |
@@ -908,19 +910,18 @@ explicit overrides:
908
910
  differs heavily from the source (1:1 from horizontal photo → tiled
909
911
  artifacts because the model has to invent rooftops and floors).
910
912
  Reserve `genfill` for SMALL aspect deltas (OG card 1200×630 from
911
- landscape source ✓). For bigger crops, prefer `gravity=auto` smart-crop
912
- which is deterministic and free (no Replicate cost, no AI invention).
913
+ landscape source ✓). For bigger crops, prefer `gravity=auto` smart-crop,
914
+ which is deterministic and free no generation, no invention.
913
915
 
914
- Powered by Replicate's `black-forest-labs/flux-fill-pro`. ~$0.05 per
915
- first request per (sha, dsl, format) tuple; subsequent identical
916
- requests are 302 redirects to the R2 cache zero Replicate cost.
917
-
918
- Server-side credentials for the generative provider are part of the
919
- deployment; nothing to provision on your side.
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.
920
921
 
921
922
  **Short-circuit**: when the source already matches the target aspect
922
923
  exactly (resized to fill the canvas with zero padding), the server
923
- returns the resized PNG without calling Replicate — you don't pay
924
+ returns the resized PNG without generating anything — you don't pay
924
925
  $0.05 for an effective no-op.
925
926
 
926
927
  ### Video transforms (`aq.transformVideo`)
@@ -943,8 +944,8 @@ const portraitUrl = aq.transformVideo(asset, {
943
944
  const webmUrl = aq.transformVideo(asset, { format: "webm", width: 1280 });
944
945
  ```
945
946
 
946
- **First request is async**. Cache miss → Cloud Run Job runs ffmpeg
947
- (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
948
949
  returns **202 Accepted** with `Retry-After: 10` and a `Location`
949
950
  header pointing at the eventual CDN URL. The response body has
950
951
  `{ status, message, retryAfterSec, outputUrl }`. Subsequent requests
@@ -954,17 +955,16 @@ Consumer pattern with Video.js v10 / `<video>`:
954
955
 
955
956
  ```tsx
956
957
  const src = aq.transformVideo(asset, { width: 1080, height: 1920 });
957
- // Pass directly to <video src={src} />. While the Job runs the
958
+ // Pass directly to <video src={src} />. While the transcode runs the
958
959
  // browser sees 202 → retry; once cached, the 302 → CDN. Most players
959
960
  // retry transparently; if yours doesn't, poll `src` every 5 s until
960
961
  // `Response.redirected` is true or the body content-type starts with
961
962
  // "video/".
962
963
  ```
963
964
 
964
- Implementation: extends the EXISTING `asset-processing` Cloud Run Job
965
- with a `transform-video` op. Reuses the same Cloud Tasks dispatch
966
- pattern as `process-video` and `compose-slideshow`. Idempotent —
967
- 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.
968
968
 
969
969
  ### Adaptive HLS streaming (`aq.streamingUrl`)
970
970
 
@@ -984,9 +984,9 @@ const teaser = aq.streamingUrl(asset, { start: 0, duration: 30 });
984
984
  ```
985
985
 
986
986
  First request to a new HLS URL returns **202 Accepted** with
987
- `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
988
988
  (typically 1-3 min for a 90 s source). Subsequent requests get
989
- **302** to the cached `master.m3u8`. The R2 layout:
989
+ **302** to the cached `master.m3u8`. The CDN layout:
990
990
 
991
991
  ```
992
992
  <tid>/v/<sha>-hls<dslHash>/master.m3u8 ← entry point
@@ -1007,7 +1007,7 @@ either loaded or timed out.
1007
1007
  ### Billing model
1008
1008
 
1009
1009
  Transforms are billed as **storage**, not as "transformations" the way
1010
- 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
1011
1011
  the CDN cache forever (until manually invalidated). The cache key is
1012
1012
  deterministic, so identical DSLs across deploys/tenants don't re-encode;
1013
1013
  mounting an existing CDN URL costs zero compute.
@@ -1050,7 +1050,7 @@ The wire format is intentionally tight: `{d, v, m, dv, lv, dm, lm}`
1050
1050
  light-muted). 7 hex strings per asset — much smaller than a full
1051
1051
  base64 LQIP, but composes into nicer ambient UX.
1052
1052
 
1053
- When sharp can't decode the image (SVG sources, exotic formats,
1053
+ When the image can't be decoded (SVG sources, exotic formats,
1054
1054
  deliberately corrupted bytes), palette + blur silently come back
1055
1055
  `null`. The rest of the pipeline still succeeds.
1056
1056
 
@@ -1092,9 +1092,8 @@ them — and the same URL never invalidates.
1092
1092
 
1093
1093
  ## Auth
1094
1094
 
1095
- Auth is a Better Auth API key (`amk_rt_*`) emitted by
1096
- `aquienpz/bootstrap-project.ts` per tenant. The key's metadata holds the tenant
1097
- 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.
1098
1097
 
1099
1098
  API key tiers:
1100
1099
 
@@ -1130,7 +1129,7 @@ own tenant (no cross-tenant data ever leaks):
1130
1129
  ```ts
1131
1130
  const usage = await aq.usage.snapshot();
1132
1131
  // {
1133
- // tenant: { id: 4, code: "realtyone-cr" },
1132
+ // tenant: { id: 4, code: "acme-co" },
1134
1133
  // storage: { totalBytes: 4_810_000_000, assetCount: 15_760 },
1135
1134
  // today: { reads: 3201, writes: 18, processes: 6, videoMinutes: 4.5, ... },
1136
1135
  // last30Days: { reads: 86_400, writes: 412, videoMinutes: 312.75, ... }
@@ -1140,11 +1139,10 @@ const chart = await aq.usage.timeseries(30); // for a 30-day line chart
1140
1139
  const byKey = await aq.usage.keys(); // who's using the most quota
1141
1140
  ```
1142
1141
 
1143
- Backed by the daily rollup of `assets.api_key_usage`
1144
- `assets.tenant_usage_daily`. The "today" window queries the raw
1145
- `api_key_usage` table since the rollup runs once per day at ~00:30 UTC —
1146
- except `videoMinutes`, which is accumulated live and so is real-time on
1147
- every window.
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.
1148
1146
 
1149
1147
  **`videoMinutes` counts minutes actually TRANSCODED, not videos stored.**
1150
1148
  It is *source minutes × encode passes*, so a 7-rung HLS ladder over a
@@ -1155,9 +1153,10 @@ or a cache hit runs no encoder and books nothing, so `0` means zero.
1155
1153
 
1156
1154
  > ⚠️ **Removed 2026-08-17:** `bytesIn` (on the usage windows and daily
1157
1155
  > points) and `bytesTotal` (on the per-key rows). They were fed by
1158
- > `api_key_usage.bytes`, which is structurally zero — measured at 440,820
1159
- > rows, none above 0 — and could not be instrumented, because uploads go to
1160
- > R2 through presigned URLs and never traverse the API. A field that always
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
1161
1160
  > reads `0` is worse than an absent one. The byte figure that is true,
1162
1161
  > `storage.totalBytes`, is unchanged.
1163
1162