@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/AGENTS.md +10 -9
- package/README.md +161 -154
- package/dist/index.d.ts +79 -50
- package/dist/index.js +73 -28
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +9 -7
- package/dist/server.js +51 -28
- package/dist/server.js.map +1 -1
- package/dist/web.d.ts +11 -12
- package/dist/web.js +51 -28
- package/dist/web.js.map +1 -1
- package/package.json +2 -2
- package/skills/nitida-sdk/SKILL.md +215 -91
- package/src/index.ts +114 -51
- package/src/server/index.ts +9 -7
- package/src/web/index.ts +16 -17
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 `
|
|
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`** | `
|
|
21
|
-
| Browser (Next.js client, Vite, CRA, web workers) | **`@nitida/sdk/web`** | `
|
|
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` | `
|
|
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<
|
|
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 {
|
|
74
|
+
import { NitidaClient } from "@nitida/sdk/web";
|
|
75
75
|
|
|
76
|
-
const aq = new
|
|
76
|
+
const aq = new NitidaClient({
|
|
77
77
|
endpoint: "/api/am", // OK: relative → same-origin BFF
|
|
78
|
-
tenantCode: "
|
|
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. "
|
|
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 {
|
|
122
|
+
import { NitidaClient } from "@nitida/sdk/server";
|
|
123
123
|
|
|
124
|
-
const aq = new
|
|
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: "
|
|
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 {
|
|
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
|
|
153
|
+
Optional, only if you compress on the client before uploading:
|
|
154
154
|
|
|
155
155
|
```bash
|
|
156
|
-
|
|
157
|
-
bun add @
|
|
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 {
|
|
172
|
+
import { NitidaClient } from "@nitida/sdk";
|
|
170
173
|
|
|
171
|
-
const aq = new
|
|
174
|
+
const aq = new NitidaClient({
|
|
172
175
|
endpoint: "https://api.nitida.gofuture.space",
|
|
173
|
-
apiKey: process.env.
|
|
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
|
|
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** (
|
|
244
|
-
|
|
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
|
|
267
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
289
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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 {
|
|
378
|
+
import { NitidaClient } from "@nitida/sdk";
|
|
374
379
|
|
|
375
|
-
const aq = new
|
|
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 {
|
|
416
|
-
import {
|
|
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
|
|
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 <
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
636
|
+
Your bytes exist in two places with **very different lifetimes**:
|
|
632
637
|
|
|
633
|
-
|
|
|
638
|
+
| Bytes | Role | Lifetime |
|
|
634
639
|
|---|---|---|
|
|
635
|
-
|
|
|
636
|
-
|
|
|
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.
|
|
643
|
-
2. raw
|
|
644
|
-
3.
|
|
645
|
-
4.
|
|
646
|
-
|
|
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. thumb ← last 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 {
|
|
685
|
+
import { NitidaClient } from "@nitida/sdk";
|
|
681
686
|
|
|
682
|
-
const aq = new
|
|
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.
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
`gravity=face`
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
|
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
|
|
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).
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
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
|
|
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
|
|
777
|
-
|
|
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
|
|
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://
|
|
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://
|
|
823
|
+
https://api.nitida.gofuture.space/admin/projects/your-tenant/strict-transforms
|
|
823
824
|
```
|
|
824
825
|
|
|
825
|
-
**Rotation cost**: cached transform variants
|
|
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`
|
|
848
|
-
|
|
848
|
+
`effect=removebg` runs on one of two server-side matting backends, chosen
|
|
849
|
+
per deployment. What the caller sees:
|
|
849
850
|
|
|
850
|
-
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
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
|
|
862
|
+
to resize the cutout in a single request (cached as one entry per
|
|
866
863
|
canonical DSL).
|
|
867
864
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
|
884
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
|
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 →
|
|
956
|
-
(typically 5-30 s for short clips) → output
|
|
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
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1105
|
-
|
|
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: "
|
|
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,
|
|
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
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
|