@nitida/sdk 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +48 -0
- package/README.md +1158 -0
- package/dist/expo.d.ts +48 -0
- package/dist/expo.js +25 -0
- package/dist/expo.js.map +1 -0
- package/dist/index.d.ts +790 -0
- package/dist/index.js +802 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +1 -0
- package/dist/native.js +16 -0
- package/dist/native.js.map +1 -0
- package/dist/react.d.ts +47 -0
- package/dist/react.js +96 -0
- package/dist/react.js.map +1 -0
- package/dist/server.d.ts +72 -0
- package/dist/server.js +808 -0
- package/dist/server.js.map +1 -0
- package/dist/web.d.ts +147 -0
- package/dist/web.js +864 -0
- package/dist/web.js.map +1 -0
- package/package.json +122 -0
- package/skills/nitida-sdk/SKILL.md +306 -0
- package/src/expo/index.ts +76 -0
- package/src/index.ts +1562 -0
- package/src/native/index.ts +48 -0
- package/src/react/index.ts +169 -0
- package/src/server/index.ts +124 -0
- package/src/web/index.ts +334 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nitida-sdk
|
|
3
|
+
description: How to consume the nitida media platform (formerly aquienpz) (@nitida/sdk + @nitida/asset-client, CDN 8ok.uk) from an app — build image/video URLs, resolve stored refs, migrate an app off a legacy CDN, and provision a tenant. Use when wiring images/videos through aquienpz, debugging 400/404/410 on 8ok.uk URLs, or onboarding a new tenant. Captures the gotchas that bit real migrations (base36 video prefix, /t/ vs /v/, the width ladder, audio, the 0-cdn build gate).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Consuming the nitida media platform
|
|
7
|
+
|
|
8
|
+
> Durable source of truth: `aquienpz` repo `docs/SDK_CONSUMER_GUIDE.md`. This is a local copy for skill activation; update the repo doc when these learnings change.
|
|
9
|
+
|
|
10
|
+
The platform stores assets in R2 and serves them through the CDN **`https://8ok.uk`**.
|
|
11
|
+
Apps are **external consumers**: install the npm packages, never vendor neo's in-tree copies.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun add @nitida/sdk @nitida/asset-client
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Both packages export the same URL builders (`@nitida/sdk` re-exports `@nitida/asset-client`). For plain URL-building in a Next.js app, importing from `@nitida/asset-client` is enough; use `@nitida/sdk`'s `AquienpzClient` only when you also upload.
|
|
18
|
+
|
|
19
|
+
## 1. Configure once at module load
|
|
20
|
+
|
|
21
|
+
The URL builders read **process-global** config. Pin it where your helpers live (a `lib/aquienpz-images.ts`), so every Server/Client Component that imports a builder is configured:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { setCdnBase, setTenantId } from "@nitida/asset-client";
|
|
25
|
+
|
|
26
|
+
const CDN = process.env.NEXT_PUBLIC_AQUIENPZ_CDN || "https://8ok.uk";
|
|
27
|
+
const TENANT_ID = Number(process.env.NEXT_PUBLIC_AQUIENPZ_TENANT_ID) || /* your id */ 0;
|
|
28
|
+
|
|
29
|
+
setCdnBase(CDN); // default is already "https://8ok.uk"
|
|
30
|
+
setTenantId(TENANT_ID); // REQUIRED for video URLs (base36 variant prefix) — see §3
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`NEXT_PUBLIC_*` so the tenant id reaches the client bundle (hero/about videos render client-side). The runtime read-only consumer needs only these public vars — the `amk_rt_*` runtime key is for **uploads** (server-only), not for building URLs.
|
|
34
|
+
|
|
35
|
+
## 2. Image URLs — `getTransformUrl` / `getTransformSrcSet`
|
|
36
|
+
|
|
37
|
+
On-the-fly transforms. **Lock `format` to your project's policy** (these projects use `webp`, never `auto`/`avif`):
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import {
|
|
41
|
+
getTransformUrl, getTransformSrcSet, TRANSFORM_WIDTHS, type TransformWidth,
|
|
42
|
+
} from "@nitida/asset-client";
|
|
43
|
+
|
|
44
|
+
const FORMAT = "webp" as const;
|
|
45
|
+
|
|
46
|
+
export const imgUrl = (sha: string, width: TransformWidth, extra?) =>
|
|
47
|
+
getTransformUrl({ sha }, { format: FORMAT, width, ...extra });
|
|
48
|
+
|
|
49
|
+
export const imgSrcSet = (sha: string, widths: readonly TransformWidth[], extra?) =>
|
|
50
|
+
getTransformSrcSet({ sha }, widths, { format: FORMAT, ...extra });
|
|
51
|
+
|
|
52
|
+
// cover-crop for fixed-aspect cards/thumbs:
|
|
53
|
+
const COVER_CROP = { fit: "cover", gravity: "auto" } as const;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Output URL shape: `https://8ok.uk/t/format=webp,width=640/<sha16>.webp`.
|
|
57
|
+
|
|
58
|
+
⚠️ **Widths MUST be on the unsigned ladder** `TRANSFORM_WIDTHS` (`160,240,256,320,400,480,600,640,800,960,1080,1200,1280,1440,1600,1920,2560,3840`). Any other width → **HTTP 400** at the edge (DoS guard). The `TransformWidth` type makes an off-ladder width a compile error — import the type, don't hardcode magic numbers. (For a one-off custom width you'd need signed URLs; not used here.)
|
|
59
|
+
|
|
60
|
+
## 3. Video URLs — `getAssetUrl(asset, "video")` ← the #1 gotcha
|
|
61
|
+
|
|
62
|
+
Videos are served from the **stored variant**, NOT a transform:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { getAssetUrl, setTenantId } from "@nitida/asset-client";
|
|
66
|
+
|
|
67
|
+
export function videoUrl(sha: string, tenantId = TENANT_ID): string {
|
|
68
|
+
if (!sha) return "";
|
|
69
|
+
setTenantId(tenantId); // sets the base36 variant prefix
|
|
70
|
+
return getAssetUrl({ sha }, "video") ?? "";
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Output: `https://8ok.uk/<tenantId.toString(36)>/v/<sha16>-v.mp4`.
|
|
75
|
+
|
|
76
|
+
**Do NOT:**
|
|
77
|
+
- ❌ Use `getVideoTransformUrl` — that builds a `/t/...` transform URL, which **410s** for a stored video sha. (`getVideoTransformUrl` is for on-the-fly re-encodes, a different feature.)
|
|
78
|
+
- ❌ Hand-roll the path with the decimal tenant id. The path segment is **base36**: `tenantId.toString(36)`. **Tenant 10 → `/a/v/`**, and the decimal `/10/v/` **404s**. This is invisible for tenants ≤ 9 (`8`→`8`, `9`→`9`) and bit a real migration only at tenant 10. Always delegate to `getAssetUrl` so the encoding can't drift.
|
|
79
|
+
|
|
80
|
+
**Audio IS supported (updated 2026-07-01 — verify against the SDK types, this used to say "not supported").** The platform now recognizes `kind: "image" | "video" | "document" | "audio" | "other"` and ships an **`mp3`** variant preset (`VariantPreset` in `@nitida/asset-client`). Upload raw audio bytes with an explicit audio MIME so it stores as `kind:"audio"` (not `"other"`): `await aq.upload(bytes, { contentType: "audio/mpeg", fileName: "track.mp3" })`. Uploads are hash-deduped (byte-identical re-uploads return the existing sha — that's *byte* dedup, NOT semantic "find a similar track"). `aq.upload` also accepts an `audioTrack` on video-composition calls. Serve via the `mp3` preset / `original`. (Legacy `.m4a/.wav` with no matching pipeline still fall back to `original` raw bytes.) Confirm the current preset/kind list in `node_modules/@nitida/asset-client/dist/index.d.ts` before relying on a specific ext.
|
|
81
|
+
|
|
82
|
+
## 3b. Keeping the ORIGINAL bytes (backup, not delivery) ← learned the hard way 2026-08-07
|
|
83
|
+
|
|
84
|
+
If you are uploading to **archive** something (not just to serve it), three server behaviours decide whether you actually can get the bytes back. All three verified against `apps/asset-manager/src` in the `aquienpz` repo, after a 97-file backup ran "successfully" and produced **zero** recoverable originals.
|
|
85
|
+
|
|
86
|
+
**1. `original` is written only if you ask for it.** In `features/assets/process.routes.ts`:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const effectivePresets: string[] = body.presets ?? ["original"];
|
|
90
|
+
const wantsOriginal = effectivePresets.includes("original");
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- Omit `presets` entirely → **original only**, no ladder (passthrough).
|
|
94
|
+
- Pass `["thumb"]` → thumb only, **no original**. The bytes you PUT are not retrievable.
|
|
95
|
+
- For a backup you want `presets: ["original", "thumb"]`.
|
|
96
|
+
|
|
97
|
+
⚠️ The migration precedent in consumer repos (`migrate-*-images.ts`) uses `["thumb"]` because its goal is *delivery*. Copying it into a backup script silently produces a non-backup.
|
|
98
|
+
|
|
99
|
+
**2. Building the `original` URL client-side is possible but easy to get wrong** (this section said "impossible" until 2026-08-15 — see the correction below). The server key is:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
variantKey(tenantId, sha256, "original", ext)
|
|
103
|
+
→ `${tenantPrefix(tenantId)}/v/${shortenSha(sha)}-o.${ext}`
|
|
104
|
+
// ext = body.rawKey.split(".").pop() ← the UPLOADED file's extension
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The client's `PRESET_EXT.original` is the literal `"bin"`, so `getAssetUrl({sha}, "original")` **without `mime`** emits `-o.bin` while a PNG is really stored at `-o.png` → 404, always.
|
|
108
|
+
|
|
109
|
+
> ⚠️ **CORRECTED 2026-08-15 — the advice below used to be "read it from `aq.assets.variants()`",
|
|
110
|
+
> and that method is now the one that fails.** Measured against the live deployment while building
|
|
111
|
+
> `neo/apps/media-harness`:
|
|
112
|
+
>
|
|
113
|
+
> - **`aq.assets.variants(id)` returns `[]`** — with a runtime key *and* with an admin key — while
|
|
114
|
+
> the database row for the same asset holds two variants. Believing the empty array makes a
|
|
115
|
+
> healthy asset look like it is missing everything.
|
|
116
|
+
> - **`getAssetUrl` now takes the asset's `mime`** and resolves a real extension:
|
|
117
|
+
> `getAssetUrl({ sha, mime }, "original")` → `…-o.webp`, HTTP **200**. The old `-o.bin` sentinel
|
|
118
|
+
> only appears when you omit `mime`.
|
|
119
|
+
> - **…but the extension the SERVER stored comes from the uploaded FILENAME, not the mime.**
|
|
120
|
+
> `variantKey(..., ext)` uses `rawKey.split(".").pop()`. For JPEG these disagree —
|
|
121
|
+
> `image/jpeg` → `jpeg` while a camera writes `.jpg`. Measured: `…-o.jpeg` **404**, `…-o.jpg` 200.
|
|
122
|
+
>
|
|
123
|
+
> ⇒ **What to do:** existence from `dto.presets` (the compact codes, always populated — use
|
|
124
|
+
> `hasPreset`), URL from `getAssetUrl({sha, mime}, "original")` with the **uploaded filename's
|
|
125
|
+
> extension** swapped in if it differs, and then **verify with a real HEAD before you rely on it**.
|
|
126
|
+
> Reference implementation: `apps/media-harness/scripts/seed-fixtures.ts` (`resolveOriginalUrl`).
|
|
127
|
+
> (`getAssetUrl` stays correct for `video`/`poster`, whose extensions are fixed.)
|
|
128
|
+
|
|
129
|
+
**3. Re-uploading does NOT repair a missing preset.** Ingest dedupes by hash and returns `deduped: true` with the existing asset; no variants are regenerated. To add a preset to an existing asset use `regenerate`, which MERGES:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
await aq.assets.regenerate(assetId, { presets: ["original"] });
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
⚠️ **There is a time window.** `admin.routes.ts` notes that once the cleanup job reaps `raw/`, regenerate reads from `variants/o.<ext>` — which is exactly what's missing. **If the first ingest didn't request `original`, the raw bytes may already be unrecoverable.** In the 2026-08-07 run, `regenerate({presets:["original"]})` returned without error and the `original` variant still did not appear — consistent with `raw/` already being gone. Get `original` right on the FIRST ingest.
|
|
136
|
+
|
|
137
|
+
**Verify, don't assume.** Round-trip every archived file: download the `original` URL from the API and compare its SHA-256 to the local file. "97 uploaded, 0 failed" was true and meaningless — all 97 were unrecoverable.
|
|
138
|
+
|
|
139
|
+
| Goal | presets | Where the URL comes from |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| Deliver images on a site | `["thumb"]` (+ what you need) | `getTransformUrl` / `getTransformSrcSet` |
|
|
142
|
+
| **Archive the source file** | **`["original", "thumb"]`** | **`aq.assets.variants(assetId)` → `preset:"original"`** |
|
|
143
|
+
|
|
144
|
+
## 3c. PLAYING a video — HLS, and the snippet that went stale in April 2026
|
|
145
|
+
|
|
146
|
+
`getHlsStreamingUrl({ sha })` returns a `master.m3u8`. **That is not a video file**: it plays
|
|
147
|
+
natively only where the browser has HLS built in, and everywhere else it needs an MSE player
|
|
148
|
+
(hls.js, or Video.js's `@videojs/http-streaming`). So every integration has to pick a branch, and
|
|
149
|
+
the test the entire web repeats is now wrong:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
if (video.canPlayType("application/vnd.apple.mpegurl")) { /* native */ } // ← the bug
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Chrome 147 (April 2026) added native HLS**, so `canPlayType` answers `"maybe"` there and this
|
|
156
|
+
sends Chrome down the native branch. It doesn't throw — it **degrades silently**. Measured on
|
|
157
|
+
Chromium 151 against a real 17 s hero loop: **426×240 for the first ~8 s** (the first segment is
|
|
158
|
+
8.33 s, and Chrome's ABR can't revise its guess until one finishes), i.e. half the loop at 240p
|
|
159
|
+
full-screen. That branch also skips whatever progressive-MP4 fallback you wrote.
|
|
160
|
+
|
|
161
|
+
**Branch on the ENGINE, not on codec support:**
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
function prefersNativeHls(video: HTMLVideoElement): boolean {
|
|
165
|
+
if (video.canPlayType("application/vnd.apple.mpegurl") === "") return false; // no native HLS
|
|
166
|
+
return "ManagedMediaSource" in globalThis || !("MediaSource" in globalThis);
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
⚠️ **Don't use hls.js's own recommended check** (`canPlayType` + `ManagedMediaSource`): it breaks
|
|
171
|
+
iOS < 17.1, which has native HLS, no `ManagedMediaSource` and **no MSE** — it would be routed to
|
|
172
|
+
hls.js, which cannot start there, and fall through to the MP4. The `|| !MediaSource` arm is what
|
|
173
|
+
covers it: native if the engine is Apple's **OR** if there is no MSE to fall back on.
|
|
174
|
+
|
|
175
|
+
**When you use hls.js, stop it guessing** — the defaults are how a fast connection still opens at
|
|
176
|
+
240p: `{ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 }`.
|
|
177
|
+
|
|
178
|
+
**The first request can answer `202`** while the Cloud Run Job builds the ladder (1–3 min for a
|
|
179
|
+
90 s source), then `302`s to the cached master. Keep the progressive MP4 as the fallback `<source>`
|
|
180
|
+
so a player pointed at HLS too early still shows something.
|
|
181
|
+
|
|
182
|
+
**With Video.js you don't write that predicate.** `@videojs/react`'s `HlsVideo` resolves the engine
|
|
183
|
+
itself — `useMse = Hls.isSupported() && type === M3U8 && preferPlayback !== "native"`, and
|
|
184
|
+
`preferPlayback` defaults to `"mse"`. That already covers iOS < 17.1 (no MSE ⇒ falls to native).
|
|
185
|
+
What it does NOT do is prefer Apple's engine where both work: a modern iPhone gets hls.js over
|
|
186
|
+
`ManagedMediaSource`. Pass `preferPlayback="native"` to buy hardware decode / battery / AirPlay.
|
|
187
|
+
hls.js tuning goes in the `config` prop:
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
const HLS_CONFIG = { capLevelToPlayerSize: false, startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 };
|
|
191
|
+
<HlsVideo src={getHlsStreamingUrl(asset)} config={HLS_CONFIG} poster={poster} playsInline crossOrigin="anonymous" />
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
⚠️ **Fall back to the MP4 on a TIMER, never on an `error` event.** A `202` is a success: no `error`
|
|
195
|
+
ever fires, so an error-only handler spins forever while the ladder transcodes. Race a timer against
|
|
196
|
+
`canplay`, and cancel it the moment `canplay` fires so healthy HLS keeps its adaptive bitrate.
|
|
197
|
+
|
|
198
|
+
### 4K is an HLS-only capability
|
|
199
|
+
|
|
200
|
+
**"Progressive" = one file, one resolution, fixed at encode time** (`-v.mp4`): the browser downloads
|
|
201
|
+
it end-to-end and plays as it goes, and if the connection degrades it stalls — there is nothing to
|
|
202
|
+
step down to. **HLS = many files**: the same video cut into segments and encoded at several rungs,
|
|
203
|
+
so the player switches mid-playback. That is the whole difference, and it is why only one of them
|
|
204
|
+
can carry 4K.
|
|
205
|
+
|
|
206
|
+
The ladder is not fixed at 1080p — the job probes the source and keeps every rung that fits
|
|
207
|
+
(`LADDER.filter(r => r.height <= srcHeight)`, up to **2160p**). What it probes decides the ceiling:
|
|
208
|
+
|
|
209
|
+
| Ladder built | Source | Ceiling |
|
|
210
|
+
|---|---|---|
|
|
211
|
+
| At ingest (automatic, unless `video.hls === false`) | the raw bytes you uploaded | **2160p from a 4K master** |
|
|
212
|
+
| On demand later | the raw if present, else the `-v.mp4` | 1080p, second-generation |
|
|
213
|
+
|
|
214
|
+
The progressive MP4 is capped unconditionally (`scale='min(1920,iw)'`), so `getAssetUrl(sha,"video")`
|
|
215
|
+
never exceeds 1080p whatever you uploaded. A rung that weighs *more* than its own source (measured:
|
|
216
|
+
5090 vs 4866 kbps) is the signature of the fallback path, not of a broken ladder.
|
|
217
|
+
|
|
218
|
+
## 4. Resolving a stored reference — `extractAssetSha`
|
|
219
|
+
|
|
220
|
+
When a stored value is already an `8ok.uk` URL, pull its sha:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { extractAssetSha } from "@nitida/asset-client";
|
|
224
|
+
const sha = extractAssetSha(storedUrl); // → "e0ef99988f4a3c9b" | null
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
A robust app resolver tries, in order: an explicit `sha` on the row → a migrated-metadata blob → a migration-map lookup by old URL → `extractAssetSha` → raw fallback.
|
|
228
|
+
|
|
229
|
+
## 5. Migrating an app OFF a legacy CDN onto aquienpz
|
|
230
|
+
|
|
231
|
+
Pattern proven on carniceria, novatibas, sueños (all off `cdn.espaciofuturo.io`):
|
|
232
|
+
|
|
233
|
+
1. **Re-ingest is required, not a host rename.** Legacy hashes (64-hex sha256) ≠ aquienpz (16-hex sha). You must **fetch each legacy asset's bytes and re-upload** to the tenant (SDK server client + `amk_rt_*` key), producing a NEW sha. Emit an idempotent `old-URL → { sha, w, h, blur, kind }` map (`scripts/asset-migration-map.json`). Upload presets: images→`thumb`, videos→`poster`+`video`. Dedupe by sha; checkpoint (re-runnable).
|
|
234
|
+
2. **Regenerate metadata** (`blur`/`w`/`h`/`aspectRatio`) from the uploaded asset (`assets.get`) — don't carry legacy values.
|
|
235
|
+
3. **Render: bake resolved `8ok.uk` URLs at author time.** Do NOT `import` the map JSON into runtime code — its old-URL *keys* ship into the client/server bundle and fail a "0 legacy refs in built output" check. Resolve at build/author time and write the literal `8ok.uk` URL.
|
|
236
|
+
4. **Next.js**: add `8ok.uk` to `images.remotePatterns`. With `images.unoptimized: true`, pass the CDN srcSet straight through. With Next optimization ON, set `unoptimized` **per slot** for CDN-backed images to avoid paying for double optimization.
|
|
237
|
+
5. **Completeness sweep before declaring done**: `grep cdn-host` must be 0 in (a) rendered HTML of every route, (b) serialized/embedded JSON payloads (this is where a stale ref hides even when the *image* renders correctly), (c) JSON-LD / OG / preconnect, (d) any **DB mirror table** (an app DB's `assets`/asset tables are easy to miss), and (e) **cached API responses** (a CDN/data cache can persist a pre-cutover payload across deploys → bust the tag, don't assume redeploy clears it). "Images load from 8ok.uk" is NOT sufficient proof.
|
|
238
|
+
|
|
239
|
+
## 6. Provisioning a new tenant (ops / WS-0)
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
# Secrets live in GCP Secret Manager, project `naye-tours`:
|
|
243
|
+
# PLATFORM_DATABASE_URL (platform Neon), ASSET_AUTH_SECRET (better-auth)
|
|
244
|
+
# 1) Insert tenant rows (idempotent) into the platform Neon:
|
|
245
|
+
# SELECT MAX(id)+1 FROM public.tenants; -- pick next id
|
|
246
|
+
# INSERT public.tenants (id, code, vertical, name) ...
|
|
247
|
+
# INSERT public.tenant_config (tenant_id, code, vertical, display_name,
|
|
248
|
+
# storefront_origins, brand_json, feature_flags, qdrant_tenant_id) ...
|
|
249
|
+
# 2) Mint keys (prints runtime/admin/ci ONCE — store immediately):
|
|
250
|
+
cd apps/asset-manager
|
|
251
|
+
PLATFORM_DATABASE_URL=… ASSET_AUTH_SECRET=… bun run scripts/bootstrap-project.ts <tenant-code>
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
- Endpoint = the asset-manager Cloud Run URL. Verify: `GET /usage` with `Authorization: Bearer <amk_rt_*>` + `X-Tenant-Code: <code>` → `{ "tenant": { "id": N } }`.
|
|
255
|
+
- The `@aquienpz/tenant-config` HTTP layer **caches tenant lookups for 60s** — a fresh tenant resolves in services within a minute (the `bootstrap` script reads the DB directly, so it works immediately).
|
|
256
|
+
- Asset-only consumers (a storefront with its own catalog DB) can skip the Qdrant/voice/scraper steps in `docs/TENANT_ONBOARDING.md` — only the two tenant rows + keys + `storefront_origins` are needed.
|
|
257
|
+
|
|
258
|
+
## Quick reference
|
|
259
|
+
|
|
260
|
+
| Need | Call | URL shape |
|
|
261
|
+
|---|---|---|
|
|
262
|
+
| Responsive image | `getTransformSrcSet({sha}, widths, {format:'webp'})` | `/t/format=webp,width=W/<sha>.webp` |
|
|
263
|
+
| Single image | `getTransformUrl({sha}, {format:'webp', width})` | same |
|
|
264
|
+
| Cover-crop card | `…, { fit:'cover', gravity:'auto' }` | `/t/fit=cover,gravity=auto,format=webp,width=W/<sha>.webp` |
|
|
265
|
+
| **Video** | `getAssetUrl({sha}, 'video')` (+ `setTenantId`) | `/<tid b36>/v/<sha>-v.mp4` |
|
|
266
|
+
| Poster | `getAssetUrl({sha}, 'poster')` | `/<tid b36>/v/<sha>-p.webp` |
|
|
267
|
+
| Sha from URL | `extractAssetSha(url)` | — |
|
|
268
|
+
|
|
269
|
+
Variant preset short codes: `thumb=q, sm=s, md=m, lg=l, xl=x, original=o, poster=p, video=v`. Exts: images `webp`, video `mp4`.
|
|
270
|
+
|
|
271
|
+
## 7. Browser-direct uploads — three things that only fail in a real browser
|
|
272
|
+
|
|
273
|
+
Measured 2026-08-15 building `neo/apps/media-harness`, the bench for this SDK. Run it
|
|
274
|
+
(`bun run scripts/verify-run.ts`) before debugging any of these by hand.
|
|
275
|
+
|
|
276
|
+
1. **The PUT goes browser → R2, so R2 answers the CORS preflight.** An origin missing from the
|
|
277
|
+
BUCKET's policy cannot be fixed in your app, in this SDK, or in `tenant_config.storefront_origins`
|
|
278
|
+
— those govern the API, not the PUT. Symptom: `R2 PUT failed: network error` with compression,
|
|
279
|
+
hashing and presign all green. Fix in `aquienpz/scripts/setup-r2-cors.sh`, and ⚠️ `cors set`
|
|
280
|
+
**REPLACES** the whole policy — list it and diff before applying.
|
|
281
|
+
2. **A VIDEO answers `processAndWait` immediately, then transcodes.** `/assets/process` dispatches a
|
|
282
|
+
Cloud Run Job and answers `{ ok: true, kind: "video", assetId, status: "processing", dispatch }`;
|
|
283
|
+
the SDK then polls that id until `ready`. Budget for it — a transcode plus the HLS ladder runs
|
|
284
|
+
1–2 min, so pass a `timeoutMs` of at least `300_000`. (Until 2026-08-16 the response carried **no
|
|
285
|
+
`assetId`** and the call threw `"process returned no assetId"`, so no video ever registered
|
|
286
|
+
through the kit; the bytes still reached R2, which is what made it invisible.)
|
|
287
|
+
3. **A video never gets an `original` variant.** `/assets/process` filters video presets through
|
|
288
|
+
`{poster, video, aiproxy, probe}` before dispatch, so `"original"` is silently dropped even when
|
|
289
|
+
you ask for it. If you need the source bytes back, archive them under a **non-video key** (e.g.
|
|
290
|
+
`clip.mp4.bin`), which takes the passthrough branch and stores them verbatim.
|
|
291
|
+
|
|
292
|
+
| Symptom | Cause |
|
|
293
|
+
|---|---|
|
|
294
|
+
| **400** on an image URL | width not on `TRANSFORM_WIDTHS` ladder |
|
|
295
|
+
| **410** on a video URL | used `/t/` (transform) for a stored video — use `getAssetUrl(...,'video')` |
|
|
296
|
+
| **404** on a video URL | decimal tenant prefix (`/10/v/`) instead of base36 (`/a/v/`) — call `setTenantId` + `getAssetUrl`. Tenant 12 → `/c/v/` |
|
|
297
|
+
| `process returned no assetId` | fixed 2026-08-16 — you are on an `asset-manager` older than that deploy, §7.2 |
|
|
298
|
+
| `waitReady timeout` on a video | a transcode + HLS ladder takes 1–2 min; the default `timeoutMs` is 5 min but a 4K source can beat it. Raise it, §7.2 |
|
|
299
|
+
| `R2 PUT failed: network error` | your origin is not in the R2 BUCKET's CORS policy, §7.1 |
|
|
300
|
+
| `aq.assets.variants()` returns `[]` | current deployment does not populate it, even for admin keys. Use `dto.presets` + `hasPreset` |
|
|
301
|
+
| `tenant_not_found` from a service | tenant rows missing, or the 60s tenant-config cache is stale |
|
|
302
|
+
| `original` 404s | (a) never written — `presets` omitted `"original"` at ingest, or the asset is a VIDEO (always dropped, §7.3); (b) `getAssetUrl(...,"original")` called WITHOUT `mime` → the `-o.bin` sentinel; (c) called WITH `mime` but the object was stored under the uploaded filename's extension (`-o.jpg`, not `-o.jpeg`). See §3b — HEAD it |
|
|
303
|
+
| "uploaded OK" but nothing to restore | `["thumb"]`-style presets store no original; verify by SHA-256 round-trip, not by upload count — see §3b |
|
|
304
|
+
| audio won't serve | stored as `kind:"other"` — re-upload with an explicit `contentType:"audio/mpeg"` so it's `kind:"audio"` + gets the `mp3` preset (audio IS supported as of 2026-07-01; see §3) |
|
|
305
|
+
|
|
306
|
+
> Source of truth for deeper detail: `docs/ASSET_MANAGER_V2.md`, `docs/CLIENT_OPERATIONS.md`, `docs/TENANT_ONBOARDING.md`, and `packages/asset-client/src/{transform.ts,index.ts}`.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nitida/sdk/expo — native multipart uploads for React Native / Expo.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `@aquienpz/asset-uploader-expo`'s `UploadTask`, which delegates
|
|
5
|
+
* the actual byte transfer to a native background session (URLSession
|
|
6
|
+
* on iOS, WorkManager on Android). The upload survives:
|
|
7
|
+
* - JS thread freezing
|
|
8
|
+
* - App backgrounding
|
|
9
|
+
* - OS-initiated kill (low-memory, user swiping away)
|
|
10
|
+
* - Network blips (retries with backoff)
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
*
|
|
14
|
+
* import { createExpoUploader, listResumableSessions } from "@nitida/sdk/expo";
|
|
15
|
+
*
|
|
16
|
+
* const aq = new AquienpzClient({ ... });
|
|
17
|
+
*
|
|
18
|
+
* // Resume any uploads from a prior app launch on boot.
|
|
19
|
+
* const resumable = await listResumableSessions();
|
|
20
|
+
* // ...show a banner offering to resume them
|
|
21
|
+
*
|
|
22
|
+
* const upload = createExpoUploader(aq, {
|
|
23
|
+
* file: { uri: assetUri, mime: "video/mp4", name: "tour.mp4" },
|
|
24
|
+
* });
|
|
25
|
+
* upload.on("progress", ({ ratio }) => setProgress(ratio));
|
|
26
|
+
* const { assetId } = await upload.start();
|
|
27
|
+
* await aq.slots.bind("storefront.tour.video", { assetId, preset: "video" });
|
|
28
|
+
*
|
|
29
|
+
* Peer dep: `@aquienpz/asset-uploader-expo` (lazy — apps that don't
|
|
30
|
+
* use the mobile SDK skip the install).
|
|
31
|
+
* @module @nitida/sdk/expo
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
UploadTask,
|
|
36
|
+
type UploadTaskOptions,
|
|
37
|
+
} from "@aquienpz/asset-uploader-expo";
|
|
38
|
+
import type { AquienpzClient } from "..";
|
|
39
|
+
|
|
40
|
+
export type ExpoUploadOptions = Omit<
|
|
41
|
+
UploadTaskOptions,
|
|
42
|
+
"tenantCode" | "endpoint" | "authToken"
|
|
43
|
+
>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Spawn a native-backed `UploadTask` bound to a configured client.
|
|
47
|
+
* Inherits the client's endpoint / api key / tenant scope; caller only
|
|
48
|
+
* has to supply the file input + any per-upload tuning (partSize,
|
|
49
|
+
* concurrency).
|
|
50
|
+
*/
|
|
51
|
+
export function createExpoUploader(
|
|
52
|
+
client: AquienpzClient,
|
|
53
|
+
options: ExpoUploadOptions,
|
|
54
|
+
): UploadTask {
|
|
55
|
+
const access = client as unknown as {
|
|
56
|
+
opts: { endpoint: string; apiKey: string; tenantCode: string };
|
|
57
|
+
};
|
|
58
|
+
return new UploadTask({
|
|
59
|
+
...options,
|
|
60
|
+
endpoint: access.opts.endpoint,
|
|
61
|
+
authToken: access.opts.apiKey,
|
|
62
|
+
tenantCode: access.opts.tenantCode,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type {
|
|
67
|
+
UploadEvents,
|
|
68
|
+
UploadFileInput,
|
|
69
|
+
UploadSessionState,
|
|
70
|
+
UploadTaskOptions,
|
|
71
|
+
} from "@aquienpz/asset-uploader-expo";
|
|
72
|
+
export {
|
|
73
|
+
cancelResumableSession,
|
|
74
|
+
listResumableSessions,
|
|
75
|
+
UploadTask,
|
|
76
|
+
} from "@aquienpz/asset-uploader-expo";
|