@nitida/asset-client 0.14.2
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 +25 -0
- package/README.md +100 -0
- package/dist/index.cjs +513 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +560 -0
- package/dist/index.d.ts +560 -0
- package/dist/index.js +455 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/index.ts +413 -0
- package/src/palette.ts +182 -0
- package/src/slots.ts +217 -0
- package/src/transform.ts +410 -0
package/src/slots.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nitida/asset-client/slots — slot resolver for tenant-named assets.
|
|
3
|
+
*
|
|
4
|
+
* Slots give tenants a way to attach stable, human-readable names
|
|
5
|
+
* ("webapp.wizard.pool-type.icon-1", "storefront.cr.hero-video.landscape_hd_16x9.mp4")
|
|
6
|
+
* to assets they uploaded. Consumers resolve names → AssetDTOs at
|
|
7
|
+
* build / runtime so their source never hardcodes a CDN URL; the
|
|
8
|
+
* admin rebinds a slot from `asset-lab-web` and every consumer picks
|
|
9
|
+
* up the swap on cache refresh.
|
|
10
|
+
*
|
|
11
|
+
* Two layers in this package:
|
|
12
|
+
* - `resolveSlot` / `resolveSlots` — universal (server, edge,
|
|
13
|
+
* workers) fetch helpers. Cache 60s by default.
|
|
14
|
+
* - React hooks live in `@nitida/asset-client/react/use-slot`
|
|
15
|
+
* (kept out of this module so the SSR-safe core stays
|
|
16
|
+
* dependency-free of react).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { AssetDTO, VariantPreset } from "./index";
|
|
20
|
+
import { getAssetUrl, hasPreset } from "./index";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Wire shape — matches `apps/asset-manager/src/features/assets/slots.routes.ts`
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export type SlotDTO = {
|
|
27
|
+
slotKey: string;
|
|
28
|
+
/** Preset hint set when the slot was bound (e.g. `thumb` for icon slots). */
|
|
29
|
+
preset: VariantPreset | null;
|
|
30
|
+
description: string | null;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
asset: AssetDTO;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type SlotResolution = {
|
|
36
|
+
/** The resolved DTO (`null` when the slot is unbound or asset missing). */
|
|
37
|
+
slot: SlotDTO | null;
|
|
38
|
+
/**
|
|
39
|
+
* Effective preset — what `url` below was built with. Resolution order:
|
|
40
|
+
* 1. caller's `preset` override
|
|
41
|
+
* 2. slot's `preset` hint
|
|
42
|
+
* 3. `lg` for images, `video` for video kind
|
|
43
|
+
*/
|
|
44
|
+
preset: VariantPreset;
|
|
45
|
+
/** The CDN URL the consumer should use. */
|
|
46
|
+
url: string | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Config
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const DEFAULT_TTL_MS = 60_000;
|
|
54
|
+
const cache = new Map<string, { fetchedAt: number; value: SlotDTO | null }>();
|
|
55
|
+
|
|
56
|
+
let endpoint = "https://aquienpz-asset-manager-nlchzy26qa-uc.a.run.app";
|
|
57
|
+
let apiKey: string | null = null;
|
|
58
|
+
let tenantCode: string | null = null;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Configure the resolver process-wide. Call once at boot from your
|
|
62
|
+
* storefront layout / server entry / worker init.
|
|
63
|
+
*
|
|
64
|
+
* configureSlotResolver({
|
|
65
|
+
* endpoint: process.env.AQUIENPZ_URL,
|
|
66
|
+
* apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY,
|
|
67
|
+
* tenantCode: "realtyone-cr",
|
|
68
|
+
* });
|
|
69
|
+
*/
|
|
70
|
+
export function configureSlotResolver(opts: {
|
|
71
|
+
endpoint?: string;
|
|
72
|
+
apiKey?: string;
|
|
73
|
+
tenantCode?: string;
|
|
74
|
+
}): void {
|
|
75
|
+
if (opts.endpoint) endpoint = opts.endpoint.replace(/\/+$/, "");
|
|
76
|
+
if (opts.apiKey !== undefined) apiKey = opts.apiKey;
|
|
77
|
+
if (opts.tenantCode !== undefined) tenantCode = opts.tenantCode;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Wipe the in-process cache (test helper or forced refresh). */
|
|
81
|
+
export function invalidateSlotCache(slotKey?: string): void {
|
|
82
|
+
if (slotKey === undefined) cache.clear();
|
|
83
|
+
else
|
|
84
|
+
for (const k of cache.keys())
|
|
85
|
+
if (k.endsWith(`:${slotKey}`)) cache.delete(k);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Internal fetch helper
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
const baseHeaders = (): Record<string, string> => {
|
|
93
|
+
const h: Record<string, string> = {};
|
|
94
|
+
if (apiKey) h.Authorization = `Bearer ${apiKey}`;
|
|
95
|
+
if (tenantCode) h["X-Tenant-Code"] = tenantCode;
|
|
96
|
+
return h;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
async function fetchSlot(slotKey: string): Promise<SlotDTO | null> {
|
|
100
|
+
const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {
|
|
101
|
+
headers: baseHeaders(),
|
|
102
|
+
});
|
|
103
|
+
if (r.status === 404) return null;
|
|
104
|
+
if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);
|
|
105
|
+
return (await r.json()) as SlotDTO;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function fetchSlotsBulk(
|
|
109
|
+
slotKeys: string[],
|
|
110
|
+
): Promise<Record<string, SlotDTO | null>> {
|
|
111
|
+
if (slotKeys.length === 0) return {};
|
|
112
|
+
const r = await fetch(`${endpoint}/slots/resolve`, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { ...baseHeaders(), "Content-Type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ keys: slotKeys }),
|
|
116
|
+
});
|
|
117
|
+
if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);
|
|
118
|
+
const body = (await r.json()) as { resolved: Record<string, SlotDTO | null> };
|
|
119
|
+
return body.resolved;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Public API
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export type ResolveSlotOptions = {
|
|
127
|
+
/** Override preset (caller knows the use case better than the slot binding). */
|
|
128
|
+
preset?: VariantPreset;
|
|
129
|
+
/** TTL for the in-process cache. Default 60s. Set 0 to bypass. */
|
|
130
|
+
ttlMs?: number;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve a single slot to a CDN URL. Returns `{slot: null, url: null}`
|
|
135
|
+
* when the slot is unbound — callers fall back to a placeholder.
|
|
136
|
+
*
|
|
137
|
+
* Cached for `ttlMs` (default 60s). Slot rebindings propagate within the
|
|
138
|
+
* TTL window without an app restart.
|
|
139
|
+
*/
|
|
140
|
+
export async function resolveSlot(
|
|
141
|
+
slotKey: string,
|
|
142
|
+
opts: ResolveSlotOptions = {},
|
|
143
|
+
): Promise<SlotResolution> {
|
|
144
|
+
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
145
|
+
const cacheKey = `${tenantCode ?? "_"}:${slotKey}`;
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
let dto: SlotDTO | null;
|
|
148
|
+
const hit = cache.get(cacheKey);
|
|
149
|
+
if (hit && now - hit.fetchedAt < ttl) {
|
|
150
|
+
dto = hit.value;
|
|
151
|
+
} else {
|
|
152
|
+
dto = await fetchSlot(slotKey);
|
|
153
|
+
cache.set(cacheKey, { fetchedAt: now, value: dto });
|
|
154
|
+
}
|
|
155
|
+
return materializeResolution(dto, opts.preset);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Bulk-resolve N slot keys in one round-trip. The SDK's `useSlots`
|
|
160
|
+
* React hook calls this so every storefront header (logo + tagline +
|
|
161
|
+
* nav cover + …) loads as one request.
|
|
162
|
+
*/
|
|
163
|
+
export async function resolveSlots(
|
|
164
|
+
slotKeys: string[],
|
|
165
|
+
opts: ResolveSlotOptions = {},
|
|
166
|
+
): Promise<Record<string, SlotResolution>> {
|
|
167
|
+
if (slotKeys.length === 0) return {};
|
|
168
|
+
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const missing: string[] = [];
|
|
171
|
+
const out: Record<string, SlotResolution> = {};
|
|
172
|
+
for (const k of slotKeys) {
|
|
173
|
+
const cacheKey = `${tenantCode ?? "_"}:${k}`;
|
|
174
|
+
const hit = cache.get(cacheKey);
|
|
175
|
+
if (hit && now - hit.fetchedAt < ttl) {
|
|
176
|
+
out[k] = materializeResolution(hit.value, opts.preset);
|
|
177
|
+
} else {
|
|
178
|
+
missing.push(k);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (missing.length > 0) {
|
|
182
|
+
const resolved = await fetchSlotsBulk(missing);
|
|
183
|
+
for (const k of missing) {
|
|
184
|
+
const dto = resolved[k] ?? null;
|
|
185
|
+
cache.set(`${tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
|
|
186
|
+
out[k] = materializeResolution(dto, opts.preset);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Helpers
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
function defaultPresetFor(asset: AssetDTO | undefined): VariantPreset {
|
|
197
|
+
if (!asset) return "lg";
|
|
198
|
+
return asset.kind === "video" ? "video" : "lg";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function materializeResolution(
|
|
202
|
+
dto: SlotDTO | null,
|
|
203
|
+
overridePreset?: VariantPreset,
|
|
204
|
+
): SlotResolution {
|
|
205
|
+
if (!dto) return { slot: null, preset: overridePreset ?? "lg", url: null };
|
|
206
|
+
const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);
|
|
207
|
+
// Fall back to "lg" when the bound preset doesn't exist on the asset
|
|
208
|
+
// (e.g. slot was bound to a video but caller asked for a thumb).
|
|
209
|
+
const finalPreset = hasPreset(dto.asset, effective)
|
|
210
|
+
? effective
|
|
211
|
+
: defaultPresetFor(dto.asset);
|
|
212
|
+
return {
|
|
213
|
+
slot: dto,
|
|
214
|
+
preset: finalPreset,
|
|
215
|
+
url: getAssetUrl(dto.asset, finalPreset),
|
|
216
|
+
};
|
|
217
|
+
}
|
package/src/transform.ts
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-the-fly transform URL builder.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated
|
|
5
|
+
* here hashes to the same R2 cache key as the server's canonical form.
|
|
6
|
+
*
|
|
7
|
+
* Canonicalization rules (keep in sync with
|
|
8
|
+
* `apps/asset-manager/src/features/assets/transform.dsl.ts`):
|
|
9
|
+
* - Drop entries whose value is `undefined`
|
|
10
|
+
* - Sort keys alphabetically
|
|
11
|
+
* - Numbers rendered without leading zeros or trailing dots
|
|
12
|
+
* - String values lowercased
|
|
13
|
+
*
|
|
14
|
+
* URL shape:
|
|
15
|
+
* <cdnBase>/t/<dsl>/<sha>.<ext>
|
|
16
|
+
*
|
|
17
|
+
* The `.ext` is informational (browser content-sniff hint); the server
|
|
18
|
+
* decides the actual output format from the DSL `format` param + the
|
|
19
|
+
* request's Accept header.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { AssetDTO } from "./index";
|
|
23
|
+
import { getCdnBase } from "./index";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Widths the CDN edge whitelists (DoS guard — see `apps/cdn-proxy` WHITELIST_WIDTHS).
|
|
27
|
+
* Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the
|
|
28
|
+
* 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import
|
|
29
|
+
* this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.
|
|
30
|
+
*/
|
|
31
|
+
export const TRANSFORM_WIDTHS = [
|
|
32
|
+
96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280,
|
|
33
|
+
1440, 1600, 1920, 2560, 3840,
|
|
34
|
+
] as const;
|
|
35
|
+
/**
|
|
36
|
+
* A CDN-whitelisted transform width — the only widths `TransformOptions.width`
|
|
37
|
+
* accepts. Off-ladder widths are a compile error; for signed URLs that need a
|
|
38
|
+
* custom width, use {@link SignedTransformOptions} (number) via
|
|
39
|
+
* {@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`.
|
|
40
|
+
*/
|
|
41
|
+
export type TransformWidth = (typeof TRANSFORM_WIDTHS)[number];
|
|
42
|
+
|
|
43
|
+
export type TransformFit = "cover" | "contain" | "fill" | "inside" | "outside";
|
|
44
|
+
export type TransformGravity =
|
|
45
|
+
| "auto"
|
|
46
|
+
| "face"
|
|
47
|
+
| "center"
|
|
48
|
+
| "north"
|
|
49
|
+
| "south"
|
|
50
|
+
| "east"
|
|
51
|
+
| "west";
|
|
52
|
+
export type TransformFormat =
|
|
53
|
+
| "auto"
|
|
54
|
+
| "avif"
|
|
55
|
+
| "webp"
|
|
56
|
+
| "jpeg"
|
|
57
|
+
| "png"
|
|
58
|
+
// Video-only formats (Phase 4). The image path ignores them.
|
|
59
|
+
| "mp4"
|
|
60
|
+
| "webm"
|
|
61
|
+
// HLS adaptive ladder (Phase 5). Video-only. Output is a directory
|
|
62
|
+
// of m3u8 + .ts segments fronted by master.m3u8; the SDK returns
|
|
63
|
+
// the master URL via `getHlsStreamingUrl`.
|
|
64
|
+
| "hls";
|
|
65
|
+
export type TransformEffect = "removebg" | "genfill";
|
|
66
|
+
|
|
67
|
+
export type TransformOptions = {
|
|
68
|
+
/**
|
|
69
|
+
* Target max-side width in CSS pixels (multiplied by `dpr` server-side).
|
|
70
|
+
* MUST be a {@link TRANSFORM_WIDTHS} value — off-ladder widths are rejected
|
|
71
|
+
* (HTTP 400) by the edge whitelist for unsigned URLs, so the type forbids
|
|
72
|
+
* them at compile time. For SIGNED URLs with a custom width, use
|
|
73
|
+
* {@link SignedTransformOptions} (which widens this to `number`).
|
|
74
|
+
*/
|
|
75
|
+
width?: TransformWidth;
|
|
76
|
+
/** Target max-side height. Multiplied by `dpr` server-side. */
|
|
77
|
+
height?: number;
|
|
78
|
+
/** Resize fit mode. Default `cover` server-side. */
|
|
79
|
+
fit?: TransformFit;
|
|
80
|
+
/** Crop gravity. `auto` uses sharp's `attention` strategy. */
|
|
81
|
+
gravity?: TransformGravity;
|
|
82
|
+
/** Output format. `auto` → policy decides (see asset-manager bench). */
|
|
83
|
+
format?: TransformFormat;
|
|
84
|
+
/** Output quality. `auto` → format-specific default. */
|
|
85
|
+
quality?: "auto" | number;
|
|
86
|
+
/** Device pixel ratio. Width/height are multiplied by this before resize. */
|
|
87
|
+
dpr?: 1 | 2 | 3;
|
|
88
|
+
/**
|
|
89
|
+
* AI effect applied before resize/encode.
|
|
90
|
+
*
|
|
91
|
+
* - `removebg`: remove the background; output is a transparent PNG
|
|
92
|
+
* of the foreground subject. Forces `format=png` regardless of
|
|
93
|
+
* other format hints. Runs U²-Net ONNX locally (or BRIA via
|
|
94
|
+
* Replicate when `BG_REMOVAL_BACKEND=replicate`). Single cache
|
|
95
|
+
* miss per (sha, dsl) tuple; subsequent identical DSLs serve
|
|
96
|
+
* from R2 — no inference, no per-image cost.
|
|
97
|
+
*
|
|
98
|
+
* - `genfill`: aspect-extension outpaint via Flux-Fill Pro on
|
|
99
|
+
* Replicate. Requires BOTH `width` and `height` — the server
|
|
100
|
+
* fits the source centered into the target canvas and outpaints
|
|
101
|
+
* the gutters. Output is PNG (forced) at exactly target dims.
|
|
102
|
+
* ~$0.05/image first time; same R2 cache as removebg after.
|
|
103
|
+
* Primary use case: building OG cards (1200×630) from portrait
|
|
104
|
+
* listing photos without awkward edge mirroring.
|
|
105
|
+
*/
|
|
106
|
+
effect?: TransformEffect;
|
|
107
|
+
/**
|
|
108
|
+
* Video-only: clip start in seconds. Image transforms ignore.
|
|
109
|
+
* Accepts decimals (e.g. 1.5 for sub-second seek).
|
|
110
|
+
*/
|
|
111
|
+
start?: number;
|
|
112
|
+
/**
|
|
113
|
+
* Video-only: clip duration in seconds (1..300). Image transforms
|
|
114
|
+
* ignore. With `start`, lets a single request grab a sub-clip.
|
|
115
|
+
*/
|
|
116
|
+
duration?: number;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Like {@link TransformOptions} but with `width` widened to any `number` —
|
|
121
|
+
* the escape hatch for SIGNED URLs that need an off-ladder custom width.
|
|
122
|
+
*
|
|
123
|
+
* The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid
|
|
124
|
+
* `?sig=` earns the whitelist bypass at the worker (the asset-manager still
|
|
125
|
+
* does the real HMAC check). So a custom width is ONLY safe when the URL is
|
|
126
|
+
* signed — hence this type is accepted exclusively by the signing helpers
|
|
127
|
+
* ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),
|
|
128
|
+
* never by the plain unsigned {@link getTransformUrl}.
|
|
129
|
+
*/
|
|
130
|
+
export type SignedTransformOptions = Omit<TransformOptions, "width"> & {
|
|
131
|
+
/** Off-ladder width — valid ONLY on signed URLs (edge whitelist bypass). */
|
|
132
|
+
width?: number;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Serialize transform options into the canonical DSL path segment.
|
|
137
|
+
* Empty options return an empty string (caller should fall back to a
|
|
138
|
+
* variant URL instead of a transform URL in that case).
|
|
139
|
+
*
|
|
140
|
+
* Accepts {@link SignedTransformOptions} (the wider type) so it also covers
|
|
141
|
+
* custom-width signed URLs; {@link TransformOptions} is assignable to it.
|
|
142
|
+
*/
|
|
143
|
+
/**
|
|
144
|
+
* Extract the 16-hex short sha from an aquienpz CDN URL, regardless of
|
|
145
|
+
* shape:
|
|
146
|
+
* - tenant-prefixed variant: `https://8ok.uk/4/v/<sha16>-<preset>.<ext>`
|
|
147
|
+
* - legacy variant: `https://8ok.uk/<sha16>-<preset>.<ext>`
|
|
148
|
+
* - on-the-fly transform: `https://8ok.uk/t/<dsl>/<sha16>.<ext>`
|
|
149
|
+
* - streaming HLS: `https://8ok.uk/t/format=hls/<sha16>.m3u8`
|
|
150
|
+
*
|
|
151
|
+
* Returns `null` for non-aquienpz URLs (pexels, googleusercontent, raw
|
|
152
|
+
* uploaded URLs to other CDNs) so call sites can fall back to the URL
|
|
153
|
+
* with a plain `<img>` instead of generating a broken transform URL.
|
|
154
|
+
*
|
|
155
|
+
* Useful when a value reaches the component as a pre-built URL string
|
|
156
|
+
* (legacy data, site-config JSON, third-party feeds) but you want to
|
|
157
|
+
* drop in `getTransformSrcSet` for the responsive ladder if it happens
|
|
158
|
+
* to be an aquienpz asset.
|
|
159
|
+
*/
|
|
160
|
+
export function extractAssetSha(url: string | null | undefined): string | null {
|
|
161
|
+
if (!url) return null;
|
|
162
|
+
// Match the canonical aquienpz sha pattern: 16 lowercase hex chars
|
|
163
|
+
// appearing as a path segment, optionally followed by `-<preset>`
|
|
164
|
+
// (variant URL) or `.<ext>` (transform URL).
|
|
165
|
+
const m = url.match(/\/([0-9a-f]{16})(?:[-.]|$)/);
|
|
166
|
+
return m ? m[1]! : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function serializeTransform(opts: SignedTransformOptions): string {
|
|
170
|
+
const entries: Array<[string, string]> = [];
|
|
171
|
+
const keys = Object.keys(opts).sort() as Array<keyof SignedTransformOptions>;
|
|
172
|
+
for (const k of keys) {
|
|
173
|
+
const v = opts[k];
|
|
174
|
+
if (v == null) continue;
|
|
175
|
+
const serialized = typeof v === "string" ? v.toLowerCase() : String(v);
|
|
176
|
+
entries.push([k, serialized]);
|
|
177
|
+
}
|
|
178
|
+
return entries.map(([k, v]) => `${k}=${v}`).join(",");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function extForOptions(opts: SignedTransformOptions): string {
|
|
182
|
+
// effect=removebg forces PNG output server-side (needs alpha).
|
|
183
|
+
if (opts.effect === "removebg") return "png";
|
|
184
|
+
// effect=genfill defaults to WebP (12× lighter than the raw Flux-Fill
|
|
185
|
+
// PNG output with no visible loss at q=85). Explicit `format=png`
|
|
186
|
+
// opts back into lossless for print / marketing fold-outs. The server
|
|
187
|
+
// re-encodes Flux's PNG → target format before R2 cache.
|
|
188
|
+
if (opts.effect === "genfill") {
|
|
189
|
+
switch (opts.format) {
|
|
190
|
+
case "png":
|
|
191
|
+
return "png";
|
|
192
|
+
case "avif":
|
|
193
|
+
return "avif";
|
|
194
|
+
case "jpeg":
|
|
195
|
+
return "jpg";
|
|
196
|
+
default:
|
|
197
|
+
return "webp";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
switch (opts.format) {
|
|
201
|
+
case "avif":
|
|
202
|
+
return "avif";
|
|
203
|
+
case "jpeg":
|
|
204
|
+
return "jpg";
|
|
205
|
+
case "png":
|
|
206
|
+
return "png";
|
|
207
|
+
case "mp4":
|
|
208
|
+
return "mp4";
|
|
209
|
+
case "webm":
|
|
210
|
+
return "webm";
|
|
211
|
+
case "hls":
|
|
212
|
+
// HLS uses `.m3u8` as the URL extension; the route resolves the
|
|
213
|
+
// master playlist under the cache key prefix.
|
|
214
|
+
return "m3u8";
|
|
215
|
+
default:
|
|
216
|
+
// "webp" / "auto" / undefined / anything new
|
|
217
|
+
return "webp";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Build a transform URL for a VIDEO asset. Same DSL shape as image
|
|
223
|
+
* transforms; the server branches on the asset's `kind` column. Video
|
|
224
|
+
* URLs use `.mp4` (default) or `.webm` extension and on cache miss the
|
|
225
|
+
* server returns 202 Accepted while a Cloud Run Job encodes the clip;
|
|
226
|
+
* subsequent GETs return 302 to the cached R2 object.
|
|
227
|
+
*
|
|
228
|
+
* <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}
|
|
229
|
+
* autoPlay muted loop playsInline />
|
|
230
|
+
*/
|
|
231
|
+
export function getVideoTransformUrl(
|
|
232
|
+
asset: Pick<AssetDTO, "sha">,
|
|
233
|
+
opts: TransformOptions,
|
|
234
|
+
): string | null {
|
|
235
|
+
const dsl = serializeTransform(opts);
|
|
236
|
+
if (!dsl) return null;
|
|
237
|
+
const ext = opts.format === "webm" ? "webm" : "mp4";
|
|
238
|
+
return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build an HLS streaming URL for a VIDEO asset (Phase 5). Returns the
|
|
243
|
+
* master.m3u8 entry point — HLS-aware players (Video.js's
|
|
244
|
+
* @videojs/http-streaming, hls.js, native iOS Safari) follow it to
|
|
245
|
+
* fetch the variant playlist + segments at the appropriate bitrate
|
|
246
|
+
* for the connection.
|
|
247
|
+
*
|
|
248
|
+
* ⚠️ A master.m3u8 is NOT a video file. Assigning it to `<video src>`
|
|
249
|
+
* works only where the engine has native HLS; everywhere else it needs
|
|
250
|
+
* an MSE player. And the classic feature test is now WRONG: Chrome 147
|
|
251
|
+
* (April 2026) added native HLS, so `canPlayType("application/vnd.apple.mpegurl")`
|
|
252
|
+
* answers "maybe" there and routes Chrome to the native branch, where it
|
|
253
|
+
* opened a measured 17 s hero at 426x240 for ~8 s. Branch on the ENGINE:
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```ts
|
|
257
|
+
* function prefersNativeHls(video: HTMLVideoElement): boolean {
|
|
258
|
+
* if (video.canPlayType("application/vnd.apple.mpegurl") === "") return false;
|
|
259
|
+
* // Apple's engine, or an engine with no MSE to fall back on (iOS < 17.1).
|
|
260
|
+
* return "ManagedMediaSource" in globalThis || !("MediaSource" in globalThis);
|
|
261
|
+
* }
|
|
262
|
+
*
|
|
263
|
+
* const src = getHlsStreamingUrl(asset);
|
|
264
|
+
* if (prefersNativeHls(video)) {
|
|
265
|
+
* video.src = src;
|
|
266
|
+
* } else {
|
|
267
|
+
* // Defaults open at a fixed low rung — measure instead of guessing.
|
|
268
|
+
* const hls = new Hls({ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 });
|
|
269
|
+
* hls.loadSource(src);
|
|
270
|
+
* hls.attachMedia(video);
|
|
271
|
+
* }
|
|
272
|
+
* ```
|
|
273
|
+
*
|
|
274
|
+
* On first request the server returns 202 Accepted while a Cloud Run
|
|
275
|
+
* Job transcodes the ladder (typically 1-3 min for a 90 s source);
|
|
276
|
+
* subsequent requests get 302 to the cached master.m3u8. Keep the
|
|
277
|
+
* progressive MP4 as a fallback source for that window.
|
|
278
|
+
*
|
|
279
|
+
* The ladder's ceiling is the source the job probes: built at ingest it
|
|
280
|
+
* reads the RAW upload and a 4K master yields 1440p/2160p rungs; rebuilt
|
|
281
|
+
* on demand after the raw is unavailable it reads the `-v.mp4`, which is
|
|
282
|
+
* capped at 1920 wide. `getAssetUrl(sha, "video")` is always <= 1080p.
|
|
283
|
+
*/
|
|
284
|
+
export function getHlsStreamingUrl(
|
|
285
|
+
asset: Pick<AssetDTO, "sha">,
|
|
286
|
+
opts: Omit<TransformOptions, "format"> = {},
|
|
287
|
+
): string {
|
|
288
|
+
// Always serialize with format=hls so the server routes correctly.
|
|
289
|
+
const merged: TransformOptions = { ...opts, format: "hls" };
|
|
290
|
+
const dsl = serializeTransform(merged);
|
|
291
|
+
return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build a transform URL. Returns null when the caller passed no options —
|
|
296
|
+
* callers should prefer the existing variant URL builder in that case so
|
|
297
|
+
* the request hits a pre-generated variant instead of an on-the-fly encode.
|
|
298
|
+
*/
|
|
299
|
+
function buildTransformUrl(
|
|
300
|
+
asset: Pick<AssetDTO, "sha">,
|
|
301
|
+
opts: SignedTransformOptions,
|
|
302
|
+
): string | null {
|
|
303
|
+
const dsl = serializeTransform(opts);
|
|
304
|
+
if (!dsl) return null;
|
|
305
|
+
const ext = extForOptions(opts);
|
|
306
|
+
return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function getTransformUrl(
|
|
310
|
+
asset: Pick<AssetDTO, "sha">,
|
|
311
|
+
opts: TransformOptions,
|
|
312
|
+
): string | null {
|
|
313
|
+
return buildTransformUrl(asset, opts);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build AND sign a transform URL, allowing an off-ladder custom `width`.
|
|
318
|
+
*
|
|
319
|
+
* This is the escape hatch for {@link SignedTransformOptions}: off-ladder
|
|
320
|
+
* widths only pass the edge whitelist when the URL is signed, so building one
|
|
321
|
+
* and signing it must happen together. For on-ladder widths prefer the plain
|
|
322
|
+
* {@link getTransformUrl} (+ {@link signTransformUrl} if you need a signature).
|
|
323
|
+
*
|
|
324
|
+
* Returns `null` only when `opts` serialize to an empty DSL (no transform
|
|
325
|
+
* requested) — same contract as {@link getTransformUrl}.
|
|
326
|
+
*/
|
|
327
|
+
export function getSignedTransformUrl(
|
|
328
|
+
asset: Pick<AssetDTO, "sha">,
|
|
329
|
+
opts: SignedTransformOptions,
|
|
330
|
+
signingKey: string,
|
|
331
|
+
): Promise<string> | null {
|
|
332
|
+
const url = buildTransformUrl(asset, opts);
|
|
333
|
+
if (!url) return null;
|
|
334
|
+
return signTransformUrl(url, signingKey);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Sign a transform URL with the tenant's HMAC signing key. Appends
|
|
339
|
+
* `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).
|
|
340
|
+
*
|
|
341
|
+
* Must agree byte-for-byte with the server's `verifyTransformSignature`.
|
|
342
|
+
* Uses WebCrypto, so works in browsers, Node ≥ 16, Bun, and Workers.
|
|
343
|
+
*
|
|
344
|
+
* The canonical DSL is the one already produced by `serializeTransform`
|
|
345
|
+
* (sort keys + lowercase strings), so signing a URL built by `getTransformUrl`
|
|
346
|
+
* is automatic — the same canonical form is in the URL path.
|
|
347
|
+
*/
|
|
348
|
+
export async function signTransformUrl(
|
|
349
|
+
unsignedUrl: string,
|
|
350
|
+
signingKey: string,
|
|
351
|
+
): Promise<string> {
|
|
352
|
+
const u = new URL(unsignedUrl);
|
|
353
|
+
// Path shape: /t/<dsl>/<filename>
|
|
354
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
355
|
+
// First segment must be "t"; the rest is dsl groups + filename. With
|
|
356
|
+
// Phase 1 we ship a single DSL group; chained groups stay flat for
|
|
357
|
+
// signing purposes (server canonicalizer flattens them too).
|
|
358
|
+
if (parts[0] !== "t" || parts.length < 3) {
|
|
359
|
+
throw new Error(`signTransformUrl: unexpected URL shape ${unsignedUrl}`);
|
|
360
|
+
}
|
|
361
|
+
const filename = parts[parts.length - 1]!;
|
|
362
|
+
const dsl = parts.slice(1, -1).join("/");
|
|
363
|
+
const message = `${dsl}/${filename}`;
|
|
364
|
+
const sig = await hmacSha256Hex(signingKey, message);
|
|
365
|
+
u.searchParams.set("sig", sig);
|
|
366
|
+
return u.toString();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function hmacSha256Hex(key: string, message: string): Promise<string> {
|
|
370
|
+
const enc = new TextEncoder();
|
|
371
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
372
|
+
"raw",
|
|
373
|
+
enc.encode(key),
|
|
374
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
375
|
+
false,
|
|
376
|
+
["sign"],
|
|
377
|
+
);
|
|
378
|
+
const buf = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(message));
|
|
379
|
+
return [...new Uint8Array(buf)]
|
|
380
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
381
|
+
.join("");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Build a responsive `srcSet` string by generating one transform URL per
|
|
386
|
+
* width. All other options apply to every URL.
|
|
387
|
+
*
|
|
388
|
+
* <img
|
|
389
|
+
* src={aq.transform(asset, { width: 800 })!}
|
|
390
|
+
* srcSet={aq.transformSrcSet(asset, [320, 640, 960, 1280])}
|
|
391
|
+
* sizes="(max-width: 768px) 100vw, 50vw"
|
|
392
|
+
* />
|
|
393
|
+
*/
|
|
394
|
+
export function getTransformSrcSet(
|
|
395
|
+
asset: Pick<AssetDTO, "sha">,
|
|
396
|
+
widths: number[],
|
|
397
|
+
extraOpts: Omit<TransformOptions, "width"> = {},
|
|
398
|
+
): string {
|
|
399
|
+
return widths
|
|
400
|
+
.map((w) => {
|
|
401
|
+
// Build via the internal (number-width) builder: `widths` is an explicit
|
|
402
|
+
// responsive ladder the caller chose, so it stays `number[]`. Off-ladder
|
|
403
|
+
// unsigned widths 400 at the edge — the caller's responsibility, exactly
|
|
404
|
+
// as before the width type was tightened.
|
|
405
|
+
const url = buildTransformUrl(asset, { ...extraOpts, width: w });
|
|
406
|
+
return url ? `${url} ${w}w` : null;
|
|
407
|
+
})
|
|
408
|
+
.filter((s): s is string => s != null)
|
|
409
|
+
.join(", ");
|
|
410
|
+
}
|