UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

719 lines (718 loc) • 33.2 kB
/** * Vision-provider image format compatibility. * * NeuroLink identifies far more image formats than any vision API accepts. BMP, * TIFF, AVIF, ICO and JPEG 2000 are accepted by none of them; HEIC and HEIF are * accepted only by Google. Until this module existed those formats were * detected correctly, labelled correctly, and then forwarded verbatim — the * request reached the provider and came back as an opaque HTTP 400, which is * the least useful outcome available: the file was clearly an image, NeuroLink * knew exactly which kind, and still nothing worked. * * A phone photo is the common case. iOS writes HEIC by default, so "attach a * photo and ask what is in it" failed for every provider except Google. * * Anything outside the universal set is transcoded to PNG. PNG rather than JPEG * because the sources are frequently lossless (TIFF, BMP, ICO) or already * carry alpha, and a lossy re-encode of an image the model is about to read * closely is the wrong default. * * @module adapters/imageFormatSupport */ import { extensionForMimeType } from "../processors/config/fileTypeRegistry.js"; import { withTimeout } from "../utils/errorHandling.js"; import { logger } from "../utils/logger.js"; import { tryImport } from "../utils/tryImport.js"; import { getFfmpegPath, JPEG_QUALITY, runFfmpeg, } from "./video/ffmpegAdapter.js"; /** * Default transcode target, and the one every existing caller keeps getting: * see the module header for why PNG rather than JPEG is the right default. */ const DEFAULT_VISION_IMAGE_OUTPUT_FORMAT = "png"; /** * Per-backend ceiling for one image conversion. * * Generous enough for a large TIFF or a HEIC burst frame on a loaded machine, * short enough that a hung decoder cannot hold a generation request open. */ const IMAGE_TRANSCODE_TIMEOUT_MS = 30_000; /** * MIME types every vision-capable provider accepts as-is. * * This is the intersection across OpenAI, Anthropic, Google (AI Studio and * Vertex), Bedrock, Azure and Mistral — deliberately the intersection and not * a per-provider matrix. Google additionally accepts HEIC/HEIF natively, but * converting those for Google as well costs one transcode and removes an * entire axis of provider-specific branching from the dispatch path. */ export const UNIVERSAL_VISION_IMAGE_MIME_TYPES = new Set([ "image/png", "image/jpeg", "image/gif", "image/webp", ]); /** * Formats that are transcoded to PNG before dispatch. * * An explicit allowlist rather than "anything not universal": a MIME type this * module does not recognise is more likely a mislabelled file than a format * sharp can decode, and passing it through unchanged preserves the provider's * own error message instead of replacing it with a decode failure here. */ const TRANSCODABLE_IMAGE_MIME_TYPES = new Set([ "image/bmp", "image/x-ms-bmp", "image/tiff", "image/x-tiff", "image/avif", "image/heic", "image/heic-sequence", "image/heif", "image/heif-sequence", "image/x-icon", "image/vnd.microsoft.icon", "image/jp2", "image/jpx", "image/apng", // SVG reaches this module only when it arrives as raw bytes in `input.images` // rather than through detection (which routes .svg to the sanitizer). No // vision provider accepts image/svg+xml, and sharp rasterises SVG natively, // so converting is strictly better than shipping markup labelled as an image. "image/svg+xml", ]); /** * Every image MIME type NeuroLink accepts as *input*. * * The union of what providers take as-is and what this module can convert for * them. Intake validation must use this rather than the universal set alone: * a format we can transcode is a format we accept, and gating intake on the * provider-acceptable list rejects the file before conversion ever runs. */ export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = new Set([ ...UNIVERSAL_VISION_IMAGE_MIME_TYPES, ...TRANSCODABLE_IMAGE_MIME_TYPES, ]); /** * True when a MIME type needs transcoding before it can be sent to a vision * provider. Cheap enough to call on every image; callers use it to avoid * reading a file off disk that would not have been converted anyway. */ export function needsVisionTranscode(mimeType) { const normalized = mimeType.split(";")[0].trim().toLowerCase(); return (!UNIVERSAL_VISION_IMAGE_MIME_TYPES.has(normalized) && TRANSCODABLE_IMAGE_MIME_TYPES.has(normalized)); } /** * Formats whose spec allows an EXIF/XMP orientation tag. * * GIF has no such field, so it is deliberately absent. PNG 1.5 added the * `eXIf` chunk and is included; the bounded chunk walk below keeps ordinary * PNGs off sharp's metadata path just as the JPEG/WebP probes do. */ const EXIF_ORIENTABLE_MIME_TYPES = new Set([ "image/png", "image/jpeg", "image/tiff", "image/x-tiff", "image/webp", "image/avif", "image/heic", "image/heic-sequence", "image/heif", "image/heif-sequence", ]); /** * True when a MIME type's format can carry an orientation tag at all. Cheap * enough to call on every image; callers use it to skip reading a file off * disk (or invoking sharp) for a format that structurally cannot need it. */ export function mayCarryExifOrientation(mimeType) { const normalized = mimeType.split(";")[0].trim().toLowerCase(); return EXIF_ORIENTABLE_MIME_TYPES.has(normalized); } /** * Leading bytes {@link probeExifOrientation} needs to reach a verdict on the * containers it parses. * * A JPEG's EXIF block lives in an APP1 segment whose length field is 16 bits * wide, so the segment cannot exceed 64 KiB — and IFD0, which holds the * orientation tag, sits at the very front of it. One window this size * therefore spans the entire tag block of any file written EXIF-first, which * is every file a camera or phone produces. WebP settles in 21 bytes; PNG * walks complete chunks through the first IDAT and falls back if this window * ends first. * * Sized deliberately in terms of what the format guarantees rather than what * a sample of files happens to need: a window chosen by measurement would * turn an unusual-but-legal layout into a silent wrong answer, whereas * running out of this one returns `"inconclusive"` and costs only the read it * was trying to avoid. */ export const EXIF_PROBE_PREFIX_BYTES = 64 * 1024; /** Orientation, tag 0x0112 of TIFF/EXIF IFD0. */ const EXIF_TAG_ORIENTATION = 0x0112; /** TIFF field types this probe knows how to read a scalar out of. */ const TIFF_TYPE_SHORT = 3; const TIFF_TYPE_LONG = 4; function readUint16(buffer, offset, littleEndian) { if (offset < 0 || offset + 2 > buffer.length) { return undefined; } return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset); } function readUint32(buffer, offset, littleEndian) { if (offset < 0 || offset + 4 > buffer.length) { return undefined; } return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset); } /** * Read the orientation tag out of a TIFF header block — the payload of a JPEG * APP1 segment past its `Exif\0\0` signature, or a `.tif` file's own opening * bytes, which are the same structure. * * Only IFD0 is walked. That is where every writer puts orientation, and it is * also the only IFD libvips reads it from, so a deeper walk would find tags * that the decoder this probe is standing in for would itself ignore. */ function probeTiffBlockOrientation(tiff) { const byteOrder = readUint16(tiff, 0, false); const littleEndian = byteOrder === 0x4949; // "II" if (!littleEndian && byteOrder !== 0x4d4d /* "MM" */) { return "inconclusive"; } if (readUint16(tiff, 2, littleEndian) !== 42) { return "inconclusive"; } const ifdOffset = readUint32(tiff, 4, littleEndian); if (ifdOffset === undefined) { return "inconclusive"; } const entryCount = readUint16(tiff, ifdOffset, littleEndian); if (entryCount === undefined) { return "inconclusive"; } // Every entry must be inside the window before "no orientation tag here" // can mean anything — a half-read IFD is a question, not an answer. if (ifdOffset + 2 + entryCount * 12 > tiff.length) { return "inconclusive"; } for (let i = 0; i < entryCount; i++) { const entry = ifdOffset + 2 + i * 12; if (readUint16(tiff, entry, littleEndian) !== EXIF_TAG_ORIENTATION) { continue; } const fieldType = readUint16(tiff, entry + 2, littleEndian); // A scalar that fits in four bytes is stored left-justified in the value // field, so both widths start at the same place. An exotic field type is // left to the decoder rather than guessed at. const value = fieldType === TIFF_TYPE_SHORT ? readUint16(tiff, entry + 8, littleEndian) : fieldType === TIFF_TYPE_LONG ? readUint32(tiff, entry + 8, littleEndian) : undefined; if (value === undefined) { return "inconclusive"; } // Matches `normalizeImageOrientation`'s own condition: 1 is upright and 0 // is the absent/invalid encoding, and neither is worth a re-encode. return value <= 1 ? "absent" : "present"; } return "absent"; } /** * Walk a JPEG's marker segments looking for the EXIF APP1. * * Reaching SOS or EOI is a conclusive "absent": metadata segments are * required to precede the entropy-coded scan, so nothing past that point can * introduce one. Running off the end of the window is not conclusive and says * so. */ function probeJpegOrientation(prefix) { if (prefix.length < 4 || prefix.readUInt16BE(0) !== 0xffd8 /* SOI */) { // The extension claimed JPEG and the bytes disagree. Whatever this is, // the decoder gets to say so. return "inconclusive"; } let offset = 2; while (offset + 4 <= prefix.length) { if (prefix[offset] !== 0xff) { return "inconclusive"; } // Any run of 0xFF ahead of the marker id is legal fill. let markerAt = offset + 1; while (markerAt < prefix.length && prefix[markerAt] === 0xff) { markerAt++; } if (markerAt >= prefix.length) { return "inconclusive"; } const marker = prefix[markerAt]; if (marker === 0xd9 /* EOI */ || marker === 0xda /* SOS */) { return "absent"; } // Standalone markers carry no length field to skip over. if (marker === 0x01 || marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7)) { offset = markerAt + 1; continue; } const length = readUint16(prefix, markerAt + 1, false); if (length === undefined || length < 2) { return "inconclusive"; } const payloadStart = markerAt + 3; const segmentEnd = markerAt + 1 + length; if (marker === 0xe1 /* APP1 */) { if (segmentEnd > prefix.length) { return "inconclusive"; } const payload = prefix.subarray(payloadStart, segmentEnd); if (payload.length > 6 && payload.subarray(0, 4).toString("latin1") === "Exif" && payload[4] === 0x00 && payload[5] === 0x00) { return probeTiffBlockOrientation(payload.subarray(6)); } // Some other APP1 — an XMP packet, most often. Keep walking; the EXIF // one may still be ahead. } offset = segmentEnd; } return "inconclusive"; } /** * Read the VP8X feature flags of a WebP container. * * A simple-format file (`VP8 ` / `VP8L` as its only chunk) has nowhere to put * metadata, which settles it outright. An extended file advertises whether an * EXIF chunk exists anywhere in the file, and that flag alone is enough to * clear the common case — the chunk itself is conventionally written after * the image data, so a set flag is where a header prefix stops being able to * answer. */ function probeWebpOrientation(prefix) { if (prefix.length < 16 || prefix.subarray(0, 4).toString("latin1") !== "RIFF" || prefix.subarray(8, 12).toString("latin1") !== "WEBP") { return "inconclusive"; } const firstChunk = prefix.subarray(12, 16).toString("latin1"); if (firstChunk === "VP8 " || firstChunk === "VP8L") { return "absent"; } // fourCC (4) + chunk length (4) puts the flags byte at 20. if (firstChunk !== "VP8X" || prefix.length < 21) { return "inconclusive"; } const EXIF_CHUNK_FLAG = 0x08; return (prefix[20] & EXIF_CHUNK_FLAG) === 0 ? "absent" : "inconclusive"; } /** * Walk complete PNG chunks through the first IDAT looking for `eXIf`. * * PNG requires metadata chunks to precede image data, so reaching IDAT is a * conclusive absence. Running out inside any preceding chunk is not: the next * complete chunk might be `eXIf`, and treating a bounded read as the whole * file would turn the performance probe into a correctness bug. */ function probePngOrientation(prefix) { const signature = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]); if (prefix.length < signature.length || !prefix.subarray(0, 8).equals(signature)) { return "inconclusive"; } let offset = signature.length; let sawHeader = false; while (offset + 12 <= prefix.length) { const length = prefix.readUInt32BE(offset); const type = prefix.subarray(offset + 4, offset + 8).toString("latin1"); const end = offset + 12 + length; if (end > prefix.length) { return "inconclusive"; } if (!sawHeader) { if (type !== "IHDR") { return "inconclusive"; } sawHeader = true; } else if (type === "eXIf") { return probeTiffBlockOrientation(prefix.subarray(offset + 8, offset + 8 + length)); } else if (type === "IDAT" || type === "IEND") { return "absent"; } offset = end; } return "inconclusive"; } /** * Decide from a bounded header prefix whether an image carries an EXIF * orientation worth acting on — without decoding it, and without needing all * of its bytes. * * This exists because the orientation question is asked of every JPEG, PNG * and WebP a caller supplies, while the answer is "no" for almost all of them. * Answering it with a decoder means reading the whole file into memory and * loading sharp to look at a header; answering it here costs * {@link EXIF_PROBE_PREFIX_BYTES} at most, and usually far less. * * The verdict is deliberately three-valued. `"absent"` is a promise — the * container was parsed far enough to rule an actionable tag out — and callers * may skip the expensive path on it. Everything else, including every * container this probe does not parse (HEIC, HEIF, AVIF: ISOBMFF, where * orientation is an item property rather than a header field), comes back * `"inconclusive"` and must fall through to the decoder. * * @param prefix - Leading bytes of the image; the whole image is also fine. * @param mimeType - Detected MIME type of the image `prefix` came from. */ export function probeExifOrientation(prefix, mimeType) { const normalized = mimeType.split(";")[0].trim().toLowerCase(); switch (normalized) { case "image/png": return probePngOrientation(prefix); case "image/jpeg": return probeJpegOrientation(prefix); case "image/webp": return probeWebpOrientation(prefix); case "image/tiff": case "image/x-tiff": return probeTiffBlockOrientation(prefix); default: return "inconclusive"; } } /** * Decode with sharp. Covers TIFF, AVIF, GIF and SVG in-process with no temp * files, which is the fast path. * * Deliberately does NOT cover every transcodable format: sharp's prebuilt * binaries report `heif` as an input format but that is AV1-in-HEIF (AVIF) * only — actual HEVC-coded HEIC fails inside libheif, and BMP, ICO and * JPEG 2000 are not compiled in at all. Those fall through to ffmpeg below. */ async function transcodeWithSharp(buffer, outputFormat, autoOrient) { const sharpModule = await tryImport("sharp", "Image format conversion for vision providers"); // A shape guard, not a crash guard: an absent or malformed sharp already // fails safely, because `tryImport` throws a named install error and any // TypeError from calling a non-function is caught by the backend loop, which // then tries ffmpeg. What this adds is a legible reason in that loop's // failure list instead of "sharpModule.default is not a function". // // It deliberately throws rather than returning `buffer`: returning the input // would report a successful conversion and relabel the original bytes under // the requested format, and would also skip the ffmpeg backend — the one // that actually handles HEIC, BMP, ICO and JPEG 2000. if (typeof sharpModule?.default !== "function") { throw new Error("the installed sharp package does not expose a callable default export"); } // `pages: 1` keeps a multi-frame source (animated AVIF, a .heics sequence, // a multi-page TIFF) from being flattened into one tall strip — the first // frame is what a vision model should receive. const pipeline = sharpModule.default(buffer, { pages: 1 }); if (autoOrient && typeof pipeline.rotate !== "function") { throw new Error("the installed sharp package returned a pipeline without a rotate() transform"); } // `.rotate()` with no argument auto-orients from the EXIF tag and strips it, // and is applied before the format-specific encoder regardless of which one // runs, so an image needing both a transcode and an orientation fix costs one // decode and one encode rather than two of each — and, for a source sharp // writes lossily (TIFF defaults to JPEG compression), skips a whole // generation of quality loss on the way. const oriented = autoOrient ? pipeline.rotate() : pipeline; if (outputFormat === "jpeg") { // The instance shape is checked as well as the factory: a build that // exports a callable but returns something without `.jpeg()` would // otherwise fail as an opaque TypeError inside the backend loop. if (typeof oriented?.jpeg !== "function") { throw new Error("the installed sharp package returned a pipeline without a jpeg() encoder"); } return oriented.jpeg().toBuffer(); } if (typeof oriented?.png !== "function") { throw new Error("the installed sharp package returned a pipeline without a png() encoder"); } return oriented.png().toBuffer(); } /** * Decode with ffmpeg, which handles what sharp cannot — most importantly HEIC, * the format iPhones write by default and therefore the single most common * "why can't the model see my photo" case. * * ffmpeg is already a soft dependency for video keyframe extraction and is * resolved through the same `FFMPEG_PATH` → `ffmpeg-static` → system-PATH * chain, so this adds a code path rather than a new requirement. * * Requires temp files: ffmpeg's image demuxers seek, so piping through stdin is * not reliable for these formats. The temp directory is removed in `finally` * whether or not the conversion succeeded. * * The Node builtins are imported dynamically rather than at module scope * because the browser bundle stubs `node:fs/promises` and its stub has no * `mkdtemp`. This whole path is server-only — nothing in a browser is going to * spawn ffmpeg — so the import belongs where it is used. */ async function transcodeWithFfmpeg(buffer, extension, outputFormat, binaryPath) { const [{ randomUUID }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join },] = await Promise.all([ import("node:crypto"), import("node:fs/promises"), import("node:os"), import("node:path"), ]); const workDir = await mkdtemp(join(tmpdir(), "neurolink-img-")); const inputPath = join(workDir, `${randomUUID()}${extension}`); // "mjpeg" is ffmpeg's codec name for a JPEG image2 stream; "png" doubles as // both the codec name and the output extension. const outputExtension = outputFormat === "jpeg" ? ".jpg" : ".png"; const codecArgs = outputFormat === "jpeg" ? ["-c:v", "mjpeg", "-q:v", JPEG_QUALITY] : ["-c:v", "png"]; const outputPath = join(workDir, `${randomUUID()}${outputExtension}`); try { await writeFile(inputPath, buffer); await runFfmpeg([ "-y", "-v", "error", "-i", inputPath, // Take a single frame so a multi-image container yields one output // image rather than ffmpeg erroring on a missing sequence pattern. "-frames:v", "1", "-f", "image2", ...codecArgs, outputPath, ], binaryPath ? { binaryPath } : {}); return await readFile(outputPath); } finally { await rm(workDir, { recursive: true, force: true }).catch(() => undefined); } } /** * The decode backends to try, in order, cheapest first. * * The two ffmpeg entries are not redundant. `getFfmpegPath()` prefers the * `ffmpeg-static` package, whose LGPL build omits HEVC and therefore cannot * read HEIC — the format iPhones write by default. A system ffmpeg usually can, * so when the resolved binary is not already the system one it is retried * explicitly rather than reporting a photo as unsupported. */ async function* transcodeBackends(buffer, extension, outputFormat, autoOrient) { yield { name: "sharp", run: () => transcodeWithSharp(buffer, outputFormat, autoOrient), }; const resolved = await getFfmpegPath().catch(() => "ffmpeg"); yield { name: "ffmpeg", run: () => transcodeWithFfmpeg(buffer, extension, outputFormat), }; if (resolved !== "ffmpeg") { yield { name: "system ffmpeg", run: () => transcodeWithFfmpeg(buffer, extension, outputFormat, "ffmpeg"), }; } } /** * Return image bytes every vision provider can read, transcoding to PNG when * the source format is one no provider accepts. * * Never throws for image reasons. When neither backend can decode the input, * the original bytes are returned with a warning naming the format — the * request then fails at the provider exactly as it did before, rather than this * compatibility step becoming a new way for a previously working request to * break. * * `options.autoOrient` folds an EXIF orientation correction into the same * decode, for a format that needs both (HEIC, HEIF, TIFF, AVIF are in both * sets). The sharp backend honours it explicitly, via `.rotate()`. * * The ffmpeg backends take no flag for it, and `-autorotate` would only * restate a default rather than fix anything — but that is established for * one orientation mechanism only, so read the scope before relying on it. * * **Measured — EXIF-IFD orientation.** A 300x150 JPEG tagged `Orientation=6` * came back 150x300 with the tag consumed on system ffmpeg 8.1.1 and on the * bundled `ffmpeg-static` 6.0, and stayed 300x150 on both only when * `-noautorotate` was forced, which is the control showing the default did * the work. TIFF stores orientation the same way. * * ⚠️ **Not measured — ISOBMFF `irot`/`imir`.** HEIC, HEIF and AVIF carry * orientation as item properties, a structurally different mechanism the * EXIF result says nothing about. That gap matters more than its size * suggests: HEVC-coded HEIC is the main reason this fallback exists at all, * since sharp cannot decode it, whereas the JPEG used above never reaches * ffmpeg in production — JPEG is universal and is never transcoded, so the * fixture demonstrates the binaries' behaviour rather than a path a request * takes. Whether an `irot`-bearing HEIC is corrected here is untested in * both directions; treat it as unknown rather than working. * * `autoOrient` is in any case a request rather than a guarantee, exactly like * `converted`: a source no backend can decode is returned untouched. * * ⚠️ Two further things would quietly break the measured case, and neither * fails a test, because the fused path reports `converted: true` either way. * Passing `-noautorotate`, or an ffmpeg build defaulting it off, drops the * rotation. And the sharp-first ordering in {@link transcodeBackends} is what * keeps the two backends' orientation behaviour equivalent rather than merely * similar — sharp strips the tag while ffmpeg's PNG output carries none to * begin with. * * @param buffer - Raw image bytes. * @param mimeType - Detected MIME type of `buffer`. * @param options - See {@link VisionTranscodeOptions}. `outputFormat` * defaults to `"png"` — see the module header for why that stays the * default for every caller who doesn't opt into `"jpeg"` explicitly. */ export async function toVisionCompatibleImage(buffer, mimeType, options) { if (!needsVisionTranscode(mimeType)) { return { buffer, mimeType, converted: false }; } const normalized = mimeType.split(";")[0].trim().toLowerCase(); const extension = extensionForMimeType(normalized) ?? ".bin"; const outputFormat = options?.outputFormat ?? DEFAULT_VISION_IMAGE_OUTPUT_FORMAT; const targetMimeType = outputFormat === "jpeg" ? "image/jpeg" : "image/png"; const autoOrient = options?.autoOrient === true; const failures = []; for await (const backend of transcodeBackends(buffer, extension, outputFormat, autoOrient)) { try { // Both backends can stall — sharp on a malformed stream, ffmpeg on a // container it half-understands — and this runs inline on the request // path. A bounded failure falls through to the next backend and finally // to pass-through, which is the same degradation as a decode error. const converted = await withTimeout(backend.run(), IMAGE_TRANSCODE_TIMEOUT_MS, new Error(`${backend.name} image transcode exceeded ${IMAGE_TRANSCODE_TIMEOUT_MS}ms`)); if (converted.length === 0) { throw new Error("produced an empty image"); } logger.debug(`[imageFormatSupport] Transcoded ${normalized} → ${targetMimeType} via ${backend.name} ` + `(${buffer.length} → ${converted.length} bytes) for vision compatibility`); return { buffer: converted, mimeType: targetMimeType, converted: true }; } catch (error) { failures.push(`${backend.name}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`); } } logger.warn(`[imageFormatSupport] Could not transcode ${normalized} to ${targetMimeType} — sending ` + `the original bytes, which most vision providers will reject. Install a full ` + `ffmpeg build (or set FFMPEG_PATH to one) to enable this format. ` + `Tried ${failures.join("; ")}`); return { buffer, mimeType, converted: false }; } /** * Auto-orient an image from its EXIF/XMP tag and strip the tag, so a photo * that was stored rotated (the common case for phone cameras, since the * sensor is read in one fixed orientation and rotation is recorded as a flag * rather than baked into the pixels) is sent upright instead of sideways. * * Three costs are guarded deliberately, matching how `toVisionCompatibleImage` * guards its own: * - **Cost**: `mayCarryExifOrientation` skips formats that cannot carry the * tag; `probeExifOrientation` then settles the formats that can from their * own header bytes, so the overwhelmingly common orientation-less JPEG or * WebP never loads sharp at all. Only a container the probe cannot rule * out reaches a `metadata()` call, and only a tag that is present and not * the already-upright value `1` reaches the decode-and-re-encode. * - **Availability**: sharp is an optional dependency (the ffmpeg fallback * path above exists precisely because it can be absent). Any failure — * missing package, malformed pixels, a timeout — is caught here and * degrades to returning the original bytes unchanged, never throwing. * - **Size**: re-encoding changes byte length. This function only returns the * new bytes; it does not itself re-validate size limits. Callers that * re-encode must re-run their size guard on the returned buffer. * * Never throws for image reasons — mirrors `toVisionCompatibleImage`. * * @param buffer - Raw image bytes. * @param mimeType - Detected MIME type of `buffer`. */ export async function normalizeImageOrientation(buffer, mimeType) { if (!mayCarryExifOrientation(mimeType)) { return { buffer, normalized: false }; } // Only "absent" is actionable here. "inconclusive" means the parse ran out // of container it understands, which is a question for the decoder rather // than a no — see {@link probeExifOrientation}. if (probeExifOrientation(buffer, mimeType) === "absent") { return { buffer, normalized: false }; } try { const sharpModule = await tryImport("sharp", "EXIF orientation normalization"); if (typeof sharpModule?.default !== "function") { throw new Error("the installed sharp package does not expose a callable default export"); } // One shared deadline for the metadata read and the conditional re-encode, // not one each. Both awaits are sequential, so two independent budgets // are additive rather than shared: a metadata read that merely takes // close to the full budget, followed by a rotate that stalls to its own // full budget, costs close to double `IMAGE_TRANSCODE_TIMEOUT_MS` for one // image before falling back — and that multiplies per image, since // callers process `input.images` sequentially to preserve ordering. A // single race caps the whole operation at one budget, matching every // other timeout in this module (one per backend attempt). const result = await withTimeout((async () => { const metadata = await sharpModule.default(buffer).metadata(); // 1 is "already upright" (and the implicit value when the tag is // absent altogether) — nothing to correct, so skip the re-encode // entirely. if (!metadata.orientation || metadata.orientation === 1) { return { buffer, normalized: false }; } const rotated = await sharpModule.default(buffer).rotate().toBuffer(); if (rotated.length === 0) { throw new Error("auto-orient produced an empty image"); } logger.debug(`[imageFormatSupport] Normalized EXIF orientation ${metadata.orientation} for ` + `${mimeType} (${buffer.length} → ${rotated.length} bytes)`); return { buffer: rotated, normalized: true }; })(), IMAGE_TRANSCODE_TIMEOUT_MS, new Error(`EXIF auto-orient exceeded ${IMAGE_TRANSCODE_TIMEOUT_MS}ms`)); return result; } catch (error) { logger.warn(`[imageFormatSupport] Could not normalize EXIF orientation for ${mimeType} — ` + `sending the original bytes as-is: ` + `${error instanceof Error ? error.message : String(error)}`); return { buffer, normalized: false }; } } /** * Whether an `input.images` entry is the `{ data, altText }` wrapper. * * Narrowing on the `data` property rather than on `typeof entry === "object"`: * a Buffer is also an object, so the looser test leaves `ImageWithAltText` in * the union on the false branch and only compiles behind a cast. */ function isImageWithAltTextEntry(entry) { return (typeof entry === "object" && entry !== null && !Buffer.isBuffer(entry) && "data" in entry); } /** * Unwrap an `input.images` entry to its payload. * * `ImageWithAltText` (`{ data, altText }`) is a documented public input shape, * but the provider image loops typed the array as `Buffer | string` and so * treated a wrapper as raw bytes — `toString("base64")` on the object yields * the literal "[object Object]", which is valid base64 that decodes to seven * bytes of garbage. The request therefore reached the API and failed as * "invalid image data", with nothing pointing at the stringification. * * Alt text has no representation in Vertex's `inlineData` part, so it is * dropped here deliberately rather than corrupting the payload to carry it. */ export function unwrapImagePayload(entry) { return isImageWithAltTextEntry(entry) ? entry.data : entry; }