@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/src/index.ts ADDED
@@ -0,0 +1,1562 @@
1
+ /**
2
+ * @nitida/sdk — universal client for the aquienpz multi-tenant asset
3
+ * platform.
4
+ *
5
+ * One ergonomic facade over the underlying packages
6
+ * (`@nitida/asset-client` URL builders + `@aquienpz/asset-uploader-web`
7
+ * + the slot resolver). Auth is a Better Auth API key (`amk_rt_*`)
8
+ * issued by aquienpz `bootstrap-project.ts`; tenant scope comes from
9
+ * the key's metadata (`X-Tenant-Code` is log-only).
10
+ *
11
+ * Usage:
12
+ *
13
+ * import { AquienpzClient } from "@nitida/sdk";
14
+ *
15
+ * const aq = new AquienpzClient({
16
+ * endpoint: "https://aquienpz-asset-manager-xxx.run.app",
17
+ * apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY!,
18
+ * tenantCode: "realtyone-cr",
19
+ * cdnBase: "https://8ok.uk", // optional override
20
+ * tenantId: 4, // required for tenant-prefixed URLs
21
+ * });
22
+ *
23
+ * // Slot system (recommended — admin can rebind without redeploys).
24
+ * const hero = await aq.slots.resolve("storefront.cr.hero");
25
+ * const set = await aq.slots.resolveMany(["a", "b", "c"]);
26
+ *
27
+ * // Lower-level asset operations.
28
+ * const asset = await aq.assets.byHash(sha);
29
+ * const list = await aq.assets.list({ limit: 50 });
30
+ *
31
+ * // Upload bytes / files.
32
+ * const up = await aq.upload(file, { fileName: "cover.jpg" });
33
+ *
34
+ * For React, see `@nitida/sdk/react` (useSlot, useSlots, useAsset).
35
+ * @module @nitida/sdk
36
+ */
37
+
38
+ import {
39
+ type AssetDTO,
40
+ type AssetVariant,
41
+ configureSlotResolver,
42
+ getAssetSrcSet,
43
+ getAssetUrl,
44
+ getHlsStreamingUrl,
45
+ getSignedTransformUrl,
46
+ getTransformSrcSet,
47
+ getTransformUrl,
48
+ getVideoTransformUrl,
49
+ hasPreset,
50
+ invalidateSlotCache,
51
+ type ResolveSlotOptions,
52
+ resolveSlot,
53
+ resolveSlots,
54
+ type SignedTransformOptions,
55
+ type SlotDTO,
56
+ type SlotResolution,
57
+ setCdnBase,
58
+ setTenantId,
59
+ type TransformOptions,
60
+ type VariantPreset,
61
+ } from "@nitida/asset-client";
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Config
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Permissive constructor options for the root `AquienpzClient`.
69
+ *
70
+ * App code should NOT import this type directly — prefer the strict
71
+ * variants from the subpaths:
72
+ *
73
+ * - `WebClientOptions` from `@nitida/sdk/web` (no `apiKey`)
74
+ * - `ServerClientOptions` from `@nitida/sdk/server` (`apiKey` required)
75
+ *
76
+ * This root type is the union both modes resolve to; the underlying class
77
+ * accepts both shapes so subpath wrappers can extend without duplication.
78
+ */
79
+ export type AquienpzClientOptions = {
80
+ /**
81
+ * Base URL of the aquienpz asset-manager (Cloud Run service URL).
82
+ *
83
+ * May be relative (e.g. `/api/am`) ONLY in browser contexts where the
84
+ * SDK resolves it against `window.location.origin`. Node/Bun consumers
85
+ * must always pass an absolute URL.
86
+ */
87
+ endpoint: string;
88
+ /**
89
+ * Better Auth API key with the `amk_rt_*` prefix.
90
+ *
91
+ * **Server-only.** Omit when constructing from `@nitida/sdk/web` —
92
+ * your BFF / route handler injects the bearer header in proxy mode.
93
+ */
94
+ apiKey?: string;
95
+ /**
96
+ * Extra headers merged into every request. The documented way for
97
+ * mobile/Expo clients to authenticate a BFF that gates on the Better Auth
98
+ * session: they can't send cookies automatically, so they pass
99
+ * `{ Cookie: authClient.getCookie() }` here (see Better Auth Expo docs,
100
+ * "Making Authenticated Requests to Your Server"). Web/server consumers omit
101
+ * this — browsers attach the same-origin cookie and servers pass `apiKey`.
102
+ */
103
+ headers?: Record<string, string>;
104
+ /** Tenant code — sent as `X-Tenant-Code` (log-only). Authoritative scope is the key's metadata.tenantId. */
105
+ tenantCode: string;
106
+ /** Numeric tenant id — used to build tenant-prefixed CDN URLs `<cdn>/<tenantId b36>/v/<sha>-<preset>.<ext>`. */
107
+ tenantId: number;
108
+ /** Override the public CDN base. Defaults to `https://8ok.uk`. */
109
+ cdnBase?: string;
110
+ /**
111
+ * Tenant's HMAC signing key for transform URLs (Phase 3). Required
112
+ * only when calling `aq.transform(asset, opts, { sign: true })`.
113
+ *
114
+ * Generated server-side per tenant (see `infra/sql/tenants_signed_transforms.sql`);
115
+ * fetch via `GET /admin/tenants/:id` with an admin key. **Keep it
116
+ * server-side only** — do not ship in `NEXT_PUBLIC_*` env vars. Sign
117
+ * URLs from a BFF route handler, or pre-sign at build time.
118
+ */
119
+ signingKey?: string;
120
+ };
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // URL construction
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Build a fully-qualified URL for an aquienpz endpoint path.
128
+ *
129
+ * Accepts both absolute endpoints (`https://aquienpz...run.app`) and
130
+ * relative ones (`/api/am`) — the latter only works in browser contexts
131
+ * (resolved against `window.location.origin`). Node/Bun throws a clear
132
+ * error if a relative endpoint is configured.
133
+ *
134
+ * The native `URL` constructor throws on relative inputs, so every fetch
135
+ * site in the SDK must go through this helper instead of `new URL(...)`.
136
+ */
137
+ function endpointUrl(
138
+ opts: Pick<AquienpzClientOptions, "endpoint">,
139
+ path: string,
140
+ searchParams?: Record<string, string | number | boolean | undefined>,
141
+ ): URL {
142
+ const endpoint = opts.endpoint.replace(/\/+$/, "");
143
+ const isAbsolute = /^https?:\/\//i.test(endpoint);
144
+ let base: string;
145
+ if (isAbsolute) {
146
+ base = `${endpoint}${path}`;
147
+ } else if (typeof window !== "undefined" && window.location?.origin) {
148
+ base = `${window.location.origin}${endpoint}${path}`;
149
+ } else {
150
+ throw new Error(
151
+ `[@nitida/sdk] relative endpoint "${endpoint}" requires a browser; ` +
152
+ "pass an absolute URL when using the SDK from Node/Bun.",
153
+ );
154
+ }
155
+ const u = new URL(base);
156
+ if (searchParams) {
157
+ for (const [k, v] of Object.entries(searchParams)) {
158
+ if (v !== undefined && v !== null && v !== "") {
159
+ u.searchParams.set(k, String(v));
160
+ }
161
+ }
162
+ }
163
+ return u;
164
+ }
165
+
166
+ /** Build a request URL as a plain string (no search params). */
167
+ function endpointHref(
168
+ opts: Pick<AquienpzClientOptions, "endpoint">,
169
+ path: string,
170
+ ): string {
171
+ return endpointUrl(opts, path).toString();
172
+ }
173
+
174
+ /**
175
+ * Auth headers — conditionally includes `Authorization` only when an
176
+ * `apiKey` is present. In BFF-proxy mode (browser via `/web`) the
177
+ * proxy injects the real bearer header, so we omit it here.
178
+ */
179
+ function authHeaders(opts: AquienpzClientOptions): Record<string, string> {
180
+ const h: Record<string, string> = {
181
+ // Caller-supplied headers first; `X-Tenant-Code` stays authoritative below.
182
+ ...opts.headers,
183
+ "X-Tenant-Code": opts.tenantCode,
184
+ };
185
+ if (opts.apiKey) h.Authorization = `Bearer ${opts.apiKey}`;
186
+ return h;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Re-exports (so consumers don't double-import from asset-client)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export type {
194
+ AssetDTO,
195
+ AssetVariant,
196
+ ResolveSlotOptions,
197
+ SignedTransformOptions,
198
+ SlotDTO,
199
+ SlotResolution,
200
+ TransformEffect,
201
+ TransformFit,
202
+ TransformFormat,
203
+ TransformGravity,
204
+ TransformOptions,
205
+ VariantPreset,
206
+ } from "@nitida/asset-client";
207
+ export {
208
+ computeVariantDimensions,
209
+ extractAssetSha,
210
+ getAssetDimensions,
211
+ getAssetSrcSet,
212
+ getAssetUrl,
213
+ getHlsStreamingUrl,
214
+ getSignedTransformUrl,
215
+ getTenantId,
216
+ getTransformSrcSet,
217
+ getTransformUrl,
218
+ getVideoTransformUrl,
219
+ hasPreset,
220
+ serializeTransform,
221
+ setTenantId,
222
+ signTransformUrl,
223
+ } from "@nitida/asset-client";
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Sub-namespaces
227
+ // ---------------------------------------------------------------------------
228
+
229
+ class SlotsApi {
230
+ constructor(private readonly opts: AquienpzClientOptions) {}
231
+
232
+ /** Resolve one slot — returns `{slot, preset, url}` or `{slot: null, url: null}` when unbound. */
233
+ resolve(
234
+ slotKey: string,
235
+ options: ResolveSlotOptions = {},
236
+ ): Promise<SlotResolution> {
237
+ return resolveSlot(slotKey, options);
238
+ }
239
+
240
+ /** Bulk-resolve N slots in one HTTP round-trip. */
241
+ resolveMany(
242
+ slotKeys: string[],
243
+ options: ResolveSlotOptions = {},
244
+ ): Promise<Record<string, SlotResolution>> {
245
+ return resolveSlots(slotKeys, options);
246
+ }
247
+
248
+ /** List slots for the tenant (admin). Optional prefix filter for tree views. */
249
+ async list(
250
+ opts: { prefix?: string; limit?: number } = {},
251
+ ): Promise<SlotDTO[]> {
252
+ const u = endpointUrl(this.opts, "/slots", {
253
+ prefix: opts.prefix,
254
+ limit: opts.limit,
255
+ });
256
+ const r = await fetch(u, { headers: this.headers() });
257
+ if (!r.ok) throw new Error(`slots list ${r.status}: ${await r.text()}`);
258
+ const body = (await r.json()) as { slots: SlotDTO[] };
259
+ return body.slots;
260
+ }
261
+
262
+ /** Bind / rebind a slot to an asset. Admin-only operation. */
263
+ async bind(
264
+ slotKey: string,
265
+ body: {
266
+ assetId: string;
267
+ preset?: VariantPreset;
268
+ description?: string;
269
+ updatedBy?: string;
270
+ },
271
+ ): Promise<{ ok: true; slotKey: string; assetId: string }> {
272
+ const r = await fetch(
273
+ endpointHref(this.opts, `/slots/${encodeURIComponent(slotKey)}`),
274
+ {
275
+ method: "PUT",
276
+ headers: { ...this.headers(), "Content-Type": "application/json" },
277
+ body: JSON.stringify(body),
278
+ },
279
+ );
280
+ if (!r.ok) throw new Error(`slot bind ${r.status}: ${await r.text()}`);
281
+ return (await r.json()) as { ok: true; slotKey: string; assetId: string };
282
+ }
283
+
284
+ /**
285
+ * Recent bindings for a slot. Lets the admin audit who changed
286
+ * what and restore a previous binding without remembering the
287
+ * asset id. Default limit 20, max 100.
288
+ */
289
+ async history(
290
+ slotKey: string,
291
+ opts: { limit?: number } = {},
292
+ ): Promise<SlotHistoryEntry[]> {
293
+ const u = endpointUrl(
294
+ this.opts,
295
+ `/slots/${encodeURIComponent(slotKey)}/history`,
296
+ { limit: opts.limit },
297
+ );
298
+ const r = await fetch(u, { headers: this.headers() });
299
+ if (!r.ok) throw new Error(`slots history ${r.status}: ${await r.text()}`);
300
+ const body = (await r.json()) as { history: SlotHistoryEntry[] };
301
+ return body.history;
302
+ }
303
+
304
+ /**
305
+ * Restore the slot to a previous binding. Equivalent to
306
+ * `bind(key, { assetId: previous.assetId, action: "restore" })`
307
+ * — the audit row is tagged `restore` instead of `bind`.
308
+ */
309
+ async restore(
310
+ slotKey: string,
311
+ args: { assetId: string; preset?: VariantPreset; updatedBy?: string },
312
+ ): Promise<{ ok: true; slotKey: string; assetId: string }> {
313
+ const r = await fetch(
314
+ endpointHref(this.opts, `/slots/${encodeURIComponent(slotKey)}`),
315
+ {
316
+ method: "PUT",
317
+ headers: { ...this.headers(), "Content-Type": "application/json" },
318
+ body: JSON.stringify({ ...args, action: "restore" }),
319
+ },
320
+ );
321
+ if (!r.ok) throw new Error(`slot restore ${r.status}: ${await r.text()}`);
322
+ return (await r.json()) as { ok: true; slotKey: string; assetId: string };
323
+ }
324
+
325
+ /** Remove a slot binding. The asset itself is left alone. */
326
+ async unbind(slotKey: string): Promise<{ ok: true; removed: number }> {
327
+ const r = await fetch(
328
+ endpointHref(this.opts, `/slots/${encodeURIComponent(slotKey)}`),
329
+ {
330
+ method: "DELETE",
331
+ headers: this.headers(),
332
+ },
333
+ );
334
+ if (!r.ok) throw new Error(`slot unbind ${r.status}: ${await r.text()}`);
335
+ return (await r.json()) as { ok: true; removed: number };
336
+ }
337
+
338
+ /** Invalidate the in-process cache after a slot rebind. */
339
+ invalidateCache(slotKey?: string): void {
340
+ invalidateSlotCache(slotKey);
341
+ }
342
+
343
+ private headers(): Record<string, string> {
344
+ return authHeaders(this.opts);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Returned by `aq.assets.regenerate(...)`. The shape varies by kind —
350
+ * images return immediately with the merged variant list; videos
351
+ * return a dispatch handle (the actual transcode runs in a Cloud Run
352
+ * Job and finishes async).
353
+ */
354
+ export type RegenerateResult =
355
+ | {
356
+ ok: true;
357
+ kind: "image";
358
+ /** Full variant set after the merge. */
359
+ variants: AssetVariant[];
360
+ /** Presets newly written this run. Useful for showing "added X". */
361
+ newVariants: VariantPreset[];
362
+ /**
363
+ * Which source the server read to derive the new variants:
364
+ * - `"original"` / `"raw"` → lossless source bytes (best)
365
+ * - `"xl"` / `"lg"` / `"md"` / `"sm"` / `"thumb"` → a previously
366
+ * encoded WebP variant was used as the source. Output is
367
+ * re-encoded WebP — fine for thumb/sm from lg, lossier when
368
+ * working from already-small sources.
369
+ *
370
+ * The no-upscale clamp still applies: deriving `lg` (1920) from
371
+ * a 640 `sm` source produces a 640-side `lg` variant, not a
372
+ * stretched 1920.
373
+ */
374
+ sourceUsed: VariantPreset | "raw";
375
+ }
376
+ | {
377
+ ok: true;
378
+ kind: "video";
379
+ dispatch: unknown;
380
+ regenerated: string[] | "default";
381
+ };
382
+
383
+ /**
384
+ * Wire shape returned by `POST /assets/upload-url`. Either the server
385
+ * resolves the upload synchronously via dedup (`deduped: true` + existing
386
+ * asset DTO) or it returns a presigned R2 PUT URL plus a `process` payload
387
+ * the caller must POST to `/assets/process` after the PUT lands.
388
+ */
389
+ export type UploadUrlResult =
390
+ | { deduped: true; asset: AssetDTO }
391
+ | {
392
+ deduped: false;
393
+ upload: { url: string; headers?: Record<string, string> };
394
+ process: { url: string; body: Record<string, unknown> };
395
+ };
396
+
397
+ /**
398
+ * VIDEO-only delivery knobs threaded into `/assets/process`. Ignored for
399
+ * image / audio / other uploads. Both fields default to today's behavior when
400
+ * omitted, so existing callers are unaffected.
401
+ */
402
+ export type UploadVideoOptions = {
403
+ /**
404
+ * `false` → skip the auto-dispatched HLS adaptive ladder (240p–2160p). Use
405
+ * for download-only assets served as a progressive `-v.mp4` and never
406
+ * streamed (e.g. share-video reels) — it avoids a second Cloud Run Job no
407
+ * one watches. Default/absent → the ladder is generated as before.
408
+ */
409
+ hls?: boolean;
410
+ /**
411
+ * `true` → when the uploaded MP4 is ALREADY web-safe (H.264 + yuv420p),
412
+ * re-mux the `video` variant with `-c copy` instead of re-encoding. Use for
413
+ * delivery-ready uploads (the bytes are already H.264 High / yuv420p /
414
+ * +faststart / capped bitrate) to skip a wasteful re-encode + generational
415
+ * quality loss. Falls back to a full re-encode automatically when the source
416
+ * is not web-safe. Default/absent → unconditional re-encode (today's path).
417
+ */
418
+ passthrough?: boolean;
419
+ };
420
+
421
+ /** Input shape accepted by `aq.assets.presignUploadUrl(...)`. */
422
+ export type PresignUploadUrlOptions = {
423
+ /** Full sha256 (64 hex) of the bytes that will be PUT to R2. */
424
+ sha256: string;
425
+ /** MIME type of the bytes (e.g. `image/jpeg`, `video/mp4`). */
426
+ mime: string;
427
+ /** Byte length of the upload payload. */
428
+ bytes: number;
429
+ /** Suggested file name; surfaces in admin dashboards + extension fallback. */
430
+ fileName: string;
431
+ /**
432
+ * Variant ladder to generate after `/assets/process`. Defaults to
433
+ * `["original"]` server-side when omitted — same contract as `aq.upload`.
434
+ */
435
+ presets?: VariantPreset[];
436
+ /**
437
+ * Pre-compression size of the source (useful when the browser ran
438
+ * compressorjs / heic2any before computing `bytes`). Surfaces in admin
439
+ * dashboards under `assets.client_original_bytes`.
440
+ */
441
+ clientOriginalBytes?: number;
442
+ /** VIDEO-only delivery knobs forwarded into `/assets/process`. See {@link UploadVideoOptions}. */
443
+ video?: UploadVideoOptions;
444
+ };
445
+
446
+ /** Input shape accepted by `aq.assets.composeMarketing(...)`. */
447
+ export type ComposeMarketingSegment = {
448
+ /** Public URL of the source clip (typically a `/t/.../video.mp4` transform). */
449
+ sourceUrl: string;
450
+ /** Optional clip duration in seconds (cap for that segment). */
451
+ durationSec?: number;
452
+ };
453
+
454
+ export type ComposeMarketingComposition = {
455
+ /** Transition between consecutive segments. Default `"cut"`. */
456
+ transition?: "cut" | "fade";
457
+ /** Optional audio track to mix on top of the final composition. */
458
+ audioTrack?: { url: string };
459
+ /** Final composition length in seconds (server may clamp). */
460
+ finalDurationSec?: number;
461
+ };
462
+
463
+ export type ComposeMarketingOptions = {
464
+ /** Marketing-kit id this composition belongs to (server uses it for naming + dedup). */
465
+ marketingKitId: string;
466
+ /** Ordered clip segments to stitch. */
467
+ segments: ComposeMarketingSegment[];
468
+ /** Optional composition-level knobs (transitions, audio, duration). */
469
+ composition?: ComposeMarketingComposition;
470
+ };
471
+
472
+ export type ComposeMarketingResult = {
473
+ /** Aquienpz asset id of the in-flight composition. Poll `aq.assets.waitReady(id)`. */
474
+ assetId: string;
475
+ /** Asset status at dispatch time — usually `"processing"`. */
476
+ status: "processing" | "ready" | "failed";
477
+ };
478
+
479
+ class AssetsApi {
480
+ constructor(private readonly opts: AquienpzClientOptions) {}
481
+
482
+ /** Look up an asset by full sha256 (64 hex). Returns null on 404. */
483
+ async byHash(sha256: string): Promise<AssetDTO | null> {
484
+ const r = await fetch(
485
+ endpointHref(this.opts, `/assets/by-hash/${sha256}`),
486
+ { headers: this.headers() },
487
+ );
488
+ if (r.status === 404) return null;
489
+ if (!r.ok) throw new Error(`assets byHash ${r.status}: ${await r.text()}`);
490
+ return (await r.json()) as AssetDTO;
491
+ }
492
+
493
+ /** Bulk lookup by sha256s. */
494
+ async byHashes(
495
+ hashes: string[],
496
+ ): Promise<{ existing: AssetDTO[]; missing: string[] }> {
497
+ const r = await fetch(endpointHref(this.opts, "/assets/by-hashes"), {
498
+ method: "POST",
499
+ headers: { ...this.headers(), "Content-Type": "application/json" },
500
+ body: JSON.stringify({ hashes }),
501
+ });
502
+ if (!r.ok)
503
+ throw new Error(`assets byHashes ${r.status}: ${await r.text()}`);
504
+ return (await r.json()) as { existing: AssetDTO[]; missing: string[] };
505
+ }
506
+
507
+ /** Paginated list of recent assets for the tenant. */
508
+ async list(
509
+ opts: { limit?: number; cursor?: string; includeDeleted?: boolean } = {},
510
+ ): Promise<{
511
+ assets: AssetDTO[];
512
+ nextCursor: string | null;
513
+ }> {
514
+ const u = endpointUrl(this.opts, "/assets", {
515
+ limit: opts.limit,
516
+ cursor: opts.cursor,
517
+ include_deleted: opts.includeDeleted ? "true" : undefined,
518
+ });
519
+ const r = await fetch(u, { headers: this.headers() });
520
+ if (!r.ok) throw new Error(`assets list ${r.status}: ${await r.text()}`);
521
+ return (await r.json()) as {
522
+ assets: AssetDTO[];
523
+ nextCursor: string | null;
524
+ };
525
+ }
526
+
527
+ /** Full DTO for an asset (admin view — includes audit-only fields). */
528
+ async get(assetId: string): Promise<AssetDTO & Record<string, unknown>> {
529
+ const r = await fetch(endpointHref(this.opts, `/assets/${assetId}`), {
530
+ headers: this.headers(),
531
+ });
532
+ if (!r.ok) throw new Error(`asset get ${r.status}: ${await r.text()}`);
533
+ return (await r.json()) as AssetDTO & Record<string, unknown>;
534
+ }
535
+
536
+ /**
537
+ * Slot bindings pointing at an asset. Use this before deleting an
538
+ * asset so the admin sees which storefront slots would suddenly
539
+ * resolve to nothing.
540
+ */
541
+ async bindings(assetId: string): Promise<
542
+ Array<{
543
+ slotKey: string;
544
+ preset: VariantPreset | null;
545
+ description: string | null;
546
+ updatedAt: string;
547
+ updatedBy: string | null;
548
+ }>
549
+ > {
550
+ const r = await fetch(endpointHref(this.opts, `/assets/${assetId}/slots`), {
551
+ headers: this.headers(),
552
+ });
553
+ if (!r.ok) throw new Error(`asset bindings ${r.status}: ${await r.text()}`);
554
+ const body = (await r.json()) as {
555
+ slots: Array<{
556
+ slotKey: string;
557
+ preset: VariantPreset | null;
558
+ description: string | null;
559
+ updatedAt: string;
560
+ updatedBy: string | null;
561
+ }>;
562
+ };
563
+ return body.slots;
564
+ }
565
+
566
+ /**
567
+ * Full variant list for an asset — preset, URL, dimensions, bytes.
568
+ * Stronger-typed wrapper around `get()` that exposes only the
569
+ * `variants` field with the proper `AssetVariant[]` shape.
570
+ *
571
+ * const v = await aq.assets.variants(logoId);
572
+ * v.map((x) => x.preset); // → ("thumb" | "sm" | … | "original")[]
573
+ */
574
+ async variants(assetId: string): Promise<AssetVariant[]> {
575
+ const dto = await this.get(assetId);
576
+ return Array.isArray((dto as { variants?: unknown }).variants)
577
+ ? (dto as { variants: AssetVariant[] }).variants
578
+ : [];
579
+ }
580
+
581
+ /**
582
+ * Add or rebuild variants on an existing asset. Image presets are
583
+ * MERGED with what's there — passing `{ presets: ["thumb"] }` adds
584
+ * the thumb variant without touching `lg`, `sm`, `original`, etc.
585
+ *
586
+ * // Day 0: upload original-only logo
587
+ * const { assetId } = await aq.upload(logoFile); // defaults to ["original"]
588
+ *
589
+ * // Day 7: need a thumb without re-uploading
590
+ * await aq.assets.regenerate(assetId, { presets: ["thumb"] });
591
+ *
592
+ * const after = await aq.assets.variants(assetId);
593
+ * after.map((v) => v.preset); // → ["original", "thumb"]
594
+ *
595
+ * Passing no presets re-runs the FULL default pipeline for that
596
+ * asset's kind (thumb+sm+md+lg for images, poster+video for video).
597
+ *
598
+ * If the asset was uploaded original-only and the cleanup job has
599
+ * already reaped `raw/`, the route falls back to reading the source
600
+ * bytes from `variants/o.<ext>` — no need to re-upload.
601
+ *
602
+ * Video presets are filtered to `["poster","video","aiproxy"]` and
603
+ * dispatched to the Cloud Run Job (the call returns immediately
604
+ * with a dispatch handle; poll `aq.assets.get(id).status` for
605
+ * completion).
606
+ */
607
+ async regenerate(
608
+ assetId: string,
609
+ opts: { presets?: VariantPreset[] } = {},
610
+ ): Promise<RegenerateResult> {
611
+ const r = await fetch(
612
+ endpointHref(this.opts, `/assets/${assetId}/regenerate`),
613
+ {
614
+ method: "POST",
615
+ headers: { ...this.headers(), "Content-Type": "application/json" },
616
+ body: JSON.stringify(opts.presets ? { presets: opts.presets } : {}),
617
+ },
618
+ );
619
+ if (!r.ok)
620
+ throw new Error(`asset regenerate ${r.status}: ${await r.text()}`);
621
+ return (await r.json()) as RegenerateResult;
622
+ }
623
+
624
+ /** Merge metadata into an asset (role / slot / description / tags). */
625
+ async patchMetadata(
626
+ assetId: string,
627
+ metadata: Record<string, unknown>,
628
+ ): Promise<{ ok: true; metadata: Record<string, unknown> }> {
629
+ const r = await fetch(endpointHref(this.opts, `/assets/${assetId}`), {
630
+ method: "PATCH",
631
+ headers: { ...this.headers(), "Content-Type": "application/json" },
632
+ body: JSON.stringify({ metadata }),
633
+ });
634
+ if (!r.ok) throw new Error(`asset patch ${r.status}: ${await r.text()}`);
635
+ return (await r.json()) as { ok: true; metadata: Record<string, unknown> };
636
+ }
637
+
638
+ /**
639
+ * Request a presigned R2 PUT URL for direct browser-side uploads.
640
+ *
641
+ * Mirrors the first half of `aq.upload()` — the caller (typically a
642
+ * BFF / share-link dropzone) computes sha256 in the browser, then
643
+ * uploads bytes straight to R2 with the returned `upload.url`, then
644
+ * POSTs `process.body` to `/assets/process` (see {@link processAndWait})
645
+ * once R2 has the bytes.
646
+ *
647
+ * If the sha is already known to the tenant the server short-circuits
648
+ * with `{ deduped: true, asset }` — no PUT needed.
649
+ *
650
+ * @example The browser-direct flow, in full
651
+ * ```ts
652
+ * // SERVER (holds the amk_rt_* key — never the browser):
653
+ * const presign = await aq.assets.presignUploadUrl({ sha256, mime, bytes, fileName, presets });
654
+ * if (presign.deduped) return presign.asset; // those bytes already exist; none fly
655
+ *
656
+ * // BROWSER: PUT straight to presign.upload.url — the bytes never touch your server.
657
+ * // ⚠️ R2 answers that preflight ITSELF, so your origin must be in the BUCKET's CORS policy.
658
+ * // Symptom when it is not: "PUT failed: network error" with every earlier step green —
659
+ * // and it cannot be fixed in this SDK, in your app, or in `storefront_origins`.
660
+ *
661
+ * // SERVER again, forwarding presign.process.body VERBATIM:
662
+ * const asset = await aq.assets.processAndWait(presign.process.body, { timeoutMs: 300_000 });
663
+ * ```
664
+ *
665
+ * Works for images AND video. A video answers immediately with
666
+ * `{ assetId, status: "processing" }` while a Cloud Run Job transcodes, so
667
+ * give `processAndWait` a bigger `timeoutMs` (a transcode + HLS ladder runs
668
+ * 1–2 min; 300_000 is a sane floor).
669
+ */
670
+ async presignUploadUrl(
671
+ opts: PresignUploadUrlOptions,
672
+ ): Promise<UploadUrlResult> {
673
+ const body: Record<string, unknown> = {
674
+ sha256: opts.sha256,
675
+ mime: opts.mime,
676
+ bytes: opts.bytes,
677
+ fileName: opts.fileName,
678
+ };
679
+ if (opts.presets && opts.presets.length > 0) body.presets = opts.presets;
680
+ if (opts.clientOriginalBytes != null)
681
+ body.clientOriginalBytes = opts.clientOriginalBytes;
682
+ if (opts.video != null) body.video = opts.video;
683
+
684
+ const r = await fetch(endpointHref(this.opts, "/assets/upload-url"), {
685
+ method: "POST",
686
+ headers: { ...this.headers(), "Content-Type": "application/json" },
687
+ body: JSON.stringify(body),
688
+ });
689
+ if (!r.ok) throw new Error(`upload-url ${r.status}: ${await r.text()}`);
690
+ return (await r.json()) as UploadUrlResult;
691
+ }
692
+
693
+ /**
694
+ * Dispatch `/assets/process` with the body returned by a prior
695
+ * {@link presignUploadUrl} call, then poll until the asset transitions
696
+ * to `ready` or `failed`. Throws on `failed` or timeout.
697
+ *
698
+ * Use this when bytes were uploaded directly from the browser to R2 —
699
+ * `aq.upload()` already does presign + PUT + process + wait in one
700
+ * step when the server holds the bytes.
701
+ */
702
+ async processAndWait(
703
+ processBody: Record<string, unknown>,
704
+ opts: { timeoutMs?: number } = {},
705
+ ): Promise<AssetDTO> {
706
+ const r = await fetch(endpointHref(this.opts, "/assets/process"), {
707
+ method: "POST",
708
+ headers: { ...this.headers(), "Content-Type": "application/json" },
709
+ body: JSON.stringify(processBody),
710
+ });
711
+ if (!r.ok) throw new Error(`process ${r.status}: ${await r.text()}`);
712
+ const proc = (await r.json()) as { assetId?: string };
713
+ if (!proc.assetId) throw new Error("process returned no assetId");
714
+ const final = await this.waitReady(proc.assetId, opts.timeoutMs);
715
+ if (final.status === "failed") {
716
+ throw new Error(
717
+ `processAndWait: asset ${proc.assetId} ended status=failed`,
718
+ );
719
+ }
720
+ return final;
721
+ }
722
+
723
+ /**
724
+ * Poll `GET /assets/:id` until the asset transitions to `ready` or
725
+ * `failed`. Returns the final DTO (whether ready OR failed — callers
726
+ * decide whether to throw on `failed`). Throws on timeout.
727
+ *
728
+ * Default timeout is 5 minutes; videos / HLS ladders may need a
729
+ * higher cap (pass `10 * 60_000` for compositions, transcodes).
730
+ */
731
+ async waitReady(assetId: string, timeoutMs = 5 * 60_000): Promise<AssetDTO> {
732
+ const start = Date.now();
733
+ let delay = 500;
734
+ let last: AssetDTO | null = null;
735
+ while (Date.now() - start < timeoutMs) {
736
+ last = (await this.get(assetId)) as AssetDTO;
737
+ if (last.status === "ready" || last.status === "failed") return last;
738
+ await new Promise((r) => setTimeout(r, delay));
739
+ delay = Math.min(delay * 1.5, 5_000);
740
+ }
741
+ if (!last) throw new Error(`waitReady: no asset ${assetId}`);
742
+ throw new Error(`waitReady timeout for ${assetId}`);
743
+ }
744
+
745
+ /**
746
+ * Dispatch `POST /assets/compose-marketing` to stitch pre-uploaded clip
747
+ * segments into a single MP4 composition. Returns the processing asset
748
+ * id immediately — does NOT block on completion. Callers poll via
749
+ * {@link waitReady} (typical timeout: 10 min for multi-segment kits).
750
+ *
751
+ * Tenant scope is inherited from the SDK client; `tenantCode` is added
752
+ * to the request body so the Cloud Run Job can resolve it without
753
+ * re-reading the header.
754
+ */
755
+ async composeMarketing(
756
+ opts: ComposeMarketingOptions,
757
+ ): Promise<ComposeMarketingResult> {
758
+ const r = await fetch(
759
+ endpointHref(this.opts, "/assets/compose-marketing"),
760
+ {
761
+ method: "POST",
762
+ headers: { ...this.headers(), "Content-Type": "application/json" },
763
+ body: JSON.stringify({
764
+ tenantCode: this.opts.tenantCode,
765
+ marketingKitId: opts.marketingKitId,
766
+ segments: opts.segments,
767
+ composition: opts.composition ?? {},
768
+ }),
769
+ },
770
+ );
771
+ if (!r.ok) {
772
+ throw new Error(`compose-marketing ${r.status}: ${await r.text()}`);
773
+ }
774
+ const body = (await r.json()) as {
775
+ asset?: { id: string; status: "processing" | "ready" | "failed" };
776
+ assetId?: string;
777
+ status?: "processing" | "ready" | "failed";
778
+ };
779
+ // Server returns `{ asset: { id, status }, dispatch? }`; some older
780
+ // builds returned `{ assetId, status }` directly. Normalize both.
781
+ const assetId = body.asset?.id ?? body.assetId;
782
+ const status = body.asset?.status ?? body.status ?? "processing";
783
+ if (!assetId) {
784
+ throw new Error("compose-marketing: response missing assetId");
785
+ }
786
+ return { assetId, status };
787
+ }
788
+
789
+ private headers(): Record<string, string> {
790
+ return authHeaders(this.opts);
791
+ }
792
+ }
793
+
794
+ // ---------------------------------------------------------------------------
795
+ // Upload helpers
796
+ // ---------------------------------------------------------------------------
797
+
798
+ /**
799
+ * Best-effort MIME from a file name's extension. Used as a fallback for `Uint8Array`
800
+ * uploads (which carry no inherent type) so they still get classified correctly instead
801
+ * of silently becoming `application/octet-stream` → `kind:"other"`. Returns `null` when
802
+ * the extension is unknown.
803
+ */
804
+ const MIME_BY_EXT: Record<string, string> = {
805
+ webp: "image/webp",
806
+ jpg: "image/jpeg",
807
+ jpeg: "image/jpeg",
808
+ png: "image/png",
809
+ gif: "image/gif",
810
+ avif: "image/avif",
811
+ heic: "image/heic",
812
+ heif: "image/heif",
813
+ svg: "image/svg+xml",
814
+ bmp: "image/bmp",
815
+ tiff: "image/tiff",
816
+ mp4: "video/mp4",
817
+ webm: "video/webm",
818
+ mov: "video/quicktime",
819
+ m4v: "video/x-m4v",
820
+ pdf: "application/pdf",
821
+ };
822
+ export function mimeFromFileName(fileName: string | undefined): string | null {
823
+ if (!fileName) return null;
824
+ const ext = fileName.split(".").pop()?.toLowerCase();
825
+ return ext ? (MIME_BY_EXT[ext] ?? null) : null;
826
+ }
827
+
828
+ /**
829
+ * Subset of compressorjs options exposed through the SDK. Re-imported here
830
+ * to avoid a hard import dependency on `./web` from this top-level module
831
+ * (the /web subpath uses browser-only APIs). The runtime `compress`
832
+ * implementation is lazy-loaded so Node/Bun callers don't pay the bundle
833
+ * cost — see `aq.upload` below.
834
+ */
835
+ export type CompressOptions = {
836
+ quality?: number;
837
+ mimeType?: "image/jpeg" | "image/webp";
838
+ maxWidth?: number;
839
+ maxHeight?: number;
840
+ convertSize?: number;
841
+ strict?: boolean;
842
+ keepOriginalDimensions?: boolean;
843
+ convertHeic?: boolean;
844
+ onProgress?: (
845
+ stage: "convertingHeic" | "compressing" | "compressingKeepingDimensions",
846
+ ) => void;
847
+ };
848
+
849
+ export type UploadOptions = {
850
+ fileName?: string;
851
+ /**
852
+ * MIME type of the bytes. **Only needed for a `Uint8Array` input** — a `File`/`Blob`
853
+ * already carries its `.type`. Raw bytes have no inherent MIME, so without this (and
854
+ * without an extension on `fileName` to infer from) they upload as
855
+ * `application/octet-stream`, which the asset-manager classifies as `kind:"other"` —
856
+ * meaning NO image/video variants are generated and `regenerate()` is unsupported.
857
+ * Resolution order for the effective MIME: `Blob.type` → `contentType` →
858
+ * inferred from `fileName`'s extension → `application/octet-stream`.
859
+ *
860
+ * aq.upload(bytes, { fileName: "cover.webp" }) // inferred → image/webp ✓
861
+ * aq.upload(bytes, { contentType: "image/webp" }) // explicit ✓
862
+ * aq.upload(bytes) // octet-stream → kind:"other" ⚠
863
+ */
864
+ contentType?: string;
865
+ /** Computed sha256 of bytes. Skip to compute locally with WebCrypto (browser only). */
866
+ sha256?: string;
867
+ /**
868
+ * Client-side compression before upload. Saves user bandwidth — typical
869
+ * 5–10× reduction for raw phone photos. Browser-only; in Node/Bun this
870
+ * silently no-ops with a console.warn and the raw bytes upload as-is.
871
+ *
872
+ * - `true` → use SDK `DEFAULT_COMPRESSION_OPTIONS` (webapp-tuned)
873
+ * - `CompressOptions` → merge over defaults
874
+ * - `false` / omit → no compression (current default behavior)
875
+ *
876
+ * Implementation is lazy-imported from `@nitida/sdk/web` so callers
877
+ * that never set `compress` don't pay the compressorjs + heic2any
878
+ * bundle cost. Skipped for non-image MIMEs (video, PDF) regardless of
879
+ * this option — those go to the upload pipeline raw.
880
+ *
881
+ * @see {@link CompressOptions}
882
+ * @see https://github.com/espaciofuturoio/aquienpz/tree/main/packages/sdk#client-side-compression-browsers
883
+ */
884
+ compress?: boolean | CompressOptions;
885
+ /**
886
+ * Variant set to generate. **Defaults to `["original"]`** —
887
+ * if you omit this option, only the raw bytes land on the CDN
888
+ * under the `o` path. Pass an explicit array to request more.
889
+ *
890
+ * Image presets (`thumb` 256 · `sm` 640 · `md` 1280 · `lg` 1920 ·
891
+ * `xl` 3840 · `original`):
892
+ * - `["original"]` (default) → just the raw bytes. Right call for
893
+ * logos / SVGs / anything you'll resize browser-side or via
894
+ * `aq.assets.regenerate(id, { presets: ["thumb"] })` later.
895
+ * - `["thumb","sm","md","lg"]` → the classic responsive ladder.
896
+ * - `["thumb","sm","md","lg","xl"]` → add 4K.
897
+ *
898
+ * Video presets (`poster`, `video`, `aiproxy`): omit `aiproxy` if
899
+ * the tenant doesn't need the low-res transcode for AI captioning.
900
+ *
901
+ * No upscaling. Each size preset is a **ceiling**; a 1080×720 source
902
+ * asked for `xl` (3840) yields a 1080×720 xl variant, not a stretched
903
+ * 3840-wide image.
904
+ *
905
+ * Idempotent: you can always add missing variants later via
906
+ * `aq.assets.regenerate(id, { presets: [...] })`. The platform
907
+ * stores the source so regeneration doesn't require re-uploading.
908
+ */
909
+ presets?: VariantPreset[];
910
+ /**
911
+ * Max time to wait for the asset to transition to `ready` (or `failed`)
912
+ * after dispatch. Default `5 * 60_000` (5 min). Bump higher for large
913
+ * videos / HLS transcodes — aquienpz processing time scales with input
914
+ * size and per-instance CPU.
915
+ *
916
+ * Throws `Error("waitReady timeout for <id>")` if the deadline passes
917
+ * without the asset transitioning. The asset row stays in aquienpz
918
+ * (status="processing") and the next byHash lookup will return it once
919
+ * processing completes; the caller can resume with their own poll.
920
+ */
921
+ timeoutMs?: number;
922
+ /**
923
+ * VIDEO-only delivery knobs forwarded into `/assets/process`. See
924
+ * {@link UploadVideoOptions}. Ignored for non-video uploads.
925
+ *
926
+ * // A delivery-ready reel: skip the unused HLS ladder + skip re-encode.
927
+ * await aq.upload(mp4Bytes, {
928
+ * fileName: "reel.mp4",
929
+ * presets: ["poster", "video"],
930
+ * video: { hls: false, passthrough: true },
931
+ * });
932
+ */
933
+ video?: UploadVideoOptions;
934
+ };
935
+
936
+ /** Default preset set the SDK sends to `/assets/upload-url` when the caller omits `presets`. */
937
+ const DEFAULT_UPLOAD_PRESETS: VariantPreset[] = ["original"];
938
+
939
+ export type UploadResult = {
940
+ assetId: string;
941
+ sha256: string;
942
+ cdnUrl: string;
943
+ };
944
+
945
+ async function computeSha256(
946
+ bytes: ArrayBuffer | Uint8Array | Blob,
947
+ ): Promise<string> {
948
+ const buf =
949
+ bytes instanceof Blob
950
+ ? await bytes.arrayBuffer()
951
+ : bytes instanceof Uint8Array
952
+ ? (bytes.buffer as ArrayBuffer)
953
+ : bytes;
954
+ const digest = await crypto.subtle.digest("SHA-256", buf);
955
+ return [...new Uint8Array(digest)]
956
+ .map((b) => b.toString(16).padStart(2, "0"))
957
+ .join("");
958
+ }
959
+
960
+ // ---------------------------------------------------------------------------
961
+ // Main facade
962
+ // ---------------------------------------------------------------------------
963
+
964
+ // ---------------------------------------------------------------------------
965
+ // Usage (Cloudinary-style consumption dashboard data)
966
+ // ---------------------------------------------------------------------------
967
+
968
+ // ---------------------------------------------------------------------------
969
+ // Slot history
970
+ // ---------------------------------------------------------------------------
971
+
972
+ export type SlotHistoryEntry = {
973
+ id: string;
974
+ action: "bind" | "unbind" | "restore";
975
+ preset: VariantPreset | null;
976
+ description: string | null;
977
+ updatedAt: string;
978
+ updatedBy: string | null;
979
+ assetId: string | null;
980
+ /** Resolved DTO when the asset still exists; `null` after delete / 404. */
981
+ asset: AssetDTO | null;
982
+ };
983
+
984
+ export type UsageSnapshot = {
985
+ tenant: { id: number; code: string };
986
+ storage: { totalBytes: number; assetCount: number };
987
+ today: UsageWindow;
988
+ last30Days: UsageWindow;
989
+ };
990
+
991
+ export type UsageWindow = {
992
+ reads: number;
993
+ writes: number;
994
+ lists: number;
995
+ deletes: number;
996
+ admins: number;
997
+ upscales: number;
998
+ processes: number;
999
+ bytesIn: number;
1000
+ };
1001
+
1002
+ export type UsageDailyPoint = {
1003
+ date: string;
1004
+ reads: number;
1005
+ writes: number;
1006
+ lists: number;
1007
+ deletes: number;
1008
+ processes: number;
1009
+ bytesIn: number;
1010
+ bytesStored: number;
1011
+ };
1012
+
1013
+ export type UsagePerKey = {
1014
+ apiKeyId: string;
1015
+ prefix: string | null;
1016
+ name: string | null;
1017
+ opsTotal: number;
1018
+ bytesTotal: number;
1019
+ lastSeen: string;
1020
+ };
1021
+
1022
+ class UsageApi {
1023
+ constructor(private readonly opts: AquienpzClientOptions) {}
1024
+
1025
+ /** Snapshot for the active tenant — storage + today + last 30 days totals. */
1026
+ async snapshot(): Promise<UsageSnapshot> {
1027
+ const r = await fetch(endpointHref(this.opts, "/usage"), {
1028
+ headers: this.headers(),
1029
+ });
1030
+ if (!r.ok) throw new Error(`usage snapshot ${r.status}: ${await r.text()}`);
1031
+ return (await r.json()) as UsageSnapshot;
1032
+ }
1033
+
1034
+ /** Daily rollup for charts — 1..365 days, default 30. */
1035
+ async timeseries(days = 30): Promise<{
1036
+ tenant: { id: number; code: string };
1037
+ days: UsageDailyPoint[];
1038
+ }> {
1039
+ const r = await fetch(
1040
+ endpointUrl(this.opts, "/usage/timeseries", { days }),
1041
+ { headers: this.headers() },
1042
+ );
1043
+ if (!r.ok)
1044
+ throw new Error(`usage timeseries ${r.status}: ${await r.text()}`);
1045
+ return (await r.json()) as {
1046
+ tenant: { id: number; code: string };
1047
+ days: UsageDailyPoint[];
1048
+ };
1049
+ }
1050
+
1051
+ /** Per-API-key breakdown for the current month. */
1052
+ async keys(): Promise<{
1053
+ tenant: { id: number; code: string };
1054
+ monthStart: string;
1055
+ keys: UsagePerKey[];
1056
+ }> {
1057
+ const r = await fetch(endpointHref(this.opts, "/usage/keys"), {
1058
+ headers: this.headers(),
1059
+ });
1060
+ if (!r.ok) throw new Error(`usage keys ${r.status}: ${await r.text()}`);
1061
+ return (await r.json()) as {
1062
+ tenant: { id: number; code: string };
1063
+ monthStart: string;
1064
+ keys: UsagePerKey[];
1065
+ };
1066
+ }
1067
+
1068
+ private headers(): Record<string, string> {
1069
+ return authHeaders(this.opts);
1070
+ }
1071
+ }
1072
+
1073
+ export class AquienpzClient {
1074
+ readonly slots: SlotsApi;
1075
+ readonly assets: AssetsApi;
1076
+ readonly usage: UsageApi;
1077
+ /**
1078
+ * Effective options — read-only. Exposed so the `/web` and `/expo`
1079
+ * subpaths can inherit endpoint / apiKey / tenant scope from the
1080
+ * configured client without re-passing them per call site.
1081
+ */
1082
+ readonly opts: AquienpzClientOptions;
1083
+
1084
+ constructor(opts: AquienpzClientOptions) {
1085
+ this.opts = opts;
1086
+ const cdn = opts.cdnBase ?? "https://8ok.uk";
1087
+ setCdnBase(cdn);
1088
+ // Configure the process-global tenant so every variant URL builder
1089
+ // (`getAssetUrl`, `urlFor`, `srcSetFor`, upload `cdnUrl`) emits the
1090
+ // tenant-prefixed path `<cdn>/<tid b36>/v/<sha>-<preset>.<ext>`.
1091
+ setTenantId(opts.tenantId);
1092
+ configureSlotResolver({
1093
+ endpoint: opts.endpoint,
1094
+ apiKey: opts.apiKey,
1095
+ tenantCode: opts.tenantCode,
1096
+ });
1097
+ this.slots = new SlotsApi(opts);
1098
+ this.assets = new AssetsApi(opts);
1099
+ this.usage = new UsageApi(opts);
1100
+ }
1101
+
1102
+ /** Tenant id as base36 path segment (e.g. tenantId=4 → "4/v/"). */
1103
+ get tenantSegment(): string {
1104
+ return `${this.opts.tenantId.toString(36)}/v/`;
1105
+ }
1106
+
1107
+ /** Build the canonical CDN URL deterministically from sha + preset. */
1108
+ urlFor(asset: Pick<AssetDTO, "sha">, preset: VariantPreset = "lg"): string {
1109
+ return getAssetUrl(asset, preset);
1110
+ }
1111
+
1112
+ /** Build a responsive srcSet across the available image presets. */
1113
+ srcSetFor(asset: Pick<AssetDTO, "sha" | "presets">): string {
1114
+ return getAssetSrcSet(asset);
1115
+ }
1116
+
1117
+ /**
1118
+ * Build an on-the-fly transform URL — `<cdn>/t/<dsl>/<sha>.<ext>`.
1119
+ *
1120
+ * ## URL CONVENTION — transforms are NOT tenant-prefixed (variants are)
1121
+ * Two distinct delivery paths, by design:
1122
+ * - **Variants / presets** (`urlFor`, `srcSetFor`, upload `cdnUrl`):
1123
+ * `<cdn>/<tenantId b36>/v/<sha>-<preset>.<ext>` ← tenant-scoped (e.g. `/4/v/<sha>-lg.webp`)
1124
+ * - **On-the-fly transforms** (`transform`, `transformSrcSet`):
1125
+ * `<cdn>/t/<dsl>/<sha>.<ext>` ← GLOBAL, no tenant segment (`/t/...`)
1126
+ * The transform service is content-addressed by sha + resizes from the source on demand,
1127
+ * so it needs no tenant in the path. Prefixing a transform URL with `/<tenant>/t/...` 404s.
1128
+ * Consumers that build URLs by hand must NOT add the tenant segment to `/t/` URLs.
1129
+ *
1130
+ * Returns the canonical `lg` variant URL when called with empty options,
1131
+ * so callers can swap `urlFor()` for `transform()` without thinking.
1132
+ *
1133
+ * URLs with the same params in different order produce the same R2
1134
+ * cache entry (the server canonicalizes both sides). Safe to use as
1135
+ * stable cache keys.
1136
+ *
1137
+ * <Image
1138
+ * src={aq.transform(asset, { width: 1280 })}
1139
+ * srcSet={aq.transformSrcSet(asset, [640, 960, 1280, 1920])}
1140
+ * sizes="(max-width: 768px) 100vw, 50vw"
1141
+ * />
1142
+ *
1143
+ * @see {@link TransformOptions} for the full param matrix.
1144
+ */
1145
+ // Overload 1: no signing — synchronous, on-ladder width only (strict).
1146
+ transform(asset: Pick<AssetDTO, "sha">, opts?: TransformOptions): string;
1147
+ // Overload 2: with { sign: true } — async; returns `?sig=<hmac>` URL.
1148
+ // Accepts SignedTransformOptions so a signed URL may carry an off-ladder
1149
+ // custom width (the signature earns the edge whitelist bypass).
1150
+ transform(
1151
+ asset: Pick<AssetDTO, "sha">,
1152
+ opts: SignedTransformOptions,
1153
+ signOpts: { sign: true },
1154
+ ): Promise<string>;
1155
+ transform(
1156
+ asset: Pick<AssetDTO, "sha">,
1157
+ opts: SignedTransformOptions = {},
1158
+ signOpts?: { sign: true },
1159
+ ): string | Promise<string> {
1160
+ if (!signOpts?.sign) {
1161
+ // Unsigned path. Overload 1 constrains `width` to the ladder at every
1162
+ // public call site, so an off-ladder width can't reach here through the
1163
+ // typed API — narrow back to TransformOptions for the strict builder.
1164
+ return (
1165
+ getTransformUrl(asset, opts as TransformOptions) ??
1166
+ this.urlFor(asset, "lg")
1167
+ );
1168
+ }
1169
+ if (!this.opts.signingKey) {
1170
+ throw new Error(
1171
+ "aq.transform({ sign: true }) requires `signingKey` in AquienpzClientOptions. " +
1172
+ "Pull the tenant's signing key from /admin/tenants/:id and pass it to the SDK constructor on a SERVER-side instance only.",
1173
+ );
1174
+ }
1175
+ // Signed path — custom (off-ladder) widths allowed. Empty opts → no
1176
+ // transform DSL, fall back to the unsigned `lg` variant URL.
1177
+ return (
1178
+ getSignedTransformUrl(asset, opts, this.opts.signingKey) ??
1179
+ Promise.resolve(this.urlFor(asset, "lg"))
1180
+ );
1181
+ }
1182
+
1183
+ /**
1184
+ * Build a responsive `srcSet` string. One transform URL per width; all
1185
+ * other options apply to every URL.
1186
+ *
1187
+ * Pass `{ sign: true }` to return signed URLs (async). Without it, the
1188
+ * call stays synchronous as before.
1189
+ */
1190
+ transformSrcSet(
1191
+ asset: Pick<AssetDTO, "sha">,
1192
+ widths: number[],
1193
+ extraOpts?: Omit<TransformOptions, "width">,
1194
+ ): string;
1195
+ transformSrcSet(
1196
+ asset: Pick<AssetDTO, "sha">,
1197
+ widths: number[],
1198
+ extraOpts: Omit<TransformOptions, "width">,
1199
+ signOpts: { sign: true },
1200
+ ): Promise<string>;
1201
+ transformSrcSet(
1202
+ asset: Pick<AssetDTO, "sha">,
1203
+ widths: number[],
1204
+ extraOpts: Omit<TransformOptions, "width"> = {},
1205
+ signOpts?: { sign: true },
1206
+ ): string | Promise<string> {
1207
+ if (!signOpts?.sign) return getTransformSrcSet(asset, widths, extraOpts);
1208
+ if (!this.opts.signingKey) {
1209
+ throw new Error(
1210
+ "aq.transformSrcSet({ sign: true }) requires `signingKey` in AquienpzClientOptions.",
1211
+ );
1212
+ }
1213
+ const key = this.opts.signingKey;
1214
+ return Promise.all(
1215
+ widths.map(async (w) => {
1216
+ // Signed path → off-ladder widths allowed; build+sign via the
1217
+ // custom-width helper (the strict `getTransformUrl` would reject a
1218
+ // raw `number` width).
1219
+ const signed = await getSignedTransformUrl(
1220
+ asset,
1221
+ { ...extraOpts, width: w },
1222
+ key,
1223
+ );
1224
+ return signed ? `${signed} ${w}w` : null;
1225
+ }),
1226
+ ).then((parts) => parts.filter((s): s is string => s != null).join(", "));
1227
+ }
1228
+
1229
+ /**
1230
+ * Build an on-the-fly VIDEO transform URL — Phase 4.
1231
+ *
1232
+ * Same DSL shape as `transform()` but the URL has a `.mp4` (default)
1233
+ * or `.webm` extension and the server routes the request to a Cloud
1234
+ * Run Job for ffmpeg encoding (vs the inline sharp pipeline for
1235
+ * images).
1236
+ *
1237
+ * On the first request the route returns **202 Accepted** with
1238
+ * `Retry-After: 10` while the Job runs (typically 5-30 s for a
1239
+ * short clip). The response body includes `outputUrl` which is the
1240
+ * eventual CDN URL — poll the same transform URL after the
1241
+ * retry-after window to get a 302 redirect to it.
1242
+ *
1243
+ * const url = aq.transformVideo(asset, {
1244
+ * width: 1080, height: 1920, fit: "cover",
1245
+ * start: 0, duration: 15,
1246
+ * });
1247
+ * // Pass to Video.js / <video src={url}>; on the first load it
1248
+ * // gets 202 + body.outputUrl; subsequent loads hit cache → 302.
1249
+ *
1250
+ * Video-specific DSL params:
1251
+ * - `start` (seconds, decimal OK)
1252
+ * - `duration` (seconds, 1..300)
1253
+ * - `format`: "mp4" (default) or "webm"
1254
+ *
1255
+ * The other params (`width`, `height`, `fit`) work identically to
1256
+ * image transforms. `gravity`, `quality`, `effect`, `dpr` are
1257
+ * accepted by the DSL but currently ignored on the video path.
1258
+ */
1259
+ transformVideo(
1260
+ asset: Pick<AssetDTO, "sha">,
1261
+ opts: TransformOptions = {},
1262
+ ): string {
1263
+ return getVideoTransformUrl(asset, opts) ?? this.urlFor(asset, "lg");
1264
+ }
1265
+
1266
+ /**
1267
+ * Build the HLS master playlist URL for a VIDEO asset (Phase 5).
1268
+ *
1269
+ * Returns `<cdn>/t/format=hls(,start=…,duration=…)/<sha>.m3u8`. Pass
1270
+ * to an HLS-aware player:
1271
+ *
1272
+ * <video
1273
+ * src={aq.streamingUrl(asset)}
1274
+ * controls playsInline
1275
+ * // Video.js v10's @videojs/http-streaming ships native HLS —
1276
+ * // no plugin needed.
1277
+ * />
1278
+ *
1279
+ * On the first request the server returns **202 Accepted** while a
1280
+ * Cloud Run Job builds the multi-rung ladder (typically 1-3 min for
1281
+ * a 90 s source — five rungs of 240p/360p/480p/720p/1080p @ AAC).
1282
+ * Subsequent requests hit the cache → **302** to the master.m3u8.
1283
+ *
1284
+ * Supports `start` + `duration` to ladder a sub-clip. Other DSL
1285
+ * params (width, height, fit) are ignored on the HLS path because
1286
+ * the rungs determine resolution.
1287
+ */
1288
+ streamingUrl(
1289
+ asset: Pick<AssetDTO, "sha">,
1290
+ opts: Omit<TransformOptions, "format"> = {},
1291
+ ): string {
1292
+ return getHlsStreamingUrl(asset, opts);
1293
+ }
1294
+
1295
+ /**
1296
+ * Upload a file or raw bytes. Returns the new asset id + canonical
1297
+ * URL. Hash-deduped — uploading the same bytes twice returns the
1298
+ * existing asset.
1299
+ *
1300
+ * Browser-first: uses `Blob` + WebCrypto. For Node 20+, pass a
1301
+ * Uint8Array and a precomputed `sha256` (since `crypto.subtle` works
1302
+ * but isn't always available depending on the runtime).
1303
+ */
1304
+ /**
1305
+ * Upload bytes end to end: optional client compression → sha256 → presign → **direct-to-R2 PUT**
1306
+ * → `/assets/process` → wait until the asset is ready.
1307
+ *
1308
+ * ⚠️ `presets` decides what exists FOREVER. Omit it and only `original` is written; ask for
1309
+ * `["thumb"]` and the bytes you just uploaded are **not retrievable**. A variant not requested in
1310
+ * this first ingest cannot be added later once the cleanup job reaps `raw/` — measured once as
1311
+ * "97 files archived successfully, zero recoverable".
1312
+ *
1313
+ * @example Deliver an image on a site (the responsive ladder)
1314
+ * ```ts
1315
+ * import { AquienpzClient } from "@nitida/sdk/server";
1316
+ *
1317
+ * const aq = new AquienpzClient({ endpoint, apiKey, tenantCode, tenantId });
1318
+ * const { assetId, sha256 } = await aq.upload(file, {
1319
+ * fileName: file.name,
1320
+ * presets: ["thumb", "sm", "md", "lg"],
1321
+ * });
1322
+ * ```
1323
+ *
1324
+ * @example ARCHIVE a file — you must ask for `original`
1325
+ * ```ts
1326
+ * await aq.upload(bytes, {
1327
+ * fileName: "contrato.pdf",
1328
+ * contentType: "application/pdf",
1329
+ * presets: ["original"], // without this the bytes are unrecoverable
1330
+ * });
1331
+ * ```
1332
+ *
1333
+ * @example Raw bytes need an explicit MIME
1334
+ * ```ts
1335
+ * await aq.upload(bytes, { fileName: "track.mp3", contentType: "audio/mpeg" });
1336
+ * // Without either, it stores as kind:"other" — no variants, and regenerate() is unsupported.
1337
+ * ```
1338
+ *
1339
+ * @example Video — and what does NOT work there
1340
+ * ```ts
1341
+ * // `original` is accepted and then silently DROPPED: /assets/process filters video presets to
1342
+ * // {poster, video, aiproxy, probe} before dispatching the transcode Job.
1343
+ * await aq.upload(clip, { fileName: "tour.mp4", presets: ["poster", "video"] });
1344
+ *
1345
+ * // Omit `aiproxy`/`probe` unless the asset really goes to a vision model — they cost Job time
1346
+ * // and permanent R2 objects that nothing else reads.
1347
+ * ```
1348
+ */
1349
+ async upload(
1350
+ input: File | Blob | Uint8Array,
1351
+ opts: UploadOptions = {},
1352
+ ): Promise<UploadResult> {
1353
+ // 1. Resolve incoming bytes + mime first (the source of truth for
1354
+ // "should we compress this?")
1355
+ const sourceIsBlob = input instanceof File || input instanceof Blob;
1356
+ // A Uint8Array has no inherent MIME. Fall back to an explicit `contentType`, then to
1357
+ // the file extension, before octet-stream (which the asset-manager files as
1358
+ // `kind:"other"` — no variants). A File/Blob's own `.type` always wins when present.
1359
+ const sourceMime =
1360
+ (sourceIsBlob ? input.type : "") ||
1361
+ opts.contentType ||
1362
+ mimeFromFileName(opts.fileName) ||
1363
+ "application/octet-stream";
1364
+ if (!sourceIsBlob && sourceMime === "application/octet-stream") {
1365
+ console.warn(
1366
+ "[@nitida/sdk] upload(Uint8Array): no MIME resolved (no `contentType`, no recognizable " +
1367
+ '`fileName` extension) — the asset will be stored as kind:"other" with NO image/video ' +
1368
+ "variants and regenerate() unsupported. Pass `contentType` or a `fileName` with an extension.",
1369
+ );
1370
+ }
1371
+
1372
+ // 2. Optional client-side compression. Image MIMEs only; non-image
1373
+ // sources pass through unchanged so a video or PDF upload still
1374
+ // works when the caller sets `compress: true` blanket-fashion.
1375
+ let bytes: Uint8Array;
1376
+ let effectiveMime: string;
1377
+ let clientOriginalBytes: number | undefined;
1378
+
1379
+ const wantCompression =
1380
+ !!opts.compress &&
1381
+ sourceIsBlob &&
1382
+ typeof window !== "undefined" &&
1383
+ sourceMime.startsWith("image/");
1384
+
1385
+ if (wantCompression) {
1386
+ const compressOpts =
1387
+ opts.compress === true ? {} : (opts.compress as CompressOptions);
1388
+ // Lazy: keeps compressorjs/heic2any out of bundles that don't use it.
1389
+ //
1390
+ // Resolve `./web` against `import.meta.url` at runtime so no static
1391
+ // analyzer — esbuild, tsup, `bun build --compile`, vite, rollup —
1392
+ // can follow the specifier. Holding the path in a plain `const`
1393
+ // was not enough: `bun build --compile` constant-folds simple
1394
+ // strings and still eagerly bundled `./web.js`, which
1395
+ // top-level-imports the browser-only peer deps
1396
+ // `@aquienpz/asset-uploader-web` / `@nitida/asset-compressor-web`
1397
+ // — neither installed on server consumers — crashing the
1398
+ // single-binary on boot with `Cannot find module
1399
+ // '@aquienpz/asset-uploader-web'`.
1400
+ //
1401
+ // The earlier `new URL("./web.js", import.meta.url)` + `await import(URL)`
1402
+ // dance survived bun-compile but Turbopack still tracks the URL literal
1403
+ // and tries to resolve `./web.js` at build time (no such file exists in
1404
+ // src/, only src/web/index.ts), failing with "Module not found".
1405
+ //
1406
+ // Indirect-eval via the Function constructor is opaque to BOTH static
1407
+ // analyzers — neither bun-compile nor Turbopack can follow the string
1408
+ // back to a module specifier. The browser `typeof window` guard above
1409
+ // ensures the branch never runs on the server, so the indirection is
1410
+ // safe at runtime.
1411
+ // Build the URL inside the Function body too — Turbopack still tracks
1412
+ // `new URL(<any expr>, import.meta.url)` as a resolution pattern even
1413
+ // when the first arg isn't a literal, so we have to hide both the URL
1414
+ // construction AND the import() behind indirect eval.
1415
+ const dynImport = new Function(
1416
+ "base",
1417
+ "return import(new URL('./' + 'web' + '.js', base).href)",
1418
+ ) as (base: string) => Promise<typeof import("./web")>;
1419
+ const { compressImage } = await dynImport(import.meta.url);
1420
+ const result = await compressImage(input as File | Blob, compressOpts);
1421
+ bytes = new Uint8Array(await result.blob.arrayBuffer());
1422
+ clientOriginalBytes = result.originalBytes;
1423
+ effectiveMime = result.blob.type || sourceMime;
1424
+ } else {
1425
+ if (opts.compress && !sourceIsBlob) {
1426
+ console.warn(
1427
+ "[@nitida/sdk] compress: true requires a File or Blob input; got Uint8Array — uploading raw bytes.",
1428
+ );
1429
+ } else if (opts.compress && typeof window === "undefined") {
1430
+ console.warn(
1431
+ "[@nitida/sdk] compress: true is browser-only — uploading raw bytes.",
1432
+ );
1433
+ } else if (opts.compress && !sourceMime.startsWith("image/")) {
1434
+ // Silent: callers can set `compress: true` for batched mixed
1435
+ // media and not have to special-case images vs videos.
1436
+ }
1437
+ bytes =
1438
+ input instanceof Uint8Array
1439
+ ? input
1440
+ : new Uint8Array(await input.arrayBuffer());
1441
+ effectiveMime = sourceMime;
1442
+ }
1443
+
1444
+ const sha = opts.sha256 ?? (await computeSha256(bytes));
1445
+ const mime = effectiveMime;
1446
+ const fileName =
1447
+ opts.fileName ??
1448
+ (input instanceof File ? input.name : `upload-${sha.slice(0, 8)}.bin`);
1449
+
1450
+ // Dedup probe.
1451
+ const existing = await this.assets.byHash(sha);
1452
+ if (existing && existing.status === "ready") {
1453
+ return {
1454
+ assetId: existing.id,
1455
+ sha256: sha,
1456
+ cdnUrl: this.urlFor(existing, this.bestPresetForAsset(existing, mime)),
1457
+ };
1458
+ }
1459
+
1460
+ // Presign + PUT + process. Delegates to `assets.presignUploadUrl()` so
1461
+ // the public method and `aq.upload()` share one code path; always
1462
+ // threads an explicit preset list so the default matches the SDK
1463
+ // contract (`["original"]`) instead of inheriting the server's older
1464
+ // default (`thumb+sm+md+lg`).
1465
+ const presign = await this.assets.presignUploadUrl({
1466
+ sha256: sha,
1467
+ mime,
1468
+ bytes: bytes.byteLength,
1469
+ fileName,
1470
+ presets:
1471
+ opts.presets && opts.presets.length > 0
1472
+ ? opts.presets
1473
+ : DEFAULT_UPLOAD_PRESETS,
1474
+ ...(clientOriginalBytes != null && { clientOriginalBytes }),
1475
+ ...(opts.video != null && { video: opts.video }),
1476
+ });
1477
+ if (presign.deduped) {
1478
+ return {
1479
+ assetId: presign.asset.id,
1480
+ sha256: sha,
1481
+ cdnUrl: this.urlFor(presign.asset, this.defaultPresetForMime(mime)),
1482
+ };
1483
+ }
1484
+
1485
+ const putR = await fetch(presign.upload.url, {
1486
+ method: "PUT",
1487
+ headers: { "Content-Type": mime, ...(presign.upload.headers ?? {}) },
1488
+ body: new Blob([bytes as unknown as ArrayBuffer], { type: mime }),
1489
+ });
1490
+ if (!putR.ok)
1491
+ throw new Error(`R2 PUT ${putR.status}: ${await putR.text()}`);
1492
+
1493
+ const procR = await fetch(endpointHref(this.opts, presign.process.url), {
1494
+ method: "POST",
1495
+ headers: {
1496
+ ...authHeaders(this.opts),
1497
+ "Content-Type": "application/json",
1498
+ },
1499
+ body: JSON.stringify(presign.process.body),
1500
+ });
1501
+ if (!procR.ok)
1502
+ throw new Error(`process ${procR.status}: ${await procR.text()}`);
1503
+ const proc = (await procR.json()) as { assetId?: string; kind?: string };
1504
+
1505
+ let assetId = proc.assetId;
1506
+ if (!assetId && proc.kind === "video") {
1507
+ // Videos are async — poll by-hash until the row lands.
1508
+ const start = Date.now();
1509
+ let delay = 1000;
1510
+ while (Date.now() - start < 60_000) {
1511
+ const dto = await this.assets.byHash(sha);
1512
+ if (dto?.id) {
1513
+ assetId = dto.id;
1514
+ break;
1515
+ }
1516
+ await new Promise((r) => setTimeout(r, delay));
1517
+ delay = Math.min(delay * 1.5, 5_000);
1518
+ }
1519
+ }
1520
+ if (!assetId) throw new Error("upload: process returned no assetId");
1521
+
1522
+ // Wait until variants are ready so the URL works immediately.
1523
+ // Passing `undefined` keeps waitReady's own default (5 min). Callers
1524
+ // uploading large videos can opt in to a longer deadline via opts.timeoutMs.
1525
+ const final = await this.assets.waitReady(assetId, opts.timeoutMs);
1526
+ if (final.status !== "ready")
1527
+ throw new Error(`upload: asset ended status=${final.status}`);
1528
+ return {
1529
+ assetId,
1530
+ sha256: sha,
1531
+ cdnUrl: this.urlFor(final, this.bestPresetForAsset(final, mime)),
1532
+ };
1533
+ }
1534
+
1535
+ private defaultPresetForMime(mime: string): VariantPreset {
1536
+ if (mime.startsWith("video/")) return "video";
1537
+ // The `mp3` variant only exists AFTER /process transcodes it; at presign
1538
+ // time only the original is guaranteed to be playable, so bind to it.
1539
+ if (mime.startsWith("audio/")) return "original";
1540
+ return "lg";
1541
+ }
1542
+
1543
+ /**
1544
+ * Pick a sensible preset to build a URL for, given the asset's actual
1545
+ * `presets` string. Falls back through the preference order
1546
+ * lg → md → sm → thumb → original (for images)
1547
+ * video → poster (for videos)
1548
+ * mp3 → original (for audio)
1549
+ * so an upload that was processed with e.g. `["original"]` still
1550
+ * returns a non-404 URL in `aq.upload`'s result.
1551
+ */
1552
+ private bestPresetForAsset(asset: AssetDTO, mime: string): VariantPreset {
1553
+ const order: VariantPreset[] = mime.startsWith("video/")
1554
+ ? ["video", "poster"]
1555
+ : mime.startsWith("audio/")
1556
+ ? ["mp3", "original"] // prefer the cross-browser mp3, else the playable original; never image presets
1557
+ : ["lg", "md", "sm", "thumb", "xl", "original"];
1558
+ return (
1559
+ order.find((p) => hasPreset(asset, p)) ?? this.defaultPresetForMime(mime)
1560
+ );
1561
+ }
1562
+ }