@juspay/neurolink 12.28.1 → 12.28.2
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 +2 -2
- package/dist/adapters/imageFormatSupport.d.ts +115 -5
- package/dist/adapters/imageFormatSupport.js +424 -12
- package/dist/browser/neurolink.min.js +367 -367
- package/dist/types/file.d.ts +39 -0
- package/dist/utils/messageBuilder.js +154 -16
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
## [12.28.
|
|
1
|
+
## [12.28.2](https://github.com/juspay/neurolink/compare/v12.28.1...v12.28.2) (2026-09-26)
|
|
2
2
|
|
|
3
3
|
### Bug Fixes
|
|
4
4
|
|
|
5
|
-
- **(
|
|
5
|
+
- **(images):** honour EXIF orientation before sending an image to a provider ([8fc9088](https://github.com/juspay/neurolink/commit/8fc908837a5b85b1c2dcfc2e3d4c39577ec0abd0))
|
|
6
6
|
|
|
7
7
|
## [11.2.3](https://github.com/juspay/neurolink/compare/v11.2.2...v11.2.3) (2026-08-19)
|
|
8
8
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*
|
|
20
20
|
* @module adapters/imageFormatSupport
|
|
21
21
|
*/
|
|
22
|
-
import type { ImageWithAltText, VisionImageConversion,
|
|
22
|
+
import type { ExifOrientationProbe, ImageOrientationNormalization, ImageWithAltText, VisionImageConversion, VisionTranscodeOptions } from "../types/index.js";
|
|
23
23
|
/**
|
|
24
24
|
* MIME types every vision-capable provider accepts as-is.
|
|
25
25
|
*
|
|
@@ -45,6 +45,53 @@ export declare const SUPPORTED_INPUT_IMAGE_MIME_TYPES: ReadonlySet<string>;
|
|
|
45
45
|
* reading a file off disk that would not have been converted anyway.
|
|
46
46
|
*/
|
|
47
47
|
export declare function needsVisionTranscode(mimeType: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* True when a MIME type's format can carry an orientation tag at all. Cheap
|
|
50
|
+
* enough to call on every image; callers use it to skip reading a file off
|
|
51
|
+
* disk (or invoking sharp) for a format that structurally cannot need it.
|
|
52
|
+
*/
|
|
53
|
+
export declare function mayCarryExifOrientation(mimeType: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Leading bytes {@link probeExifOrientation} needs to reach a verdict on the
|
|
56
|
+
* containers it parses.
|
|
57
|
+
*
|
|
58
|
+
* A JPEG's EXIF block lives in an APP1 segment whose length field is 16 bits
|
|
59
|
+
* wide, so the segment cannot exceed 64 KiB — and IFD0, which holds the
|
|
60
|
+
* orientation tag, sits at the very front of it. One window this size
|
|
61
|
+
* therefore spans the entire tag block of any file written EXIF-first, which
|
|
62
|
+
* is every file a camera or phone produces. WebP settles in 21 bytes; PNG
|
|
63
|
+
* walks complete chunks through the first IDAT and falls back if this window
|
|
64
|
+
* ends first.
|
|
65
|
+
*
|
|
66
|
+
* Sized deliberately in terms of what the format guarantees rather than what
|
|
67
|
+
* a sample of files happens to need: a window chosen by measurement would
|
|
68
|
+
* turn an unusual-but-legal layout into a silent wrong answer, whereas
|
|
69
|
+
* running out of this one returns `"inconclusive"` and costs only the read it
|
|
70
|
+
* was trying to avoid.
|
|
71
|
+
*/
|
|
72
|
+
export declare const EXIF_PROBE_PREFIX_BYTES: number;
|
|
73
|
+
/**
|
|
74
|
+
* Decide from a bounded header prefix whether an image carries an EXIF
|
|
75
|
+
* orientation worth acting on — without decoding it, and without needing all
|
|
76
|
+
* of its bytes.
|
|
77
|
+
*
|
|
78
|
+
* This exists because the orientation question is asked of every JPEG, PNG
|
|
79
|
+
* and WebP a caller supplies, while the answer is "no" for almost all of them.
|
|
80
|
+
* Answering it with a decoder means reading the whole file into memory and
|
|
81
|
+
* loading sharp to look at a header; answering it here costs
|
|
82
|
+
* {@link EXIF_PROBE_PREFIX_BYTES} at most, and usually far less.
|
|
83
|
+
*
|
|
84
|
+
* The verdict is deliberately three-valued. `"absent"` is a promise — the
|
|
85
|
+
* container was parsed far enough to rule an actionable tag out — and callers
|
|
86
|
+
* may skip the expensive path on it. Everything else, including every
|
|
87
|
+
* container this probe does not parse (HEIC, HEIF, AVIF: ISOBMFF, where
|
|
88
|
+
* orientation is an item property rather than a header field), comes back
|
|
89
|
+
* `"inconclusive"` and must fall through to the decoder.
|
|
90
|
+
*
|
|
91
|
+
* @param prefix - Leading bytes of the image; the whole image is also fine.
|
|
92
|
+
* @param mimeType - Detected MIME type of the image `prefix` came from.
|
|
93
|
+
*/
|
|
94
|
+
export declare function probeExifOrientation(prefix: Buffer, mimeType: string): ExifOrientationProbe;
|
|
48
95
|
/**
|
|
49
96
|
* Return image bytes every vision provider can read, transcoding to PNG when
|
|
50
97
|
* the source format is one no provider accepts.
|
|
@@ -55,13 +102,76 @@ export declare function needsVisionTranscode(mimeType: string): boolean;
|
|
|
55
102
|
* compatibility step becoming a new way for a previously working request to
|
|
56
103
|
* break.
|
|
57
104
|
*
|
|
105
|
+
* `options.autoOrient` folds an EXIF orientation correction into the same
|
|
106
|
+
* decode, for a format that needs both (HEIC, HEIF, TIFF, AVIF are in both
|
|
107
|
+
* sets). The sharp backend honours it explicitly, via `.rotate()`.
|
|
108
|
+
*
|
|
109
|
+
* The ffmpeg backends take no flag for it, and `-autorotate` would only
|
|
110
|
+
* restate a default rather than fix anything — but that is established for
|
|
111
|
+
* one orientation mechanism only, so read the scope before relying on it.
|
|
112
|
+
*
|
|
113
|
+
* **Measured — EXIF-IFD orientation.** A 300x150 JPEG tagged `Orientation=6`
|
|
114
|
+
* came back 150x300 with the tag consumed on system ffmpeg 8.1.1 and on the
|
|
115
|
+
* bundled `ffmpeg-static` 6.0, and stayed 300x150 on both only when
|
|
116
|
+
* `-noautorotate` was forced, which is the control showing the default did
|
|
117
|
+
* the work. TIFF stores orientation the same way.
|
|
118
|
+
*
|
|
119
|
+
* ⚠️ **Not measured — ISOBMFF `irot`/`imir`.** HEIC, HEIF and AVIF carry
|
|
120
|
+
* orientation as item properties, a structurally different mechanism the
|
|
121
|
+
* EXIF result says nothing about. That gap matters more than its size
|
|
122
|
+
* suggests: HEVC-coded HEIC is the main reason this fallback exists at all,
|
|
123
|
+
* since sharp cannot decode it, whereas the JPEG used above never reaches
|
|
124
|
+
* ffmpeg in production — JPEG is universal and is never transcoded, so the
|
|
125
|
+
* fixture demonstrates the binaries' behaviour rather than a path a request
|
|
126
|
+
* takes. Whether an `irot`-bearing HEIC is corrected here is untested in
|
|
127
|
+
* both directions; treat it as unknown rather than working.
|
|
128
|
+
*
|
|
129
|
+
* `autoOrient` is in any case a request rather than a guarantee, exactly like
|
|
130
|
+
* `converted`: a source no backend can decode is returned untouched.
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ Two further things would quietly break the measured case, and neither
|
|
133
|
+
* fails a test, because the fused path reports `converted: true` either way.
|
|
134
|
+
* Passing `-noautorotate`, or an ffmpeg build defaulting it off, drops the
|
|
135
|
+
* rotation. And the sharp-first ordering in {@link transcodeBackends} is what
|
|
136
|
+
* keeps the two backends' orientation behaviour equivalent rather than merely
|
|
137
|
+
* similar — sharp strips the tag while ffmpeg's PNG output carries none to
|
|
138
|
+
* begin with.
|
|
139
|
+
*
|
|
140
|
+
* @param buffer - Raw image bytes.
|
|
141
|
+
* @param mimeType - Detected MIME type of `buffer`.
|
|
142
|
+
* @param options - See {@link VisionTranscodeOptions}. `outputFormat`
|
|
143
|
+
* defaults to `"png"` — see the module header for why that stays the
|
|
144
|
+
* default for every caller who doesn't opt into `"jpeg"` explicitly.
|
|
145
|
+
*/
|
|
146
|
+
export declare function toVisionCompatibleImage(buffer: Buffer, mimeType: string, options?: VisionTranscodeOptions): Promise<VisionImageConversion>;
|
|
147
|
+
/**
|
|
148
|
+
* Auto-orient an image from its EXIF/XMP tag and strip the tag, so a photo
|
|
149
|
+
* that was stored rotated (the common case for phone cameras, since the
|
|
150
|
+
* sensor is read in one fixed orientation and rotation is recorded as a flag
|
|
151
|
+
* rather than baked into the pixels) is sent upright instead of sideways.
|
|
152
|
+
*
|
|
153
|
+
* Three costs are guarded deliberately, matching how `toVisionCompatibleImage`
|
|
154
|
+
* guards its own:
|
|
155
|
+
* - **Cost**: `mayCarryExifOrientation` skips formats that cannot carry the
|
|
156
|
+
* tag; `probeExifOrientation` then settles the formats that can from their
|
|
157
|
+
* own header bytes, so the overwhelmingly common orientation-less JPEG or
|
|
158
|
+
* WebP never loads sharp at all. Only a container the probe cannot rule
|
|
159
|
+
* out reaches a `metadata()` call, and only a tag that is present and not
|
|
160
|
+
* the already-upright value `1` reaches the decode-and-re-encode.
|
|
161
|
+
* - **Availability**: sharp is an optional dependency (the ffmpeg fallback
|
|
162
|
+
* path above exists precisely because it can be absent). Any failure —
|
|
163
|
+
* missing package, malformed pixels, a timeout — is caught here and
|
|
164
|
+
* degrades to returning the original bytes unchanged, never throwing.
|
|
165
|
+
* - **Size**: re-encoding changes byte length. This function only returns the
|
|
166
|
+
* new bytes; it does not itself re-validate size limits. Callers that
|
|
167
|
+
* re-encode must re-run their size guard on the returned buffer.
|
|
168
|
+
*
|
|
169
|
+
* Never throws for image reasons — mirrors `toVisionCompatibleImage`.
|
|
170
|
+
*
|
|
58
171
|
* @param buffer - Raw image bytes.
|
|
59
172
|
* @param mimeType - Detected MIME type of `buffer`.
|
|
60
|
-
* @param outputFormat - Transcode target. Defaults to `"png"` — see the
|
|
61
|
-
* module header for why that stays the default for every caller who
|
|
62
|
-
* doesn't opt into `"jpeg"` explicitly.
|
|
63
173
|
*/
|
|
64
|
-
export declare function
|
|
174
|
+
export declare function normalizeImageOrientation(buffer: Buffer, mimeType: string): Promise<ImageOrientationNormalization>;
|
|
65
175
|
/**
|
|
66
176
|
* Unwrap an `input.images` entry to its payload.
|
|
67
177
|
*
|
|
@@ -102,6 +102,295 @@ export function needsVisionTranscode(mimeType) {
|
|
|
102
102
|
return (!UNIVERSAL_VISION_IMAGE_MIME_TYPES.has(normalized) &&
|
|
103
103
|
TRANSCODABLE_IMAGE_MIME_TYPES.has(normalized));
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Formats whose spec allows an EXIF/XMP orientation tag.
|
|
107
|
+
*
|
|
108
|
+
* GIF has no such field, so it is deliberately absent. PNG 1.5 added the
|
|
109
|
+
* `eXIf` chunk and is included; the bounded chunk walk below keeps ordinary
|
|
110
|
+
* PNGs off sharp's metadata path just as the JPEG/WebP probes do.
|
|
111
|
+
*/
|
|
112
|
+
const EXIF_ORIENTABLE_MIME_TYPES = new Set([
|
|
113
|
+
"image/png",
|
|
114
|
+
"image/jpeg",
|
|
115
|
+
"image/tiff",
|
|
116
|
+
"image/x-tiff",
|
|
117
|
+
"image/webp",
|
|
118
|
+
"image/avif",
|
|
119
|
+
"image/heic",
|
|
120
|
+
"image/heic-sequence",
|
|
121
|
+
"image/heif",
|
|
122
|
+
"image/heif-sequence",
|
|
123
|
+
]);
|
|
124
|
+
/**
|
|
125
|
+
* True when a MIME type's format can carry an orientation tag at all. Cheap
|
|
126
|
+
* enough to call on every image; callers use it to skip reading a file off
|
|
127
|
+
* disk (or invoking sharp) for a format that structurally cannot need it.
|
|
128
|
+
*/
|
|
129
|
+
export function mayCarryExifOrientation(mimeType) {
|
|
130
|
+
const normalized = mimeType.split(";")[0].trim().toLowerCase();
|
|
131
|
+
return EXIF_ORIENTABLE_MIME_TYPES.has(normalized);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Leading bytes {@link probeExifOrientation} needs to reach a verdict on the
|
|
135
|
+
* containers it parses.
|
|
136
|
+
*
|
|
137
|
+
* A JPEG's EXIF block lives in an APP1 segment whose length field is 16 bits
|
|
138
|
+
* wide, so the segment cannot exceed 64 KiB — and IFD0, which holds the
|
|
139
|
+
* orientation tag, sits at the very front of it. One window this size
|
|
140
|
+
* therefore spans the entire tag block of any file written EXIF-first, which
|
|
141
|
+
* is every file a camera or phone produces. WebP settles in 21 bytes; PNG
|
|
142
|
+
* walks complete chunks through the first IDAT and falls back if this window
|
|
143
|
+
* ends first.
|
|
144
|
+
*
|
|
145
|
+
* Sized deliberately in terms of what the format guarantees rather than what
|
|
146
|
+
* a sample of files happens to need: a window chosen by measurement would
|
|
147
|
+
* turn an unusual-but-legal layout into a silent wrong answer, whereas
|
|
148
|
+
* running out of this one returns `"inconclusive"` and costs only the read it
|
|
149
|
+
* was trying to avoid.
|
|
150
|
+
*/
|
|
151
|
+
export const EXIF_PROBE_PREFIX_BYTES = 64 * 1024;
|
|
152
|
+
/** Orientation, tag 0x0112 of TIFF/EXIF IFD0. */
|
|
153
|
+
const EXIF_TAG_ORIENTATION = 0x0112;
|
|
154
|
+
/** TIFF field types this probe knows how to read a scalar out of. */
|
|
155
|
+
const TIFF_TYPE_SHORT = 3;
|
|
156
|
+
const TIFF_TYPE_LONG = 4;
|
|
157
|
+
function readUint16(buffer, offset, littleEndian) {
|
|
158
|
+
if (offset < 0 || offset + 2 > buffer.length) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
return littleEndian
|
|
162
|
+
? buffer.readUInt16LE(offset)
|
|
163
|
+
: buffer.readUInt16BE(offset);
|
|
164
|
+
}
|
|
165
|
+
function readUint32(buffer, offset, littleEndian) {
|
|
166
|
+
if (offset < 0 || offset + 4 > buffer.length) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
return littleEndian
|
|
170
|
+
? buffer.readUInt32LE(offset)
|
|
171
|
+
: buffer.readUInt32BE(offset);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Read the orientation tag out of a TIFF header block — the payload of a JPEG
|
|
175
|
+
* APP1 segment past its `Exif\0\0` signature, or a `.tif` file's own opening
|
|
176
|
+
* bytes, which are the same structure.
|
|
177
|
+
*
|
|
178
|
+
* Only IFD0 is walked. That is where every writer puts orientation, and it is
|
|
179
|
+
* also the only IFD libvips reads it from, so a deeper walk would find tags
|
|
180
|
+
* that the decoder this probe is standing in for would itself ignore.
|
|
181
|
+
*/
|
|
182
|
+
function probeTiffBlockOrientation(tiff) {
|
|
183
|
+
const byteOrder = readUint16(tiff, 0, false);
|
|
184
|
+
const littleEndian = byteOrder === 0x4949; // "II"
|
|
185
|
+
if (!littleEndian && byteOrder !== 0x4d4d /* "MM" */) {
|
|
186
|
+
return "inconclusive";
|
|
187
|
+
}
|
|
188
|
+
if (readUint16(tiff, 2, littleEndian) !== 42) {
|
|
189
|
+
return "inconclusive";
|
|
190
|
+
}
|
|
191
|
+
const ifdOffset = readUint32(tiff, 4, littleEndian);
|
|
192
|
+
if (ifdOffset === undefined) {
|
|
193
|
+
return "inconclusive";
|
|
194
|
+
}
|
|
195
|
+
const entryCount = readUint16(tiff, ifdOffset, littleEndian);
|
|
196
|
+
if (entryCount === undefined) {
|
|
197
|
+
return "inconclusive";
|
|
198
|
+
}
|
|
199
|
+
// Every entry must be inside the window before "no orientation tag here"
|
|
200
|
+
// can mean anything — a half-read IFD is a question, not an answer.
|
|
201
|
+
if (ifdOffset + 2 + entryCount * 12 > tiff.length) {
|
|
202
|
+
return "inconclusive";
|
|
203
|
+
}
|
|
204
|
+
for (let i = 0; i < entryCount; i++) {
|
|
205
|
+
const entry = ifdOffset + 2 + i * 12;
|
|
206
|
+
if (readUint16(tiff, entry, littleEndian) !== EXIF_TAG_ORIENTATION) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const fieldType = readUint16(tiff, entry + 2, littleEndian);
|
|
210
|
+
// A scalar that fits in four bytes is stored left-justified in the value
|
|
211
|
+
// field, so both widths start at the same place. An exotic field type is
|
|
212
|
+
// left to the decoder rather than guessed at.
|
|
213
|
+
const value = fieldType === TIFF_TYPE_SHORT
|
|
214
|
+
? readUint16(tiff, entry + 8, littleEndian)
|
|
215
|
+
: fieldType === TIFF_TYPE_LONG
|
|
216
|
+
? readUint32(tiff, entry + 8, littleEndian)
|
|
217
|
+
: undefined;
|
|
218
|
+
if (value === undefined) {
|
|
219
|
+
return "inconclusive";
|
|
220
|
+
}
|
|
221
|
+
// Matches `normalizeImageOrientation`'s own condition: 1 is upright and 0
|
|
222
|
+
// is the absent/invalid encoding, and neither is worth a re-encode.
|
|
223
|
+
return value <= 1 ? "absent" : "present";
|
|
224
|
+
}
|
|
225
|
+
return "absent";
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Walk a JPEG's marker segments looking for the EXIF APP1.
|
|
229
|
+
*
|
|
230
|
+
* Reaching SOS or EOI is a conclusive "absent": metadata segments are
|
|
231
|
+
* required to precede the entropy-coded scan, so nothing past that point can
|
|
232
|
+
* introduce one. Running off the end of the window is not conclusive and says
|
|
233
|
+
* so.
|
|
234
|
+
*/
|
|
235
|
+
function probeJpegOrientation(prefix) {
|
|
236
|
+
if (prefix.length < 4 || prefix.readUInt16BE(0) !== 0xffd8 /* SOI */) {
|
|
237
|
+
// The extension claimed JPEG and the bytes disagree. Whatever this is,
|
|
238
|
+
// the decoder gets to say so.
|
|
239
|
+
return "inconclusive";
|
|
240
|
+
}
|
|
241
|
+
let offset = 2;
|
|
242
|
+
while (offset + 4 <= prefix.length) {
|
|
243
|
+
if (prefix[offset] !== 0xff) {
|
|
244
|
+
return "inconclusive";
|
|
245
|
+
}
|
|
246
|
+
// Any run of 0xFF ahead of the marker id is legal fill.
|
|
247
|
+
let markerAt = offset + 1;
|
|
248
|
+
while (markerAt < prefix.length && prefix[markerAt] === 0xff) {
|
|
249
|
+
markerAt++;
|
|
250
|
+
}
|
|
251
|
+
if (markerAt >= prefix.length) {
|
|
252
|
+
return "inconclusive";
|
|
253
|
+
}
|
|
254
|
+
const marker = prefix[markerAt];
|
|
255
|
+
if (marker === 0xd9 /* EOI */ || marker === 0xda /* SOS */) {
|
|
256
|
+
return "absent";
|
|
257
|
+
}
|
|
258
|
+
// Standalone markers carry no length field to skip over.
|
|
259
|
+
if (marker === 0x01 ||
|
|
260
|
+
marker === 0xd8 ||
|
|
261
|
+
(marker >= 0xd0 && marker <= 0xd7)) {
|
|
262
|
+
offset = markerAt + 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const length = readUint16(prefix, markerAt + 1, false);
|
|
266
|
+
if (length === undefined || length < 2) {
|
|
267
|
+
return "inconclusive";
|
|
268
|
+
}
|
|
269
|
+
const payloadStart = markerAt + 3;
|
|
270
|
+
const segmentEnd = markerAt + 1 + length;
|
|
271
|
+
if (marker === 0xe1 /* APP1 */) {
|
|
272
|
+
if (segmentEnd > prefix.length) {
|
|
273
|
+
return "inconclusive";
|
|
274
|
+
}
|
|
275
|
+
const payload = prefix.subarray(payloadStart, segmentEnd);
|
|
276
|
+
if (payload.length > 6 &&
|
|
277
|
+
payload.subarray(0, 4).toString("latin1") === "Exif" &&
|
|
278
|
+
payload[4] === 0x00 &&
|
|
279
|
+
payload[5] === 0x00) {
|
|
280
|
+
return probeTiffBlockOrientation(payload.subarray(6));
|
|
281
|
+
}
|
|
282
|
+
// Some other APP1 — an XMP packet, most often. Keep walking; the EXIF
|
|
283
|
+
// one may still be ahead.
|
|
284
|
+
}
|
|
285
|
+
offset = segmentEnd;
|
|
286
|
+
}
|
|
287
|
+
return "inconclusive";
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Read the VP8X feature flags of a WebP container.
|
|
291
|
+
*
|
|
292
|
+
* A simple-format file (`VP8 ` / `VP8L` as its only chunk) has nowhere to put
|
|
293
|
+
* metadata, which settles it outright. An extended file advertises whether an
|
|
294
|
+
* EXIF chunk exists anywhere in the file, and that flag alone is enough to
|
|
295
|
+
* clear the common case — the chunk itself is conventionally written after
|
|
296
|
+
* the image data, so a set flag is where a header prefix stops being able to
|
|
297
|
+
* answer.
|
|
298
|
+
*/
|
|
299
|
+
function probeWebpOrientation(prefix) {
|
|
300
|
+
if (prefix.length < 16 ||
|
|
301
|
+
prefix.subarray(0, 4).toString("latin1") !== "RIFF" ||
|
|
302
|
+
prefix.subarray(8, 12).toString("latin1") !== "WEBP") {
|
|
303
|
+
return "inconclusive";
|
|
304
|
+
}
|
|
305
|
+
const firstChunk = prefix.subarray(12, 16).toString("latin1");
|
|
306
|
+
if (firstChunk === "VP8 " || firstChunk === "VP8L") {
|
|
307
|
+
return "absent";
|
|
308
|
+
}
|
|
309
|
+
// fourCC (4) + chunk length (4) puts the flags byte at 20.
|
|
310
|
+
if (firstChunk !== "VP8X" || prefix.length < 21) {
|
|
311
|
+
return "inconclusive";
|
|
312
|
+
}
|
|
313
|
+
const EXIF_CHUNK_FLAG = 0x08;
|
|
314
|
+
return (prefix[20] & EXIF_CHUNK_FLAG) === 0 ? "absent" : "inconclusive";
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Walk complete PNG chunks through the first IDAT looking for `eXIf`.
|
|
318
|
+
*
|
|
319
|
+
* PNG requires metadata chunks to precede image data, so reaching IDAT is a
|
|
320
|
+
* conclusive absence. Running out inside any preceding chunk is not: the next
|
|
321
|
+
* complete chunk might be `eXIf`, and treating a bounded read as the whole
|
|
322
|
+
* file would turn the performance probe into a correctness bug.
|
|
323
|
+
*/
|
|
324
|
+
function probePngOrientation(prefix) {
|
|
325
|
+
const signature = Buffer.from([
|
|
326
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
327
|
+
]);
|
|
328
|
+
if (prefix.length < signature.length ||
|
|
329
|
+
!prefix.subarray(0, 8).equals(signature)) {
|
|
330
|
+
return "inconclusive";
|
|
331
|
+
}
|
|
332
|
+
let offset = signature.length;
|
|
333
|
+
let sawHeader = false;
|
|
334
|
+
while (offset + 12 <= prefix.length) {
|
|
335
|
+
const length = prefix.readUInt32BE(offset);
|
|
336
|
+
const type = prefix.subarray(offset + 4, offset + 8).toString("latin1");
|
|
337
|
+
const end = offset + 12 + length;
|
|
338
|
+
if (end > prefix.length) {
|
|
339
|
+
return "inconclusive";
|
|
340
|
+
}
|
|
341
|
+
if (!sawHeader) {
|
|
342
|
+
if (type !== "IHDR") {
|
|
343
|
+
return "inconclusive";
|
|
344
|
+
}
|
|
345
|
+
sawHeader = true;
|
|
346
|
+
}
|
|
347
|
+
else if (type === "eXIf") {
|
|
348
|
+
return probeTiffBlockOrientation(prefix.subarray(offset + 8, offset + 8 + length));
|
|
349
|
+
}
|
|
350
|
+
else if (type === "IDAT" || type === "IEND") {
|
|
351
|
+
return "absent";
|
|
352
|
+
}
|
|
353
|
+
offset = end;
|
|
354
|
+
}
|
|
355
|
+
return "inconclusive";
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Decide from a bounded header prefix whether an image carries an EXIF
|
|
359
|
+
* orientation worth acting on — without decoding it, and without needing all
|
|
360
|
+
* of its bytes.
|
|
361
|
+
*
|
|
362
|
+
* This exists because the orientation question is asked of every JPEG, PNG
|
|
363
|
+
* and WebP a caller supplies, while the answer is "no" for almost all of them.
|
|
364
|
+
* Answering it with a decoder means reading the whole file into memory and
|
|
365
|
+
* loading sharp to look at a header; answering it here costs
|
|
366
|
+
* {@link EXIF_PROBE_PREFIX_BYTES} at most, and usually far less.
|
|
367
|
+
*
|
|
368
|
+
* The verdict is deliberately three-valued. `"absent"` is a promise — the
|
|
369
|
+
* container was parsed far enough to rule an actionable tag out — and callers
|
|
370
|
+
* may skip the expensive path on it. Everything else, including every
|
|
371
|
+
* container this probe does not parse (HEIC, HEIF, AVIF: ISOBMFF, where
|
|
372
|
+
* orientation is an item property rather than a header field), comes back
|
|
373
|
+
* `"inconclusive"` and must fall through to the decoder.
|
|
374
|
+
*
|
|
375
|
+
* @param prefix - Leading bytes of the image; the whole image is also fine.
|
|
376
|
+
* @param mimeType - Detected MIME type of the image `prefix` came from.
|
|
377
|
+
*/
|
|
378
|
+
export function probeExifOrientation(prefix, mimeType) {
|
|
379
|
+
const normalized = mimeType.split(";")[0].trim().toLowerCase();
|
|
380
|
+
switch (normalized) {
|
|
381
|
+
case "image/png":
|
|
382
|
+
return probePngOrientation(prefix);
|
|
383
|
+
case "image/jpeg":
|
|
384
|
+
return probeJpegOrientation(prefix);
|
|
385
|
+
case "image/webp":
|
|
386
|
+
return probeWebpOrientation(prefix);
|
|
387
|
+
case "image/tiff":
|
|
388
|
+
case "image/x-tiff":
|
|
389
|
+
return probeTiffBlockOrientation(prefix);
|
|
390
|
+
default:
|
|
391
|
+
return "inconclusive";
|
|
392
|
+
}
|
|
393
|
+
}
|
|
105
394
|
/**
|
|
106
395
|
* Decode with sharp. Covers TIFF, AVIF, GIF and SVG in-process with no temp
|
|
107
396
|
* files, which is the fast path.
|
|
@@ -111,7 +400,7 @@ export function needsVisionTranscode(mimeType) {
|
|
|
111
400
|
* only — actual HEVC-coded HEIC fails inside libheif, and BMP, ICO and
|
|
112
401
|
* JPEG 2000 are not compiled in at all. Those fall through to ffmpeg below.
|
|
113
402
|
*/
|
|
114
|
-
async function transcodeWithSharp(buffer, outputFormat) {
|
|
403
|
+
async function transcodeWithSharp(buffer, outputFormat, autoOrient) {
|
|
115
404
|
const sharpModule = await tryImport("sharp", "Image format conversion for vision providers");
|
|
116
405
|
// A shape guard, not a crash guard: an absent or malformed sharp already
|
|
117
406
|
// fails safely, because `tryImport` throws a named install error and any
|
|
@@ -130,19 +419,29 @@ async function transcodeWithSharp(buffer, outputFormat) {
|
|
|
130
419
|
// a multi-page TIFF) from being flattened into one tall strip — the first
|
|
131
420
|
// frame is what a vision model should receive.
|
|
132
421
|
const pipeline = sharpModule.default(buffer, { pages: 1 });
|
|
422
|
+
if (autoOrient && typeof pipeline.rotate !== "function") {
|
|
423
|
+
throw new Error("the installed sharp package returned a pipeline without a rotate() transform");
|
|
424
|
+
}
|
|
425
|
+
// `.rotate()` with no argument auto-orients from the EXIF tag and strips it,
|
|
426
|
+
// and is applied before the format-specific encoder regardless of which one
|
|
427
|
+
// runs, so an image needing both a transcode and an orientation fix costs one
|
|
428
|
+
// decode and one encode rather than two of each — and, for a source sharp
|
|
429
|
+
// writes lossily (TIFF defaults to JPEG compression), skips a whole
|
|
430
|
+
// generation of quality loss on the way.
|
|
431
|
+
const oriented = autoOrient ? pipeline.rotate() : pipeline;
|
|
133
432
|
if (outputFormat === "jpeg") {
|
|
134
433
|
// The instance shape is checked as well as the factory: a build that
|
|
135
434
|
// exports a callable but returns something without `.jpeg()` would
|
|
136
435
|
// otherwise fail as an opaque TypeError inside the backend loop.
|
|
137
|
-
if (typeof
|
|
436
|
+
if (typeof oriented?.jpeg !== "function") {
|
|
138
437
|
throw new Error("the installed sharp package returned a pipeline without a jpeg() encoder");
|
|
139
438
|
}
|
|
140
|
-
return
|
|
439
|
+
return oriented.jpeg().toBuffer();
|
|
141
440
|
}
|
|
142
|
-
if (typeof
|
|
441
|
+
if (typeof oriented?.png !== "function") {
|
|
143
442
|
throw new Error("the installed sharp package returned a pipeline without a png() encoder");
|
|
144
443
|
}
|
|
145
|
-
return
|
|
444
|
+
return oriented.png().toBuffer();
|
|
146
445
|
}
|
|
147
446
|
/**
|
|
148
447
|
* Decode with ffmpeg, which handles what sharp cannot — most importantly HEIC,
|
|
@@ -210,10 +509,10 @@ async function transcodeWithFfmpeg(buffer, extension, outputFormat, binaryPath)
|
|
|
210
509
|
* so when the resolved binary is not already the system one it is retried
|
|
211
510
|
* explicitly rather than reporting a photo as unsupported.
|
|
212
511
|
*/
|
|
213
|
-
async function* transcodeBackends(buffer, extension, outputFormat) {
|
|
512
|
+
async function* transcodeBackends(buffer, extension, outputFormat, autoOrient) {
|
|
214
513
|
yield {
|
|
215
514
|
name: "sharp",
|
|
216
|
-
run: () => transcodeWithSharp(buffer, outputFormat),
|
|
515
|
+
run: () => transcodeWithSharp(buffer, outputFormat, autoOrient),
|
|
217
516
|
};
|
|
218
517
|
const resolved = await getFfmpegPath().catch(() => "ffmpeg");
|
|
219
518
|
yield {
|
|
@@ -237,21 +536,58 @@ async function* transcodeBackends(buffer, extension, outputFormat) {
|
|
|
237
536
|
* compatibility step becoming a new way for a previously working request to
|
|
238
537
|
* break.
|
|
239
538
|
*
|
|
539
|
+
* `options.autoOrient` folds an EXIF orientation correction into the same
|
|
540
|
+
* decode, for a format that needs both (HEIC, HEIF, TIFF, AVIF are in both
|
|
541
|
+
* sets). The sharp backend honours it explicitly, via `.rotate()`.
|
|
542
|
+
*
|
|
543
|
+
* The ffmpeg backends take no flag for it, and `-autorotate` would only
|
|
544
|
+
* restate a default rather than fix anything — but that is established for
|
|
545
|
+
* one orientation mechanism only, so read the scope before relying on it.
|
|
546
|
+
*
|
|
547
|
+
* **Measured — EXIF-IFD orientation.** A 300x150 JPEG tagged `Orientation=6`
|
|
548
|
+
* came back 150x300 with the tag consumed on system ffmpeg 8.1.1 and on the
|
|
549
|
+
* bundled `ffmpeg-static` 6.0, and stayed 300x150 on both only when
|
|
550
|
+
* `-noautorotate` was forced, which is the control showing the default did
|
|
551
|
+
* the work. TIFF stores orientation the same way.
|
|
552
|
+
*
|
|
553
|
+
* ⚠️ **Not measured — ISOBMFF `irot`/`imir`.** HEIC, HEIF and AVIF carry
|
|
554
|
+
* orientation as item properties, a structurally different mechanism the
|
|
555
|
+
* EXIF result says nothing about. That gap matters more than its size
|
|
556
|
+
* suggests: HEVC-coded HEIC is the main reason this fallback exists at all,
|
|
557
|
+
* since sharp cannot decode it, whereas the JPEG used above never reaches
|
|
558
|
+
* ffmpeg in production — JPEG is universal and is never transcoded, so the
|
|
559
|
+
* fixture demonstrates the binaries' behaviour rather than a path a request
|
|
560
|
+
* takes. Whether an `irot`-bearing HEIC is corrected here is untested in
|
|
561
|
+
* both directions; treat it as unknown rather than working.
|
|
562
|
+
*
|
|
563
|
+
* `autoOrient` is in any case a request rather than a guarantee, exactly like
|
|
564
|
+
* `converted`: a source no backend can decode is returned untouched.
|
|
565
|
+
*
|
|
566
|
+
* ⚠️ Two further things would quietly break the measured case, and neither
|
|
567
|
+
* fails a test, because the fused path reports `converted: true` either way.
|
|
568
|
+
* Passing `-noautorotate`, or an ffmpeg build defaulting it off, drops the
|
|
569
|
+
* rotation. And the sharp-first ordering in {@link transcodeBackends} is what
|
|
570
|
+
* keeps the two backends' orientation behaviour equivalent rather than merely
|
|
571
|
+
* similar — sharp strips the tag while ffmpeg's PNG output carries none to
|
|
572
|
+
* begin with.
|
|
573
|
+
*
|
|
240
574
|
* @param buffer - Raw image bytes.
|
|
241
575
|
* @param mimeType - Detected MIME type of `buffer`.
|
|
242
|
-
* @param
|
|
243
|
-
* module header for why that stays the
|
|
244
|
-
* doesn't opt into `"jpeg"` explicitly.
|
|
576
|
+
* @param options - See {@link VisionTranscodeOptions}. `outputFormat`
|
|
577
|
+
* defaults to `"png"` — see the module header for why that stays the
|
|
578
|
+
* default for every caller who doesn't opt into `"jpeg"` explicitly.
|
|
245
579
|
*/
|
|
246
|
-
export async function toVisionCompatibleImage(buffer, mimeType,
|
|
580
|
+
export async function toVisionCompatibleImage(buffer, mimeType, options) {
|
|
247
581
|
if (!needsVisionTranscode(mimeType)) {
|
|
248
582
|
return { buffer, mimeType, converted: false };
|
|
249
583
|
}
|
|
250
584
|
const normalized = mimeType.split(";")[0].trim().toLowerCase();
|
|
251
585
|
const extension = extensionForMimeType(normalized) ?? ".bin";
|
|
586
|
+
const outputFormat = options?.outputFormat ?? DEFAULT_VISION_IMAGE_OUTPUT_FORMAT;
|
|
252
587
|
const targetMimeType = outputFormat === "jpeg" ? "image/jpeg" : "image/png";
|
|
588
|
+
const autoOrient = options?.autoOrient === true;
|
|
253
589
|
const failures = [];
|
|
254
|
-
for await (const backend of transcodeBackends(buffer, extension, outputFormat)) {
|
|
590
|
+
for await (const backend of transcodeBackends(buffer, extension, outputFormat, autoOrient)) {
|
|
255
591
|
try {
|
|
256
592
|
// Both backends can stall — sharp on a malformed stream, ffmpeg on a
|
|
257
593
|
// container it half-understands — and this runs inline on the request
|
|
@@ -275,6 +611,82 @@ export async function toVisionCompatibleImage(buffer, mimeType, outputFormat = D
|
|
|
275
611
|
`Tried ${failures.join("; ")}`);
|
|
276
612
|
return { buffer, mimeType, converted: false };
|
|
277
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Auto-orient an image from its EXIF/XMP tag and strip the tag, so a photo
|
|
616
|
+
* that was stored rotated (the common case for phone cameras, since the
|
|
617
|
+
* sensor is read in one fixed orientation and rotation is recorded as a flag
|
|
618
|
+
* rather than baked into the pixels) is sent upright instead of sideways.
|
|
619
|
+
*
|
|
620
|
+
* Three costs are guarded deliberately, matching how `toVisionCompatibleImage`
|
|
621
|
+
* guards its own:
|
|
622
|
+
* - **Cost**: `mayCarryExifOrientation` skips formats that cannot carry the
|
|
623
|
+
* tag; `probeExifOrientation` then settles the formats that can from their
|
|
624
|
+
* own header bytes, so the overwhelmingly common orientation-less JPEG or
|
|
625
|
+
* WebP never loads sharp at all. Only a container the probe cannot rule
|
|
626
|
+
* out reaches a `metadata()` call, and only a tag that is present and not
|
|
627
|
+
* the already-upright value `1` reaches the decode-and-re-encode.
|
|
628
|
+
* - **Availability**: sharp is an optional dependency (the ffmpeg fallback
|
|
629
|
+
* path above exists precisely because it can be absent). Any failure —
|
|
630
|
+
* missing package, malformed pixels, a timeout — is caught here and
|
|
631
|
+
* degrades to returning the original bytes unchanged, never throwing.
|
|
632
|
+
* - **Size**: re-encoding changes byte length. This function only returns the
|
|
633
|
+
* new bytes; it does not itself re-validate size limits. Callers that
|
|
634
|
+
* re-encode must re-run their size guard on the returned buffer.
|
|
635
|
+
*
|
|
636
|
+
* Never throws for image reasons — mirrors `toVisionCompatibleImage`.
|
|
637
|
+
*
|
|
638
|
+
* @param buffer - Raw image bytes.
|
|
639
|
+
* @param mimeType - Detected MIME type of `buffer`.
|
|
640
|
+
*/
|
|
641
|
+
export async function normalizeImageOrientation(buffer, mimeType) {
|
|
642
|
+
if (!mayCarryExifOrientation(mimeType)) {
|
|
643
|
+
return { buffer, normalized: false };
|
|
644
|
+
}
|
|
645
|
+
// Only "absent" is actionable here. "inconclusive" means the parse ran out
|
|
646
|
+
// of container it understands, which is a question for the decoder rather
|
|
647
|
+
// than a no — see {@link probeExifOrientation}.
|
|
648
|
+
if (probeExifOrientation(buffer, mimeType) === "absent") {
|
|
649
|
+
return { buffer, normalized: false };
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
const sharpModule = await tryImport("sharp", "EXIF orientation normalization");
|
|
653
|
+
if (typeof sharpModule?.default !== "function") {
|
|
654
|
+
throw new Error("the installed sharp package does not expose a callable default export");
|
|
655
|
+
}
|
|
656
|
+
// One shared deadline for the metadata read and the conditional re-encode,
|
|
657
|
+
// not one each. Both awaits are sequential, so two independent budgets
|
|
658
|
+
// are additive rather than shared: a metadata read that merely takes
|
|
659
|
+
// close to the full budget, followed by a rotate that stalls to its own
|
|
660
|
+
// full budget, costs close to double `IMAGE_TRANSCODE_TIMEOUT_MS` for one
|
|
661
|
+
// image before falling back — and that multiplies per image, since
|
|
662
|
+
// callers process `input.images` sequentially to preserve ordering. A
|
|
663
|
+
// single race caps the whole operation at one budget, matching every
|
|
664
|
+
// other timeout in this module (one per backend attempt).
|
|
665
|
+
const result = await withTimeout((async () => {
|
|
666
|
+
const metadata = await sharpModule.default(buffer).metadata();
|
|
667
|
+
// 1 is "already upright" (and the implicit value when the tag is
|
|
668
|
+
// absent altogether) — nothing to correct, so skip the re-encode
|
|
669
|
+
// entirely.
|
|
670
|
+
if (!metadata.orientation || metadata.orientation === 1) {
|
|
671
|
+
return { buffer, normalized: false };
|
|
672
|
+
}
|
|
673
|
+
const rotated = await sharpModule.default(buffer).rotate().toBuffer();
|
|
674
|
+
if (rotated.length === 0) {
|
|
675
|
+
throw new Error("auto-orient produced an empty image");
|
|
676
|
+
}
|
|
677
|
+
logger.debug(`[imageFormatSupport] Normalized EXIF orientation ${metadata.orientation} for ` +
|
|
678
|
+
`${mimeType} (${buffer.length} → ${rotated.length} bytes)`);
|
|
679
|
+
return { buffer: rotated, normalized: true };
|
|
680
|
+
})(), IMAGE_TRANSCODE_TIMEOUT_MS, new Error(`EXIF auto-orient exceeded ${IMAGE_TRANSCODE_TIMEOUT_MS}ms`));
|
|
681
|
+
return result;
|
|
682
|
+
}
|
|
683
|
+
catch (error) {
|
|
684
|
+
logger.warn(`[imageFormatSupport] Could not normalize EXIF orientation for ${mimeType} — ` +
|
|
685
|
+
`sending the original bytes as-is: ` +
|
|
686
|
+
`${error instanceof Error ? error.message : String(error)}`);
|
|
687
|
+
return { buffer, normalized: false };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
278
690
|
/**
|
|
279
691
|
* Whether an `input.images` entry is the `{ data, altText }` wrapper.
|
|
280
692
|
*
|