@zerotal/media 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import {
6
6
  type ImageResult,
7
7
  } from "./ImageDriver.ts";
8
8
  import { MediaError } from "../errors.ts";
9
+ import { resolveGeometry } from "./raster.ts";
9
10
  import type { ConversionFormat } from "../types.ts";
10
11
 
11
12
  // ── Minimal structural view of sharp ──────────────────────────────────────────
@@ -57,14 +58,19 @@ async function loadSharp(): Promise<SharpFactory> {
57
58
  throw new MediaError(
58
59
  'config/media.ts sets driver: "sharp" but the sharp package is not installed.\n' +
59
60
  "Fix: `bun add sharp`, or switch back to the built-in driver with " +
60
- 'driver: "bun" (which cannot crop see fit: "cover").',
61
+ 'driver: "bun" which supports every manipulation this one does, ' +
62
+ 'including fit: "cover".',
61
63
  );
62
64
  }
63
65
  }
64
66
 
65
67
  /**
66
- * Image processing on `sharp`, for apps that need what `Bun.Image` does not do —
67
- * principally `fit: "cover"`, the centre-crop behind every square thumbnail.
68
+ * Image processing on `sharp`.
69
+ *
70
+ * No longer required for `fit: "cover"` — the default `BunImageDriver` crops
71
+ * natively, and a shared parity suite holds the two to the same output
72
+ * dimensions. Reach for this one when you want libvips' throughput on large
73
+ * batches, or a codec Bun's `system` backend does not carry on your hosts.
68
74
  *
69
75
  * Opt in by installing `sharp` and setting `driver: "sharp"` in `config/media.ts`.
70
76
  * `sharp` builds against Node-API v9, which Bun implements, so it runs here; it
@@ -87,19 +93,59 @@ export class SharpImageDriver implements ImageDriver {
87
93
  }
88
94
 
89
95
  async convert(bytes: Uint8Array, manipulation: ImageManipulation): Promise<ImageResult> {
90
- const { width, height, fit = "inside", format, quality, rotate } = manipulation;
96
+ const { format, quality, rotate } = manipulation;
91
97
  const sharp = await loadSharp();
92
98
 
93
- let pipeline = sharp(bytes);
94
-
95
- if (rotate !== undefined && rotate !== 0) pipeline = pipeline.rotate(rotate);
99
+ // Rotation first, and materialised, so the geometry below is computed
100
+ // against the shape it actually produces. Mirrors BunImageDriver — both
101
+ // drivers must resolve dimensions from the same numbers or they cannot
102
+ // agree on the rotated cases.
103
+ let source = bytes;
104
+ let sourceWidth: number;
105
+ let sourceHeight: number;
106
+
107
+ if (rotate !== undefined && rotate !== 0) {
108
+ const rotated = await sharp(bytes).rotate(rotate).png().toBuffer({ resolveWithObject: true });
109
+ source = rotated.data;
110
+ sourceWidth = rotated.info.width;
111
+ sourceHeight = rotated.info.height;
112
+ } else {
113
+ const meta = await this.metadata(bytes);
114
+ if (meta === null)
115
+ throw new MediaError("[Zerotal Media] The source image could not be read.");
116
+ sourceWidth = meta.width;
117
+ sourceHeight = meta.height;
118
+ }
96
119
 
97
- if (width !== undefined || height !== undefined) {
120
+ const geometry = resolveGeometry({
121
+ sourceWidth,
122
+ sourceHeight,
123
+ targetWidth: manipulation.width,
124
+ targetHeight: manipulation.height,
125
+ fit: manipulation.fit ?? "inside",
126
+ withoutEnlargement: manipulation.withoutEnlargement ?? true,
127
+ });
128
+
129
+ let pipeline = sharp(source);
130
+
131
+ if (geometry.cropWidth !== undefined && geometry.cropHeight !== undefined) {
132
+ // sharp crops natively, and its own covering scale matches the one
133
+ // `resolveGeometry` computed — so handing it the final crop box with
134
+ // enlargement unlocked reproduces the same window without an extract step.
135
+ pipeline = pipeline.resize({
136
+ width: geometry.cropWidth,
137
+ height: geometry.cropHeight,
138
+ fit: "cover",
139
+ withoutEnlargement: false,
140
+ });
141
+ } else if (!geometry.resizeIsNoop) {
142
+ // Exact dimensions are already resolved, so `fill` asks for precisely
143
+ // these numbers rather than letting sharp re-derive them.
98
144
  pipeline = pipeline.resize({
99
- ...(width !== undefined ? { width } : {}),
100
- ...(height !== undefined ? { height } : {}),
101
- fit,
102
- withoutEnlargement: manipulation.withoutEnlargement ?? true,
145
+ width: geometry.resizeWidth,
146
+ height: geometry.resizeHeight,
147
+ fit: "fill",
148
+ withoutEnlargement: false,
103
149
  });
104
150
  }
105
151
 
@@ -21,6 +21,7 @@ export function setConversionDispatcher(dispatcher: ConversionDispatcher | null)
21
21
  }
22
22
 
23
23
  /** Whether conversions can currently be deferred. */
24
+ /** @internal — queue-bridge wiring. */
24
25
  export function isQueueAvailable(): boolean {
25
26
  return _dispatcher !== null;
26
27
  }
@@ -31,6 +32,8 @@ export function isQueueAvailable(): boolean {
31
32
  * A no-op when nothing is installed — callers check {@link isQueueAvailable}
32
33
  * first and run inline instead, so this is only reached if a queue disappeared
33
34
  * between the check and the dispatch.
35
+ *
36
+ * @internal — queue-bridge wiring.
34
37
  */
35
38
  export async function dispatchConversions(mediaId: number, conversions: string[]): Promise<void> {
36
39
  if (_dispatcher === null) return;
@@ -0,0 +1,510 @@
1
+ /**
2
+ * Raw-pixel helpers that give {@link BunImageDriver} a centre-crop.
3
+ *
4
+ * `Bun.Image` exposes no crop, extract or composite primitive, and its
5
+ * `resize()` accepts only `fit: "fill" | "inside"` — so a cover-fit thumbnail
6
+ * cannot be expressed through its API alone. What it does expose is `.png()`,
7
+ * a *lossless* way out of the pipeline, and the PNG it emits is always the same
8
+ * narrow shape: 8-bit, colour type 6 (RGBA), non-interlaced, no palette.
9
+ *
10
+ * That is enough. The scaling stays with Bun (native, and the same resampling
11
+ * every other conversion gets); this module only decodes the scaled result,
12
+ * copies out the centre window, and re-encodes. The pixel work therefore happens
13
+ * on an already-downscaled image — a thumbnail-sized buffer, not the original.
14
+ *
15
+ * Nothing here is exported from the package. It is an implementation detail of
16
+ * one driver, and a hand-rolled PNG codec is not an API worth supporting.
17
+ *
18
+ * @module
19
+ * @internal
20
+ */
21
+ import { deflateSync, inflateSync } from "node:zlib";
22
+ import { RasterFormatError } from "../errors.ts";
23
+
24
+ /** A decoded image: 8-bit RGBA, row-major, no row padding. */
25
+ export interface RasterImage {
26
+ width: number;
27
+ height: number;
28
+ /** Exactly `width * height * 4` bytes, in RGBA order. */
29
+ rgba: Uint8Array;
30
+ }
31
+
32
+ /** What a PNG's IHDR declares. */
33
+ export interface PngHeader {
34
+ width: number;
35
+ height: number;
36
+ bitDepth: number;
37
+ /** 0 grey, 2 RGB, 3 palette, 4 grey+alpha, 6 RGBA. */
38
+ colorType: number;
39
+ /** 0 none, 1 Adam7. */
40
+ interlace: number;
41
+ }
42
+
43
+ /** The eight bytes every PNG starts with. */
44
+ const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const;
45
+
46
+ /** Bytes per pixel for each colour type this module decodes, at bit depth 8. */
47
+ const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 4: 2, 6: 4 };
48
+
49
+ /**
50
+ * Read a PNG's IHDR without decoding any pixels.
51
+ *
52
+ * Returns `null` when the bytes are not a PNG at all, which callers treat as
53
+ * "not ours to handle" rather than as a failure.
54
+ */
55
+ export function readPngHeader(bytes: Uint8Array): PngHeader | null {
56
+ // 8-byte signature + 4 length + 4 "IHDR" + 13 data.
57
+ if (bytes.length < 29) return null;
58
+ for (let i = 0; i < 8; i++) {
59
+ if (bytes[i] !== SIGNATURE[i]) return null;
60
+ }
61
+
62
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
63
+ return {
64
+ width: view.getUint32(16),
65
+ height: view.getUint32(20),
66
+ bitDepth: view.getUint8(24),
67
+ colorType: view.getUint8(25),
68
+ interlace: view.getUint8(28),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Decode a PNG to RGBA.
74
+ *
75
+ * Deliberately narrow: 8-bit non-interlaced, colour types 0/2/4/6. That covers
76
+ * everything `Bun.Image.png()` emits with room to spare, and anything outside it
77
+ * throws {@link RasterFormatError} by name rather than producing wrong pixels.
78
+ * Palette and 16-bit inputs never reach here — this only ever decodes PNGs this
79
+ * package just asked Bun to produce.
80
+ *
81
+ * @param maxPixels Refuse images above this pixel count, before allocating.
82
+ */
83
+ export function decodePng(bytes: Uint8Array, maxPixels = Number.POSITIVE_INFINITY): RasterImage {
84
+ const header = readPngHeader(bytes);
85
+ if (header === null) throw new RasterFormatError("the bytes are not a PNG");
86
+
87
+ const { width, height, bitDepth, colorType, interlace } = header;
88
+
89
+ if (bitDepth !== 8) {
90
+ throw new RasterFormatError(`bit depth ${bitDepth} is not supported (expected 8)`);
91
+ }
92
+ if (interlace !== 0) {
93
+ throw new RasterFormatError("interlaced (Adam7) PNGs are not supported");
94
+ }
95
+ const channels = CHANNELS[colorType];
96
+ if (channels === undefined) {
97
+ throw new RasterFormatError(
98
+ `colour type ${colorType} is not supported (expected 0, 2, 4 or 6)`,
99
+ );
100
+ }
101
+ if (width <= 0 || height <= 0) {
102
+ throw new RasterFormatError(`degenerate dimensions ${width}x${height}`);
103
+ }
104
+ if (width * height > maxPixels) {
105
+ throw new RasterFormatError(
106
+ `${width}x${height} exceeds the ${maxPixels}-pixel limit for intermediate buffers`,
107
+ );
108
+ }
109
+
110
+ // The spec allows IDAT to be split across any number of chunks. Bun emits one,
111
+ // but concatenating is three lines and removes the assumption.
112
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
113
+ const parts: Uint8Array[] = [];
114
+ let offset = 8;
115
+ while (offset + 8 <= bytes.length) {
116
+ const length = view.getUint32(offset);
117
+ const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
118
+ if (type === "IDAT") parts.push(bytes.subarray(offset + 8, offset + 8 + length));
119
+ if (type === "IEND") break;
120
+ offset += 12 + length;
121
+ }
122
+ if (parts.length === 0) throw new RasterFormatError("PNG has no IDAT chunk");
123
+
124
+ // `inflateSync` here, not `Bun.inflateSync`: the Bun helpers are *raw* deflate
125
+ // and IDAT is a zlib stream. Mixing them yields a file whose header parses and
126
+ // whose pixels do not.
127
+ const inflated = inflateSync(_concat(parts));
128
+
129
+ const sourceStride = width * channels;
130
+ const expected = height * (sourceStride + 1);
131
+ if (inflated.length < expected) {
132
+ throw new RasterFormatError(
133
+ `IDAT holds ${inflated.length} bytes, short of the ${expected} needed for ${width}x${height}`,
134
+ );
135
+ }
136
+
137
+ const raw = _unfilter(inflated, width, height, channels);
138
+ return { width, height, rgba: channels === 4 ? raw : _toRgba(raw, width, height, channels) };
139
+ }
140
+
141
+ /**
142
+ * Encode RGBA as a PNG.
143
+ *
144
+ * Every scanline uses filter 0 (None) and compression level 1. This is a
145
+ * throwaway intermediate handed straight back to `Bun.Image` in memory, never a
146
+ * stored artifact, so time spent choosing filters or squeezing bytes would buy
147
+ * nothing — the file is decoded and discarded microseconds later.
148
+ */
149
+ export function encodePng(image: RasterImage): Uint8Array {
150
+ const { width, height, rgba } = image;
151
+ const stride = width * 4;
152
+
153
+ // One leading filter-type byte per scanline; 0 means "store the row as-is".
154
+ const raw = new Uint8Array(height * (stride + 1));
155
+ for (let y = 0; y < height; y++) {
156
+ raw.set(rgba.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1);
157
+ }
158
+
159
+ const ihdr = new Uint8Array(13);
160
+ const header = new DataView(ihdr.buffer);
161
+ header.setUint32(0, width);
162
+ header.setUint32(4, height);
163
+ ihdr[8] = 8; // bit depth
164
+ ihdr[9] = 6; // colour type: RGBA
165
+
166
+ return _concat([
167
+ Uint8Array.from(SIGNATURE),
168
+ _chunk("IHDR", ihdr),
169
+ _chunk("IDAT", deflateSync(raw, { level: 1 })),
170
+ _chunk("IEND", new Uint8Array(0)),
171
+ ]);
172
+ }
173
+
174
+ /**
175
+ * Copy the centre `cropWidth` × `cropHeight` window out of `image`.
176
+ *
177
+ * The offset rounds *up* on a half-pixel, matching sharp: cropping 2 columns
178
+ * from 5 keeps columns 2–3, not 1–2. Sub-pixel, but it is the difference between
179
+ * two drivers producing identical thumbnails and merely similar ones.
180
+ */
181
+ export function cropCentre(image: RasterImage, cropWidth: number, cropHeight: number): RasterImage {
182
+ const width = Math.min(cropWidth, image.width);
183
+ const height = Math.min(cropHeight, image.height);
184
+ const left = Math.round((image.width - width) / 2);
185
+ const top = Math.round((image.height - height) / 2);
186
+
187
+ const stride = width * 4;
188
+ const rgba = new Uint8Array(height * stride);
189
+ for (let y = 0; y < height; y++) {
190
+ const from = ((top + y) * image.width + left) * 4;
191
+ rgba.set(image.rgba.subarray(from, from + stride), y * stride);
192
+ }
193
+
194
+ return { width, height, rgba };
195
+ }
196
+
197
+ /** The resize-then-crop plan that turns a source into a cover-fit target. */
198
+ export interface CoverGeometry {
199
+ /** What to hand `Bun.Image.resize()`, with `fit: "fill"`. */
200
+ resizeWidth: number;
201
+ resizeHeight: number;
202
+ /** The window to take from that result. */
203
+ cropWidth: number;
204
+ cropHeight: number;
205
+ /** True when the resize is a no-op and the source can be cropped directly. */
206
+ resizeIsNoop: boolean;
207
+ }
208
+
209
+ /**
210
+ * The exact output dimensions for one manipulation, and whether a crop follows.
211
+ *
212
+ * Every driver resolves geometry through here and then asks its backend for
213
+ * *those exact numbers*, rather than passing the user's box down and trusting
214
+ * the backend's own rounding. That is what makes `media.driver` safe to flip:
215
+ * the two backends round differently — `Bun.Image` floors, and can return a
216
+ * width a pixel short of the one requested — so agreement has to be built
217
+ * rather than assumed.
218
+ */
219
+ export interface ResolvedGeometry {
220
+ /** Scale the (already-rotated) source to exactly this, ignoring aspect ratio. */
221
+ resizeWidth: number;
222
+ resizeHeight: number;
223
+ /** When set, take this centre window out of the scaled result. */
224
+ cropWidth?: number;
225
+ cropHeight?: number;
226
+ /** True when the resize would change nothing. */
227
+ resizeIsNoop: boolean;
228
+ }
229
+
230
+ /**
231
+ * Dimensions for `fit: "inside"` — scale to fit within the box, aspect
232
+ * preserved.
233
+ *
234
+ * `round`, not `floor`, on both axes: verified against sharp 0.34 across
235
+ * exact, half-pixel and long-tail ratios.
236
+ */
237
+ export function insideGeometry(params: {
238
+ sourceWidth: number;
239
+ sourceHeight: number;
240
+ targetWidth?: number | undefined;
241
+ targetHeight?: number | undefined;
242
+ withoutEnlargement: boolean;
243
+ }): { width: number; height: number } {
244
+ const { sourceWidth, sourceHeight, targetWidth, targetHeight, withoutEnlargement } = params;
245
+
246
+ const byWidth = targetWidth === undefined ? Number.POSITIVE_INFINITY : targetWidth / sourceWidth;
247
+ const byHeight =
248
+ targetHeight === undefined ? Number.POSITIVE_INFINITY : targetHeight / sourceHeight;
249
+
250
+ let scale = Math.min(byWidth, byHeight);
251
+ if (!Number.isFinite(scale)) scale = 1;
252
+ if (withoutEnlargement && scale > 1) scale = 1;
253
+
254
+ return {
255
+ width: Math.max(1, Math.round(sourceWidth * scale)),
256
+ height: Math.max(1, Math.round(sourceHeight * scale)),
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Resolve any manipulation into exact dimensions plus an optional centre crop.
262
+ *
263
+ * The awkward corners, all matched to sharp:
264
+ *
265
+ * - `cover` needs a box. Given one dimension there is nothing to crop away, so
266
+ * it degrades to `inside` rather than inventing a second meaning.
267
+ * - `fill` also needs a box: with one dimension sharp stretches that axis alone
268
+ * and leaves the other at source size, which is almost never what a caller
269
+ * meant. Treated as `inside`, and documented as such.
270
+ * - `fill` under `withoutEnlargement` clamps per axis to `min(target, source)`,
271
+ * which can change the aspect ratio the caller asked to stretch to.
272
+ */
273
+ export function resolveGeometry(params: {
274
+ sourceWidth: number;
275
+ sourceHeight: number;
276
+ targetWidth?: number | undefined;
277
+ targetHeight?: number | undefined;
278
+ fit: "inside" | "fill" | "cover";
279
+ withoutEnlargement: boolean;
280
+ }): ResolvedGeometry {
281
+ const { sourceWidth, sourceHeight, targetWidth, targetHeight, fit, withoutEnlargement } = params;
282
+
283
+ const noop: ResolvedGeometry = {
284
+ resizeWidth: sourceWidth,
285
+ resizeHeight: sourceHeight,
286
+ resizeIsNoop: true,
287
+ };
288
+
289
+ // Nothing asked for: re-encode at source size.
290
+ if (targetWidth === undefined && targetHeight === undefined) return noop;
291
+
292
+ const hasBox = targetWidth !== undefined && targetHeight !== undefined;
293
+
294
+ if (fit === "cover" && hasBox) {
295
+ const cover = coverGeometry({
296
+ sourceWidth,
297
+ sourceHeight,
298
+ targetWidth,
299
+ targetHeight,
300
+ withoutEnlargement,
301
+ });
302
+ return {
303
+ resizeWidth: cover.resizeWidth,
304
+ resizeHeight: cover.resizeHeight,
305
+ cropWidth: cover.cropWidth,
306
+ cropHeight: cover.cropHeight,
307
+ resizeIsNoop: cover.resizeIsNoop,
308
+ };
309
+ }
310
+
311
+ if (fit === "fill" && hasBox) {
312
+ const width = withoutEnlargement ? Math.min(targetWidth, sourceWidth) : targetWidth;
313
+ const height = withoutEnlargement ? Math.min(targetHeight, sourceHeight) : targetHeight;
314
+ return {
315
+ resizeWidth: width,
316
+ resizeHeight: height,
317
+ resizeIsNoop: width === sourceWidth && height === sourceHeight,
318
+ };
319
+ }
320
+
321
+ const inside = insideGeometry({
322
+ sourceWidth,
323
+ sourceHeight,
324
+ targetWidth,
325
+ targetHeight,
326
+ withoutEnlargement,
327
+ });
328
+ return {
329
+ resizeWidth: inside.width,
330
+ resizeHeight: inside.height,
331
+ resizeIsNoop: inside.width === sourceWidth && inside.height === sourceHeight,
332
+ };
333
+ }
334
+
335
+ /**
336
+ * Work out how to cover-fit `source` into `target`.
337
+ *
338
+ * Scaling by `max(tw/sw, th/sh)` makes the result overflow the box in at most
339
+ * one axis, and the overflow is what gets cropped away. Because both dimensions
340
+ * are scaled by the same factor, passing them to `fit: "fill"` — which would
341
+ * otherwise stretch — distorts nothing; it just sidesteps `inside`'s refusal to
342
+ * overflow the box, which is the only reason `fill` is used here.
343
+ *
344
+ * `withoutEnlargement` follows sharp exactly, including the part that is not
345
+ * obvious: when the source is too small, the output is `min(tw, sw) × min(th, sh)`
346
+ * and the requested *aspect ratio is not preserved*. A 300×500 source asked to
347
+ * cover 400×400 yields 300×400, not 300×300.
348
+ */
349
+ export function coverGeometry(params: {
350
+ sourceWidth: number;
351
+ sourceHeight: number;
352
+ targetWidth: number;
353
+ targetHeight: number;
354
+ withoutEnlargement: boolean;
355
+ }): CoverGeometry {
356
+ const { sourceWidth, sourceHeight, targetWidth, targetHeight, withoutEnlargement } = params;
357
+
358
+ const scale = Math.max(targetWidth / sourceWidth, targetHeight / sourceHeight);
359
+
360
+ // Clamped: no scaling at all, just a crop of whatever the source can supply.
361
+ if (withoutEnlargement && scale > 1) {
362
+ return {
363
+ resizeWidth: sourceWidth,
364
+ resizeHeight: sourceHeight,
365
+ cropWidth: Math.min(targetWidth, sourceWidth),
366
+ cropHeight: Math.min(targetHeight, sourceHeight),
367
+ resizeIsNoop: true,
368
+ };
369
+ }
370
+
371
+ // Round, then floor the box to what the scaled image can actually supply: a
372
+ // half-pixel rounding down would otherwise ask for a window one pixel wider
373
+ // than the image it is cut from.
374
+ const resizeWidth = Math.max(1, Math.round(sourceWidth * scale));
375
+ const resizeHeight = Math.max(1, Math.round(sourceHeight * scale));
376
+
377
+ return {
378
+ resizeWidth,
379
+ resizeHeight,
380
+ cropWidth: Math.min(targetWidth, resizeWidth),
381
+ cropHeight: Math.min(targetHeight, resizeHeight),
382
+ resizeIsNoop: resizeWidth === sourceWidth && resizeHeight === sourceHeight,
383
+ };
384
+ }
385
+
386
+ // ── Private ──────────────────────────────────────────────────────────────────
387
+
388
+ /** Undo PNG's per-scanline filtering (spec §9.2). */
389
+ function _unfilter(
390
+ inflated: Uint8Array,
391
+ width: number,
392
+ height: number,
393
+ channels: number,
394
+ ): Uint8Array {
395
+ const stride = width * channels;
396
+ const out = new Uint8Array(height * stride);
397
+
398
+ for (let y = 0; y < height; y++) {
399
+ const rowStart = y * (stride + 1);
400
+ const filter = inflated[rowStart]!;
401
+ const line = inflated.subarray(rowStart + 1, rowStart + 1 + stride);
402
+ const row = out.subarray(y * stride, y * stride + stride);
403
+ const prior = y > 0 ? out.subarray((y - 1) * stride, y * stride) : null;
404
+
405
+ // Filter 0 is a straight copy — the common case for our own encoder, and
406
+ // worth not walking byte by byte.
407
+ if (filter === 0) {
408
+ row.set(line);
409
+ continue;
410
+ }
411
+
412
+ for (let x = 0; x < stride; x++) {
413
+ // a = pixel to the left, b = pixel above, c = pixel above-left.
414
+ const a = x >= channels ? row[x - channels]! : 0;
415
+ const b = prior ? prior[x]! : 0;
416
+ const c = prior && x >= channels ? prior[x - channels]! : 0;
417
+ let value = line[x]!;
418
+
419
+ switch (filter) {
420
+ case 1:
421
+ value += a;
422
+ break;
423
+ case 2:
424
+ value += b;
425
+ break;
426
+ case 3:
427
+ value += (a + b) >> 1;
428
+ break;
429
+ case 4: {
430
+ // Paeth: pick whichever neighbour the linear predictor lands nearest.
431
+ const p = a + b - c;
432
+ const pa = Math.abs(p - a);
433
+ const pb = Math.abs(p - b);
434
+ const pc = Math.abs(p - c);
435
+ value += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
436
+ break;
437
+ }
438
+ default:
439
+ throw new RasterFormatError(`unknown scanline filter ${filter} on row ${y}`);
440
+ }
441
+
442
+ row[x] = value & 0xff;
443
+ }
444
+ }
445
+
446
+ return out;
447
+ }
448
+
449
+ /** Widen grey / grey+alpha / RGB samples to RGBA. */
450
+ function _toRgba(raw: Uint8Array, width: number, height: number, channels: number): Uint8Array {
451
+ const count = width * height;
452
+ const rgba = new Uint8Array(count * 4);
453
+
454
+ for (let i = 0; i < count; i++) {
455
+ const from = i * channels;
456
+ const to = i * 4;
457
+ if (channels === 1 || channels === 2) {
458
+ const grey = raw[from]!;
459
+ rgba[to] = grey;
460
+ rgba[to + 1] = grey;
461
+ rgba[to + 2] = grey;
462
+ rgba[to + 3] = channels === 2 ? raw[from + 1]! : 0xff;
463
+ } else {
464
+ rgba[to] = raw[from]!;
465
+ rgba[to + 1] = raw[from + 1]!;
466
+ rgba[to + 2] = raw[from + 2]!;
467
+ rgba[to + 3] = 0xff;
468
+ }
469
+ }
470
+
471
+ return rgba;
472
+ }
473
+
474
+ /** Wrap `data` in a PNG chunk: length, type, payload, CRC. */
475
+ function _chunk(type: string, data: Uint8Array): Uint8Array {
476
+ const out = new Uint8Array(12 + data.length);
477
+ const view = new DataView(out.buffer);
478
+ view.setUint32(0, data.length);
479
+ for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
480
+ out.set(data, 8);
481
+ // The CRC covers the type and the payload, but not the length.
482
+ view.setUint32(8 + data.length, _crc32(out.subarray(4, 8 + data.length)));
483
+ return out;
484
+ }
485
+
486
+ const _CRC_TABLE = /* @__PURE__ */ (() => {
487
+ const table = new Uint32Array(256);
488
+ for (let n = 0; n < 256; n++) {
489
+ let c = n;
490
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
491
+ table[n] = c >>> 0;
492
+ }
493
+ return table;
494
+ })();
495
+
496
+ function _crc32(bytes: Uint8Array): number {
497
+ let crc = 0xffffffff;
498
+ for (const byte of bytes) crc = _CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
499
+ return (crc ^ 0xffffffff) >>> 0;
500
+ }
501
+
502
+ function _concat(parts: Uint8Array[]): Uint8Array {
503
+ const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
504
+ let at = 0;
505
+ for (const part of parts) {
506
+ out.set(part, at);
507
+ at += part.length;
508
+ }
509
+ return out;
510
+ }
package/src/errors.ts CHANGED
@@ -57,9 +57,9 @@ export class FileTooLargeError extends MediaError {
57
57
  /**
58
58
  * A conversion asked for something the active image driver cannot do.
59
59
  *
60
- * Overwhelmingly this is `fit: "cover"` on the default `BunImageDriver`:
61
- * `Bun.Image.resize()` accepts only `fit: 'fill' | 'inside'` and the class
62
- * exposes no crop primitive, so a centre-cropped thumbnail is not expressible.
60
+ * No longer thrown for `fit: "cover"` the default driver crops natively. It
61
+ * remains the way any driver reports a manipulation it cannot express, which a
62
+ * third-party `ImageDriver` may well need.
63
63
  */
64
64
  export class UnsupportedManipulationError extends MediaError {
65
65
  constructor(driver: string, what: string, fix: string) {
@@ -86,6 +86,30 @@ export class UnsupportedFormatError extends MediaError {
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * An intermediate raster buffer was not the shape the crop path expects.
91
+ *
92
+ * Cropping round-trips through a PNG that `Bun.Image` itself produced, so in
93
+ * practice this fires for one reason: a Bun upgrade changed what `.png()`
94
+ * emits. It is deliberately loud — the alternative to a named error here is
95
+ * silently misread pixels. A canary test asserts the expected shape so the
96
+ * change is caught in CI rather than in someone's thumbnails.
97
+ */
98
+ export class RasterFormatError extends MediaError {
99
+ constructor(detail: string) {
100
+ super(
101
+ `[Zerotal Media] Cannot read the intermediate image: ${detail}.\n` +
102
+ "This usually means the Bun version in use encodes PNG differently than " +
103
+ "the crop path expects.\n" +
104
+ 'Fix: report the Bun version, and set `driver: "sharp"` in config/media.ts ' +
105
+ "to route conversions around this path in the meantime.",
106
+ "E_MEDIA_RASTER_FORMAT",
107
+ 500,
108
+ { detail },
109
+ );
110
+ }
111
+ }
112
+
89
113
  /** A file could not be read back from the disk it was recorded on. */
90
114
  export class MediaFileMissingError extends MediaError {
91
115
  constructor(path: string, disk: string) {