@zerotal/media 1.4.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.
- package/CHANGELOG.md +81 -1
- package/README.md +10 -6
- package/package.json +8 -6
- package/src/Media.ts +2 -3
- package/src/MediaItem.ts +1 -0
- package/src/collections/resolve.ts +5 -0
- package/src/collections/retention.ts +2 -0
- package/src/config.ts +1 -0
- package/src/conversions/BunImageDriver.ts +151 -43
- package/src/conversions/ConversionRunner.ts +5 -0
- package/src/conversions/ImageDriver.ts +46 -10
- package/src/conversions/SharpImageDriver.ts +58 -12
- package/src/conversions/dispatch.ts +3 -0
- package/src/conversions/raster.ts +510 -0
- package/src/errors.ts +27 -3
- package/src/index.ts +9 -21
- package/src/mediaSchemaConcern.ts +2 -0
- package/src/paths/PathGenerator.ts +1 -1
- package/src/sources.ts +8 -0
- package/src/support/disks.ts +4 -0
- package/src/testing.ts +35 -0
- package/src/types.ts +16 -2
|
@@ -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"
|
|
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
|
|
67
|
-
*
|
|
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 {
|
|
96
|
+
const { format, quality, rotate } = manipulation;
|
|
91
97
|
const sharp = await loadSharp();
|
|
92
98
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
fit,
|
|
102
|
-
withoutEnlargement:
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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) {
|