@nitida/sdk 0.24.1 → 0.25.1
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 +128 -129
- package/dist/index.d.ts +28 -27
- package/dist/index.js +56 -11
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +9 -7
- package/dist/server.js +34 -11
- package/dist/server.js.map +1 -1
- package/dist/web.d.ts +11 -12
- package/dist/web.js +34 -11
- package/dist/web.js.map +1 -1
- package/package.json +2 -2
- package/skills/nitida-sdk/SKILL.md +45 -49
- package/src/index.ts +60 -26
- 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` + `compressImage` (HEIC→WebP, compressorjs). Multipart is not exposed — see *Large uploads (web)*. |
|
|
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` / `useNitidaClient`. |
|
|
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
|
|
@@ -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`
|
|
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.
|
|
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
|
|---|---:|---:|---:|
|
|
@@ -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
|
|
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
|
|
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
|
|
776
|
-
|
|
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
|
|
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://
|
|
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://
|
|
823
|
+
https://api.nitida.gofuture.space/admin/projects/your-tenant/strict-transforms
|
|
822
824
|
```
|
|
823
825
|
|
|
824
|
-
**Rotation cost**: cached transform variants
|
|
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
|
|
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
|
|
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
|
|
876
|
-
//
|
|
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
|
|
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
|
|
913
|
+
landscape source ✓). For bigger crops, prefer `gravity=auto` smart-crop,
|
|
914
|
+
which is deterministic and free — no generation, no invention.
|
|
913
915
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
|
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 →
|
|
947
|
-
(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
|
|
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
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1096
|
-
|
|
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: "
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
>
|
|
1159
|
-
> rows, none above 0 — and could not be instrumented, because uploads go
|
|
1160
|
-
>
|
|
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
|
|