@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.223

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.
Files changed (121) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-keys.js +3 -3
  3. package/src/lib/server/api-keys.js.map +1 -1
  4. package/src/lib/server/api-v1-resources.d.ts +44 -3
  5. package/src/lib/server/api-v1-resources.js +82 -5
  6. package/src/lib/server/api-v1-resources.js.map +1 -1
  7. package/src/lib/server/collection-live-pages.d.ts +2 -2
  8. package/src/lib/server/collection-live-pages.js +1 -1
  9. package/src/lib/server/collection-live-pages.js.map +1 -1
  10. package/src/lib/server/email-delivery-log.d.ts +17 -73
  11. package/src/lib/server/email-delivery-log.js +44 -222
  12. package/src/lib/server/email-delivery-log.js.map +1 -1
  13. package/src/lib/server/email-flow-gate.js +27 -38
  14. package/src/lib/server/email-flow-gate.js.map +1 -1
  15. package/src/lib/server/erase-person.d.ts +34 -54
  16. package/src/lib/server/erase-person.js +44 -239
  17. package/src/lib/server/erase-person.js.map +1 -1
  18. package/src/lib/server/ga4-measurement-protocol.js +41 -28
  19. package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
  20. package/src/lib/server/host-visitor-records.d.ts +25 -4
  21. package/src/lib/server/host-visitor-records.js +39 -11
  22. package/src/lib/server/host-visitor-records.js.map +1 -1
  23. package/src/lib/server/hosted-page-review.js +52 -36
  24. package/src/lib/server/hosted-page-review.js.map +1 -1
  25. package/src/lib/server/link-reputation-review.d.ts +6 -2
  26. package/src/lib/server/link-reputation-review.js +61 -20
  27. package/src/lib/server/link-reputation-review.js.map +1 -1
  28. package/src/lib/server/live-page-drops.d.ts +67 -0
  29. package/src/lib/server/live-page-drops.js +57 -0
  30. package/src/lib/server/live-page-drops.js.map +1 -0
  31. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  32. package/src/lib/server/media-bandwidth-cap.js +180 -0
  33. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  34. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  35. package/src/lib/server/media-cdn-icon.js +140 -0
  36. package/src/lib/server/media-cdn-icon.js.map +1 -0
  37. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  38. package/src/lib/server/media-delivery-regeneration.js +216 -0
  39. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  40. package/src/lib/server/media-delivery.d.ts +2 -0
  41. package/src/lib/server/media-delivery.js +2 -1
  42. package/src/lib/server/media-delivery.js.map +1 -1
  43. package/src/lib/server/media-serve-count.d.ts +50 -0
  44. package/src/lib/server/media-serve-count.js +62 -0
  45. package/src/lib/server/media-serve-count.js.map +1 -0
  46. package/src/lib/server/media-signing.d.ts +13 -2
  47. package/src/lib/server/media-signing.js +12 -8
  48. package/src/lib/server/media-signing.js.map +1 -1
  49. package/src/lib/server/media-tombstone.js +7 -5
  50. package/src/lib/server/media-tombstone.js.map +1 -1
  51. package/src/lib/server/media-variants.d.ts +151 -20
  52. package/src/lib/server/media-variants.js +204 -27
  53. package/src/lib/server/media-variants.js.map +1 -1
  54. package/src/lib/server/organizations.d.ts +102 -2
  55. package/src/lib/server/organizations.js +388 -58
  56. package/src/lib/server/organizations.js.map +1 -1
  57. package/src/lib/server/outbound-send-review.js +11 -7
  58. package/src/lib/server/outbound-send-review.js.map +1 -1
  59. package/src/lib/server/paged-sweeps.d.ts +51 -0
  60. package/src/lib/server/paged-sweeps.js +79 -0
  61. package/src/lib/server/paged-sweeps.js.map +1 -0
  62. package/src/lib/server/payment-fraud-signal.d.ts +49 -9
  63. package/src/lib/server/payment-fraud-signal.js +192 -48
  64. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  65. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  66. package/src/lib/server/platform-marketing-consent.js +27 -11
  67. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  68. package/src/lib/server/render-monitor.d.ts +166 -0
  69. package/src/lib/server/render-monitor.js +412 -0
  70. package/src/lib/server/render-monitor.js.map +1 -0
  71. package/src/lib/server/risk-notice.d.ts +7 -0
  72. package/src/lib/server/risk-notice.js +6 -0
  73. package/src/lib/server/risk-notice.js.map +1 -1
  74. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  75. package/src/lib/server/serve-media-cdn.js +424 -70
  76. package/src/lib/server/serve-media-cdn.js.map +1 -1
  77. package/src/lib/server/site-icon-render.d.ts +68 -0
  78. package/src/lib/server/site-icon-render.js +157 -0
  79. package/src/lib/server/site-icon-render.js.map +1 -0
  80. package/src/lib/server/staff-seat.d.ts +50 -0
  81. package/src/lib/server/staff-seat.js +118 -0
  82. package/src/lib/server/staff-seat.js.map +1 -0
  83. package/src/lib/server/transfer-export.d.ts +46 -0
  84. package/src/lib/server/transfer-export.js +221 -0
  85. package/src/lib/server/transfer-export.js.map +1 -0
  86. package/src/lib/server/transfer-jobs.d.ts +385 -0
  87. package/src/lib/server/transfer-jobs.js +2368 -0
  88. package/src/lib/server/transfer-jobs.js.map +1 -0
  89. package/src/lib/server/transfer-packages.d.ts +127 -0
  90. package/src/lib/server/transfer-packages.js +1036 -0
  91. package/src/lib/server/transfer-packages.js.map +1 -0
  92. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  93. package/src/lib/server/upgrade-proposal.js +312 -0
  94. package/src/lib/server/upgrade-proposal.js.map +1 -0
  95. package/src/lib/server/upsert-contact.d.ts +17 -8
  96. package/src/lib/server/upsert-contact.js +82 -16
  97. package/src/lib/server/upsert-contact.js.map +1 -1
  98. package/src/lib/server/web-risk.d.ts +50 -9
  99. package/src/lib/server/web-risk.js +290 -155
  100. package/src/lib/server/web-risk.js.map +1 -1
  101. package/src/tenancy.d.ts +3 -3
  102. package/src/tenancy.js +5 -3
  103. package/src/tenancy.js.map +1 -1
  104. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  105. package/src/lib/server/campaign-attribution-store.js +0 -95
  106. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  107. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  108. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  109. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  110. package/src/lib/server/crm-email-activity.d.ts +0 -118
  111. package/src/lib/server/crm-email-activity.js +0 -148
  112. package/src/lib/server/crm-email-activity.js.map +0 -1
  113. package/src/lib/server/crm-next-activity.d.ts +0 -74
  114. package/src/lib/server/crm-next-activity.js +0 -194
  115. package/src/lib/server/crm-next-activity.js.map +0 -1
  116. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  117. package/src/lib/server/dataset-live-pages.js +0 -421
  118. package/src/lib/server/dataset-live-pages.js.map +0 -1
  119. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  120. package/src/lib/server/email-revenue-attribution.js +0 -321
  121. package/src/lib/server/email-revenue-attribution.js.map +0 -1
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import type { Sharp, SharpOptions } from 'sharp';
17
18
  /**
18
19
  * WebP variant generation for a media asset (AGL-175), and the record of
19
20
  * whether it worked (AGL-1468).
@@ -50,28 +51,104 @@
50
51
  * A partial run reports both: the widths that landed AND the error, because
51
52
  * the widths that landed are real files that the CDN route can serve.
52
53
  */
53
- /** The shape of `sharp`'s default export, narrowed to what is used here. */
54
- type SharpFactory = (input: Buffer) => {
55
- resize(options: {
56
- width: number;
57
- withoutEnlargement: boolean;
58
- }): {
59
- webp(options: {
60
- quality: number;
61
- }): {
62
- toBuffer(): Promise<Buffer>;
63
- };
64
- };
65
- };
54
+ /** The shape of `sharp`'s default export. */
55
+ type SharpFactory = (input?: Buffer, options?: SharpOptions) => Sharp;
56
+ /**
57
+ * What a media document records about its delivery copies' display half
58
+ * (AGL-3486). Present only when a display object was written; its absence on
59
+ * a document at the current encoder generation means the original is clean
60
+ * enough to serve as it is.
61
+ */
62
+ export interface MediaDisplayCopy {
63
+ /** Always the original's own type — the display copy never changes format. */
64
+ contentType: string;
65
+ width: number;
66
+ height: number;
67
+ sizeBytes: number;
68
+ }
66
69
  export interface MediaVariantOutcome {
67
70
  /** Widths actually written. Safe to store — every entry is a real object. */
68
71
  variants: number[];
72
+ /**
73
+ * The display copy written for this asset, when one was needed and made.
74
+ * Only ever produced when the caller asked for one (`display: true`).
75
+ */
76
+ display?: MediaDisplayCopy;
69
77
  /**
70
78
  * Present ONLY when generation was attempted and did not complete.
71
79
  * `undefined` means nothing went wrong, which includes "nothing to do".
72
80
  */
73
81
  error?: string;
74
82
  }
83
+ /**
84
+ * Writes one derived object. `contentType` is the type the bytes ARE: WebP
85
+ * for a variant, the original's own type for a display copy.
86
+ */
87
+ export type SaveMediaDerivedObject = (path: string, bytes: Buffer, contentType: string) => Promise<void>;
88
+ /**
89
+ * The WebP encoder settings for every `?w=` variant (AGL-3486).
90
+ *
91
+ * Quality 72 is where a photo stops showing a difference to the eye at the
92
+ * size it is painted; 80, the setting since AGL-175, spent 15-25% more bytes
93
+ * on detail nobody sees. Effort 5 of 6: measured on the four images the issue
94
+ * names, effort 6 saved another 1-2% for twice the encode time, and the
95
+ * encode runs inside an upload request or after a CDN response.
96
+ *
97
+ * Changing either value changes the bytes every asset should hold, so it
98
+ * bumps `MEDIA_VARIANT_ENCODER_VERSION` — that is what reaches the existing
99
+ * corpus.
100
+ */
101
+ export declare const MEDIA_VARIANT_WEBP_OPTIONS: Readonly<{
102
+ quality: 72;
103
+ effort: 5;
104
+ }>;
105
+ /**
106
+ * The quality the display copy is re-encoded at (AGL-3486). Higher than a
107
+ * variant's because the display copy is what a link preview, an email client
108
+ * and a CSS background receive at full size — it stands in for the original.
109
+ */
110
+ export declare const MEDIA_DISPLAY_QUALITY = 82;
111
+ /**
112
+ * Formats that get a display copy. JPEG, PNG and WebP are the formats a
113
+ * camera, a screenshot tool and a design export produce, and the ones `sharp`
114
+ * re-encodes losslessly enough to stand in for the original. Not GIF, whose
115
+ * animation the encoder would flatten, and not SVG or ICO, which have no
116
+ * variants at all.
117
+ */
118
+ export declare const MEDIA_DISPLAY_TYPES: ReadonlySet<string>;
119
+ /**
120
+ * Encode one `?w=` variant: auto-oriented, downscaled, metadata stripped.
121
+ *
122
+ * `rotate()` with no angle applies the EXIF orientation, which a WebP cannot
123
+ * carry — before AGL-3486 a portrait phone photo's variants came out lying on
124
+ * their side. `sharp` writes no EXIF, XMP or IPTC unless asked to keep it, so
125
+ * a photo's GPS position never reaches a variant.
126
+ */
127
+ export declare function encodeMediaVariant(sharp: SharpFactory, source: Buffer, width: number): Promise<Buffer>;
128
+ /**
129
+ * The display copy of an image, or null when the original can be served as
130
+ * it is (AGL-3486).
131
+ *
132
+ * The bare URL is what a link preview, an email client, a CSS background and
133
+ * every pasted Copy URL fetch, and before this it served the upload byte for
134
+ * byte: a 6 MB, 6000-pixel phone photo with the GPS position it was taken at.
135
+ * A display copy is made when the original has any of three properties:
136
+ *
137
+ * - **larger than {@link MEDIA_DELIVERY_MAX_EDGE} on its long edge** —
138
+ * downscaled to fit, since nothing on a page paints more;
139
+ * - **EXIF, XMP or IPTC metadata** — stripped. This is the privacy half, and
140
+ * it is kept even when the re-encode is not smaller: a location is not
141
+ * something to trade for bytes;
142
+ * - **an EXIF orientation** — applied, so the pixels are upright without it.
143
+ *
144
+ * Same format as the original, so nothing that could read the original loses
145
+ * the ability to read the copy. Null for an animated image, whose frames the
146
+ * encoder would drop, and for a format outside {@link MEDIA_DISPLAY_TYPES}.
147
+ */
148
+ export declare function encodeMediaDisplay(sharp: SharpFactory, source: Buffer, contentType: string): Promise<{
149
+ bytes: Buffer;
150
+ copy: MediaDisplayCopy;
151
+ } | null>;
75
152
  /**
76
153
  * `image/*` types that produce no variants, for two different reasons.
77
154
  *
@@ -85,9 +162,10 @@ export interface MediaVariantOutcome {
85
162
  * outcome, and it has to read as one or the fault counter measures the favicon
86
163
  * instead of a fault (AGL-3121).
87
164
  *
88
- * Exported because the backfill script has to skip exactly this set. It kept a
89
- * second copy of the SVG half, which is how ICO came to be missing from one
90
- * and not the other.
165
+ * Exported because lazy regeneration (`media-delivery-regeneration.ts`) has to
166
+ * skip exactly this set. The backfill script it replaced kept a second copy of
167
+ * the SVG half, which is how ICO came to be missing from one and not the
168
+ * other.
91
169
  */
92
170
  export declare const MEDIA_TYPES_WITHOUT_VARIANTS: ReadonlySet<string>;
93
171
  /**
@@ -143,12 +221,25 @@ export declare function loadSharp(): Promise<SharpFactory>;
143
221
  */
144
222
  export declare function classifyLoadFailure(error: unknown): string;
145
223
  /**
146
- * Generate the WebP variants for one asset and report the outcome.
224
+ * Generate the delivery copies for one asset and report the outcome: its
225
+ * WebP variants, and with `display: true` its display copy (AGL-3486).
147
226
  *
148
227
  * `saveVariant` is a callback rather than a `Bucket` so this stays free of a
149
228
  * storage dependency and so a spec can assert on the BYTES it produces —
150
229
  * which is the only assertion that distinguishes a working variant from the
151
230
  * original served back under a `?w=` that selects nothing.
231
+ *
232
+ * ## A variant is never larger than what it replaces
233
+ *
234
+ * A width the asset has no variant for is answered with the display copy, or
235
+ * the original when there is none — so a variant is only worth storing when
236
+ * it is SMALLER than that answer. Measured on a customer's 1280px JPEGs before
237
+ * AGL-3486: the source-width WebP at `?w=1280` was 384 KB against a 305 KB
238
+ * original, so the variant made every retina visit 26% heavier. A width whose
239
+ * encode comes out no smaller is now skipped and the smaller bytes serve it.
240
+ *
241
+ * The display copy is made FIRST for that reason: it is what the comparison
242
+ * is against.
152
243
  */
153
244
  export declare function generateMediaVariants(options: {
154
245
  buffer: Buffer;
@@ -156,8 +247,46 @@ export declare function generateMediaVariants(options: {
156
247
  sourceWidth?: number | null;
157
248
  /** Object path of the ORIGINAL; variants are `${objectPath}__w{n}.webp`. */
158
249
  objectPath: string;
159
- saveVariant: (path: string, webp: Buffer) => Promise<void>;
250
+ saveVariant: SaveMediaDerivedObject;
251
+ /** Also make the display copy. Asked for by an asset, never by a poster. */
252
+ display?: boolean;
160
253
  }): Promise<MediaVariantOutcome>;
254
+ /**
255
+ * The media document fields a generation outcome writes (AGL-3486), shared by
256
+ * every route that generates so none of them can forget the encoder
257
+ * generation — the field lazy regeneration keys on.
258
+ *
259
+ * `display` is written as `null` rather than left out when there is none, so
260
+ * a replace whose new bytes need no display copy clears the previous one's
261
+ * record instead of pointing at an object it just deleted. The caller decides
262
+ * `variantsError`, because the routes record it with a counter beside it.
263
+ */
264
+ export declare function mediaVariantDocFields(outcome: Pick<MediaVariantOutcome, 'variants' | 'display'>): {
265
+ variants: number[];
266
+ variantEncoderVersion: number;
267
+ display: MediaDisplayCopy | null;
268
+ };
269
+ /**
270
+ * Every derived object a media document names, given its original's path
271
+ * (AGL-3486): the `?w=` variants, the display copy, a video's poster and the
272
+ * poster's widths, and its renditions.
273
+ *
274
+ * The one list every path that moves, replaces or deletes an asset reads, so
275
+ * a new kind of derived object is added in one place. Before it, each of the
276
+ * three kept its own: a replace dropped all four kinds, while a folder move
277
+ * and a delete knew only the variants — so a moved film lost its poster and
278
+ * renditions to the old prefix, and a deleted one left them in the bucket.
279
+ */
280
+ export declare function mediaDerivedObjectPaths(objectPath: string,
281
+ /** The media document's fields, or its snapshot. */
282
+ document: {
283
+ variants?: unknown;
284
+ display?: unknown;
285
+ poster?: unknown;
286
+ videoRenditions?: unknown;
287
+ } | {
288
+ get(field: string): unknown;
289
+ }): string[];
161
290
  /**
162
291
  * The largest object this will pull back OUT of storage in order to generate.
163
292
  *
@@ -210,8 +339,10 @@ export declare function generateStoredMediaVariants(options: {
210
339
  objectPath: string;
211
340
  /** Fetches the whole object. Only ever called when there is work to do. */
212
341
  readSource: () => Promise<Buffer>;
213
- saveVariant: (path: string, webp: Buffer) => Promise<void>;
342
+ saveVariant: SaveMediaDerivedObject;
214
343
  maxSourceBytes?: number;
344
+ /** Also make the display copy (see {@link generateMediaVariants}). */
345
+ display?: boolean;
215
346
  }): Promise<MediaVariantOutcome>;
216
347
  /**
217
348
  * Can this deployment produce a variant at all?
@@ -303,7 +434,7 @@ export declare function generateMediaPoster(options: {
303
434
  buffer: Buffer;
304
435
  /** Object path of the ORIGINAL video, without the poster suffix. */
305
436
  objectPath: string;
306
- saveVariant: (path: string, bytes: Buffer) => Promise<void>;
437
+ saveVariant: SaveMediaDerivedObject;
307
438
  maxWidth?: number;
308
439
  }): Promise<MediaPosterOutcome>;
309
440
  export {};
@@ -14,8 +14,120 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { MEDIA_POSTER_OBJECT_SUFFIX, mediaPosterObjectPath, readImageDimensions } from "@aglyn/aglyn/server";
18
- import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
17
+ */ import { readImageDimensions } from "@aglyn/aglyn/server";
18
+ // By path: the media-ref grammar is a leaf, and the routes that spread this
19
+ // module's helpers into a document reach it the same way.
20
+ import { MEDIA_CDN_VARIANT_WIDTHS, MEDIA_DELIVERY_MAX_EDGE, MEDIA_POSTER_OBJECT_SUFFIX, MEDIA_VARIANT_ENCODER_VERSION, mediaDisplayObjectPath, mediaPosterObjectPath, mediaRenditionObjectPath, parseMediaRenditions } from "@aglyn/aglyn/app-utils/media-ref";
21
+ /**
22
+ * The WebP encoder settings for every `?w=` variant (AGL-3486).
23
+ *
24
+ * Quality 72 is where a photo stops showing a difference to the eye at the
25
+ * size it is painted; 80, the setting since AGL-175, spent 15-25% more bytes
26
+ * on detail nobody sees. Effort 5 of 6: measured on the four images the issue
27
+ * names, effort 6 saved another 1-2% for twice the encode time, and the
28
+ * encode runs inside an upload request or after a CDN response.
29
+ *
30
+ * Changing either value changes the bytes every asset should hold, so it
31
+ * bumps `MEDIA_VARIANT_ENCODER_VERSION` — that is what reaches the existing
32
+ * corpus.
33
+ */ export const MEDIA_VARIANT_WEBP_OPTIONS = Object.freeze({
34
+ quality: 72,
35
+ effort: 5
36
+ });
37
+ /**
38
+ * The quality the display copy is re-encoded at (AGL-3486). Higher than a
39
+ * variant's because the display copy is what a link preview, an email client
40
+ * and a CSS background receive at full size — it stands in for the original.
41
+ */ export const MEDIA_DISPLAY_QUALITY = 82;
42
+ /**
43
+ * Formats that get a display copy. JPEG, PNG and WebP are the formats a
44
+ * camera, a screenshot tool and a design export produce, and the ones `sharp`
45
+ * re-encodes losslessly enough to stand in for the original. Not GIF, whose
46
+ * animation the encoder would flatten, and not SVG or ICO, which have no
47
+ * variants at all.
48
+ */ export const MEDIA_DISPLAY_TYPES = new Set([
49
+ 'image/jpeg',
50
+ 'image/jpg',
51
+ 'image/png',
52
+ 'image/webp'
53
+ ]);
54
+ /**
55
+ * Encode one `?w=` variant: auto-oriented, downscaled, metadata stripped.
56
+ *
57
+ * `rotate()` with no angle applies the EXIF orientation, which a WebP cannot
58
+ * carry — before AGL-3486 a portrait phone photo's variants came out lying on
59
+ * their side. `sharp` writes no EXIF, XMP or IPTC unless asked to keep it, so
60
+ * a photo's GPS position never reaches a variant.
61
+ */ export async function encodeMediaVariant(sharp, source, width) {
62
+ return sharp(source).rotate().resize({
63
+ width,
64
+ withoutEnlargement: true
65
+ }).webp(MEDIA_VARIANT_WEBP_OPTIONS).toBuffer();
66
+ }
67
+ /**
68
+ * The display copy of an image, or null when the original can be served as
69
+ * it is (AGL-3486).
70
+ *
71
+ * The bare URL is what a link preview, an email client, a CSS background and
72
+ * every pasted Copy URL fetch, and before this it served the upload byte for
73
+ * byte: a 6 MB, 6000-pixel phone photo with the GPS position it was taken at.
74
+ * A display copy is made when the original has any of three properties:
75
+ *
76
+ * - **larger than {@link MEDIA_DELIVERY_MAX_EDGE} on its long edge** —
77
+ * downscaled to fit, since nothing on a page paints more;
78
+ * - **EXIF, XMP or IPTC metadata** — stripped. This is the privacy half, and
79
+ * it is kept even when the re-encode is not smaller: a location is not
80
+ * something to trade for bytes;
81
+ * - **an EXIF orientation** — applied, so the pixels are upright without it.
82
+ *
83
+ * Same format as the original, so nothing that could read the original loses
84
+ * the ability to read the copy. Null for an animated image, whose frames the
85
+ * encoder would drop, and for a format outside {@link MEDIA_DISPLAY_TYPES}.
86
+ */ export async function encodeMediaDisplay(sharp, source, contentType) {
87
+ var _metadata_pages, _metadata_width, _metadata_height, _metadata_orientation;
88
+ if (!MEDIA_DISPLAY_TYPES.has(contentType)) return null;
89
+ const metadata = await sharp(source).metadata();
90
+ if (((_metadata_pages = metadata.pages) != null ? _metadata_pages : 1) > 1) return null;
91
+ const width = (_metadata_width = metadata.width) != null ? _metadata_width : 0;
92
+ const height = (_metadata_height = metadata.height) != null ? _metadata_height : 0;
93
+ const oversize = Math.max(width, height) > MEDIA_DELIVERY_MAX_EDGE;
94
+ const oriented = ((_metadata_orientation = metadata.orientation) != null ? _metadata_orientation : 1) > 1;
95
+ const carriesMetadata = Boolean(metadata.exif || metadata.xmp || metadata.iptc);
96
+ if (!oversize && !oriented && !carriesMetadata) return null;
97
+ let pipeline = sharp(source).rotate();
98
+ if (oversize) {
99
+ pipeline = pipeline.resize({
100
+ width: MEDIA_DELIVERY_MAX_EDGE,
101
+ height: MEDIA_DELIVERY_MAX_EDGE,
102
+ fit: 'inside',
103
+ withoutEnlargement: true
104
+ });
105
+ }
106
+ pipeline = contentType === 'image/png' ? pipeline.png({
107
+ compressionLevel: 9,
108
+ // A palette PNG re-encoded as truecolor can triple in size.
109
+ palette: Boolean(metadata.isPalette)
110
+ }) : contentType === 'image/webp' ? pipeline.webp(_extends({}, MEDIA_VARIANT_WEBP_OPTIONS, {
111
+ quality: MEDIA_DISPLAY_QUALITY
112
+ })) : pipeline.jpeg({
113
+ quality: MEDIA_DISPLAY_QUALITY,
114
+ mozjpeg: true
115
+ });
116
+ const { data, info } = await pipeline.toBuffer({
117
+ resolveWithObject: true
118
+ });
119
+ // Only the privacy reason justifies a copy that is not smaller.
120
+ if (!carriesMetadata && data.length >= source.length) return null;
121
+ return {
122
+ bytes: data,
123
+ copy: {
124
+ contentType,
125
+ width: info.width,
126
+ height: info.height,
127
+ sizeBytes: data.length
128
+ }
129
+ };
130
+ }
19
131
  /**
20
132
  * Formats a WebP copy at the SOURCE'S OWN width beats.
21
133
  *
@@ -49,9 +161,10 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
49
161
  * outcome, and it has to read as one or the fault counter measures the favicon
50
162
  * instead of a fault (AGL-3121).
51
163
  *
52
- * Exported because the backfill script has to skip exactly this set. It kept a
53
- * second copy of the SVG half, which is how ICO came to be missing from one
54
- * and not the other.
164
+ * Exported because lazy regeneration (`media-delivery-regeneration.ts`) has to
165
+ * skip exactly this set. The backfill script it replaced kept a second copy of
166
+ * the SVG half, which is how ICO came to be missing from one and not the
167
+ * other.
55
168
  */ export const MEDIA_TYPES_WITHOUT_VARIANTS = new Set([
56
169
  'image/svg+xml',
57
170
  'image/x-icon',
@@ -143,42 +256,110 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
143
256
  return text.slice(0, 300);
144
257
  }
145
258
  /**
146
- * Generate the WebP variants for one asset and report the outcome.
259
+ * Generate the delivery copies for one asset and report the outcome: its
260
+ * WebP variants, and with `display: true` its display copy (AGL-3486).
147
261
  *
148
262
  * `saveVariant` is a callback rather than a `Bucket` so this stays free of a
149
263
  * storage dependency and so a spec can assert on the BYTES it produces —
150
264
  * which is the only assertion that distinguishes a working variant from the
151
265
  * original served back under a `?w=` that selects nothing.
266
+ *
267
+ * ## A variant is never larger than what it replaces
268
+ *
269
+ * A width the asset has no variant for is answered with the display copy, or
270
+ * the original when there is none — so a variant is only worth storing when
271
+ * it is SMALLER than that answer. Measured on a customer's 1280px JPEGs before
272
+ * AGL-3486: the source-width WebP at `?w=1280` was 384 KB against a 305 KB
273
+ * original, so the variant made every retina visit 26% heavier. A width whose
274
+ * encode comes out no smaller is now skipped and the smaller bytes serve it.
275
+ *
276
+ * The display copy is made FIRST for that reason: it is what the comparison
277
+ * is against.
152
278
  */ export async function generateMediaVariants(options) {
153
279
  const widths = mediaVariantWidthsFor(options);
154
- if (!widths.length) return {
280
+ const wantsDisplay = options.display === true && MEDIA_DISPLAY_TYPES.has(options.contentType);
281
+ if (!widths.length && !wantsDisplay) return {
155
282
  variants: []
156
283
  };
157
284
  const variants = [];
285
+ let display;
158
286
  try {
159
287
  const sharp = await loadSharp();
288
+ let fallbackBytes = options.buffer.length;
289
+ if (wantsDisplay) {
290
+ const made = await encodeMediaDisplay(sharp, options.buffer, options.contentType);
291
+ if (made) {
292
+ await options.saveVariant(mediaDisplayObjectPath(options.objectPath), made.bytes, options.contentType);
293
+ display = made.copy;
294
+ fallbackBytes = made.bytes.length;
295
+ }
296
+ }
160
297
  for (const width of widths){
161
- const webp = await sharp(options.buffer).resize({
162
- width,
163
- withoutEnlargement: true
164
- }).webp({
165
- quality: 80
166
- }).toBuffer();
167
- await options.saveVariant(`${options.objectPath}__w${width}.webp`, webp);
298
+ const webp = await encodeMediaVariant(sharp, options.buffer, width);
299
+ if (webp.length >= fallbackBytes) continue;
300
+ await options.saveVariant(`${options.objectPath}__w${width}.webp`, webp, 'image/webp');
168
301
  variants.push(width);
169
302
  }
170
303
  } catch (error) {
171
304
  // Still never fails the upload — but no longer only whispers.
172
305
  console.error('media variant generation failed', options.objectPath, error);
173
- return {
174
- variants,
306
+ return _extends({
307
+ variants
308
+ }, display ? {
309
+ display
310
+ } : {}, {
175
311
  error: describe(error)
176
- };
312
+ });
177
313
  }
178
- return {
314
+ return _extends({
179
315
  variants
316
+ }, display ? {
317
+ display
318
+ } : {});
319
+ }
320
+ /**
321
+ * The media document fields a generation outcome writes (AGL-3486), shared by
322
+ * every route that generates so none of them can forget the encoder
323
+ * generation — the field lazy regeneration keys on.
324
+ *
325
+ * `display` is written as `null` rather than left out when there is none, so
326
+ * a replace whose new bytes need no display copy clears the previous one's
327
+ * record instead of pointing at an object it just deleted. The caller decides
328
+ * `variantsError`, because the routes record it with a counter beside it.
329
+ */ export function mediaVariantDocFields(outcome) {
330
+ var _outcome_display;
331
+ return {
332
+ variants: outcome.variants,
333
+ variantEncoderVersion: MEDIA_VARIANT_ENCODER_VERSION,
334
+ display: (_outcome_display = outcome.display) != null ? _outcome_display : null
180
335
  };
181
336
  }
337
+ /**
338
+ * Every derived object a media document names, given its original's path
339
+ * (AGL-3486): the `?w=` variants, the display copy, a video's poster and the
340
+ * poster's widths, and its renditions.
341
+ *
342
+ * The one list every path that moves, replaces or deletes an asset reads, so
343
+ * a new kind of derived object is added in one place. Before it, each of the
344
+ * three kept its own: a replace dropped all four kinds, while a folder move
345
+ * and a delete knew only the variants — so a moved film lost its poster and
346
+ * renditions to the old prefix, and a deleted one left them in the bucket.
347
+ */ export function mediaDerivedObjectPaths(objectPath, /** The media document's fields, or its snapshot. */ document) {
348
+ const read = (field)=>'get' in document && typeof document.get === 'function' ? document.get(field) : document[field];
349
+ const widths = (value)=>Array.isArray(value) ? value.filter((width)=>Number.isInteger(width) && width > 0) : [];
350
+ const poster = read('poster');
351
+ return [
352
+ ...widths(read('variants')).map((width)=>`${objectPath}__w${width}.webp`),
353
+ ...read('display') ? [
354
+ mediaDisplayObjectPath(objectPath)
355
+ ] : [],
356
+ ...poster ? [
357
+ mediaPosterObjectPath(objectPath)
358
+ ] : [],
359
+ ...widths(poster == null ? void 0 : poster.variants).map((width)=>`${objectPath}${MEDIA_POSTER_OBJECT_SUFFIX}__w${width}.webp`),
360
+ ...parseMediaRenditions(read('videoRenditions')).map((rendition)=>mediaRenditionObjectPath(objectPath, rendition))
361
+ ];
362
+ }
182
363
  /**
183
364
  * The largest object this will pull back OUT of storage in order to generate.
184
365
  *
@@ -224,7 +405,8 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
224
405
  */ export async function generateStoredMediaVariants(options) {
225
406
  var _options_maxSourceBytes;
226
407
  const widths = mediaVariantWidthsFor(options);
227
- if (!widths.length) return {
408
+ const wantsDisplay = options.display === true && MEDIA_DISPLAY_TYPES.has(options.contentType);
409
+ if (!widths.length && !wantsDisplay) return {
228
410
  variants: []
229
411
  };
230
412
  const maxSourceBytes = (_options_maxSourceBytes = options.maxSourceBytes) != null ? _options_maxSourceBytes : MEDIA_VARIANT_SOURCE_MAX_BYTES;
@@ -288,12 +470,7 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
288
470
  channels: 3
289
471
  }
290
472
  }).png().toBuffer();
291
- const resized = await sharp(source).resize({
292
- width: 320,
293
- withoutEnlargement: true
294
- }).webp({
295
- quality: 80
296
- }).toBuffer();
473
+ const resized = await encodeMediaVariant(sharp, source, 320);
297
474
  // `RIFF....WEBP` — a WebP, not the PNG handed back.
298
475
  const isWebp = resized.length > 12 && resized.toString('ascii', 0, 4) === 'RIFF' && resized.toString('ascii', 8, 12) === 'WEBP';
299
476
  if (!isWebp) return {
@@ -333,7 +510,7 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
333
510
  * the largest realistic player box is the 1920 the variant widths already
334
511
  * top out at.
335
512
  */ export const MEDIA_POSTER_MAX_WIDTH = 1920;
336
- /** WebP quality for the poster. Higher than a variant's 80 would not show. */ const POSTER_QUALITY = 72;
513
+ /** WebP quality for the poster — the variants' own since AGL-3486. */ const POSTER_QUALITY = MEDIA_VARIANT_WEBP_OPTIONS.quality;
337
514
  /**
338
515
  * Turn a client-supplied still into the asset's poster (AGL-2742).
339
516
  *
@@ -397,7 +574,7 @@ import { MEDIA_CDN_VARIANT_WIDTHS } from "./serve-media-cdn.js";
397
574
  };
398
575
  }
399
576
  try {
400
- await options.saveVariant(posterPath, webp);
577
+ await options.saveVariant(posterPath, webp, 'image/webp');
401
578
  } catch (error) {
402
579
  console.error('media poster save failed', posterPath, error);
403
580
  return {