@wxxb789/anc 0.0.1 → 0.0.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.
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Goal 0011's one encoder: a build-time WebP candidate for already-authorized
3
+ * raster bytes.
4
+ *
5
+ * This module is deliberately **not** a publication authority. It receives bytes
6
+ * the producer has already resolved, authorized and sniffed, and it returns
7
+ * either a byte buffer the build may publish or a bounded failure reason. It
8
+ * never reads a path, never sees a filename, and has no way to reach a source
9
+ * file the walk did not approve. `src/lib/media.ts` remains the module that
10
+ * identifies formats and public identity.
11
+ *
12
+ * ## The policy, exactly as `docs/core-design/media-pipeline.md` states it
13
+ *
14
+ * | Validated input | Candidate | Keep it only when |
15
+ * | --- | --- | --- |
16
+ * | JPEG | lossy WebP, quality 80, effort 4 | its bytes are smaller than the JPEG's |
17
+ * | 8-bit PNG | lossless WebP, effort 4 | its bytes are smaller than the PNG's; pixels are not compared here |
18
+ * | 16-bit or other-depth PNG | none | the original bytes are published unchanged |
19
+ * | WebP / AVIF | none | the original bytes are published unchanged |
20
+ * | animated GIF / animated WebP | none | ditto; no first frame is extracted |
21
+ * | static GIF | none | no policy row selects an encoder for it |
22
+ * | corrupt / over budget | none | the build fails; there is no fallback to the input |
23
+ *
24
+ * The comparison is **candidate emitted bytes vs source bytes**, never an
25
+ * estimate and never a perceptual score. Lossy quality is a qualification
26
+ * property (tests and the measurement corpus), not a runtime knob.
27
+ *
28
+ * ## Order of operations that must not be swapped
29
+ *
30
+ * `.autoOrient()` is applied before output. Sharp then strips source metadata on
31
+ * a transcode, so the published pixels are the displayed pixels and the emitted
32
+ * header carries the displayed size. Callers read dimensions from the *emitted*
33
+ * bytes with 0010's reader, which is why a rotated JPEG does not leak its coded
34
+ * frame into `width`/`height`.
35
+ *
36
+ * `.withIccProfile('srgb', { attach: false })` converts an embedded colour
37
+ * profile to sRGB and attaches none. Without it, stripping the ICC profile from a
38
+ * Display-P3 photo would change the colours a browser shows; with it, the
39
+ * conversion happens and the output stays metadata-free.
40
+ *
41
+ * ## Bounds
42
+ *
43
+ * Every operation runs under a byte cap, a decoded-pixel cap and a wall-clock
44
+ * deadline. `unlimited` is never set. The caps are chosen from the measured
45
+ * corpus and hostile controls recorded in goal 0011; they are finite because a
46
+ * build on a personal-notes host must fail on a decompression bomb rather than
47
+ * be killed by the operating system.
48
+ */
49
+ import sharp, {} from 'sharp';
50
+ import { assertCompleteContainer, imageDimensions, isAnimatedPng, isPositiveDimension, pngCanUseLosslessWebp, } from "./media.js";
51
+ /**
52
+ * The fixed encoder settings for each format.
53
+ *
54
+ * The producer's cache is keyed by source digest alone because these are module
55
+ * constants for the lifetime of one build; changing a quality or effort here is
56
+ * a code change, not a per-build input. There is no author-facing knob and no
57
+ * configuration key.
58
+ */
59
+ export const ENCODER_SETTINGS = Object.freeze({
60
+ /** Lossy WebP for JPEG photographs. */
61
+ lossy: Object.freeze({ quality: 80, effort: 4 }),
62
+ /** Lossless WebP for PNG screenshots, diagrams and alpha. */
63
+ lossless: Object.freeze({ effort: 4 }),
64
+ /** Converted-to-sRGB, metadata-free transcodes. */
65
+ colourspace: 'srgb',
66
+ });
67
+ /**
68
+ * libwebp's maximum supported dimension, `WEBP_MAX_DIMENSION` in
69
+ * `webp/encode.h`: 16383 pixels per side, inclusive.
70
+ *
71
+ * A source at or below this bound may have a WebP candidate; a valid image with
72
+ * either side above it cannot, so it must take the passthrough path rather than
73
+ * being mislabeled `corrupt` by the encoder. This is the library's format
74
+ * boundary, not a second project budget.
75
+ */
76
+ export const WEBP_MAX_DIMENSION = 16_383;
77
+ /**
78
+ * Finite limits for one image, in the units each one bounds.
79
+ *
80
+ * - `maxSourceBytes` stops a absurdly large file before libvips parses it.
81
+ * - `maxPixels` is sharp's `limitInputPixels`; a single decoded frame above it
82
+ * is refused by libvips before allocation.
83
+ * - `maxAnimationPixels` bounds the stack of all frames when an animated file is
84
+ * decode-validated, because the full-animation check is the one place this
85
+ * build decodes more than the frame it displays.
86
+ * - The deadlines bound wall time per operation. libvips has no cancellable
87
+ * decode, so a deadline abandons the promise and fails the build; the worker's
88
+ * thread is left to finish, which is why the operation count is also bounded.
89
+ *
90
+ * Measured on the 4 vCPU / 8 GB qualification host rather than guessed; the goal
91
+ * record carries the throughput, peak-RSS and hostile-input results.
92
+ */
93
+ export const MEDIA_LIMITS = Object.freeze({
94
+ maxSourceBytes: 32 * 1024 * 1024,
95
+ maxPixels: 50_000_000,
96
+ maxAnimationPixels: 50_000_000,
97
+ decodeTimeoutMs: 20_000,
98
+ encodeTimeoutMs: 20_000,
99
+ });
100
+ /** A rejected operation whose classification the caller needs. */
101
+ class MediaLimitError extends Error {
102
+ reason;
103
+ constructor(reason, message) {
104
+ super(message);
105
+ this.reason = reason;
106
+ }
107
+ }
108
+ /** Sharp's error text for libvips' pixel-limit refusal, across versions. */
109
+ const PIXEL_LIMIT_ERROR = /pixel limit|exceeds.*limit/i;
110
+ /**
111
+ * Configure libvips once for this process.
112
+ *
113
+ * The cache is off because the producer already caches per source digest and a
114
+ * second, opaque cache inside libvips would only retain buffers; the thread count
115
+ * is explicit so a build's resource use is not whatever the host's CPU count
116
+ * happens to be. Both settings are process-wide and therefore not per-image
117
+ * budgets; every sharp consumer in this repository shares them.
118
+ */
119
+ const SHARP_CONCURRENCY = 4;
120
+ sharp.cache(false);
121
+ sharp.concurrency(SHARP_CONCURRENCY);
122
+ /** Raise the timeout classification above the generic sharp wrapper. */
123
+ function classify(error) {
124
+ if (error instanceof MediaLimitError)
125
+ return error.reason;
126
+ if (error instanceof Error && PIXEL_LIMIT_ERROR.test(error.message))
127
+ return 'too-large';
128
+ return 'corrupt';
129
+ }
130
+ /**
131
+ * Run one libvips operation under a deadline.
132
+ *
133
+ * `Promise.race` rather than an abort signal because sharp exposes none: the
134
+ * winning promise is settled and the loser's rejection is still observed by the
135
+ * race's own handler, so it cannot become an unhandled rejection. On timeout the
136
+ * build fails with a bounded reason; it does not fall back to publishing bytes
137
+ * this process could not read.
138
+ */
139
+ async function withDeadline(work, milliseconds) {
140
+ const timeout = Promise.withResolvers();
141
+ const timer = setTimeout(() => timeout.reject(new MediaLimitError('timeout', 'operation exceeded its deadline')), milliseconds);
142
+ try {
143
+ return await Promise.race([work, timeout.promise]);
144
+ }
145
+ finally {
146
+ clearTimeout(timer);
147
+ }
148
+ }
149
+ /** The constructor options every operation on untrusted bytes uses. */
150
+ function inputOptions(limits, animated = false) {
151
+ return {
152
+ // Sharp's strict default: a warning is a failure, not a partially decoded image.
153
+ failOn: 'warning',
154
+ limitInputPixels: limits.maxPixels,
155
+ // Never lifted: this is the decompression-bomb boundary.
156
+ unlimited: false,
157
+ // The bytes are a buffer, so sequential access is the truthful hint.
158
+ sequentialRead: true,
159
+ ...(animated ? { animated: true } : {}),
160
+ };
161
+ }
162
+ /** Read a source's own header without trusting its filename. */
163
+ async function readMetadata(bytes, limits, animated) {
164
+ return withDeadline(sharp(bytes, inputOptions(limits, animated)).metadata(), limits.decodeTimeoutMs);
165
+ }
166
+ /** The displayed pixel size after `autoOrient`, from the coded frame and its tag. */
167
+ function displayedDimensions(metadata) {
168
+ const { width, height, orientation } = metadata;
169
+ if (!isPositiveDimension(width) || !isPositiveDimension(height)) {
170
+ throw new MediaLimitError('corrupt', 'the image header declares no positive pixel size');
171
+ }
172
+ const transposed = orientation !== undefined && orientation >= 5 && orientation <= 8;
173
+ return transposed ? { width: height, height: width } : { width, height };
174
+ }
175
+ /**
176
+ * Fully decode a passthrough image once, so a corrupt or truncated file fails
177
+ * the build instead of reaching a reader.
178
+ *
179
+ * A header read is not decode validation: measured, a JPEG truncated to 10% of
180
+ * its bytes still yields valid metadata. `stats()` decodes the visible frame. An
181
+ * animated file's first metadata read already used `animated: true`, so the
182
+ * stacked height and frame count are available without opening the file twice;
183
+ * the raw decode checks every frame under the separate total-pixel budget.
184
+ */
185
+ async function validateDecode(bytes, metadata, limits) {
186
+ const pages = metadata.pages ?? 1;
187
+ if (pages > 1) {
188
+ const { width, height } = metadata;
189
+ if (!isPositiveDimension(width) || !isPositiveDimension(height)) {
190
+ throw new MediaLimitError('corrupt', 'the animated image header declares no positive pixel size');
191
+ }
192
+ // With `animated: true` sharp reports a stacked image: `height` is already
193
+ // the sum of every frame, so multiplying by `pages` would count the frames
194
+ // twice. Measured on animated WebP and GIF, both of which also expose
195
+ // `pageHeight`; the bound is the actual decoded surface.
196
+ if (width * height > limits.maxAnimationPixels) {
197
+ throw new MediaLimitError('too-large', 'the animated image exceeds the total decoded-pixel budget');
198
+ }
199
+ await withDeadline(sharp(bytes, inputOptions(limits, true)).raw().toBuffer(), limits.decodeTimeoutMs);
200
+ return;
201
+ }
202
+ await withDeadline(sharp(bytes, inputOptions(limits)).stats(), limits.decodeTimeoutMs);
203
+ }
204
+ /** The one `.webp()` option object for a policy row. */
205
+ function webpOptions(format) {
206
+ return format === 'jpeg' ? ENCODER_SETTINGS.lossy : { lossless: true, ...ENCODER_SETTINGS.lossless };
207
+ }
208
+ /**
209
+ * Encode one validation-passing candidate and prove what came back is what the
210
+ * policy says it should be.
211
+ *
212
+ * Returning `undefined` means "no candidate": the encoder produced bytes but the
213
+ * output failed validation. That is a build failure, never a silent fallback,
214
+ * because a codec that returned something unreadable is exactly the case in which
215
+ * publishing the original would hide the defect.
216
+ */
217
+ async function transcode(bytes, format, metadata, limits) {
218
+ const expected = displayedDimensions(metadata);
219
+ const encoded = await withDeadline(sharp(bytes, inputOptions(limits))
220
+ .autoOrient()
221
+ .withIccProfile(ENCODER_SETTINGS.colourspace, { attach: false })
222
+ .webp(webpOptions(format))
223
+ .toBuffer(), limits.encodeTimeoutMs);
224
+ if (encoded.length === 0)
225
+ return undefined;
226
+ const produced = await readMetadata(encoded, limits, false);
227
+ if (produced.format !== 'webp')
228
+ return undefined;
229
+ if (produced.orientation !== undefined && produced.orientation !== 1)
230
+ return undefined;
231
+ if (produced.pages !== undefined && produced.pages > 1)
232
+ return undefined;
233
+ if (produced.width !== expected.width || produced.height !== expected.height)
234
+ return undefined;
235
+ return encoded;
236
+ }
237
+ /**
238
+ * Prepare the one published representation of an authorized image.
239
+ *
240
+ * @param bytes The exact bytes the walk authorized; never re-read from a path.
241
+ * @param format The format `sniffImageFormat` detected, which the walk already
242
+ * used to classify the file; the encoder does not second-guess it.
243
+ */
244
+ export async function prepareImage(bytes, format, limits = MEDIA_LIMITS) {
245
+ if (bytes.length > limits.maxSourceBytes) {
246
+ return { ok: false, reason: 'too-large' };
247
+ }
248
+ // A successful header read is not a complete container: measured, libvips
249
+ // decodes a 96x96 PNG with its last 20 bytes missing and a GIF truncated to
250
+ // 90% with a garbage tail. The structural gate runs before any sharp call, so
251
+ // an incomplete source never reaches libvips at all. GIF's one cross-check
252
+ // that needs decoded metadata is repeated after the header read below.
253
+ try {
254
+ assertCompleteContainer(format, bytes);
255
+ }
256
+ catch {
257
+ return { ok: false, reason: 'corrupt' };
258
+ }
259
+ // Animation is a property of the already-sniffed container, not a second
260
+ // decode: choosing it here makes the first metadata read sufficient.
261
+ const animated = format === 'gif' || format === 'webp' || format === 'avif';
262
+ let metadata;
263
+ try {
264
+ metadata = await readMetadata(bytes, limits, animated);
265
+ }
266
+ catch (error) {
267
+ return { ok: false, reason: classify(error) };
268
+ }
269
+ // The descriptor walk above counted frames, but only the decoder can say how
270
+ // many pages it accepted. Requiring the two numbers to agree catches a stream
271
+ // whose descriptors are structurally present but whose page table disagrees.
272
+ if (format === 'gif') {
273
+ try {
274
+ assertCompleteContainer(format, bytes, { pages: metadata.pages });
275
+ }
276
+ catch {
277
+ return { ok: false, reason: 'corrupt' };
278
+ }
279
+ }
280
+ const sourceResult = () => ({
281
+ ok: true,
282
+ image: {
283
+ bytes,
284
+ format,
285
+ dimensions: imageDimensions(bytes, format),
286
+ transcoded: false,
287
+ },
288
+ });
289
+ const passthrough = async () => {
290
+ try {
291
+ await validateDecode(bytes, metadata, limits);
292
+ }
293
+ catch (error) {
294
+ return { ok: false, reason: classify(error) };
295
+ }
296
+ return sourceResult();
297
+ };
298
+ // The policy table has no encoding row for these formats, and an animation is
299
+ // preserved rather than flattened.
300
+ if (format === 'gif' || format === 'webp' || format === 'avif')
301
+ return passthrough();
302
+ const pages = metadata.pages ?? 1;
303
+ if (pages > 1)
304
+ return passthrough();
305
+ // APNG is a PNG whose animation sharp does not report as pages. Preserve the
306
+ // source exactly rather than publishing its first frame as a still WebP.
307
+ // A PNG whose depth or colour model WebP lossless cannot carry faithfully
308
+ // (16-bit and other depths) is likewise published unchanged, even when an
309
+ // 8-bit candidate would be smaller: that candidate is a visible reduction.
310
+ if (format === 'png' && (isAnimatedPng(bytes) || !pngCanUseLosslessWebp(bytes))) {
311
+ return passthrough();
312
+ }
313
+ // WebP cannot represent a side above libwebp's 16,383-pixel limit. The source
314
+ // is valid, so publish it unchanged instead of asking the encoder for a
315
+ // candidate that cannot exist; the metadata read above already supplies the
316
+ // displayed dimensions, so this does not decode again.
317
+ let sourceDimensions;
318
+ try {
319
+ sourceDimensions = displayedDimensions(metadata);
320
+ }
321
+ catch (error) {
322
+ return { ok: false, reason: classify(error) };
323
+ }
324
+ if (sourceDimensions.width > WEBP_MAX_DIMENSION || sourceDimensions.height > WEBP_MAX_DIMENSION) {
325
+ return passthrough();
326
+ }
327
+ let candidate;
328
+ try {
329
+ candidate = await transcode(bytes, format, metadata, limits);
330
+ }
331
+ catch (error) {
332
+ return { ok: false, reason: classify(error) };
333
+ }
334
+ if (candidate === undefined)
335
+ return { ok: false, reason: 'encode-failed' };
336
+ // The only automatic comparison in this pass: emitted candidate bytes against
337
+ // the bytes the source would have published. A larger or equal candidate is
338
+ // discarded, not shipped for the sake of a `.webp` name.
339
+ if (candidate.length >= bytes.length)
340
+ return sourceResult();
341
+ // 0010's reader owns the pair the artifact publishes, and it reads the emitted
342
+ // bytes.
343
+ const emitted = imageDimensions(candidate, 'webp');
344
+ if (emitted === undefined)
345
+ return { ok: false, reason: 'encode-failed' };
346
+ return {
347
+ ok: true,
348
+ image: {
349
+ bytes: candidate,
350
+ format: 'webp',
351
+ dimensions: emitted,
352
+ transcoded: true,
353
+ },
354
+ };
355
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Where a published image is delivered from, when the build was explicitly
3
+ * opted into a managed image host.
4
+ *
5
+ * The artifact and the producer always speak the same-origin path
6
+ * (`/_assets/<sha256>-<name>`); that path is the asset's identity. This module
7
+ * is the only place the *delivery location* changes: when
8
+ * `anc build --release --media r2` sets {@link MEDIA_BASE_URL_VARIABLE} before
9
+ * Astro renders, `markdown.ts` prefixes each authorized asset with the
10
+ * configured public custom-domain origin. No other consumer sees the absolute
11
+ * form, so the output inventory, the artifact, the copy step, and the digest
12
+ * checks keep comparing the one stable path.
13
+ *
14
+ * ## Why an environment variable
15
+ *
16
+ * `astro.config.mjs` and its pages are evaluated by Astro after the CLI has
17
+ * changed the working directory to this package's root. The CLI already
18
+ * crosses that boundary with `PUBLISH_CONFIG_DIR`, `CONTENT_ARTIFACT`, and
19
+ * `SNAPSHOT_WORKSPACE`, and there is no function argument available for the
20
+ * same reason: nothing in this repository calls the page module directly. An
21
+ * environment variable is the seam, and it is set only for the opted-in build.
22
+ *
23
+ * ## Why the value is validated again
24
+ *
25
+ * It comes from a configuration table the loader already validated, so a
26
+ * malformed value here is a defect rather than a user mistake. It is still
27
+ * checked: a renderer that concatenated an arbitrary string into `src` would
28
+ * be one environment variable away from emitting a `javascript:` URL or a
29
+ * non-HTTPS image, and the image sanitizer's scheme allowlist runs *before*
30
+ * this rewrite. Failing closed here is cheaper than teaching the sanitizer
31
+ * about a second pass.
32
+ */
33
+ /** The environment variable naming the public media origin, when opted in. */
34
+ export const MEDIA_BASE_URL_VARIABLE = 'PUBLISH_MEDIA_BASE_URL';
35
+ /**
36
+ * The bare HTTPS origin images are delivered from, or `undefined` for the
37
+ * ordinary same-origin build.
38
+ *
39
+ * Empty is absent: a shell unsets a variable for one command by setting it
40
+ * empty, which is the same convention `artifact-source.ts` uses.
41
+ */
42
+ export function mediaDeliveryOrigin() {
43
+ const value = process.env[MEDIA_BASE_URL_VARIABLE];
44
+ if (value === undefined || value === '')
45
+ return undefined;
46
+ let url;
47
+ try {
48
+ url = new URL(value);
49
+ }
50
+ catch {
51
+ throw new Error('the media delivery origin is not an absolute URL');
52
+ }
53
+ if (url.protocol !== 'https:' ||
54
+ url.username !== '' ||
55
+ url.password !== '' ||
56
+ url.pathname !== '/' ||
57
+ url.search !== '' ||
58
+ url.hash !== '') {
59
+ throw new Error('the media delivery origin must be a bare HTTPS origin with no credentials');
60
+ }
61
+ return url.origin;
62
+ }
63
+ /**
64
+ * The absolute HTTPS URL a reader fetches for a published asset path.
65
+ *
66
+ * `assetPath` is the artifact's canonical `/_assets/...` path. Concatenation
67
+ * rather than `new URL()` is deliberate: `URL` would normalize percent-encoded
68
+ * bytes into a second spelling, and the path's canonical encoding is what the
69
+ * object key, the staged filename, and the output inventory all agree on.
70
+ */
71
+ export function deliveredMediaUrl(assetPath, origin) {
72
+ if (!assetPath.startsWith('/')) {
73
+ throw new Error('a delivered media path must be root-absolute');
74
+ }
75
+ return `${origin}${assetPath}`;
76
+ }