@nitida/asset-client 0.16.1 → 0.16.3
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 +7 -6
- package/README.md +5 -3
- package/dist/index.cjs +45 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +120 -37
- package/dist/index.d.ts +120 -37
- package/dist/index.js +43 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +154 -24
- package/src/palette.ts +20 -19
- package/src/slots.ts +11 -14
- package/src/transform.ts +35 -22
package/src/index.ts
CHANGED
|
@@ -37,12 +37,19 @@ export type VariantPreset =
|
|
|
37
37
|
| "poster"
|
|
38
38
|
| "video"
|
|
39
39
|
| "aiproxy"
|
|
40
|
+
// The adaptive HLS ladder. Unlike every other preset this is NOT one file:
|
|
41
|
+
// it is a `master.m3u8` plus one media playlist + segment set per rung. It is
|
|
42
|
+
// a preset because the question callers ask about it is the same one they ask
|
|
43
|
+
// about the others — *does this asset have one?* — and `hasPreset` is the
|
|
44
|
+
// place that question is answered. What it HAS, rung by rung, is in the
|
|
45
|
+
// `hls` entry of `variants` ({@link AssetVariant.rungs}).
|
|
46
|
+
| "hls"
|
|
40
47
|
// audio preset — the cross-browser mp3 transcode of a voice note
|
|
41
48
|
// (libmp3lame) emitted alongside the original so chat audio plays on
|
|
42
49
|
// both Chrome/Android (webm/opus) and iOS Safari (which can't decode opus).
|
|
43
50
|
| "mp3";
|
|
44
51
|
|
|
45
|
-
/** 1-char alias used in
|
|
52
|
+
/** 1-char alias used in storage keys / wire `presets` string. */
|
|
46
53
|
export const PRESET_SHORT: Record<VariantPreset, string> = {
|
|
47
54
|
thumb: "q",
|
|
48
55
|
sm: "s",
|
|
@@ -53,7 +60,12 @@ export const PRESET_SHORT: Record<VariantPreset, string> = {
|
|
|
53
60
|
poster: "p",
|
|
54
61
|
video: "v",
|
|
55
62
|
aiproxy: "a",
|
|
56
|
-
//
|
|
63
|
+
// `h` — free in both directions: no other preset claims it, and no
|
|
64
|
+
// multi-character token that `stripMultiCharTokens` removes donates one
|
|
65
|
+
// (`transform-<hex>` → t/r/a/n/s/f/o/m + a–f; `pr`; `mp3`). So a server that
|
|
66
|
+
// starts emitting `h` cannot make an OLDER client answer `true` to anything.
|
|
67
|
+
hls: "h",
|
|
68
|
+
// 3 chars, NOT a 1-char alias: the server has no short-form for
|
|
57
69
|
// audio so its `shortPreset("mp3")` falls through to the literal token,
|
|
58
70
|
// and the deployed server already writes the `-mp3.mp3` variant + emits
|
|
59
71
|
// the bare `mp3` token in the wire `presets` string. Must stay in lockstep.
|
|
@@ -74,6 +86,9 @@ export const PRESET_EXT: Record<VariantPreset, string> = {
|
|
|
74
86
|
poster: "webp",
|
|
75
87
|
video: "mp4",
|
|
76
88
|
aiproxy: "mp4",
|
|
89
|
+
// The ladder's ENTRY file. Never used to build a key — see `getAssetUrl`,
|
|
90
|
+
// which refuses to derive `<sha>-h.m3u8` because no such object exists.
|
|
91
|
+
hls: "m3u8",
|
|
77
92
|
mp3: "mp3",
|
|
78
93
|
};
|
|
79
94
|
|
|
@@ -88,6 +103,10 @@ export const PRESET_MAX_DIM: Record<VariantPreset, number | null> = {
|
|
|
88
103
|
poster: null,
|
|
89
104
|
video: null,
|
|
90
105
|
aiproxy: null,
|
|
106
|
+
// A ladder has no single max side — it has a rung per size. `null` keeps it
|
|
107
|
+
// out of `getAssetSrcSet`, where offering an `.m3u8` as an `<img>` candidate
|
|
108
|
+
// would be nonsense. Its ceiling is `variants.find(v => v.preset === "hls").height`.
|
|
109
|
+
hls: null,
|
|
91
110
|
// audio has no pixel dimensions; `null` keeps mp3 out of the
|
|
92
111
|
// dimension-based `getAssetSrcSet` / `computeVariantDimensions` logic.
|
|
93
112
|
mp3: null,
|
|
@@ -102,11 +121,11 @@ export type AssetVariant = {
|
|
|
102
121
|
preset: VariantPreset;
|
|
103
122
|
/** Public CDN URL of this variant. */
|
|
104
123
|
url: string;
|
|
105
|
-
/** Pixel width. Absent for `original`-only assets where
|
|
124
|
+
/** Pixel width. Absent for `original`-only assets where image processing was skipped, or for video presets. */
|
|
106
125
|
width?: number;
|
|
107
126
|
/** Pixel height. Same caveat as `width`. */
|
|
108
127
|
height?: number;
|
|
109
|
-
/** Byte size of the variant file
|
|
128
|
+
/** Byte size of the stored variant file. */
|
|
110
129
|
bytes: number;
|
|
111
130
|
/**
|
|
112
131
|
* Where the bytes for this variant came from. Useful for quality
|
|
@@ -120,11 +139,110 @@ export type AssetVariant = {
|
|
|
120
139
|
*
|
|
121
140
|
* Absent on variants written before the trace field existed.
|
|
122
141
|
*/
|
|
123
|
-
sourceFrom?:
|
|
142
|
+
sourceFrom?:
|
|
143
|
+
| VariantPreset
|
|
144
|
+
| "upload"
|
|
145
|
+
// Written by the on-demand `/t/` route (`transform-<hash>` entries).
|
|
146
|
+
| "transform-route"
|
|
147
|
+
// The two ways an `hls` entry comes to exist: written by the transcode that
|
|
148
|
+
// produced the ladder, or read back off the CDN by the backfill/self-heal.
|
|
149
|
+
| "hls-transcode"
|
|
150
|
+
| "cdn-probe";
|
|
124
151
|
/** ISO timestamp this variant was written. Absent on pre-trace variants. */
|
|
125
152
|
createdAt?: string;
|
|
153
|
+
/**
|
|
154
|
+
* **`preset: "hls"` only** — the ladder's rungs, in MASTER ORDER.
|
|
155
|
+
*
|
|
156
|
+
* The reason this exists: an entry that only says *there is a ladder* leaves
|
|
157
|
+
* a consumer that plans a composition exactly as blind as no entry at all,
|
|
158
|
+
* because the ceiling of a composition is its weakest ingredient and there is
|
|
159
|
+
* no upscale. Before this field the only way to learn a clip's real rungs was
|
|
160
|
+
* to fetch the master playlist — or worse, download the asset.
|
|
161
|
+
*
|
|
162
|
+
* `rungs[0]` is the rung every client OPENS on (RFC 8216 §6.3.4 for native
|
|
163
|
+
* HLS; hls.js with `startLevel` unset uses "the first level in the
|
|
164
|
+
* manifest"), so the order is a delivery fact — do not sort it in place.
|
|
165
|
+
*
|
|
166
|
+
* Use {@link hlsLadderAlignment} rather than eyeballing `segments`: a ladder
|
|
167
|
+
* can be complete and still unable to adapt.
|
|
168
|
+
*/
|
|
169
|
+
rungs?: HlsRung[];
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* One rung of an adaptive HLS ladder.
|
|
174
|
+
*
|
|
175
|
+
* `segments` / `durationSec` are what make a ladder JUDGEABLE rather than
|
|
176
|
+
* merely present. Measured on a prod ladder of an 87 s 4K source: 240p cut at
|
|
177
|
+
* 14.35 s in 12 segments, 720p at 5.63 s in 12, 2160p at 5.88 s in 14. Cuts
|
|
178
|
+
* that do not line up cannot be swapped, and a swap is what a rendition switch
|
|
179
|
+
* IS — so that ladder looked complete and adapted badly. One segment means zero
|
|
180
|
+
* switch points: whichever rung the player opens on is the rung it finishes on.
|
|
181
|
+
*
|
|
182
|
+
* Both are optional because a rung whose playlist could not be read is recorded
|
|
183
|
+
* WITHOUT them rather than with a zero — unmeasured and none are different
|
|
184
|
+
* facts, and a `0` there would read as the latter.
|
|
185
|
+
*/
|
|
186
|
+
export type HlsRung = {
|
|
187
|
+
/** Rung directory / identity — `"720p"`. */
|
|
188
|
+
name: string;
|
|
189
|
+
/** `RESOLUTION` from the master playlist. */
|
|
190
|
+
width: number;
|
|
191
|
+
height: number;
|
|
192
|
+
/** `BANDWIDTH` in bits per second. */
|
|
193
|
+
bandwidth: number;
|
|
194
|
+
/** Absolute URL of this rung's media playlist. */
|
|
195
|
+
url: string;
|
|
196
|
+
/** `#EXTINF` count. */
|
|
197
|
+
segments?: number;
|
|
198
|
+
/** Sum of the `#EXTINF` values, seconds. */
|
|
199
|
+
durationSec?: number;
|
|
126
200
|
};
|
|
127
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The ladder of an asset, or `null` when it has none / the DTO does not carry
|
|
204
|
+
* `variants` (the slim list shape never does — use `hasPreset(a, "hls")` there).
|
|
205
|
+
*/
|
|
206
|
+
export function getHlsLadder(
|
|
207
|
+
asset: Pick<AssetDTO, "variants">,
|
|
208
|
+
): AssetVariant | null {
|
|
209
|
+
return asset.variants?.find((v) => v.preset === "hls") ?? null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* What the recorded rungs actually support — computed here, never stored, so
|
|
214
|
+
* one rule serves every consumer and a change to it does not need a backfill.
|
|
215
|
+
*
|
|
216
|
+
* - `switchable`: more than one rung AND more than one segment. False means the
|
|
217
|
+
* player is pinned to whatever rung it opens on for the entire clip.
|
|
218
|
+
* - `aligned`: every measured rung reports the same segment count. `null` means
|
|
219
|
+
* fewer than two rungs were measured — **unknown, not false.** Treating that
|
|
220
|
+
* as `false` rejects ladders nobody looked at.
|
|
221
|
+
* - `ceilingHeight`: the tallest rung. The ceiling of any composition using it.
|
|
222
|
+
*/
|
|
223
|
+
export function hlsLadderAlignment(rungs: HlsRung[]): {
|
|
224
|
+
rungCount: number;
|
|
225
|
+
ceilingHeight: number;
|
|
226
|
+
floorHeight: number;
|
|
227
|
+
switchable: boolean;
|
|
228
|
+
aligned: boolean | null;
|
|
229
|
+
minSegments: number | null;
|
|
230
|
+
} {
|
|
231
|
+
const heights = rungs.map((r) => r.height);
|
|
232
|
+
const measured = rungs
|
|
233
|
+
.map((r) => r.segments)
|
|
234
|
+
.filter((s): s is number => typeof s === "number" && s > 0);
|
|
235
|
+
const minSegments = measured.length > 0 ? Math.min(...measured) : null;
|
|
236
|
+
return {
|
|
237
|
+
rungCount: rungs.length,
|
|
238
|
+
ceilingHeight: heights.length > 0 ? Math.max(...heights) : 0,
|
|
239
|
+
floorHeight: heights.length > 0 ? Math.min(...heights) : 0,
|
|
240
|
+
switchable: rungs.length > 1 && (minSegments ?? 0) > 1,
|
|
241
|
+
aligned: measured.length >= 2 ? new Set(measured).size === 1 : null,
|
|
242
|
+
minSegments,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
128
246
|
/**
|
|
129
247
|
* Compact wire shape — what the server actually sends. Aliases (`w`, `h`,
|
|
130
248
|
* `dur`) are intentional to shave bytes per asset on dense lists.
|
|
@@ -181,8 +299,8 @@ export {
|
|
|
181
299
|
getPaletteCssVars,
|
|
182
300
|
getTextColorForBackground,
|
|
183
301
|
iteratePaletteSwatches,
|
|
184
|
-
relativeLuminance,
|
|
185
302
|
pickAmbientBackground,
|
|
303
|
+
relativeLuminance,
|
|
186
304
|
} from "./palette";
|
|
187
305
|
|
|
188
306
|
import type { AssetPalette } from "./palette";
|
|
@@ -208,7 +326,7 @@ export function getCdnBase(): string {
|
|
|
208
326
|
// Tenant scope
|
|
209
327
|
//
|
|
210
328
|
// Post-May-2026 the CDN serves variants under a tenant-prefixed path
|
|
211
|
-
// `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see
|
|
329
|
+
// `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see the server
|
|
212
330
|
// `variantKey`). Variant URL builders MUST include that prefix or every
|
|
213
331
|
// URL 404s. The tenant id is process-global (one tenant per client/app),
|
|
214
332
|
// set once at boot — `NitidaClient` does this from its `tenantId` option;
|
|
@@ -268,15 +386,13 @@ const ORIGINAL_EXT_BY_MIME: Record<string, string> = {
|
|
|
268
386
|
"video/mp4": "mp4",
|
|
269
387
|
"video/webm": "webm",
|
|
270
388
|
"video/quicktime": "mov",
|
|
271
|
-
// Audio —
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
// 404 y `-o.m4a` da 200.
|
|
389
|
+
// Audio — absent until 2026-08-17. While they were missing this table
|
|
390
|
+
// returned the `bin` sentinel for every voice note, so consumers had to
|
|
391
|
+
// hand-roll the extension themselves. Measured: `-o.bin` 404s, `-o.m4a` 200s.
|
|
275
392
|
//
|
|
276
|
-
// ⚠️
|
|
277
|
-
// `.m4a`
|
|
278
|
-
//
|
|
279
|
-
// corregir por su cuenta.
|
|
393
|
+
// ⚠️ These are keyed by the FULL mime, not the subtype: `audio/mp4` is stored
|
|
394
|
+
// as `.m4a` and `video/mp4` as `.mp4`. A `switch` on the `mp4` subtype cannot
|
|
395
|
+
// tell them apart — a mistake worth not repeating.
|
|
280
396
|
"audio/mpeg": "mpga",
|
|
281
397
|
"audio/mp4": "m4a",
|
|
282
398
|
"audio/x-m4a": "m4a",
|
|
@@ -344,6 +460,19 @@ export function getAssetUrl(
|
|
|
344
460
|
const ext = asset.oext || originalExtForMime(asset.mime);
|
|
345
461
|
return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT.original}.${ext}`;
|
|
346
462
|
}
|
|
463
|
+
if (preset === "hls") {
|
|
464
|
+
// A ladder is a PREFIX (`<sha16>-hls<dslHash>/master.m3u8`), not a
|
|
465
|
+
// `<sha16>-h.m3u8` file, and `<dslHash>` is a server-side hash. Deriving a
|
|
466
|
+
// key from the pattern below would produce a URL that 404s on every asset
|
|
467
|
+
// — the same class of polite lie that once pointed a large share of assets
|
|
468
|
+
// at a `-o.<ext>` that was never written. So: the stored URL when the DTO
|
|
469
|
+
// carries it, else the
|
|
470
|
+
// transform route, which 302s to the master and BUILDS the ladder if it is
|
|
471
|
+
// missing. Both are real; neither is a guess.
|
|
472
|
+
const stored = asset.variants?.find((v) => v.preset === "hls")?.url;
|
|
473
|
+
if (stored) return stored;
|
|
474
|
+
return `${cdnBaseUrl}/t/format=hls/${asset.sha}.m3u8`;
|
|
475
|
+
}
|
|
347
476
|
return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
|
|
348
477
|
}
|
|
349
478
|
|
|
@@ -353,7 +482,7 @@ export function getAssetUrl(
|
|
|
353
482
|
* Reads `dto.presets` — the compact 1-char code string the server sends on EVERY shape, including
|
|
354
483
|
* the slim list/resolver one that carries no `variants` at all. That is why this exists and why it
|
|
355
484
|
* stays the right existence check even now that `GET /assets/:id` really does send `variants`
|
|
356
|
-
* (it did not until 2026-08-17
|
|
485
|
+
* (it did not until the 2026-08-17 deploy).
|
|
357
486
|
*
|
|
358
487
|
* @example
|
|
359
488
|
* ```ts
|
|
@@ -378,7 +507,7 @@ export function hasPreset(
|
|
|
378
507
|
*
|
|
379
508
|
* `presets` is documented as a concatenation of 1-char codes, and membership is
|
|
380
509
|
* a 1-char substring test — so any longer token is a false-positive generator.
|
|
381
|
-
* Servers before the 2026-08-17 deploy emitted several
|
|
510
|
+
* Servers before the 2026-08-17 deploy emitted several:
|
|
382
511
|
*
|
|
383
512
|
* | token in the string | letters it donates | presets it falsely answers |
|
|
384
513
|
* |---|---|---|
|
|
@@ -386,14 +515,15 @@ export function hasPreset(
|
|
|
386
515
|
* | `pr` (probe) | p r | `poster` |
|
|
387
516
|
* | `mp3` (audio) | m p | `md` `poster` — stripped here since forever |
|
|
388
517
|
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* — which builds a `-o.<ext>`
|
|
392
|
-
*
|
|
518
|
+
* This is not theoretical: on a corpus written by pre-2026-08-17 servers the
|
|
519
|
+
* majority of rows carried a polluted string, and a large minority of those
|
|
520
|
+
* were told they have an `original` they do not — which builds a `-o.<ext>`
|
|
521
|
+
* URL that 404s. `aiproxy` is affected at the same order of magnitude, `sm`
|
|
522
|
+
* and `md` far less, and the `pr` → `poster` collision is real but rare.
|
|
393
523
|
*
|
|
394
|
-
* Current servers no longer emit these, but this stays:
|
|
395
|
-
*
|
|
396
|
-
*
|
|
524
|
+
* Current servers no longer emit these, but this stays: older server deploys
|
|
525
|
+
* keep sending them, and this is the check every guide points at as the
|
|
526
|
+
* reliable one. Order matters — strip the longest tokens first.
|
|
397
527
|
*/
|
|
398
528
|
function stripMultiCharTokens(presets: string): string {
|
|
399
529
|
return presets
|
package/src/palette.ts
CHANGED
|
@@ -100,8 +100,8 @@ export function relativeLuminance(hex: string): number {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
103
|
+
* WCAG contrast ratio between two luminances: `(L1 + 0.05) / (L2 + 0.05)`,
|
|
104
|
+
* lighter on top. 1 = identical, 21 = black against white.
|
|
105
105
|
*/
|
|
106
106
|
export function contrastRatio(l1: number, l2: number): number {
|
|
107
107
|
const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];
|
|
@@ -109,37 +109,38 @@ export function contrastRatio(l1: number, l2: number): number {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
*
|
|
112
|
+
* Black or white — whichever **actually** contrasts more against this
|
|
113
|
+
* background.
|
|
113
114
|
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
115
|
+
* The tie point is NOT `L > 0.5`, which is the threshold most implementations
|
|
116
|
+
* reach for. Equating the two contrast ratios gives it exactly:
|
|
116
117
|
*
|
|
117
118
|
* (1 + 0.05) / (L + 0.05) = (L + 0.05) / 0.05
|
|
118
119
|
* (L + 0.05)² = 0.0525
|
|
119
120
|
* L = 0.1791…
|
|
120
121
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* negro da **7,84:1**. 2,68 no pasa AA ni para texto grande.
|
|
122
|
+
* Between **0.179 and 0.5** a `L > 0.5` test picks WHITE while black contrasts
|
|
123
|
+
* more — a wide band, and exactly where saturated brand colours land. A
|
|
124
|
+
* mid-luminance gold in that band can be handed white at **2.68:1** when black
|
|
125
|
+
* would give **7.84:1**; 2.68 does not pass AA even for large text.
|
|
126
126
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
127
|
+
* So this does not swap one threshold for another: it **computes both ratios
|
|
128
|
+
* and returns the winner**. A threshold is a constant somebody has to keep
|
|
129
|
+
* correct; the comparison is correct by construction.
|
|
130
130
|
*
|
|
131
|
-
* ⚠️
|
|
132
|
-
*
|
|
133
|
-
*
|
|
131
|
+
* ⚠️ This picks the BETTER of two — it does not guarantee the result is
|
|
132
|
+
* enough. Over a mid-luminance background the best pair can still land under
|
|
133
|
+
* 4.5:1. Use {@link bestTextContrast} when you need to know: it returns the
|
|
134
|
+
* ratio it achieved.
|
|
134
135
|
*/
|
|
135
136
|
function textColorForHex(hex: string): "#000000" | "#FFFFFF" {
|
|
136
137
|
return bestTextContrast(hex).color;
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
141
|
+
* Same choice as the recommended text colour, but it also returns **the ratio
|
|
142
|
+
* it achieved** and whether that passes AA — so a caller can decide with the
|
|
143
|
+
* number in front of them instead of assuming it was enough.
|
|
143
144
|
*/
|
|
144
145
|
export function bestTextContrast(hex: string): {
|
|
145
146
|
color: "#000000" | "#FFFFFF";
|
package/src/slots.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Slots give tenants a way to attach stable, human-readable names
|
|
5
5
|
* ("webapp.wizard.pool-type.icon-1", "storefront.cr.hero-video.landscape_hd_16x9.mp4")
|
|
6
6
|
* to assets they uploaded. Consumers resolve names → AssetDTOs at
|
|
7
|
-
* build / runtime so their source never hardcodes a CDN URL;
|
|
8
|
-
* admin rebinds a slot
|
|
9
|
-
* up the swap on cache refresh.
|
|
7
|
+
* build / runtime so their source never hardcodes a CDN URL; an
|
|
8
|
+
* admin rebinds a slot in the platform console and every consumer
|
|
9
|
+
* picks up the swap on cache refresh.
|
|
10
10
|
*
|
|
11
11
|
* Two layers in this package:
|
|
12
12
|
* - `resolveSlot` / `resolveSlots` — universal (server, edge,
|
|
@@ -20,7 +20,7 @@ import type { AssetDTO, VariantPreset } from "./index";
|
|
|
20
20
|
import { getAssetUrl, hasPreset } from "./index";
|
|
21
21
|
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
|
-
// Wire shape — matches
|
|
23
|
+
// Wire shape — matches the server's slots route.
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
|
|
26
26
|
export type SlotDTO = {
|
|
@@ -53,14 +53,11 @@ export type SlotResolution = {
|
|
|
53
53
|
const DEFAULT_TTL_MS = 60_000;
|
|
54
54
|
const cache = new Map<string, { fetchedAt: number; value: SlotDTO | null }>();
|
|
55
55
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
// (and on 2026-08-17 we learned how many ways those two can go sideways —
|
|
62
|
-
// doc 242 §6.3). Both names hit the same service and return identical bodies.
|
|
63
|
-
let endpoint = "https://aquienpz-asset-manager-nlchzy26qa-uc.a.run.app";
|
|
56
|
+
// The published API host — the same address the docs give out, so a caller
|
|
57
|
+
// that configured nothing still talks to the documented endpoint. Override it
|
|
58
|
+
// with `configureSlotResolver({ endpoint })` to point at a different
|
|
59
|
+
// deployment.
|
|
60
|
+
let endpoint = "https://api.nitida.gofuture.space";
|
|
64
61
|
let apiKey: string | null = null;
|
|
65
62
|
let tenantCode: string | null = null;
|
|
66
63
|
|
|
@@ -70,8 +67,8 @@ let tenantCode: string | null = null;
|
|
|
70
67
|
*
|
|
71
68
|
* configureSlotResolver({
|
|
72
69
|
* endpoint: process.env.AQUIENPZ_URL,
|
|
73
|
-
* apiKey: process.env.
|
|
74
|
-
* tenantCode: "
|
|
70
|
+
* apiKey: process.env.AQUIENPZ_API_KEY, // amk_rt_* — server-only
|
|
71
|
+
* tenantCode: "acme-co",
|
|
75
72
|
* });
|
|
76
73
|
*/
|
|
77
74
|
export function configureSlotResolver(opts: {
|
package/src/transform.ts
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* On-the-fly transform URL builder.
|
|
3
3
|
*
|
|
4
4
|
* Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated
|
|
5
|
-
* here hashes to the same
|
|
5
|
+
* here hashes to the same cache key as the server's canonical form.
|
|
6
6
|
*
|
|
7
|
-
* Canonicalization rules (
|
|
8
|
-
* `apps/asset-manager/src/features/assets/transform.dsl.ts`):
|
|
7
|
+
* Canonicalization rules (kept in sync with the server):
|
|
9
8
|
* - Drop entries whose value is `undefined`
|
|
10
9
|
* - Sort keys alphabetically
|
|
11
10
|
* - Numbers rendered without leading zeros or trailing dots
|
|
@@ -23,7 +22,7 @@ import type { AssetDTO } from "./index";
|
|
|
23
22
|
import { getCdnBase } from "./index";
|
|
24
23
|
|
|
25
24
|
/**
|
|
26
|
-
* Widths the CDN edge whitelists (DoS guard
|
|
25
|
+
* Widths the CDN edge whitelists (DoS guard).
|
|
27
26
|
* Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the
|
|
28
27
|
* 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import
|
|
29
28
|
* this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.
|
|
@@ -77,9 +76,9 @@ export type TransformOptions = {
|
|
|
77
76
|
height?: number;
|
|
78
77
|
/** Resize fit mode. Default `cover` server-side. */
|
|
79
78
|
fit?: TransformFit;
|
|
80
|
-
/** Crop gravity. `auto`
|
|
79
|
+
/** Crop gravity. `auto` picks the region with the most visual salience. */
|
|
81
80
|
gravity?: TransformGravity;
|
|
82
|
-
/** Output format. `auto` → policy decides
|
|
81
|
+
/** Output format. `auto` → the platform's policy decides. */
|
|
83
82
|
format?: TransformFormat;
|
|
84
83
|
/** Output quality. `auto` → format-specific default. */
|
|
85
84
|
quality?: "auto" | number;
|
|
@@ -90,16 +89,15 @@ export type TransformOptions = {
|
|
|
90
89
|
*
|
|
91
90
|
* - `removebg`: remove the background; output is a transparent PNG
|
|
92
91
|
* of the foreground subject. Forces `format=png` regardless of
|
|
93
|
-
* other format hints.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* from R2 — no inference, no per-image cost.
|
|
92
|
+
* other format hints. A single cache miss per (sha, dsl) tuple;
|
|
93
|
+
* subsequent identical DSLs serve from cache — no inference, no
|
|
94
|
+
* per-image cost.
|
|
97
95
|
*
|
|
98
|
-
* - `genfill`: aspect-extension outpaint
|
|
99
|
-
*
|
|
96
|
+
* - `genfill`: aspect-extension outpaint. Requires BOTH `width`
|
|
97
|
+
* and `height` — the server
|
|
100
98
|
* fits the source centered into the target canvas and outpaints
|
|
101
99
|
* the gutters. Output is PNG (forced) at exactly target dims.
|
|
102
|
-
* ~$0.05/image first time; same
|
|
100
|
+
* ~$0.05/image first time; same cache as removebg after.
|
|
103
101
|
* Primary use case: building OG cards (1200×630) from portrait
|
|
104
102
|
* listing photos without awkward edge mirroring.
|
|
105
103
|
*/
|
|
@@ -121,7 +119,7 @@ export type TransformOptions = {
|
|
|
121
119
|
* the escape hatch for SIGNED URLs that need an off-ladder custom width.
|
|
122
120
|
*
|
|
123
121
|
* The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid
|
|
124
|
-
* `?sig=` earns the whitelist bypass at the
|
|
122
|
+
* `?sig=` earns the whitelist bypass at the edge (the server still
|
|
125
123
|
* does the real HMAC check). So a custom width is ONLY safe when the URL is
|
|
126
124
|
* signed — hence this type is accepted exclusively by the signing helpers
|
|
127
125
|
* ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),
|
|
@@ -181,10 +179,10 @@ export function serializeTransform(opts: SignedTransformOptions): string {
|
|
|
181
179
|
function extForOptions(opts: SignedTransformOptions): string {
|
|
182
180
|
// effect=removebg forces PNG output server-side (needs alpha).
|
|
183
181
|
if (opts.effect === "removebg") return "png";
|
|
184
|
-
// effect=genfill defaults to WebP (12× lighter than the raw
|
|
182
|
+
// effect=genfill defaults to WebP (12× lighter than the raw generated
|
|
185
183
|
// PNG output with no visible loss at q=85). Explicit `format=png`
|
|
186
184
|
// opts back into lossless for print / marketing fold-outs. The server
|
|
187
|
-
// re-encodes
|
|
185
|
+
// re-encodes the generated PNG → target format before caching.
|
|
188
186
|
if (opts.effect === "genfill") {
|
|
189
187
|
switch (opts.format) {
|
|
190
188
|
case "png":
|
|
@@ -222,8 +220,8 @@ function extForOptions(opts: SignedTransformOptions): string {
|
|
|
222
220
|
* Build a transform URL for a VIDEO asset. Same DSL shape as image
|
|
223
221
|
* transforms; the server branches on the asset's `kind` column. Video
|
|
224
222
|
* URLs use `.mp4` (default) or `.webm` extension and on cache miss the
|
|
225
|
-
* server returns 202 Accepted while a
|
|
226
|
-
* subsequent GETs return 302 to the cached
|
|
223
|
+
* server returns 202 Accepted while a background job encodes the clip;
|
|
224
|
+
* subsequent GETs return 302 to the cached object.
|
|
227
225
|
*
|
|
228
226
|
* <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}
|
|
229
227
|
* autoPlay muted loop playsInline />
|
|
@@ -264,15 +262,30 @@ export function getVideoTransformUrl(
|
|
|
264
262
|
* if (prefersNativeHls(video)) {
|
|
265
263
|
* video.src = src;
|
|
266
264
|
* } else {
|
|
267
|
-
*
|
|
268
|
-
* const hls = new Hls({ startLevel: -1, testBandwidth: true, abrEwmaDefaultEstimate: 1_000_000 });
|
|
265
|
+
* const hls = new Hls({ capLevelToPlayerSize: false, abrEwmaDefaultEstimate: 5_000_000 });
|
|
269
266
|
* hls.loadSource(src);
|
|
270
267
|
* hls.attachMedia(video);
|
|
271
268
|
* }
|
|
272
269
|
* ```
|
|
273
270
|
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
271
|
+
* ⚠️ **Do NOT pass `startLevel: -1` with `testBandwidth: true`.** That pair is
|
|
272
|
+
* documented by hls.js as *"forces the player to download a fragment from the
|
|
273
|
+
* lowest level to establish a bandwidth estimate"* — on a clip short enough to
|
|
274
|
+
* be one segment, the probe IS the whole video, and it plays at the bottom
|
|
275
|
+
* rung from first frame to last. (This doc-comment recommended exactly that
|
|
276
|
+
* until 2026-08-18; a 5.042 s 4K asset was measured being delivered at
|
|
277
|
+
* 426x240 because of it.) Leave `startLevel` unset: hls.js then opens on the
|
|
278
|
+
* FIRST level in the manifest, and the server puts the right one there —
|
|
279
|
+
* a mid rung for long video, the top rung for a clip under 18 s, which is the
|
|
280
|
+
* same rung native HLS opens on per RFC 8216 §6.3.4. The ladder decides; the
|
|
281
|
+
* player should not second-guess it.
|
|
282
|
+
*
|
|
283
|
+
* `capLevelToPlayerSize` is worth disabling explicitly: `@videojs/core`
|
|
284
|
+
* defaults it to `true`, which caps quality to the player's rendered pixel box,
|
|
285
|
+
* so a small inline player is pinned to 240p/360p on any connection.
|
|
286
|
+
*
|
|
287
|
+
* On first request the server returns 202 Accepted while a background
|
|
288
|
+
* job transcodes the ladder (typically 1-3 min for a 90 s source);
|
|
276
289
|
* subsequent requests get 302 to the cached master.m3u8. Keep the
|
|
277
290
|
* progressive MP4 as a fallback source for that window.
|
|
278
291
|
*
|