@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 CHANGED
@@ -1,8 +1,8 @@
1
- ## [12.28.1](https://github.com/juspay/neurolink/compare/v12.28.0...v12.28.1) (2026-09-26)
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
- - **(multimodal):** keep input.content text items on the text-only path ([a341481](https://github.com/juspay/neurolink/commit/a341481619c536ce97a2773ecb3f8155d0ff69aa)), closes [#289](https://github.com/juspay/neurolink/issues/289)
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, VisionImageOutputFormat } from "../types/index.js";
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 toVisionCompatibleImage(buffer: Buffer, mimeType: string, outputFormat?: VisionImageOutputFormat): Promise<VisionImageConversion>;
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 pipeline?.jpeg !== "function") {
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 pipeline.jpeg().toBuffer();
439
+ return oriented.jpeg().toBuffer();
141
440
  }
142
- if (typeof pipeline?.png !== "function") {
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 pipeline.png().toBuffer();
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 outputFormat - Transcode target. Defaults to `"png"` — see the
243
- * module header for why that stays the default for every caller who
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, outputFormat = DEFAULT_VISION_IMAGE_OUTPUT_FORMAT) {
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
  *